백엔드 엔지니어링수정 2026-08-29

쌓이기만 하는 데이터를 어떻게 빠르게 읽는가 — 저장소 선택과 조회 설계

알림은 지워지는 계기가 없어 무한히 쌓이는데 실제로 읽히는 것은 언제나 최신 스무 건뿐이다. 이 비대칭 위에서 저장소를 문서형으로 고른 근거를 도메인 특성에서 끌어내고, 기준값을 넘겨 받는 페이징과 읽음 경계를 시각 한 값으로 푸는 설계, 가장 자주 불리는 엔드포인트를 캐시로 받는 자리, 그리고 90일이 지난 문서를 배치 없이 치우는 만료 인덱스까지 차례로 본다.

알림 데이터에는 지워지는 계기가 없다. 게시물은 작성자가 지우고 주문은 정산이 끝나면 보관 영역으로 넘어가지만, 알림은 한 번 만들어진 뒤 누구도 치우자고 말하지 않는다.

그런데 실제로 읽히는 것은 그중 극히 일부다. 알림 탭을 열면 최신 스무 건이 보이고, 스크롤을 몇 번 더 내리는 사람은 드물며, 한 달 전 알림을 찾아 들어가는 동작은 화면에 아예 없다. 적재량은 끝없이 늘어나는데 조회 범위는 상수로 고정된다. 앞 편이 메시지를 무사히 도착시키는 데까지 왔다면, 도착한 뒤의 질문은 그 어긋남 위에서 셋으로 갈라진다. 어디에 담고, 고정된 범위를 어떻게 꺼내고, 밀려난 것을 누가 치우는가다.

편1은 요구사항 다섯 줄이 읽는 순간 구현을 확정해 버린다고 적고 그 답을 이 편으로 미뤄 두었다. 다섯 줄은 각각 저장소 형태·페이징 방식·만료 정책·읽음 경계·캐시를 부른다. 여기서 그 다섯을 전부 회수한다.

담을 곳을 도메인이 정한다

저장소를 고르는 자리에서 「어느 쪽이 빠른가」로 답하면 근거가 서지 않는다. 두 제품 다 자기가 잘하는 워크로드에서 빠르다. 물어야 할 것은 이 도메인이 무엇을 포기해도 되는가다.

관계형 저장소문서형 저장소알림 도메인의 요구
데이터 모델열이 고정된 표. 스키마를 먼저 확정한다중첩을 허용하는 문서. 문서마다 필드가 달라도 된다종류마다 필드가 다르다 → 문서형
정합성다중 행 트랜잭션과 조인문서 하나 단위의 원자성알림 한 건은 다른 데이터와 함께 묶이지 않는다 → 무승부
확장 방향수직 확장이 기본. 샤딩은 따로 설계한다샤딩이 제품 안에 들어 있다사용자 증가가 곧 데이터 증가다 → 문서형
쓰기 특성인덱스와 제약을 검사하며 쓴다제약이 적은 만큼 쓰기가 가볍다사건 하나가 수신자 수만큼 불어난다 → 문서형
만료 처리삭제 배치를 직접 만들어 돌린다만료 인덱스가 제품 기능으로 있다90일 보관 정책이 이미 요구사항이다 → 문서형

다섯 줄 중 넷이 문서형을 가리키고 남은 하나는 우열이 갈리지 않는다. 알림은 결제나 재고처럼 다른 테이블과 한 트랜잭션으로 묶여야 하는 데이터가 아니라서, 관계형 저장소가 가장 잘하는 것을 이 도메인은 애초에 쓸 일이 없다. 저장소 선택의 일반론은 관계형과 NoSQL을 무엇으로 갈라 고르는지 다룬 글에 정리해 두었다.

첫 줄은 요구사항 문장에서 곧바로 내려온다. 좋아요 알림에는 누른 사람이, 댓글 알림에는 본문 일부가, 팔로우 알림에는 상대 프로필이 붙는다. 관계형으로 담으면 종류를 늘릴 때마다 열을 더하거나 별도 표를 만들어야 하고, 그때마다 이미 쌓인 수억 건에 스키마 변경이 걸린다. 알림 종류는 서비스가 살아 있는 한 계속 늘어나는 축이다.

여기서 「문서형이 더 빠르다」로 답을 줄이면 근거가 사라진다. 실제로 말해야 하는 것은 이 워크로드가 트랜잭션 정합성보다 스키마 유연성과 수평 확장과 자동 만료를 더 필요로 한다는 판정이다.

같은 논리를 결제나 정산에 적용하면 결론이 뒤집힌다. 거기서는 금액과 잔액이 한 트랜잭션 안에서 함께 움직여야 하고 스키마가 자주 바뀌면 오히려 사고가 난다. 선택이 바뀌는 것이 아니라 도메인이 바뀌면 선택도 따라 바뀌는 것이고, 근거는 언제나 도메인 쪽에 있다.

