728x90

"DB 서버를 하나 더 붙이자"는 얘기는 많이 들어봤어도 "DB 서버를 아예 없애자"는 얘기는 드물다. lobste.rs가 정확히 후자를 했다. MariaDB VPS를 통째로 내리고 애플리케이션 서버 안의 SQLite 파일 하나로 옮겼다. 결과는 CPU·메모리 사용량 감소, 체감 응답속도 개선, 그리고 VPS 비용 절반.

이게 왜 재밌냐면, 우리가 인프라 최적화라고 하면 보통 "더 추가"하는 방향으로만 생각하기 때문이다. 캐시 레이어 추가, 리드 레플리카 추가, 커넥션 풀러 추가. lobste.rs는 반대로 "빼는" 결정으로 단순성과 비용을 동시에 잡았다. 이번 글에서 그 과정과, 우리가 실제로 따라 하려 할 때 밟게 될 지뢰를 정리한다.

1. 왜 MariaDB를 버렸나 — 동기와 배경

사실 이 마이그레이션은 갑자기 나온 게 아니다. 논의는 2019년 이슈 #539에서 시작됐다. 당시엔 MariaDB에서 벗어나자는 얘기였고, 대안으로 PostgreSQL이 유력했다. PostgreSQL은 대부분의 팀에서 "고민 없이 고르는 기본값"이니까 자연스러운 선택이었다.

그런데 2025년에 실제로 작업할 지원자가 나타났을 때, 그 사람이 SQLite로 가고 싶어 했다. 여기서 lobste.rs 팀의 판단이 흥미롭다. 원문 댓글에서 관리자가 직접 밝힌 이유는 이렇다.

PostgreSQL은 평소 기본 선택이지만 별도 서비스를 운영·조정·유지해야 하는 부담이 있음. 예상 수요보다 크고 복잡한 해법을 피하고 싶어 SQLite를 선택함.

이게 핵심이다. lobste.rs는 단일 서버로 충분히 돌아가는 규모의 커뮤니티 사이트다. 이런 서비스에 PostgreSQL을 붙인다는 건 곧 별도 프로세스, 별도 VPS, 커넥션 관리, 백업 파이프라인, 버전 업그레이드 운영을 짊어진다는 뜻이다. "우리 규모에서 그게 정말 필요한가?"라는 질문에 정직하게 아니라고 답한 결과가 SQLite였다.

실무에서 우리도 이 질문을 자주 회피한다. "일단 Postgres 깔자, 나중에 커지면 편하니까." 그런데 그 "나중"이 안 오는 서비스가 훨씬 많다. lobste.rs는 오지 않을 미래를 위해 지금의 운영 복잡도를 지불하기를 거부한 거다.

2. SQLite는 프로덕션에서 쓸 수 있나 — 오해와 실제

여기서 대부분의 엔지니어가 멈칫한다. "SQLite는 임베디드용 아냐? 동시성이 안 되잖아." 반은 맞고 반은 틀리다.

SQLite의 동시성 모델을 정확히 이해하는 게 중요하다. 원문 FAQ 논의를 정리하면 이렇다.

  • 읽기는 여러 프로세스에서 동시에 가능
  • 쓰기는 한 번에 하나만 가능 (파일 시스템 잠금으로 보장)
  • 쓰기가 겹치면 명시적 큐가 아니라 "잠깐 대기 후 재시도" 방식으로 처리된다

비유하자면 화장실이 하나인 카페다. 읽기(구경)는 여러 명이 동시에 해도 되지만, 쓰기(화장실)는 한 명씩 순서대로다. lobste.rs 같은 사이트는 읽기가 쓰기보다 압도적으로 많은 read-heavy 워크로드다. 게시물 하나 쓰면 수백 번 읽힌다. 이런 패턴에서는 "쓰기 하나만 가능"이 실질적 병목이 되지 않는다.

그리고 "SQLite는 단일 서버여야 한다"는 제약도 있다. NFS로 파일 공유하는 방식은 공식적으로 권장되지 않는다. 그래서 SQLite를 쓴다는 건 곧 "우리 서비스는 한 대로 돌린다"는 아키텍처 선언이기도 하다. 요즘 서버 한 대 스펙이면 상당한 규모를 감당하니, 생각보다 많은 서비스가 여기 해당한다.

