목차
주문 서비스는 오늘 주문이 3건이라고 하고, 정산 서비스는 2건이라고 한다. 둘 다 자기 데이터로는 맞다.
두 서비스의 숫자가 다르다
한 덩어리였을 때 이런 일은 없었다. 주문 테이블 하나를 두 화면이 읽었으니 숫자가 다를 수가 없었다.
지금은 주문 서비스가 자기 DB에 주문을 저장하고, 정산 서비스가 그 사실을 이벤트로 받아 자기 DB에 기록한다. 마지막 주문의 이벤트가 아직 처리 안 됐으면 정산 쪽은 2건이다.
버그가 아니다. 나누기로 한 순간 받아들인 것이다. 이 편은 그게 정확히 무엇을 받아들인 것인지, 그리고 어디까지 받아들여도 되는지에 관한 이야기다.
묶을 수 없다는 것부터 확인하자
Unit of Work는 업무 하나를 한 트랜잭션으로 묶어 반쪽이 남지 않게 했다. Aggregate는 그 트랜잭션이 품는 단위를 덩어리 하나로 정하고, 나머지는 곧 맞추자고 했다.
서비스를 나눈다는 건 그 경계를 프로세스와 데이터베이스 바깥까지 밀어낸 것이다.
오른쪽에는 둘을 한꺼번에 커밋하거나 한꺼번에 되돌릴 주체가 없다. 그래서 주문은 저장됐는데 정산은 아직인 시간이 반드시 존재한다. 문제는 그 시간을 없앨 수 있느냐가 아니라, 얼마나 짧게 하고 그동안 무엇을 보여줄 것이냐다.
네트워크는 끊어진다
여기서 흔한 오해를 하나 먼저 걷어야 한다. “그래도 잘 만들면 항상 맞출 수 있지 않나.”
없다. 이유는 하나다. 여러 대에 나눠 둔 이상 그 사이가 끊어지는 일을 막을 수 없기 때문이다.
케이블이 잘리는 것만 말하는 게 아니다. 스위치가 잠깐 죽거나, 패킷이 유실되거나, 응답이 타임아웃보다 늦게 도착하면 부르는 쪽에게는 끊어진 것과 구별되지 않는다. 이렇게 서비스들이 서로 연락이 안 되는 상태를 분할이라고 부른다.
앞 편들에서 계속 나온 그 문제다. 응답이 없을 때 상대의 상태를 알 방법이 없다. 그리고 이 상황에서 시스템은 둘 중 하나를 해야 한다.
끊긴 순간에만 갈리는 선택
분할이 났을 때 정산 서비스에 “오늘 주문 몇 건이냐”고 물으면 어떻게 답해야 하나. 정산은 자기 데이터가 최신인지 확인할 방법이 없다.
- 답을 준다. 자기가 아는 2건을 준다. 응답은 받았지만 틀린 값일 수 있다
- 답을 거절한다. “지금은 확신할 수 없다”며 실패시킨다. 틀린 값을 주진 않지만 응답을 못 받는다
둘 다 할 수는 없다. 최신임을 보장하려면 상대에게 확인해야 하는데 지금 연락이 안 되기 때문이다.
이게 CAP 정리가 말하는 전부다. 원래 형태로 옮기면 이렇다. 정합성(C)·가용성(A)·분할 내성(P)을 동시에 다 만족하는 분산 시스템은 없다. 여기서 각 글자의 뜻은 일상어보다 좁다.
| 글자 | 정확한 뜻 |
|---|---|
| C (정합성) | 어느 노드에 물어도 가장 최근에 쓴 값이 보인다. 한 대인 것처럼 보인다는 뜻 |
| A (가용성) | 죽지 않은 노드에 보낸 요청은 언젠가 에러가 아닌 응답을 받는다 |
| P (분할 내성) | 노드 사이 메시지가 유실·지연돼도 시스템이 계속 동작한다 |
CAP의 C는 ACID의 C가 아니다. ACID의 C는 “제약 조건을 어기는 상태로 커밋되지 않는다”이고, CAP의 C는 “모든 노드가 같은 최신 값을 보여준다”이다. 글자만 같고 다른 이야기다.
오해 셋을 걷어낸다
CAP는 오독이 워낙 흔해서, 정리 자체보다 틀린 사용법을 아는 게 실무에 더 도움이 된다.
“셋 중 둘을 고른다”가 아니다. 메뉴판에서 두 개를 고르는 그림이 널리 퍼졌지만, 셋은 대등한 선택지가 아니다. P는 고르는 게 아니라 주어지는 것이다. 네트워크로 나뉜 이상 분할은 일어나고, “우리는 P를 포기했다”는 말은 “끊기면 시스템이 어떻게 되든 상관없다”는 말과 같다. 그래서 실제 선택지는 분할이 났을 때 C냐 A냐, 둘뿐이다.
평상시에는 둘 다 가진다. 분할이 없는 동안에는 최신 값을 주면서 응답도 잘 준다. CAP는 항상 무언가를 포기하라는 게 아니라 끊긴 그 순간의 행동을 미리 정해두라는 요구다. 대부분의 시간 동안 시스템은 이 정리와 상관없이 잘 돈다.
“우리는 AP다”는 시스템 전체의 성질이 아니다. 같은 시스템 안에서도 결제 승인은 확실하지 않으면 거절하고, 상품 조회는 조금 낡아도 응답하는 게 맞다. 선택은 기능 단위·화면 단위로 한다. 시스템에 딱지를 붙이면 그 순간 판단이 뭉개진다.
분할이 없을 때도 선택이 하나 더 남는다. 여러 대에 흩어진 값을 항상 최신으로 맞추려면 확인하는 왕복이 필요하고, 그만큼 느려진다. 그래서 실제 설계는 “끊겼을 때 무엇을 포기하나”뿐 아니라 “평소에 지연과 정합성 중 무엇을 더 살 것인가”까지 정한다. CAP만으로는 이 절반이 안 보인다.
이분법이 아니라 눈금이다
실무에서 실제로 결정하는 건 C와 A 중 하나를 고르는 게 아니라, 어디까지 확인하고 답할 것인가를 정하는 일이다. 그 사이에 눈금이 있다.
왼쪽으로 갈수록 확인을 많이 하니 느리고, 끊겼을 때 멈출 확률이 높다. 오른쪽으로 갈수록 빠르고 잘 버티지만 낡은 값을 본다.
중간 눈금이 실무에서 중요하다. 예를 들어 같은 사용자는 자기가 쓴 것을 항상 볼 수 있게 만드는 것만으로 체감 문제의 상당 부분이 사라진다. 전역 최신을 보장하지 않고도 그렇다. 이 아이디어는 복제에서 “내가 바꾼 것은 리더에서 읽는다”로 이미 나왔다. 저장소에서 하던 걸 서비스 단위에서 하는 것뿐이다.
결과적 정합성은 언제 맞나
오른쪽 끝의 이름이 결과적 정합성이다. 정의는 짧다. 새로운 변경이 더 들어오지 않으면, 언젠가 모든 곳이 같은 값에 도달한다.
여기서 두 가지를 정확히 봐야 한다.
“언젠가”에 기한이 없다. 정의 자체는 1초든 한 시간이든 구별하지 않는다. 그래서 결과적 정합성을 쓰기로 했다면 얼마나 늦어질 수 있는지를 따로 알아야 한다. 밀린 메시지 양과 처리 지연을 보는 이유가 이것이다.
저절로 수렴하려면 조건이 있다. 전달이 결국 되어야 하고, 받는 쪽이 중복과 순서 뒤바뀜을 견뎌야 한다. 앞 편의 멱등성과 순서 이야기가 여기서 회수된다. 그 장치가 없으면 수렴하는 게 아니라 영구히 어긋난다.
결과적 정합성은 “대충 맞으면 된다”가 아니다. 맞을 때까지의 시간과 그동안의 화면을 설계한 상태를 말한다. 그게 없으면 그냥 데이터가 틀린 시스템이다.
어긋나 있는 동안 사용자가 보는 것
기술보다 여기가 실제로 어렵다. 데이터가 잠깐 다른 건 참을 수 있어도, 사람이 고장이라고 판단하면 참을 수 없는 게 된다.
| 상황 | 나쁜 화면 | 나은 화면 |
|---|---|---|
| 반영이 아직 안 됨 | 조회하면 없음 | ”처리 중”으로 표시 |
| 내가 방금 한 일 | 목록에 안 보임 | 내 것만은 즉시 보이게 |
| 있다가 사라짐 | 새로고침마다 바뀜 | 같은 사용자는 같은 쪽을 보게 |
| 확정이 필요한 일 | ”완료”라고 먼저 말함 | ”접수됨” 후 확정 알림 |
특히 마지막 줄이 중요하다. 아직 확정 안 된 걸 확정이라고 말하지 않는 것만 지켜도 대부분의 신고가 사라진다. 결제 승인 전에 “결제 완료”를 띄우면, 나중에 실패했을 때 되돌릴 방법이 화면에 없다.
어긋난 걸 누가 되돌리나
트랜잭션이 없다는 건 롤백이 없다는 뜻이다. 주문은 저장됐는데 결제가 실패하면 주문을 지워줄 사람이 없다. 코드가 해야 한다.
되돌리는 방식은 둘로 갈린다.
앞에서 막는다. 실패하면 곤란한 자원은 미리 잡아둔다. 재고를 먼저 예약해 두고 확정 시 확정 처리하는 식이다. 실패해도 예약만 풀면 되니 되돌릴 게 적다.
뒤에서 보정한다. 이미 벌어진 일을 상쇄하는 작업을 실행한다. 적립금을 줬으면 회수하고, 배송 요청을 넣었으면 취소한다.
보정에는 다루기 까다로운 점이 있다. 되돌리는 작업 자체도 실패할 수 있고, 그러면 다시 시도해야 하고, 그동안 어긋난 상태가 유지된다. 그래서 보정 작업도 여러 번 실행해도 되게 만들고, 끝내 안 되는 건 사람이 볼 수 있게 남긴다.
무엇을 어느 쪽으로 다룰지는 어긋났을 때의 비용으로 정한다. 통계가 몇 분 늦는 건 저절로 수렴하게 두면 된다. 초과 판매나 이중 결제는 수렴을 기다리는 동안 환불·응대가 발생하는 업무 사고다. 이런 건 앞에서 막는다.
저장소에서 이미 본 이야기다
여기까지 온 결론들은 낯선 게 아니다. 데이터베이스 쪽에서 같은 일이 먼저 벌어졌다.
- 샤딩은 데이터를 여러 대로 나눈 순간 한 트랜잭션과 조인을 잃었다. 서비스를 나눈 것도 같은 일이다
- 복제는 여러 대에 같은 데이터를 두자 방금 쓴 걸 못 읽는 상태가 생겼다. 이번 편 맨 앞의 “두 서비스의 숫자가 다르다”와 뿌리가 같다
- 그리고 해법도 같은 모양이었다. DB가 지켜주던 게 사라진 게 아니라 사람에게 넘어온 것이다
다른 점은 하나다. 복제는 가만히 둬도 팔로워가 리더를 따라잡는다. 서비스를 나눈 쪽은 누군가 이어서 처리해야 수렴한다. 그래서 앞 편들에서 만든 장치들, 그러니까 이벤트를 안 잃게 기록하고, 실패를 재시도하고, 끝내 안 되는 건 사람이 보게 남기는 일이 정합성의 부품이 된다.
무엇을 즉시 맞추고 무엇을 나중에 맞출 것인가
결국 나눈 시스템을 설계하는 일은 이 한 줄로 압축된다. 판단은 대략 이렇게 한다.
즉시 맞춰야 하는 것. 어긋난 상태가 업무 사고가 되는 것. 결제 금액과 승인, 재고와 판매 수량처럼 돈과 물건이 걸린 자리다. 이런 건 애초에 같은 서비스·같은 트랜잭션 안에 두는 게 최선이다. 나눠 놓고 정합성을 코드로 지키는 것보다, 안 나누는 쪽이 훨씬 싸다.
나중에 맞춰도 되는 것. 통계, 추천, 검색 색인, 알림. 늦어져도 업무가 안 깨지고 저절로 수렴한다. 여기는 나누는 이득이 크다.
그래서 서비스 경계를 정하는 질문이 Aggregate의 그 질문으로 되돌아온다. “이 둘이 항상 동시에 맞아야 하는가.” 그렇다면 가르지 않는 게 낫다. 가르는 순간 그 정합성은 DB가 아니라 우리가 지켜야 하고, 지키는 코드는 절대 공짜가 아니다.
정리
| 나누면 | 한 트랜잭션이 사라진다. 어긋난 시간이 반드시 존재한다 |
| 분할이란 | 서로 연락이 안 되는 상태. 끊긴 것과 느린 것은 구별되지 않는다 |
| CAP의 실제 내용 | 분할이 났을 때 틀릴 수 있는 답을 줄지, 답을 거절할지 |
| 오해 하나 | 셋 중 둘이 아니다. P는 주어지는 것이고 선택은 C냐 A냐 |
| 오해 둘 | 평상시에는 둘 다 가진다 |
| 오해 셋 | 시스템 전체의 딱지가 아니라 기능·화면 단위 결정 |
| 실무의 형태 | 이분법이 아니라 눈금. 중간에 “내가 쓴 건 보인다” 같은 자리가 있다 |
| 결과적 정합성 | 수렴한다는 약속. 기한은 따로 재야 하고, 멱등·재시도가 있어야 성립한다 |
| 되돌리기 | 롤백이 없다. 앞에서 막거나 뒤에서 보정한다 |
이 시리즈는 한 덩어리에서 출발해 배포·확장·장애 단위를 갈랐고, 그 대가로 네트워크와 정합성을 떠안았다. 그래서 마지막에 남는 문장은 처음과 같다. 나누는 건 승급이 아니라 거래다. 무엇을 얻는지 말할 수 있고, 무엇을 치르는지도 말할 수 있을 때만 이득이 된다.