되돌아오는 질문들 — 알림센터 설계 문답
알림센터 다섯 편의 마지막이다. 앞의 네 편이 주제별로 나눠 답한 것을 질문이 실제로 오는 세 자리로 다시 세운다 — 아직 아무것도 정하지 않은 「고를 때」, 이미 돌고 있는데 증상이 나온 「터졌을 때」, 그 판단의 근거를 남에게 대야 하는 「설명해야 할 때」다.
무엇을 버려도 되는지 모르면 아무것도 살 수 없다. 편4가 이 문장으로 닫혔고, 실은 앞의 세 편이 내린 결정도 전부 같은 모양이었다. 포기할 수 있는 것을 먼저 찾아내고 그 대가로 필요한 성질을 샀다.
되돌아오는 질문은 대개 그 거래를 다시 여는 형태로 온다. 「왜 저 저장소인가」는 무엇을 포기했는지를 묻는 것이고, 「알림이 왜 늦나」는 포기한 것의 청구서가 도착했다는 뜻이며, 「이걸 왜 이렇게 만들었나」는 그 거래를 남이 납득할 수 있게 설명해 보라는 요구다.
그리고 이 질문들은 편의 경계를 지키지 않는다. 「같은 알림이 두 번 보인다」 하나가 최소 한 번 전달이라는 브로커의 성질에서도 시작하고 저장할 때 무엇을 유일하다고 보았는지에서도 시작한다. 앞의 네 편이 주제로 나뉘어 있으니 이 편은 질문이 오는 자리로 다시 세운다.
| 자리 | 언제 쓰나 | 무엇이 놓이나 |
|---|---|---|
| 고를 때 | 아직 아무것도 정하지 않았다 | 설계 선택지 여섯과 각각이 내주는 것 |
| 터졌을 때 | 이미 돌고 있는데 증상이 나왔다 | 실패 모드 열과 진단 순서 |
| 설명해야 할 때 | 그 판단의 근거를 남에게 대야 한다 | 문답 스물다섯과 길게 답할 하나 |
앞의 네 편이 이미 답한 것은 다시 설명하지 않는다 — 한 줄로 답하고 그 편을 가리킨다. 새로 쓰는 자리는 둘뿐이다. 앞 편이 장단점만 늘어놓고 넘어간 곳에 고르는 기준을 주는 자리, 그리고 여러 편에 걸쳐 있어 어느 편도 통째로 답하지 못한 자리다.
고를 때
여섯 갈래다. 기준은 「무엇이 더 좋은가」가 아니라 **「무엇을 내줄 수 있는가」**로 잡는다. 오른쪽 끝은 그 선택을 논증한 편이다.
| 선택 | 얻는 것 | 내주는 것 | 이 조건이면 고른다 | 앞 편 |
|---|---|---|---|---|
| 조회 서버와 생성 서버 분리 | 부하·장애 격리, 서로 다른 기준으로 증설 | 배포 대상이 늘고 운영이 복잡해진다 | 두 워크로드의 지연 허용도가 다를 때 | 편2 |
| 브로커를 끼운 비동기 처리 | 원 서비스 보호, 스파이크 흡수, 재처리 가능 | 알림이 늦게 도착하고 인프라가 하나 는다 | 알림이 부가 기능일 때 — 거의 항상 | 편1·편3 |
| 문서형 저장소 | 스키마 유연성, 만료 인덱스, 수평 확장 | 여러 문서를 한 트랜잭션에 묶기 어렵다 | 정합성보다 확장성이 앞설 때 | 편4 |
| 기준값 페이징 | 깊은 페이지도 비용이 늘지 않고 목록이 흔들리지 않는다 | 임의 페이지로 건너뛸 수 없다 | 최신순 무한 스크롤 하나면 될 때 | 편4 |
| 읽음 상태를 시각 한 값으로 | 읽음 처리가 문서 수와 무관해진다 | 알림 한 건만 읽음 처리할 수 없다 | 정책이 「전체 읽음」으로 단순할 때 | 편4 |
| 멀티모듈 | 도메인 코드가 한 벌로 남고 경계가 컴파일 타임에 강제된다 | 빌드 구성이 복잡하고 초기 학습 비용이 든다 | 실행 모듈은 여럿인데 도메인이 하나일 때 | 편2 |
여섯이 내주는 것은 사실 세 가지다
「내주는 것」 열을 성질별로 묶으면 항목이 줄어든다. 그리고 줄여 놓고 보면 이 설계가 무엇을 일관되게 포기했는지가 드러난다.
| 내준 것 | 그렇게 한 선택 | 되사려면 무엇을 반납해야 하나 |
|---|---|---|
| 즉시성 | 비동기 처리 | 동기 호출로 되돌아가야 하고, 그러면 장애가 원 서비스로 번지는 경로도 함께 돌아온다 |
| 정합성 | 문서형 저장소 | 알림을 다른 데이터와 한 트랜잭션에 묶으면 저장소를 바꿔야 하고 확장 방식도 함께 바뀐다 |
| 자유도 | 기준값 페이징 · 읽음 상태 단일 값 | 화면에 임의 페이지 이동이나 건별 읽음이 생기면 두 설계가 동시에 무너진다 |
남은 둘 — 서버 분리와 멀티모듈 — 은 성격이 다르다. 이 둘이 내주는 것은 사용자가 볼 수 있는 성질이 아니라 운영과 빌드의 복잡도다. 앞의 셋은 제품의 약속을 깎아 성능을 사지만, 뒤의 둘은 팀이 감당할 몫을 늘려 격리와 경계를 산다. 그래서 뒤의 둘은 팀 규모가 작을수록 비싸고, 앞의 셋은 서비스가 작을수록 비싸다 — 알림이 하루 수천 건이면 즉시성을 내줄 이유가 없다.
조건이 바뀌면 셋이 뒤집힌다
같은 논리를 다른 도메인에 넣으면 결론이 반대로 나온다는 것을, 편4가 결제·정산을 예로 이미 보였다. 여기서는 어느 행이 뒤집히는지만 짚는다.
| 선택 | 알림 | 결제·정산 |
|---|---|---|
| 비동기 처리 | 늦어도 사용자는 모른다 | 결제 결과가 늦으면 그것 자체가 사고다 |
| 문서형 저장소 | 정합성을 내줘도 된다 | 다중 문서 트랜잭션이 요구사항의 첫 줄이다 |
| 읽음 상태 단일 값 | 전체 읽음이면 충분하다 | 건별 상태가 곧 원장이라 뭉갤 수 없다 |
뒤집히지 않는 것은 서버 분리와 멀티모듈이다. 워크로드의 성격이 다르면 따로 늘려야 한다는 것과, 따로 띄운다고 해서 코드를 두 벌 만들 이유는 없다는 것은 도메인을 타지 않는다. 앞의 셋은 도메인이 정하고 뒤의 둘은 구조가 정한다.
터졌을 때
원인이 아니라 증상에서 출발하는 표다. 실제로 손에 먼저 들어오는 것이 증상이기 때문이다.
| 증상 | 원인 | 대응 | 앞 편 |
|---|---|---|---|
| 알림이 수 분~수십 분 늦게 온다 | 파티션 대비 소비자 부족, 또는 다운스트림 지연 | 소비자 증설(파티션 수가 상한임을 먼저 확인), 배치 처리, 저장소 인덱스 점검 | 편3 |
| 특정 파티션만 밀린다 | 인기 게시물의 이벤트가 한 키에 몰렸다 | 키 설계 재검토, 파티션 증설, 극단적인 키는 별도 토픽으로 분리 | 편3 |
| 처리가 멈췄다 재시작하기를 반복한다 | 처리 시간이 폴링 간격 상한을 넘겨 리밸런스가 계속 일어난다 | 배치 크기 축소, 처리 시간 단축, 간격 상향, 정적 멤버십 | 편3 |
| 한 파티션이 통째로 진행하지 않는다 | 역직렬화 실패나 스키마 불일치로 같은 메시지가 무한히 실패한다 | 즉시 DLQ로 격리하고 스키마 호환성 정책을 세운다 | 편3 |
| 같은 알림이 두 건 보인다 | 최소 한 번 전달에서 재처리가 일어났다 | 자연 키로 덮어쓰기, 유일 인덱스로 저장소가 강제하게 한다 | 편3·편4 |
| 취소한 반응의 알림이 남는다 | 파티션 키를 지정하지 않아 순서가 역전됐다 | 게시물 식별자를 키로 고정하고 이벤트 발생 시각을 비교한다 | 편3 |
| 소비자가 전건 실패한다 | 저장소가 죽었다 | 소비자를 멈춰 DLQ 대량 유입을 막고 복구 후 재개한다. 메시지는 브로커에 남아 있다 | 편3·편4 |
| 특정 시각에 디스크 입출력이 튄다 | 만료 시각이 한 시점에 몰려 삭제가 한꺼번에 돈다 | 만료 시각에 흔들림을 준다 | 편4 |
| 캐시 만료 직후 저장소가 폭주한다 | 같은 키가 대량으로 동시에 만료됐다 | 만료 시각 분산, 짧은 로컬 캐시 병행 | 편4 |
| 빌드가 깨지거나 경계가 무너진다 | 공용 모듈이 실행 모듈을 참조해 순환이 생겼다 | 의존 방향 규칙을 CI에서 검증한다 | 편2 |
열 중 여섯이 브로커 쪽에 몰려 있는데, 이것이 비동기를 고른 대가의 실제 모양이다. 동기 호출이었다면 이 여섯은 애초에 존재하지 않고 대신 원 서비스가 함께 죽는다. 문제가 사라진 것이 아니라 사라져도 되는 자리로 옮겨 간 것이다.
소비자를 늘렸는데 지연이 그대로다
가장 자주 되돌아오는 증상이고, 증설이 듣지 않는 이유가 셋으로 갈린다. 그래서 처방보다 보는 순서가 먼저다.
| 순서 | 무엇을 보나 | 이것이면 무슨 뜻인가 |
|---|---|---|
| ① | 파티션 수와 소비자 수 | 소비자가 더 많으면 초과분은 아무 일도 하지 않는다. 병렬도의 상한은 파티션 수다 |
| ② | 파티션별로 밀린 양 | 한쪽에만 쌓여 있으면 부족한 것은 소비자가 아니라 키 설계다 |
| ③ | 소비자의 처리 시간과 저장소 지표 | 저장소 쓰기가 병목이면 소비자를 늘릴수록 커넥션 경합만 심해져 오히려 나빠진다 |
셋을 순서대로 보는 이유는 증설이 유효한 경우가 셋 중 하나뿐이기 때문이다. ①이면 파티션을 먼저 늘려야 하고, ②면 늘려도 몰린 파티션은 그대로이며, ③이면 늘리는 것이 악화다. 「느리면 늘린다」가 통하지 않는 구간이 이렇게 넓다.
셋을 갈라 보려면 파티션 단위의 지표가 있어야 한다. 무엇이 느린지를 합계만 보고는 알 수 없다는 문제는 도메인을 가리지 않는데, 여러 단계를 거치는 파이프라인에서 구간별 소요를 추적하는 이야기도 관측을 다룬 글에 같은 형태로 나온다.
같은 알림이 두 번 보인다
이 증상은 원인이 두 갈래이고, 갈래에 따라 손댈 편이 다르다. 그래서 먼저 중복의 성격부터 확인한다.
| 확인할 것 | 이것이면 | 원인 | 손댈 자리 |
|---|---|---|---|
| 두 건이 완전히 같은가 | 같다 | 오프셋을 커밋하기 전에 소비자가 죽어 재전달됐다 | 저장을 덮어쓰기로 (편3) |
| 두 건이 같은 게시물의 알림인가 | 갱신되지 않고 새로 쌓였다 | 유일 키에 이벤트 식별자 같은 값이 섞여 매번 다른 문서가 된다 | 유일 키 재설계 (편4) |
전자라면 배포와 리밸런스 시각이 중복 발생 시각과 겹치는지 먼저 본다. 후자라면 시각과는 무관하게 계속 쌓이므로 그것만으로도 갈래가 갈린다.
근본 대응은 어느 쪽이든 재시도 로직을 손보는 것이 아니라 저장을 덮어쓰기로 바꾸고 유일 인덱스로 저장소가 강제하게 하는 것이다. 애플리케이션에서 「먼저 조회하고 없으면 넣는다」로 막는 방식은 두 소비자가 같은 순간에 그 조회를 통과하는 상황에서 반드시 뚫린다. 하나가 실패해도 나머지를 어떻게 마감할 것인가는 알림만의 문제가 아니어서, 여러 갈래가 병렬로 도는 구조에서 부분 실패를 마감하는 규약도 같은 자리에서 출발한다.
설명해야 할 때
근거를 말로 옮기는 자리다. 스물다섯 문답을 먼저 놓고, 한 줄로 줄이면 손해인 하나를 뒤에 뒀다. 답이 길어지는 것은 그 편의 몫이라 오른쪽 열을 따라간다.
| # | 질문 | 답 | 어디 |
|---|---|---|---|
| 1 | 조회와 생성을 왜 분리했나 | 트래픽 모양과 지연 허용도가 달라서다. 합쳐 두면 생성 쪽이 튈 때 조회 응답 시간이 함께 끌려 내려간다 | 편2 |
| 2 | 브로커를 왜 넣었나 | 원 서비스로 장애가 역류하는 경로를 끊고, 스파이크를 흡수하고, 실패한 것을 다시 처리하기 위해서다 | 편1·편3 |
| 3 | 파티션 수와 소비자 수는 어떤 관계인가 | 그룹 안 병렬도의 상한이 파티션 수다. 소비자가 더 많으면 남는 쪽은 논다. 파티션은 늘릴 수만 있다 | 편3 |
| 4 | 메시지 순서는 어떻게 보장하나 | 토픽이 아니라 파티션 단위로만 보장된다. 같은 대상의 이벤트를 같은 키로 보내 한 파티션에 태운다 | 편3 |
| 5 | 특정 키에 몰리면 어떻게 하나 | 키 재설계, 파티션 증설, 극단적인 키는 별도 토픽. 어느 쪽이든 순서 보장 범위와 맞바꾼다 | 편3 |
| 6 | 최소 한 번 전달에서 중복은 어떻게 막나 | 막지 않고 무해하게 만든다. 자연 키로 덮어써 도메인 연산 자체를 여러 번 해도 같게 만든다 | 편3 |
| 7 | 정확히 한 번 전달은 왜 안 쓰나 | 브로커의 트랜잭션은 브로커 안에서만 성립한다. 외부 저장소까지 걸치면 결국 덮어쓰기 설계가 필요하고 처리량 비용도 크다 | 편3 |
| 8 | DLQ는 왜 필요한가 | 성공할 수 없는 한 건이 뒤의 수십만 건을 막는 것을 끊기 위해서다. 목적은 그 한 건의 구제가 아니라 진행 보장이다 | 편3 |
| 9 | 일시 실패와 영구 실패를 어떻게 가르나 | 예외 타입으로 분기한다. 역직렬화·검증 실패는 재시도해도 같으니 즉시 격리하고, 연결·타임아웃은 물러났다 다시 시도한다 | 편3 |
| 10 | 다른 브로커와 무엇이 다른가 | 로그를 보존해 다시 읽을 수 있고 소비자 그룹을 여럿 둘 수 있는 쪽과, 복잡한 라우팅과 작업 큐가 강한 쪽이 갈린다 | 편3 |
| 11 | 발행-구독만 되는 저장소로는 왜 안 되나 | 던지고 잊는 방식이라 구독자가 끊긴 구간의 메시지가 사라진다. 알림은 유실되면 안 되는 데이터다 | 편3 |
| 12 | 밀린 양이 계속 오르면 무엇부터 보나 | 처리 속도·편중·다운스트림 셋으로 나눠 본다. 순서를 지키지 않으면 증설이 악화가 되는 경우를 만난다 | 「터졌을 때」 |
| 13 | 리밸런스가 반복되면 | 한 번의 처리 시간이 폴링 간격 상한을 넘긴 것이다. 배치를 줄이거나 간격을 올린다 | 편3 |
| 14 | 관계형 대신 문서형을 고른 이유는 | 알림 종류마다 형태가 다르고 중첩이 있으며, 수평 확장과 만료가 이미 정책에 있어서다. 정합성이 우선인 도메인이면 반대로 고른다 | 편4 |
| 15 | 90일 보관은 어떻게 구현했나 | 삭제 기준 시각 필드에 만료 인덱스를 걸어 저장소가 백그라운드로 치우게 한다. 운영할 배치가 하나 줄어든다 | 편4 |
| 16 | 왜 기준값 페이징인가 | 뒤쪽 페이지의 비용이 늘지 않고, 목록에 계속 쓰기가 일어나도 항목이 중복되거나 누락되지 않는다 | 편4 |
| 17 | 커서가 같은 값이면 어떻게 하나 | 시각 하나만으로는 동시각에서 누락이 생긴다. 시각과 식별자를 묶은 복합 커서로 순서를 끝까지 확정한다 | 편4 |
| 18 | 읽음 처리를 왜 시각 한 값으로 했나 | 가장 자주 불리는 요청을 문서 수만큼의 갱신에서 한 건짜리 쓰기로 바꾼다. 정책이 「전체 읽음」이라 성립한다 | 편4 |
| 19 | 무엇을 캐시에 뒀나 | 폴링성 호출이 가장 많은 「새 알림 있음」 확인에 필요한 두 시각뿐이다. 목록 본문은 캐시하지 않는다 | 편4 |
| 20 | 멀티모듈로 얻은 것은 | 도메인 코드가 한 벌로 남고, 모듈 사이 의존 방향이 컴파일 타임에 강제되며, 실행 모듈만 따로 배포된다 | 편2 |
| 21 | 멀티모듈과 MSA의 차이는 | 사이에 네트워크가 있느냐다. 네트워크가 생기면 부분 실패·분산 트랜잭션·추적 비용을 추가로 문다 | 편2 |
| 22 | 언제 MSA로 넘어가야 하나 | 신호가 코드가 아니라 조직에서 온다 | 편2 · 아래 절 |
| 23 | 의존 선언에서 전파 여부는 왜 중요한가 | 컴파일 클래스패스가 전파되면 하위 모듈이 바뀔 때 재컴파일 범위가 커져 모듈로 나눈 이점이 사라진다 | 편2 |
| 24 | 브로커 추상화를 쓴 이유와 대가는 | 테스트와 교체가 쉬워지는 대신 브로커 고유 제어에 손이 덜 닿고 디버깅할 층이 하나 는다 | 편3 |
| 25 | 통합 테스트를 왜 실제 저장소로 하나 | 흉내 낸 구현은 실제와 다른 지점에서 통과한다. 컨테이너로 실물을 띄우면 로컬과 CI가 같은 것을 검증한다 | 편3 |
스물다섯 중 열셋이 편3에 걸린다. 브로커가 어려워서가 아니라 비동기를 고른 순간 설명해야 할 성질이 한꺼번에 늘어나기 때문이다. 동기 호출에는 순서도 중복도 재처리도 설명할 거리가 없다.
22 — MSA로 쪼개라는 요구가 오면
22번이 한 줄로 끝나지 않는 이유는, 이 질문에 대한 답이 기술 판단이 아니라 요구의 출처를 되묻는 절차이기 때문이다.
먼저 왜 쪼개려 하는지를 확인한다. 배포가 서로 막혀서인지, 한쪽만 훨씬 크게 띄워야 해서인지, 아니면 그렇게 하는 것이 요즘 방식이라서인지에 따라 답이 완전히 달라진다.
규모가 이유라면 이미 풀려 있다. 조회 모듈과 소비자 모듈은 코드베이스를 공유해도 별개 산출물이라 따로 배포되고 따로 늘어난다. 네트워크 경계를 세우지 않고 목적을 이룰 수 있으면 그쪽이 싸다.
반대로 팀이 나뉘어 릴리스 주기가 부딪히기 시작했다면 그때는 쪼개는 편이 맞다. 쪼개서 얻는 것의 대부분은 기술적 이점이 아니라 팀이 서로를 기다리지 않는 상태이기 때문이다. 다만 넘어가더라도 모듈 경계가 깨끗하다는 전제가 필요하다. 경계가 흐린 채로 나누면 배포만 갈라지고 릴리스는 늘 함께 해야 하는 상태에 갇힌다.
정리
세 자리를 한 줄로 줄이면 이렇게 된다. 고를 때는 무엇을 내줄지 정하는 자리이고, 터졌을 때는 내준 것의 청구서를 받는 자리이며, 설명해야 할 때는 그 둘이 같은 판단이었음을 보이는 자리다.
이 시리즈 전체가 같은 구조였다. 편1이 부하와 장애가 흐르는 방향이 어긋나 있다는 것을 지형으로 보였고, 편2가 그 어긋남을 따라 선을 그었으며, 편3이 선을 그은 대가로 받은 청구서 셋을 갚았고, 편4가 남은 비대칭 위에서 담는 자리와 읽는 자리를 정했다.
되돌아오는 질문이 매번 여러 편에 걸치는 것도 그래서다. 결정 하나가 다른 편의 문제를 만들도록 되어 있으니 질문도 그 경로를 따라온다. 답이 한 편 안에서 끝나는 질문은 대체로 아직 덜 물어본 질문이다.