실제 규모 감을 위해: lobste.rs의 SQLite 파일 크기는 원문 댓글 기준 약 3.8GB라고 언급됐다. 커뮤니티 사이트 전체 데이터가 4GB 미만이라는 얘기다. 이 정도면 파일 하나로 충분히 관리된다.

3. 마이그레이션 과정 — 스키마 변환과 첫 배포 실패

이번 사례에서 가장 배울 게 많은 부분이 여기다. 첫 배포는 실패했다.

첫 배포가 CPU 100% 찍고 롤백된 이유

2월 21일 첫 배포에서, 읽기 전용 트래픽만으로 모든 CPU가 100%에 도달했다. 사이트를 읽기 전용 상태로 열었는데도 서버가 못 버틴 거다. 원인을 못 찾아 롤백했다.

가장 뼈아팠던 조건은 이거다. 팀이 프로덕션 DB에 접근할 수 없었다. 실제 데이터로 성능을 미리 재현할 방법이 없었다는 뜻이다. 그래서 로컬에서 실제 데이터 절반 크기의 테스트 데이터를 생성했는데, 이걸 만드는 데만 일주일이 걸렸다.

원인은 결국 두 가지였다.

  1. 대형 테이블에서 두 개의 쿼리가 전체 테이블 스캔(full table scan)을 일으킴
  2. 별도의 N+1 문제

여기서 실무 교훈. MariaDB에서 잘 돌던 쿼리가 SQLite에서 똑같이 잘 돈다는 보장이 없다. 옵티마이저가 다르고, 인덱스 활용 방식이 다르다. MariaDB가 알아서 커버해주던 비효율 쿼리가 SQLite로 오면 그대로 풀스캔으로 터질 수 있다.

lobste.rs 팀이 회고에서 남긴 가장 실용적인 조언이 이거다. "테스트 중 전체 테이블 스캔이 발생하면 테스트가 실패하도록 설정할 수 있었다면 첫 배포 문제를 미리 잡았을 것." 이건 우리도 바로 적용할 수 있다. SQLite는 쿼리 플랜을 확인할 수 있다.

# 쿼리가 인덱스를 타는지 풀스캔하는지 확인
sqlite3 lobsters.db "EXPLAIN QUERY PLAN
  SELECT * FROM stories WHERE user_id = 42 ORDER BY created_at DESC;"

# 인덱스 잘 타는 경우 출력 예시
QUERY PLAN
`--SEARCH stories USING INDEX index_stories_on_user_id (user_id=?)

# 풀스캔 나는 경우 출력 예시 (이게 나오면 위험 신호)
QUERY PLAN
`--SCAN stories

출력에 SCAN이 뜨고 그 대상이 큰 테이블이면 인덱스를 검토해야 한다. SEARCH ... USING INDEX가 나오면 정상이다. 이걸 CI 테스트에 엮어서 "큰 테이블에 SCAN이 뜨면 빌드 실패"로 만들면 lobste.rs가 겪은 사고를 예방할 수 있다.

스키마 변환에서 걸린 호환성 문제

MariaDB와 SQLite는 타입 시스템과 함수가 다르다. lobste.rs가 실제로 부딪힌 것들:

  • unsigned bigint 미지원: MariaDB에서 일부 ID에 쓰던 unsigned bigint를 SQLite가 지원하지 않아 그냥 bigint로 변경했다.
  • 없는 함수 직접 구현: SQLite에 없던 regexp, if, stddev를 SQLite gem의 사용자 정의 함수(UDF)로 구현했다. (참고로 최신 SQLite에는 iif 함수가 이미 있어서, 이 부분은 버전 확인이 필요하다.)
  • 정렬/대소문자 규칙: MariaDB에서 utf8mb4_general_ci를 쓰던 걸 SQLite에서는 NOCASE로 바꿨는데, NOCASE는 ASCII 문자만 대소문자를 구분 없이 처리한다. 전체 UTF 대소문자 접기(case folding)는 안 된다.

이 마지막 항목은 한국어 서비스에 특히 중요하다. 한글은 대소문자 개념이 없어서 직접 영향은 적지만, 영문/유니코드가 섞인 검색·정렬에서 MariaDB와 다르게 동작할 수 있다. "왜 대문자로 검색하면 결과가 다르지?" 같은 버그가 여기서 나온다.