스무 건을 꺼내는 두 가지 방법

알림 조회는 API 세 개로 끝난다. 목록을 최신순 스무 건씩 내려 주는 것, 읽음 처리를 기록하는 것, 새 알림이 있는지 묻는 것이다. 셋의 성격이 전부 달라서 각각 다른 설계를 부른다.

목록부터 본다. 페이지를 넘기는 방법은 두 가지다.

번호로 건너뛰기기준값으로 이어받기
질의 형태앞의 N건을 건너뛰고 20건기준 시각보다 앞선 것 중 20건
뒤쪽 페이지 비용건너뛸 N건을 전부 읽고 버린다인덱스로 진입점을 찾고 20건만 읽는다
목록이 변할 때새 항목이 들어오면 뒤로 밀려 다시 보이거나 사라진다기준점이 고정돼 흔들리지 않는다
임의 페이지 이동가능하다불가능하다. 순차 스크롤만 된다
전체 건수셀 수 있다보통 주지 않는다

오른쪽이 왼쪽보다 우월해서 고르는 것이 아니다. 임의 페이지 이동과 전체 건수는 왼쪽만 줄 수 있는데 알림 탭은 그 둘을 화면에서 쓰지 않는다. 반대로 이 목록은 스크롤하는 동안에도 위쪽에 새 항목이 계속 들어오므로, 왼쪽을 쓰면 이미 지나친 알림이 다음 페이지에 다시 나타난다. 화면이 요구하지 않는 기능을 포기하고 화면이 실제로 겪는 문제를 없앤 것이다.

도식을 탭하면 확대해서 볼 수 있습니다

기준값 하나만 주고받으므로 서버는 이전 요청을 기억할 필요가 없다. 다만 기준값이 시각 하나뿐이면 같은 밀리초에 만들어진 두 건에서 경계가 무너진다. 스무 번째와 스물한 번째의 발생 시각이 같으면 다음 요청은 그 시각보다 앞선 것을 달라고 하므로 스물한 번째가 통째로 빠진다. 그래서 발생 시각과 문서 식별자를 함께 넘기는 복합 커서를 쓴다. 시각이 같을 때 식별자가 순서를 정하니 동점이 남지 않는다.

이 결함은 사건 하나가 수신자 수만큼 불어나 같은 시각에 대량으로 쓰일 때 드러난다. 트래픽이 낮은 개발 환경에서 거의 재현되지 않는 부류다.

읽음 경계를 시각 한 값으로

읽음 처리 API는 사용자가 알림 탭을 열 때마다 불린다. 이 호출을 어떻게 구현하느냐가 조회 트래픽을 그대로 쓰기 부하로 바꿀 수도 있고 상수로 묶어 둘 수도 있다.

방식쓰기 비용무엇을 얻고 무엇을 잃는가
알림마다 읽음 표시를 갱신안 읽은 것이 100건이면 100건 갱신건별로 정확하지만 고빈도 API가 대량 쓰기를 낸다
사용자별 읽은 시각 한 건을 갱신문서 한 건 갱신클라이언트가 발생 시각과 비교해 판정한다. 건별 조작은 못 한다

아래쪽을 고른 이유는 성능만이 아니다. 요구사항이 「읽지 않은 알림을 목록에서 선으로 구분한다」였고, 이것은 개별 알림의 상태가 아니라 목록 위의 경계선 하나를 요구하는 문장이다. 필요한 것이 경계선이면 저장할 것도 경계선 하나면 된다.

명세가 「알림을 하나씩 골라 읽음 처리한다」거나 「안 읽음으로 되돌린다」였다면 시각 한 값으로는 표현할 수 없고 건별 상태로 돌아가야 한다. 지금 구현이 가벼운 것은 좋은 최적화를 해서가 아니라 정책이 가벼운 것을 요구했기 때문이고, 정책을 확인하지 않은 채 건별 상태부터 만들면 필요 없는 비용을 매 호출마다 낸다.

가장 자주 불리는 요청을 앞에서 끊는다

세 번째 API는 새 알림이 있는지 묻는 것이다. 앱이 주기적으로 물어보므로 초당 호출 수가 셋 중 압도적으로 높고, 그런데도 응답은 참과 거짓 한 글자다. 읽은 시각과 마지막 알림 시각을 비교하기만 하면 나오는 값이라 저장소를 뒤질 이유가 없다.

캐시에 둘 값두는 이유
사용자가 마지막으로 읽은 시각폴링성 호출이 매번 이 값을 읽는다
사용자의 마지막 알림 발생 시각위 값과 비교만 하면 빨간 점 여부가 결정된다