또 전문 검색(full-text search)은 SQLite FTS를 쓰는데, 순위 산정 알고리즘이 MariaDB와 달라서 검색 결과 순서가 바뀔 수 있다. lobste.rs 팀도 "검색 테스트는 있지만 순위 검증은 적어서 기능적 변화 가능성이 있다"고 인정했다. 마이그레이션 후 검색 결과가 미묘하게 달라지는 건 각오해야 한다.

4. 운영 결과 — 실제로 뭐가 좋아졌나

7월 11일 두 번째 배포는 성공했다. 결과를 원문 기준으로 정리하면:

  • 전환 후 사이트 정상 운영, CPU·메모리 사용량 안정
  • 월요일 트래픽 급증(주간 피크)에서도 문제 없음
  • CPU·메모리 사용량 감소, 체감 응답성 개선
  • MariaDB VPS 종료로 VPS 비용 절반으로 감소

여기서 짚어야 할 포인트. 관리자도 "놀랍게도 CPU와 RAM이 모두 감소했다"고 했다. 직관과 반대다. DB를 앱 서버 안으로 끌고 왔는데 왜 리소스가 줄었을까?

추측이지만 합리적인 설명은 이렇다. MariaDB 시절엔 별도 VPS와 앱 서버 사이에 네트워크 왕복, 커넥션 관리, 직렬화/역직렬화 오버헤드가 있었다. SQLite는 같은 프로세스 안에서 함수 호출로 데이터를 읽으니 이 오버헤드가 통째로 사라진다. 네트워크를 안 타는 게 생각보다 크다. (단, 이건 이번 사례에 대한 해석이고, 모든 워크로드에서 재현되는 일반 법칙은 아니다.)

주의할 점: 이 "비용 절반, 리소스 감소"는 lobste.rs 규모와 read-heavy 패턴에서 나온 결과다. 원문에 구체적 수치(전후 CPU %, 응답시간 ms)는 차트로만 언급됐고 텍스트로는 없다. 우리 서비스에 그대로 대입하지 말고 방향성만 참고하자.

5. SQLite 프로덕션 운영 시 주의할 점 — 흔한 함정

기본 설정으로 쓰면 안 된다

SQLite를 서버에서 제대로 쓰려면 PRAGMA 튜닝이 필수다. 관리자가 원문에 공개한 lobste.rs의 실제 설정이 좋은 출발점이다.

PRAGMA foreign_keys=ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 134217728;
PRAGMA journal_size_limit = 67108864;
PRAGMA cache_size = 2000;

핵심만 보면:

  • journal_mode = WAL: Write-Ahead Logging. 이게 없으면 읽기가 쓰기를 블록한다. 서버용이면 필수다.
  • busy_timeout = 5000: 쓰기가 겹쳐서 잠겼을 때 5초까지 재시도하고 기다린다. 이게 없으면 바로 에러가 터진다.
  • synchronous = NORMAL: WAL 모드와 조합하면 성능과 안정성의 균형점.

흔한 함정: SQLITE_BUSY

SQLite를 서버에서 처음 굴리면 거의 반드시 만나는 에러가 이거다.

SQLite3::BusyException: database is locked (SQLite3::BusyException)

또는 로그에 이렇게 뜬다.

Error: database is locked (5) (SQLITE_BUSY)

쓰기 트랜잭션이 겹쳤는데 대기 시간을 초과했을 때 발생한다. 원인은 보통 둘 중 하나다.

  1. busy_timeout이 설정 안 됐거나 너무 짧다
  2. 트랜잭션이 지연 모드(deferred)로 시작돼서 잠금 획득 시점이 꼬였다

관리자가 중요한 힌트를 남겼다. "busy_timeout이 제대로 작동하려면 즉시 트랜잭션 모드(immediate transaction)도 켜야 한다." 기본 deferred 트랜잭션은 첫 쓰기 순간에야 잠금을 잡으려 하는데, 이때 이미 다른 쓰기가 진행 중이면 busy_timeout이 기대대로 안 먹힐 수 있다. immediate 모드는 트랜잭션 시작 시점에 쓰기 잠금을 확보하러 가서 이 경합을 깔끔하게 처리한다.

Rails라면 database.yml에서 이렇게 걸 수 있다.

# config/database.yml
production:
  adapter: sqlite3
  database: storage/production.sqlite3
  timeout: 5000
  # 트랜잭션 기본 모드를 IMMEDIATE로
  default_transaction_mode: IMMEDIATE
  pragmas:
    journal_mode: WAL
    synchronous: NORMAL
    foreign_keys: true
    busy_timeout: 5000
    cache_size: 2000
    temp_store: MEMORY

흔한 함정: NVMe와 안 맞는 기본값

관리자가 희미하게 기억한다며 남긴 얘기가 하나 있다. SQLite의 디스크 쓰기 방식 일부가 HDD에는 유리하지만 NVMe에는 부적합했던 부분이 있었다는 거다. 요즘 서버는 대부분 NVMe SSD니까, 기본값을 무조건 신뢰하지 말고 워크로드로 벤치마크해보는 게 안전하다. (구체적으로 어떤 PRAGMA인지는 원문에 명시 안 됐다. 공식 문서 확인 필요.)

백업은 어떻게?

파일 하나라서 백업이 오히려 단순하다. lobste.rs는 매일 밤 실행되는 작업이 restic을 호출하는 방식을 쓴다. 다만 WAL 모드에서는 파일을 그냥 복사하면 WAL에 아직 반영 안 된 데이터가 누락될 수 있으니, 안전하게 하려면 SQLite의 백업 API나 온라인 백업 명령을 쓰는 게 좋다.

# WAL을 고려한 안전한 온라인 백업 (사이트 돌아가는 중에도 OK)
sqlite3 production.sqlite3 ".backup '/backups/production-$(date +%F).sqlite3'"

# 백업 파일 무결성 검증
sqlite3 /backups/production-2025-01-15.sqlite3 "PRAGMA integrity_check;"

# 정상이면 출력
ok

실시간 복제/PITR이 필요하면 원문 댓글에서도 언급된 litestream 같은 도구를 검토하면 된다. lobste.rs는 restic 야간 백업으로 충분하다고 판단한 케이스다.

6. 정리 — 언제 이 선택이 옳은가

한 줄 요약: read-heavy이고 단일 서버로 충분한 규모라면, SQLite로 옮기는 게 비용과 운영 복잡도를 동시에 줄이는 현실적 선택이 될 수 있다.

이 선택이 맞는 경우:

  • 읽기가 쓰기보다 압도적으로 많은 워크로드 (커뮤니티, 블로그, 문서 사이트, 대시보드)
  • 단일 서버 아키텍처로 감당되는 규모 (데이터 수 GB 수준, 앞으로도 수평 확장 계획 없음)
  • 별도 DB 서버 운영 부담과 비용을 줄이고 싶은 소규모 팀

이 선택이 위험한 경우:

  • 쓰기가 매우 많거나 여러 서버가 동시에 써야 하는 워크로드 → 쓰기 직렬화가 병목이 된다
  • 수평 확장이 전제인 서비스 → SQLite는 단일 서버 가정을 강제한다
  • MariaDB/MySQL의 특정 함수·타입·정렬 규칙에 깊게 의존하는 코드베이스 → 호환성 작업 비용이 크다

lobste.rs 사례의 진짜 교훈은 SQLite 자체보다 그 판단 과정에 있다고 본다. "기본값으로 PostgreSQL"이라는 관성을 의심하고, 우리 서비스의 실제 규모와 접근 패턴을 정직하게 본 다음, 오지 않을 미래를 위한 복잡도를 지불하지 않기로 한 결정. 그리고 프로덕션 DB 접근 없이 배포했다가 CPU 100%로 롤백한 실패 경험까지 투명하게 공개한 것. 이 두 가지가 이 글이 단순 릴리스 공지가 아닌 이유다.

덧붙여, 마이그레이션 전에 꼭 하고 넘어갈 것 하나만 고르라면 "실제 데이터 규모의 테스트 셋으로 EXPLAIN QUERY PLAN을 돌려 풀스캔을 사전에 잡는 것"이다. lobste.rs가 일주일 데이터 생성하고 CPU 100% 맞고 나서야 배운 걸, 우리는 미리 하면 된다.

참고 자료

728x90

SQLite에서 UUID를 기본 키로 쓰면 안 되는 이유: 클러스터드 인덱스와 B-tree 재균형 비용