둘을 캐시에 올리면 이 API는 저장소에 한 번도 닿지 않는다. 무효화도 간단하다. 알림을 저장하는 쪽이 저장과 동시에 마지막 알림 시각을, 읽음 처리하는 쪽이 읽은 시각을 갱신하면 된다. 값을 바꾸는 주체가 각각 하나뿐이라 무효화 시점이 애매해지지 않는다.

캐시가 어려워지는 것은 이 조건이 깨질 때다. 여러 곳에서 같은 값을 쓰거나 만료로만 갱신하면 무효화 시점과 동시 접근을 따로 설계해야 하고, 그 지점의 함정들은 캐시 무효화와 동시성을 다룬 글에 모아 두었다. 여기서 쉬운 이유는 캐시가 쉬워서가 아니라 담은 것이 소유자가 분명한 단일 값이기 때문이다.

90일이 지난 문서를 누가 치우는가

보관 90일은 요구사항 한 줄이지만 구현으로 옮기면 「매일 도는 삭제 배치를 만들 것인가」라는 질문이 된다. 문서형 저장소에는 그 질문을 지우는 기능이 있다.

// 만료 인덱스: deletedAt 에 (발생 시각 + 90일)을 미리 넣어 두고 그 시각에 삭제한다
db.notifications.createIndex(
  { "deletedAt": 1 },
  { "name": "ix_deletedAt", "expireAfterSeconds": 0 }
)

// 조회 인덱스: 사용자별 최신순 페이징이 타야 하는 복합 인덱스
db.notifications.createIndex({ "userId": 1, "occurredAt": -1 })
항목알아 두어야 하는 것
삭제 시점백그라운드 작업이 약 60초 주기로 훑어 지운다. 정시 삭제가 아니다
만료 기준값 0필드에 담긴 시각이 곧 만료 시각이라는 뜻이다. 기간을 미리 더해 넣는다
부하 특성만료가 한 시점에 몰리면 삭제 I/O가 튄다. 만료 시각을 조금씩 흩뜨리면 완화된다
조회 인덱스사용자 조건과 정렬 키를 함께 태우는 복합 인덱스가 없으면 기준값 페이징도 컬렉션 전체를 훑는다

마지막 줄이 앞 절과 이어진다. 기준값 페이징이 빠른 것은 방식 자체 때문이 아니라 진입점을 인덱스로 찾을 수 있기 때문이고, 인덱스가 없으면 어떤 페이징을 써도 전부 읽는다. 복합 인덱스가 선두 열부터 순서대로 쓰인다는 성질은 SQL이 느려지는 자리를 짚은 글에서 관계형 사례로 다뤘는데, 저장소가 달라도 같다.

만료 인덱스의 값어치는 자동 삭제 자체보다 운영할 배치가 하나 줄어든다는 데 있다. 배치는 실패를 알아채야 하고 중복 실행을 막아야 하고 도는 시간대에 부하가 몰린다. 오래된 데이터를 단계적으로 밀어내는 문제는 저장소마다 다른 이름으로 나타나는데, 검색 엔진에서 인덱스 수명 주기를 나눠 관리하는 방식도 운영을 정리한 글에 같은 문제로 등장한다.

정리

이 편의 결정 넷은 전부 같은 자리에서 나왔다. 적재는 무한히 늘고 조회는 최근 스무 건에 묶인다는 비대칭이다.

무한히 느는 쪽이 저장소를 골랐다. 스키마가 계속 바뀌고 샤딩으로 흡수해야 하고 만료가 이미 정책에 있으니 문서형이었고, 정합성을 포기해도 됐던 것은 알림이 다른 데이터와 한 트랜잭션에 묶이지 않기 때문이다. 고정된 쪽은 조회 설계를 골랐다. 뒤쪽 페이지를 볼 일이 없어 임의 이동을 버리고 기준값 페이징을 얻었고, 필요한 것이 경계선 하나뿐이라 읽음 상태가 시각 한 값으로 줄었다.

되짚어 보면 어느 결정도 「더 나은 기술」을 고른 것이 아니다. 화면이 요구하지 않는 기능을 먼저 확인하고 그것을 버리는 대가로 필요한 성질을 산 것이다. 임의 페이지 이동과 전체 건수, 건별 읽음 조작, 정시 삭제는 전부 버린 것이고, 그 자리에서 얻은 것이 상수 시간 페이징과 한 건짜리 쓰기와 배치 없는 만료다. 무엇을 버려도 되는지 모르면 아무것도 살 수 없다.

다음 편은 여기까지 네 편에 되돌아오는 질문들을 모은다. 설계를 고를 때 무엇과 무엇을 저울에 올렸는지, 어디가 어떻게 터지는지, 그리고 그 판단을 남에게 설명해야 할 때 어느 순서로 말해야 하는지다.