왜 지금 이 얘기를 하나

마이크로서비스 한다고 PK를 죄다 UUID로 깔아두는 게 요즘 기본값처럼 굳어졌다. ID 충돌 안 나고, 클라이언트에서 미리 생성해도 되고, 분산 환경에서 시퀀스 조율 안 해도 되니까. 그래서 별 생각 없이 SQLite 테이블에도 id TEXT PRIMARY KEY로 UUID4를 박아 넣는다.

그런데 이게 SQLite, 그리고 정도 차이는 있지만 MySIQL/InnoDB 계열에서 의외로 비싼 선택이다. 작은 토이 프로젝트일 땐 안 보이다가, 데이터가 수십만~수백만 행 쌓이고 나서 "삽입이 왜 이렇게 느려졌지?" 하고 뒤늦게 깨닫는다. 오늘 hada.io에 올라온 "SQLite에서 UUID 기본 키의 위험성" 글이 딱 이 지점을 찌른다. 실무에서 자주 밟는 지뢰라 정리해둔다.

핵심: 클러스터드 인덱스에 랜덤 키를 박으면 생기는 일

먼저 SQLite 저장 구조부터. SQLite의 일반 테이블은 내부적으로 rowid라는 64비트 정수를 클러스터드 인덱스 키로 쓴다. 즉 데이터가 rowid 순서대로 B-tree에 물리적으로 정렬되어 저장된다는 뜻이다. INTEGER PRIMARY KEY로 선언하면 그 컬럼이 곧 rowid의 별칭이 된다.

여기서 핵심은 "물리적으로 정렬"이다. 정수 rowid는 보통 autoincrement처럼 단조 증가하니까, 새 행은 항상 B-tree의 맨 오른쪽 끝(가장 큰 키)에 붙는다. 페이지가 가득 차면 새 페이지 하나 만들어 끝에 이어 붙이면 끝이다. 디스크 입장에서도 순차 쓰기에 가깝다.

그런데 UUID4를 PK로 쓰면 어떻게 될까? UUID4는 이름 그대로 랜덤이다. 새로 생성한 값이 기존 키들 사이 어디에 끼어들지 예측이 안 된다. 그래서 삽입할 때마다:

  • B-tree에서 이 키가 들어갈 위치를 찾아 내려가고(랜덤 탐색)
  • 그 중간 페이지에 끼워 넣어야 하니, 페이지가 꽉 찼으면 페이지 분할(split)이 발생하고
  • 트리 균형을 맞추느라 재균형(rebalancing) 비용이 든다

비유하자면 정수 PK는 "맨 뒤에 책 한 권 꽂기"고, 랜덤 UUID는 "책장 중간 아무 데나 책 끼워 넣기"다. 후자는 옆 책들을 계속 밀어내야 한다. 게다가 매번 다른 페이지를 건드리니 페이지 캐시 적중률도 떨어진다.

원문에서 언급하는 수치(정수 rowid 기준 100만 행 삽입 시 대략 초당 100만 건 수준, UUID4는 그보다 현저히 느려짐)는 환경에 따라 달라지므로 그대로 믿기보다 본인 워크로드에서 직접 벤치 떠보는 걸 권한다. 다만 "랜덤 키가 순차 키보다 삽입이 느리다"는 방향성 자체는 B-tree 특성상 분명하다.

흔히 나오는 대안이 WITHOUT ROWID 테이블인데, 이건 오히려 함정이 될 수 있다:

-- rowid 안 쓰고 UUID 자체를 클러스터드 키로
CREATE TABLE events (
    id TEXT PRIMARY KEY,
    payload TEXT
) WITHOUT ROWID;

이렇게 하면 정수 rowid 오버헤드는 없앨 수 있지만, UUID(텍스트)가 그대로 클러스터드 인덱스 키가 되어 데이터 전체가 UUID 순으로 정렬 저장된다. 랜덤 UUID라면 삽입 시 재균형 문제가 더 직접적으로 터진다. 게다가 텍스트 UUID는 36바이트라 키 자체가 뚱뚱해서 페이지당 들어가는 행 수가 줄고 트리가 깊어진다.

실무 관점: 그럼 어떻게 해야 하나

정답은 워크로드에 따라 갈린다. 내가 실무에서 판단하는 기준은 이렇다.

1) 가장 무난한 선택: 정수 PK + UUID는 별도 유니크 컬럼

외부로 노출할 ID는 UUID로 두되, 물리 저장과 조인은 정수 rowid로 처리한다.

CREATE TABLE users (
    id        INTEGER PRIMARY KEY,   -- rowid, 내부 조인/저장용
    public_id TEXT NOT NULL UNIQUE,  -- 외부 노출용 UUID
    name      TEXT
);

삽입 성능은 순차 정수의 이점을 그대로 누리고, UUID는 보조 유니크 인덱스에만 영향을 준다. 다만 이 경우에도 public_id 유니크 인덱스는 랜덤이라 인덱스 갱신 비용은 든다. 그래도 데이터 본체가 흔들리는 것보단 훨씬 싸다.

2) 굳이 UUID를 PK로 써야겠다면: 시간 정렬형 UUID

UUID4 대신 UUIDv7처럼 앞부분에 타임스탬프가 들어간 정렬 가능한 ID를 쓰면 랜덤성이 크게 줄어든다. 시간순으로 거의 단조 증가하니 삽입이 다시 "맨 뒤에 붙기"에 가까워진다. UUID의 분산 친화성은 유지하면서 B-tree 재균형 문제를 완화하는 절충안이다. UUIDv7은 표준(RFC 9562)으로 확정됐고 라이브러리 지원도 늘었으니 신규 설계라면 충분히 검토할 만하다.

3) UUID를 굳이 TEXT로 저장하지 마라

이건 의외로 많이 놓친다. UUID를 TEXT(36자 문자열)로 저장하면 BLOB 16바이트 대비 두 배 이상 공간을 먹고, 비교 연산도 문자열 비교라 느리다. 정 UUID를 키로 쓸 거면 16바이트 BLOB으로 저장하는 걸 고려하라. 가독성은 떨어지지만 인덱스/저장 효율은 확실히 낫다. 디버깅 편의 때문에 TEXT를 고집하다 용량 폭증하는 경우를 봤다.

흔한 실수 정리

  • "UUID는 충돌 안 나니까 무조건 좋다"고 PK 박기 → 삽입 성능과 저장 효율을 같이 봐야 한다
  • 벤치를 작은 데이터로만 돌려보고 OK 판단 → 문제는 데이터가 수십만 행 넘어 페이지 분할이 빈번해질 때 드러난다
  • WITHOUT ROWID가 만능인 줄 알고 적용 → 랜덤 키라면 오히려 독이다. 작고 짧은 정렬형 키일 때 이득
  • UUID를 TEXT로 저장하고 인덱스까지 걸어두고 용량/속도 둘 다 손해

트레이드오프

UUID PK의 진짜 가치는 "분산 환경에서 중앙 조율 없이 ID 생성"이다. 이게 정말 필요한 설계가 아니면(예: 모놀리식 SQLite, 단일 라이터 임베디드 DB) 정수 PK가 거의 항상 유리하다. SQLite는 애초에 임베디드/단일 파일 DB라 분산 ID의 필요성이 약한 경우가 많다는 점도 같이 고려하자.

정리

한 줄 요약: SQLite처럼 클러스터드 인덱스 기반 DB에 랜덤 UUID4를 PK로 박으면 삽입마다 B-tree 재균형이 일어나 느려지고 뚱뚱해진다. 정수 PK를 쓰거나, 꼭 UUID여야 한다면 정렬형 UUIDv7 + BLOB 저장을 검토하라.

  • 정수 PK + UUID 유니크 컬럼: 대부분의 임베디드/단일 라이터 상황의 무난한 정답
  • UUIDv7 PK: 분산 ID가 꼭 필요하면서 삽입 성능도 챙기고 싶을 때
  • UUID4 PK: 정말 필요할 때만, 그리고 BLOB로 저장하고 벤치 떠보고

같은 원리가 InnoDB(MySQL)에도 적용된다. InnoDB도 PK가 클러스터드 인덱스라 랜덤 UUID PK는 똑같이 페이지 분할 지옥을 부른다. SQLite만의 얘기로 들리지만 사실 "클러스터드 인덱스 DB 공통의 함정"으로 이해하는 게 맞다.

참고 자료

사진: Microsoft Copilot / Unsplash

728x90

+ Recent posts