0장. 주문 상태 값을 하나 추가했더니 매장에 돈이 더 나갔습니다
주문팀이 환불 대기 상태를 하나 추가합니다. 주문 상태 컬럼에 값 하나가 늘었습니다.
새벽 3시에 도는 정산 배치의 조건은 이랬습니다.
WHERE status != 'CANCELED'
취소된 것만 빼고 전부 정산한다, 지금까지는 맞는 조건이었습니다.
그날 밤, 아직 환불이 끝나지 않은 주문들의 대금이 매장 계좌로 나갑니다. 다음 달 정산에서 회수해야 합니다. 매장 사장님들에게 연락을 돌려야 합니다.
주문팀은 자기 도메인에 값을 하나 늘렸을 뿐입니다. 정산팀은 조건문을 그대로 두었던 것이구요. 둘 다 맞게 일했는데 돈이 틀어졌습니다.
그러면 저 값의 뜻은 누가 정하고 있었을까요?
1장. 그 테이블은 처음엔 옳았습니다
사고의 출발점은 테이블 하나입니다. 이 배달 플랫폼이 태어날 무렵, 주문에 관한 모든 데이터는 orders 테이블 한 장에 쌓였습니다.
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT,
store_id BIGINT,
items_json TEXT,
total_amount INT,
payment_method VARCHAR(20),
payment_approved_at DATETIME,
pg_transaction_id VARCHAR(64),
cook_started_at DATETIME,
cook_done_at DATETIME,
rider_id BIGINT,
picked_up_at DATETIME,
delivered_at DATETIME,
store_fee_rate DECIMAL(5,2),
delivery_fee INT,
vat_amount INT,
settlement_batch_id BIGINT,
settled_at DATETIME,
status VARCHAR(40),
...
);
이런 성격의 컬럼이 마흔 개쯤 되는 테이블이었습니다.
서비스 초기에는 이러한 구조가 대체로 맞을 겁니다.
주문의 처음부터 끝까지가 한 곳에 있으니 조인할 일이 없는 건 큰 장점이죠. 결제와 재고와 정산을 트랜잭션 하나로 묶을 수 있으니, 셋 중 하나만 반영된 상태가 남지 않습니다. 신규 기능은 컬럼 하나를 더하면 끝납니다.
"Almost all the successful microservice stories have started with a monolith that got too big and was broken up"
성공한 마이크로서비스 이야기는 거의 전부 너무 커진 모놀리스를 쪼갠 데서 시작했다.
- Martin Fowler, "MonolithFirst"
마틴 파울러의 통찰처럼, 처음부터 완벽하게 쪼개진 시스템으로 성공한 사례는 드뭅니다. 거대한 단일 시스템으로 생존을 증명한 뒤에야 비로소 아키텍처를 찢을 자격이 주어집니다.
데이터베이스 스키마도 같은 길을 걷습니다. 사업이 살아남을지조차 모르는 시기에는 모든 데이터를 한 덩어리로 묶어두는 편이 가장 빠르고 안전합니다. 저 거대한 orders 테이블은 설계의 실패가 아닙니다. 그 시절 비즈니스가 요구했던 가장 완벽한 정답이었습니다.
문제는 이 테이블이 잘못 설계되어서 생기지 않습니다. 회사가 커지면서 이 테이블을 쓰는 팀이 하나에서 넷으로 늘어났을 때 비로소 비극이 시작됩니다.

문제는 이 테이블이 잘못 설계되어서 생기지 않습니다. 회사가 커지면서 이 테이블을 쓰는 팀이 하나에서 넷으로 늘어났을 때 생깁니다.
청구서는 사고 말고도 날아옵니다
0장의 사고는 눈에 띄는 한 건이었을 뿐입니다. 점점 커지는 문제는 청구서로서 조금씩 커집니다.
정산팀이 보류 사유를 적을 문자열 컬럼 하나를 추가하는 데 2주가 걸립니다. 요즘 MySQL이면 컬럼 하나 붙이는 일 자체는 메타데이터만 바꾸고 끝납니다. 2주가 걸리는 이유는 데이터베이스 문제가 아닙니다. 데이터가 들어갈 곳이 플랫폼의 심장인 주문 테이블이라, 그 변경은 주문팀의 리뷰와 배포 창을 거쳐야 하기 때문입니다. 정산팀에게 필요한 것은 메모장 한 칸인데, 그 한 칸을 얻으려면 남의 배포 일정에 줄을 서야 합니다.
주문팀이 레거시를 정리하며 컬럼 이름 하나를 바꾸자 새벽 정산 배치가 에러를 뱉고 죽습니다. 정산 배치가 그 컬럼을 직접 읽고 있다는 사실은 어디에도 적혀 있지 않았습니다. 읽기 권한이 모두에게 열려 있으니 누구나 남의 데이터를 직접 찌를 수 있었고, 그렇게 생긴 의존은 문서에 남지 않습니다.
저녁 7시가 되면 매장 사장님들이 새 주문을 확인하려고 앱을 새로고침합니다. 주문 테이블의 조회 부하가 치솟고, 같은 테이블을 훑어야 하는 정산 조회도 같이 느려집니다. 나누고 싶은데 나눌 선이 없습니다.
세 가지 청구서는 모양이 다르지만 청구 사유는 하나입니다. 바로 네 팀이 하나의 물건을 공유하고 있다 는 사실이죠.
그리고 이 물건은 줄어들지 않습니다. 새 요구가 들어오면 컬럼이 하나 붙는데, 붙은 컬럼을 누가 쓰는지는 아무도 확신하지 못하니 지우지도 못합니다. settlement_batch_id를 지워도 되는지 알아내려면 네 팀의 코드를 전부 뒤져야 하고, 그 조사 비용은 컬럼 하나를 그냥 남겨두는 비용보다 항상 큽니다. 그래서 남깁니다. 마흔 개는 그렇게 만들어집니다.
그런데 그럼 생각해 봅시다.
공유가 문제의 전부라면 테이블을 넷으로 복사하면 끝날 문제일까요?
복사본 넷은 곧 서로 어긋나기 시작합니다. 같은 컬럼이 팀마다 다른 뜻이라 어느 값을 정답으로 삼을지 정할 수가 없기 때문입니다. 따라서 원인은 테이블 자체가 아닌, 다른 데서 찾아야 할 것 같습니다.
같은 단어가 네 곳에서 다른 물건입니다
원인은 단어에 있습니다. 이 플랫폼에서 주문이라는 한 단어를 네 부서가 서로 다른 뜻으로 쓰고 있습니다.
| 누구에게 | 주문이란 | 관심 없는 것 |
|---|---|---|
| 손님 | 장바구니와 결제 수단과 결제 상태 | 조리가 어디까지 됐는지 |
| 매장 | 조리해야 할 항목과 수량과 요청사항 | 누가 어떤 카드로 결제했는지 |
| 라이더 | 픽업 지점과 도착 지점과 시각 | 음식이 무엇인지 |
| 정산 | 주문 금액과 수수료율과 배달비와 부가세 | 손님이 무엇을 시켰는지 |

손님에게 주문은 돈을 내는 행위입니다. 라이더에게 주문은 출발지에서 도착지로 가는 동선입니다. 정산팀에게 주문은 수수료와 부가세를 계산할 회계 항목입니다. 세 사람이 회의실에서 "그 주문 어떻게 됐어요"라고 물으면 셋 다 다른 것을 묻고 있습니다.
매장도 같습니다. 손님에게 매장은 메뉴판 사진과 영업시간이고, 정산팀에게 매장은 사업자등록번호와 입금 계좌입니다.
"These subtle polysemes could be smoothed over in conversation but not in the precise world of computers."
이런 미묘한 다의어들은 대화에서는 얼버무리고 넘어갈 수 있지만 컴퓨터의 정밀한 세계에서는 그럴 수 없다.
- Martin Fowler, "BoundedContext"
파울러는 젊은 시절 전기 회사에서 meter라는 단어가 부서마다 다른 뜻이던 경험을 이 글에 적었습니다. 전력망과 위치의 연결점인지, 전력망과 고객의 연결인지, 고장 나면 갈아 끼우는 물리적 계량기인지. 사람은 회의에서 이 차이를 문맥으로 알아서 걸러냅니다. 스키마는 걸러내지 못합니다. 컬럼 하나에는 뜻이 하나만 들어가니 말이죠.
status 컬럼 하나에 네 개의 축이 들어 있습니다
주문이 네 개의 물건이면 주문의 상태도 네 개일 것입니다. 그 넷이 컬럼 하나에 들어가 있습니다.
처음에는 얌전했습니다. PAID, COOKING, DELIVERED가 시간 순서대로 들어갔습니다. 주문 하나의 생애가 한 줄로 흘러갔으니 컬럼 하나로 충분했습니다.
현실은 한 줄로 흐르지 않습니다. 주방에서 닭을 튀기고 있는데(COOKING) 손님이 전화로 취소하는 상황을 봅시다. 이 주문의 상태 값은 무엇이 되어야 할까요?

결제 기준으로는 돈을 돌려줘야 하니 CANCELED입니다. 주방 기준으로는 이미 재료를 썼으니 WASTED입니다. 정산 기준으로는 이 손실을 매장이 부담할지 회사가 부담할지 정해야 하는 보류 상태입니다. 셋 다 맞는 답인데 컬럼은 하나입니다.
그래서 값이 늘어납니다. COOKING_CANCELED가 생기고, 그 다음에는 COOKING_CANCELED_STORE_FAULT가 생깁니다. 결제 축과 조리 축과 배달 축과 정산 축이 하나의 문자열 컬럼 안에서 곱해집니다. 앞의 표에서 손님과 매장과 라이더와 정산이 각자 보던 그 넷입니다. 결제 축만 해도 미결제와 결제됨과 취소와 환불 대기가 있고, 나머지 셋도 그만큼씩 있습니다. 축이 넷이고 축마다 값이 넷이면 산술적으로 가능한 조합은 이백쉰여섯입니다. 미결제인데 배달 완료 같은 조합은 빼야 하니 실제로 일어날 수 있는 것은 그보다 적습니다. 그래도 지금 코드가 알고 있는 값 목록보다는 훨씬 많습니다. 어느 조합이 빠져 있는지 아무도 모릅니다.
앞서 0장에서 환불 대기 상태를 하나 추가했더니 정산 금액이 틀어져버린 사고를 봤습니다. 바로 이 지점이 단일 컬럼에 억지로 우겨넣은 문맥이 조용히 붕괴하는 순간입니다.

주문팀은 결제 축에 환불 대기(REFUND_PENDING)라는 값을 하나 더했습니다. 정산팀의 조건문은 status != 'CANCELED'였습니다. 이 조건문은 결제 축을 읽으려고 쓴 것이 아닙니다. 정산해도 되는 주문인가를 묻고 있었습니다. 두 팀이 같은 컬럼에 서로 다른 질문을 걸어두었고, 한쪽이 자기 질문의 답을 하나 늘리자 다른 쪽 질문의 답이 조용히 바뀌었습니다.
이 사고에 에러 로그가 없는 이유가 여기 있습니다. 문법도 맞고 타입도 맞고 쿼리도 성공합니다. 틀린 것은 뜻뿐입니다.
"total unification of the domain model for a large system will not be feasible or cost-effective"
큰 시스템에서 도메인 모델을 완전히 통일하는 것은 실현 가능하지도 비용 효율적이지도 않다.
- Martin Fowler, "BoundedContext"에 인용된 Eric Evans
따라서 여기서 한가지를 정리해볼 수 있겠습니다.
경계를 긋는 일은 테이블을 쪼개는 일이기 이전에 단어를 포기하는 일이다.
네 부서의 현실을 주문이라는 한 단어와 status라는 한 컬럼에 담으려는 시도를 그만두는 순간이 선을 그어야 할 순간입니다. 통일을 유지하는 비용이 통일을 포기하는 비용을 넘어선 때입니다.
그러면 선을 어디에 그어야 할까요?
2장. 그럼 어디서 자를 것인가
"도메인별로 나누세요"는 답이 아닙니다. 어느 것이 도메인인지가 지금 논쟁 중이기 때문입니다. 회의실에서 판정할 수 있는 규칙이 필요합니다.
명사로 자르면 이름만 바뀐 테이블이 하나 더 생깁니다
사람들은 경계를 명사에서 찾습니다. 주문, 손님, 상품, 매장. 화이트보드에 명사를 적고 네모를 그리면 설계가 끝난 것처럼 보입니다.
그렇게 하면 주문 서비스가 생기고, 주문이라는 이름이 붙은 데이터가 전부 그 안으로 들어갑니다. 결제 승인 시각도 주문이고 조리 완료 시각도 주문이고 수수료율도 주문입니다. 마흔 개짜리 테이블이 이름표만 바꿔 달고 이사합니다. 서버가 하나 늘었고 배포가 하나 늘었고 해결된 것은 없습니다.

"splitting up into services organized around business capability"
비즈니스 역량을 중심으로 조직된 서비스로 나눈다.
- James Lewis & Martin Fowler, "Microservices"
역량은 명사가 아니라 하는 일입니다. 주문을 접수한다와 주문을 정산한다는 같은 명사를 쓰지만 다른 일이란 말이죠.
| 주문을 접수한다 | 주문을 정산한다 | |
|---|---|---|
| 무엇이 시작시키나 | 손님이 결제 버튼을 누른다 | 새벽 3시에 배치가 돈다 |
| 언제 하나 | 즉시, 초당 수천 건 | 하루 한 번, 몰아서 |
| 실패하면 | 손님이 주문을 못 한다 | 다음 날 다시 돌리면 된다 |
| 얼마나 정확해야 하나 | 재고와 어긋나지 않아야 한다 | 1원도 틀리지 않아야 한다 |
네 줄이 전부 다릅니다. 트리거가 다르고 주기가 다르고 실패의 무게가 다르고 요구되는 정확도가 다릅니다. 명사가 같다는 이유로 한 덩어리로 묶여 있었을 뿐입니다.
자르는 선은 "같이 실패해야 하는가"로 정합니다
동작으로 보기 시작하면 다음 질문으로 넘어갈 수 있습니다. 손님이 결제 버튼을 누를 때 벌어지는 세 가지를 봅시다.
- 결제 승인
- 재고 차감
- 매장에 새 주문 알림
고민이 됩니다. 이 셋을 한 트랜잭션으로 묶을 것인가.
용어를 하나 정리하고 가겠습니다. 결제 승인은 외부 결제사를 부르는 일이라 데이터베이스 트랜잭션에 넣어서는 안 됩니다. 넣는 것 자체는 되고, 그래서 실제로 많이 넣습니다. 다만 결제사는 이미 승인을 마쳤으므로 우리 데이터베이스를 되돌려도 카드 승인은 되돌아오지 않습니다. 여기서 묻는 것은 코드를 어떻게 짜느냐가 아니라 결과가 같이 확정되어야 하는가입니다. 승인 결과를 우리 쪽에 기록하는 일과 재고를 줄이는 일이 같이 성공하거나 같이 실패해야 하느냐는 뜻입니다.

묶으면 이렇게 됩니다. 매장 알림 서버가 잠깐 죽어 있을 때 손님의 결제가 롤백됩니다. 카드 승인은 결제사에 그대로 남는데 우리 쪽 주문 기록만 사라지고, 손님은 빠져나간 결제 금액과 "주문에 실패했습니다"를 동시에 보게 됩니다. 알림 서버의 장애가 결제 장애가 됐습니다.
안 묶으면 이렇게 됩니다. 결제는 끝났는데 매장 태블릿에 주문이 안 떴습니다. 손님은 음식을 기다리고 매장은 주문이 온 줄 모릅니다.
둘 다 사고처럼 보이지만 무게가 다릅니다. 앞쪽은 정상적인 요청이 남의 장애 때문에 실패합니다. 뒤쪽은 정상적으로 성공한 요청의 후속 처리가 늦어질 뿐이고, 알림 서버가 살아나면 저절로 해소됩니다. 되돌릴 필요조차 없습니다.
"Transactions should not cross aggregate boundaries."
트랜잭션은 애그리거트 경계를 넘지 않아야 한다.
- Martin Fowler, "DDD_Aggregate"
인용문의 애그리거트(Aggregate)는 도메인 주도 설계(DDD)에서 항상 한 덩어리로 저장되고 한 덩어리로 롤백되는 데이터 묶음을 가리킵니다. '주문서'와 '주문 항목'처럼 비즈니스 규칙상 절대로 쪼개질 수 없는 객체들의 집합이자, 시스템이 데이터의 무결성을 지켜내는 최소 방어선입니다.
이 문장을 뒤집어 읽으면 다음과 같은 판정 규칙을 도출해볼 수 있을 것 같네요. 트랜잭션이 넘으면 안 되는 곳이 경계라면, 반드시 같이 성공하거나 같이 실패해야 하는 것들의 바깥쪽이 경계입니다.
승인 결과 기록과 재고 차감은 같이 실패해야 합니다. 돈은 받았는데 재고는 그대로인 상태를 사업이 감당할 수 없기 때문입니다. 매장 알림은 다릅니다. 30초 늦게 도착해도 사업은 굴러갑니다.
"Any references from outside the aggregate should only go to the aggregate root."
애그리거트 바깥에서 오는 모든 참조는 애그리거트 루트로만 가야 한다.
- Martin Fowler, "DDD_Aggregate"
바깥에서 안쪽 부품을 직접 꺼내 쓰면 안 된다는 뜻입니다. 운전자가 엔진 내부의 밸브를 직접 열고 닫지 않고 엑셀러레이터라는 단일 접점만 밟는 것과 같습니다. 외부 시스템은 무조건 '주문'이라는 대표 객체(Root)하고만 대화해야 하며, 그 안의 세부 속성을 들여다보려 해서는 안 됩니다.
정산팀이 주문 하위의 pg_transaction_id를 직접 읽어 가면, 주문팀은 그 컬럼을 영원히 바꿀 수 없습니다. 안쪽에 어떤 부품이 있는지는 오직 주인만 알아야 합니다.
제가 이 규칙을 좋아하는 이유는 회의실에서 판정할 수 있기 때문입니다. "이건 주문 도메인 같은데요"는 취향 싸움이 되지만, "결제가 실패했는데 알림만 성공하는게 유저 경험에 좋나요?"와 같은 질문은 비즈니스의 문제죠.
주문 한 건에 딸린 여섯 가지 동작에 대봅니다
규칙은 대봐야 규칙입니다. 손님이 결제 버튼을 누른 뒤 이 플랫폼에서 실제로 벌어지는 일들을 전부 늘어놓고 같은 질문을 던져봅니다.
| 동작 | 결제와 같이 실패해야 하나 | 왜 | 판정 |
|---|---|---|---|
| 재고 차감 | 예 | 돈만 받고 재고가 안 줄면 이중 판매가 납니다 | 안쪽 |
| 쿠폰 사용 처리 | 예 | 결제가 취소됐는데 쿠폰만 소멸되면 되돌릴 방법이 없습니다 | 안쪽 |
| 매장 알림 | 아니오 | 30초 늦어도 됩니다 | 바깥 |
| 라이더 배차 | 아니오 | 조리가 끝나갈 때 잡아도 됩니다 | 바깥 |
| 포인트 적립 | 아니오 | 늦게 쌓여도 되고 취소되면 회수하면 됩니다 | 바깥 |
| 정산 항목 예약 | 아니오 | 새벽 배치가 만들어도 됩니다 | 바깥 |
여섯 중 둘이 안쪽이고 넷이 바깥입니다. 승인 결과 기록까지 더한 셋을 한 트랜잭션으로 묶고, 나머지 넷은 사건으로 알립니다. 이것이 이 시스템의 첫 번째 선입니다.
표를 채우다 보면 "아니오"의 근거가 전부 같은 모양이라는 것이 보입니다. 늦어도 되거나, 늦어서 생긴 것을 되돌릴 수 있거나. 포인트 적립을 밖에 둘 수 있는 이유는 회수 절차가 있기 때문입니다. 회수 절차가 없으면 밖에 두지 못합니다.
이 관찰은 4장에서 다시 볼 예정입니다.
3장. 자르는 순간 하나의 진실을 잃습니다
선을 정했으니 이제 물리적으로 나눠 봅시다. 주문의 데이터베이스와 정산의 데이터베이스가 갈라집니다.
-- 주문팀 데이터베이스
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT,
store_id BIGINT,
total_amount INT,
order_status VARCHAR(40) -- PAID, COOKING, DELIVERED, CANCELED, REFUND_PENDING
);
-- 정산팀 데이터베이스
CREATE TABLE settlements (
order_id BIGINT PRIMARY KEY,
store_id BIGINT,
settle_amount INT,
settlement_status VARCHAR(20) -- HOLD, READY, COMPLETED, CANCELED
);
컬럼 마흔 개짜리 테이블이 사라졌습니다. 드디어, order_status와 settlement_status는 각자의 생애를 살게 되었습니다! 주문팀이 환불 대기 값을 하나 더해도 정산팀의 컬럼에는 아무 일도 생기지 않습니다. 0장처럼 스키마 하나가 네 팀의 뜻을 동시에 바꾸는 일은 이 구조에서 사라집니다.
"Microservices prefer letting each service manage its own database, either different instances of the same database technology, or entirely different database systems - an approach called Polyglot Persistence."
마이크로서비스는 각 서비스가 자체 데이터베이스를 관리하도록 하는 것을 선호한다. 같은 데이터베이스 기술의 다른 인스턴스일 수도 있고 완전히 다른 데이터베이스 시스템일 수도 있는데, 이런 접근을 다중 저장소 지속성이라고 부른다.
- James Lewis & Martin Fowler, "Microservices"
대신 같은 위험이 사건 계약으로 옮겨갑니다.
눈치채신 분도 있을 것입니다. order_status에는 아직 PAID와 COOKING과 DELIVERED가 같이 들어 있습니다. 1장에서 문제라고 했던 축 섞임이 그대로입니다. 맞습니다. 여기서 그은 것은 첫 번째 선 하나뿐이고, 한 번에 다 자르지 않는 이유는 7장에서 다시 다루겠습니다.
그리고 정산팀은 환불이 대기 중이라는 사실을 이제 누군가에게 들어야 합니다. 그 방법과, 사건 계약으로 옮겨간 위험이 어떤 모양인지는 4장에서 다루겠습니다.
경계는 문서로 지켜지지 않습니다
여기서 많은 조직이 한 번쯤은 실수하는 부분이 있습니다. 스키마는 갈랐는데 접속 계정은 그대로 두는 것입니다.
정산 배치를 짜는 개발자가 월말에 급합니다. 매장 이름이 필요한데 정산 데이터베이스에는 store_id만 있습니다. API를 하나 뚫어달
라고 요청하면 다음 스프린트입니다. 그런데 주문 데이터베이스의 접속 계정을 그대로 갖고 있고 조인 한 줄이면 끝납니다.
그 한 줄이 들어가는 순간 정산은 주문의 스키마에 묶입니다. 그리고 아무도 이 사실을 문서에 적지 않습니다. 여섯 달 뒤 주문팀이 그 컬럼을 정리하면 새벽 배치가 죽습니다. 1장에서 본 그 사고가 경계를 그은 뒤에도 똑같이 반복됩니다.
"Without some kind of barrier to enforce encapsulation, developers will always be tempted to bypass a service's API and access it's data directly."
캡슐화를 강제하는 어떤 장벽이 없으면, 개발자들은 언제나 서비스의 API를 우회해서 데이터에 직접 접근하려는 유혹을 받는다.
- microservices.io, "Database per service"

해법은 규율이 아니라 계정입니다.
"You could, for example, assign a different database user id to each service and use a database access control mechanism such as grants."
예를 들어 서비스마다 다른 데이터베이스 사용자 계정을 배정하고 grant 같은 데이터베이스 접근 제어 수단을 쓸 수 있다.
- microservices.io, "Database per service"
-- 정산 서비스 계정은 정산 스키마만 볼 수 있습니다
CREATE USER 'settlement_svc'@'%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON settlement_db.* TO 'settlement_svc'@'%';
-- 주문 스키마에 대한 권한은 부여하지 않습니다
이렇게 해두면 위의 조인 한 줄은 코드 리뷰에서 걸리는 대신 실행 자체가 실패합니다. 급한 개발자가 유혹을 이겨낼 필요가 없어집니다.
새 계정을 만드는 것보다 중요한 일이 하나 더 있습니다. 예전 공용 계정을 회수하는 것입니다. 회수하지 않으면 새 계정은 그냥 하나 늘어난 선택지일 뿐입니다.
문서는 "들어가지 마시오"라고 적어둔 팻말이고, GRANT는 잠긴 문입니다. 급한 사람은 팻말을 못 본 척할 수 있지만 잠긴 문은 못 본 척할 수가 없죠.
팀에 새 경계를 도입할 때 문서만큼이나 GRANT 문이 같이 갱신되어야 할 것입니다.
자른 뒤에는 ID만 건네줍니다
그런데 권한을 닫아놓으면 이어지는 또 다른 문제가 있습니다. 정산 화면에 매장 이름을 띄워야 하는데 정산 데이터베이스에는 store_id밖에 없습니다. 매장의 상호와 사업자 정보는 매장 쪽이 갖고 있습니다.
기본은 이렇습니다. 경계를 넘어가는 건 식별자뿐이다. 정산은 주문 ID와 매장 ID를 갖고, 결제 수단이나 손님 연락처는 갖지 않습니다. 안 쓰는 것을 갖고 있으면 안 쓰는 것에까지 묶이게 되니까요.
그러면 이름처럼 실제로 필요한 값은 어떻게 가져와야 할까요?
두 가지 길이 있습니다.

물어보는 방법. 화면을 그릴 때마다 값의 주인에게 API로 묻습니다. 매장 이름이면 매장 쪽입니다. 항상 최신값을 받습니다. 대신 상대가 느려지면 정산 화면도 같이 느려집니다. 1장에서 본 조회 경합이 데이터베이스에서 네트워크로 옮겨갔을 뿐입니다.
복제하는 방법. 값이 확정되는 순간 사건으로 받아서 내 테이블에 적어둡니다. 주문 서비스가 통째로 죽어 있어도 정산 화면은 뜹니다. 대신 그 값은 낡을 수 있습니다.
그러면 어느 쪽을 골라야 할까요? 이렇게 갈라 놓으면 되지 않을까요?
확정된 뒤에 바뀌지 않는 값은 복제하고, 계속 바뀌는 값은 물어본다.
배달 완료 시각은 한 번 정해지면 영원히 그 값입니다. 결제 승인 시각도, 손님이 낸 주문 금액도 마찬가지입니다. 이런 값은 복제해도 낡지 않으니 복제합니다. 반대로 매장의 현재 영업 상태나 손님의 등급은 계속 움직이니 물어봅니다. 복제해두면 틀린 값을 보여주게 됩니다.
-- 이 장 앞부분의 정산 테이블에 사본 셋을 더한 모습입니다
CREATE TABLE settlements (
order_id BIGINT PRIMARY KEY,
store_id BIGINT, -- 식별자
store_name VARCHAR(100), -- 사본. 매장명 변경 사건으로 갱신
order_amount INT, -- 사본. 확정 뒤 안 바뀜
delivered_at DATETIME, -- 사본. 확정 뒤 안 바뀜
settle_amount INT, -- 정산팀 소유. 확정 시점에 계산해 적음
settlement_status VARCHAR(20) -- 정산팀 소유
);
매장 이름처럼 애매한 값이 있습니다. 바뀌긴 하는데 아주 드뭅니다. 이런 값은 복제해두고 변경 사건이 왔을 때만 갱신합니다. 정정 사건이 오지 않는 값은 복제하면 안 됩니다.
한 테이블에서 같은 데이터를 두 곳에 적으면 중복이고, 경계를 그은 뒤에 두 곳에 적는 것은 설계입니다. 차이는 셋입니다.
고칠 수 있는 곳이 한 곳뿐인가.
얼마나 낡아도 되는지 정해져 있는가.
어긋났을 때 다시 맞추는 경로가 있는가.
셋이 서 있으면 사본이고, 하나라도 없으면 그냥 중복이죠.
그리고 무엇을 잃었는지 정확히 적어둡니다
여기까지가 좋은 소식입니다. 나쁜 소식은 이 분리에 값이 붙는다는 것입니다.
테이블이 하나이던 시절에는 트랜잭션 하나로 모든 상태를 동시에 바꿨습니다. 주문이 취소되면 정산 항목도 같은 순간에 되돌아갔습니다. 데이터베이스가 커밋을 성공시킨 뒤로, 한 번의 변경이 절반만 반영된 상태는 존재하지 않았습니다.
이제는 존재합니다. 주문 데이터베이스에서는 취소된 주문이 정산 데이터베이스에서는 아직 정산 대기입니다. 몇 밀리초일 수도 있고 정산 서버가 재기동 중이면 몇 분일 수도 있습니다. 그동안 두 화면은 서로 다른 답을 보여줍니다.
잃은 것이 하나 더 있습니다. 질문에 답할 곳이 사라집니다. "이번 달 정산 금액 상위 매장의 주문 취소율은?"이라는 물음은 예전에는 조인 한 줄이었습니다. 이제 정산 금액은 정산 쪽에 있고 취소 여부는 주문 쪽에 있어서, 어느 데이터베이스도 혼자서는 답하지 못합니다.
한 문장으로 적어 보겠습니다.
경계를 그으면 모두가 같은 답을 보는 것을 포기하고, 대신 팀마다 자기 속도로 움직인다.
예전에는 어느 화면을 열어도 답이 하나였습니다. 이제는 두 화면이 잠깐 서로 다른 말을 합니다. 그 어긋남을 받아들이는 대신, 두 팀이 서로의 배포를 기다리지 않아도 되게 된 겁니다.
문제는 선을 그어놓고도 포기한 쪽을 계속 붙잡고 싶어 한다는 데 있습니다.
4장. 나눠놓고 다시 하나로 묶는 실수
데이터베이스는 갈랐는데 머릿속에는 단일 트랜잭션이 남아 있습니다. 그래서 취소 로직을 이렇게 짭니다.
@Transactional
public void cancelOrder(Long orderId) {
// 1. 주문 상태를 취소로 바꿉니다.
orderRepository.updateStatus(orderId, "CANCELED");
// 2. 정산 서버를 호출해 정산도 취소시킵니다.
settlementApiClient.cancel(orderId);
}
의도는 나무랄 데가 없습니다. 주문이 취소되면 정산도 멈춰야 합니다. 데이터베이스는 갈라졌지만 두 상태가 0.1초라도 어긋나는 것을 견딜 수 없어서, 트랜잭션 안에 남의 서버 호출을 넣었습니다.
금요일 저녁에 정산 서버가 느려집니다. settlementApiClient.cancel()이 타임아웃으로 예외를 던집니다. 이 예외는 런타임 예외라서 프레임워크가 기본 설정 그대로 트랜잭션을 되돌리고, 1번의 주문 취소까지 같이 사라집니다.
이제 손님은 주문을 취소할 수 없습니다. 취소 버튼을 누르면 정산 서버 이야기가 담긴 에러 화면을 봅니다. 정산팀 서버가 아프면 주문 서비스도 같이 눕습니다.
타임아웃은 상대가 그 일을 했는지 안 했는지를 알려주지 않습니다. 정산은 이미 취소를 반영했는데 주문 쪽만 되돌아가면, 두 시스템이 정반대의 사실을 각자 참이라고 믿습니다.
"Any service call could fail due to unavailability of the supplier, the client has to respond to this as gracefully as possible."
어떤 서비스 호출이든 공급자의 가용성 문제로 실패할 수 있으며, 클라이언트는 이에 최대한 우아하게 대응해야 한다.
- James Lewis & Martin Fowler, "Microservices"

데이터베이스는 쪼갰는데 결합도는 통합 테이블 시절과 같습니다. 정확히는 더 나빠졌습니다. 예전에는 같은 프로세스 안의 안정적인 호출이던 것이 이제는 네트워크를 건너가는 호출이 됐고, 네트워크는 언제든 끊깁니다. 이 상태를 분산 모놀리스라고 부릅니다. 분리의 값은 전부 치렀는데 분리의 이득은 하나도 못 받은 구조입니다.
명령을 보내지 말고 벌어진 일을 알립니다
그렇다면 어떻게 해야할까요?
엄밀히 말해 남의 시스템에 지시를 내렸다는 사실 자체가 문제는 아닙니다. 진짜 문제는 내 트랜잭션의 운명을 남의 네트워크 상태에 내맡긴 채 실시간으로 대답을 기다렸다는 것입니다.
내 데이터베이스의 문을 닫기(커밋) 전에, 저 멀리 있는 정산 서버가 "취소 완료했습니다"라고 확인해주기를 동기적으로 기다린 것이 패착입니다. 내가 성공하기 위해 상대방의 성공이 그 즉시 필요하다면, 이 둘은 물리적으로만 쪼개져 있을 뿐 논리적으로는 여전히 끈적하게 묶여 있는 하나의 덩어리입니다.
따라서 대화의 방식을 바꿔야 합니다. "이것 좀 처리해 줘"라고 지시한 뒤 대답을 기다리는 대신, 내 구역에서 이미 벌어진 기정사실을 무심하게 툭 던져놓고 내 할 일을 끝내버리는 것입니다.
주문 시스템은 메시지 브로커에 한 줄을 남기고 미련 없이 트랜잭션을 닫습니다.
123번 주문이 방금 취소되었다
"The microservice community favours an alternative approach: smart endpoints and dumb pipes."
마이크로서비스 커뮤니티는 대안적인 접근 방식을 선호한다. 바로 똑똑한 엔드포인트와 멍청한 파이프다.
- James Lewis & Martin Fowler, "Microservices"
파이프는 멍청해야 합니다. 메시지를 나르기만 하고 판단하지 않습니다. 판단은 받는 쪽이 합니다.
주문팀은 이 사실을 누가 가져가서 무엇을 하든 상관하지 않습니다. 정산팀이 읽고 환불액을 계산하든, 통계팀이 이탈률을 세든, 배차팀이 라이더 호출을 거두든 주문팀의 코드는 그대로입니다. 주문팀의 책임은 자기 상태를 바꾸고 그 사실을 알리는 데서 끝납니다.
0장의 그 값도 이렇게 흘러갑니다. 주문팀이 환불 대기라는 상태를 새로 만들고 그것을 밖에 알리기로 하면 사건 타입이 하나 늘어납니다. 사건 타입은 컬럼과 달리 밖에 내놓는 계약이라, 새로 만들려면 누가 받을지를 정하는 절차가 붙습니다.
다만 이것이 저절로 되지는 않습니다. 사건 타입 목록과 그 구독자를 어딘가에 적어두고, 새 타입이 생길 때 그 목록을 들여다보게 만들어야 합니다. 0장에서 사고가 난 이유는 값이 늘어난 사실을 아무도 마주치지 않았기 때문이고, 사건으로 바꾼다고 그 마주침이 공짜로 생기지는 않습니다. 마주치도록 만드는 것까지가 경계를 긋는 일입니다.
3장에서 미뤄둔 이야기가 여기입니다. 모르는 사건 타입이 오면 소비자는 대개 조용히 무시합니다. 버리지 말고 실패시키거나 최소한 경보를 울리게 해두어야 합니다.
정산 서버가 30분 멈춰 있어도 손님의 취소는 성공합니다. 정산 서버는 다시 살아난 뒤 큐에 쌓인 사건들을 자기 속도로 처리하면 됩니다. 장애가 큐에서 멈추고 주문팀으로 넘어오지 않습니다.
다만 브로커 자체가 죽으면 취소는 여전히 실패할 것입니다. 이 부분은 다음 장에서 추가로 보겠습니다.
경계를 넘는 순간 시간이 태어납니다
여기서 아주 중요한 변화가 일어납니다. 이 변화는 "약간 느려진다" 정도로 요약되고 넘어가서는 안 된다고 생각합니다.
"Using transactions like this helps with consistency, but imposes significant temporal coupling, which is problematic across multiple services"
이런 식으로 트랜잭션을 쓰면 일관성에는 도움이 되지만, 상당한 시간적 결합을 부과하고 이는 여러 서비스에 걸쳐 있을 때 문제가 된다.
- James Lewis & Martin Fowler, "Microservices"
이 문장을 뒤집으면 훨씬 재미있는 것을 볼 수 있습니다.
같은 트랜잭션 안에는 관측 가능한 중간 단계가 없습니다. 커밋되기 전 상태는 다른 트랜잭션의 조회 결과에 나타나지 않고, 커밋된 뒤에는 새로 읽는 쪽에 한꺼번에 나타납니다. 승인 결과를 적는 일과 재고를 줄이는 일 사이에 "결제는 기록됐는데 재고는 아직"인 순간이 물리적으로는 존재합니다.
즉, 경계를 긋는 순간 그 보호막이 사라지고 "언제"가 태어납니다.

배달 예시를 들어보면, 통합 테이블 시절 주문은 결제되는 순간 매장에도 접수된 주문이었습니다. 같은 커밋 안에 있었으니까요. 이제는 결제된 시각과 매장이 그 사실을 아는 시각이 갈라집니다. 그 사이에 이전에는 없던 상태가 하나 생깁니다.
결제는 됐는데 매장이 아직 접수하지 않음.
이 상태에는 이름이 없습니다. 어느 문서에도 없고 상태 전이도에도 없습니다. 그런데 실재합니다. 그리고 손님은 그 순간 앱을 보고 있습니다.
그래서 화면에 무언가를 띄워야 합니다. "주문을 매장에 전달하고 있어요"라고 쓸 수도 있고, 로딩 표시를 돌릴 수도 있고, 아예 접수된 것처럼 보여주고 뒤에서 조용히 처리할 수도 있습니다. 셋 다 손님이 무엇을 보게 될지를 정하는 일입니다.
경계를 어디에 그을지 정하는 일이 곧 손님 화면에 무엇을 띄울지 정하는 일입니다. 다이어그램에 선을 하나 그으면 손님이 보는 화면에 상태가 하나 늘어납니다. 그 상태를 뭐라고 부를지까지 정해야 선을 다 그은 것입니다.
그 불일치는 사업이 원래 하던 일입니다
결제는 완료되었으나 매장이 아직 인지하지 못한 틈. 이 어긋난 시간을 시스템이 허용해도 될까요? 중간에 메시지가 유실되어 매장이 주문을 영영 모르게 될 위험을 안고 가는 것이 맞을까요?
답은 코드가 아니라 손님과 매장이 실제로 무엇을 잃느냐에 있습니다.
항공사는 좌석보다 많은 표를 팝니다. 호텔도 방보다 많은 예약을 받습니다. 두 사업 다 "판매된 좌석 수 = 실제 좌석 수"라는 일관성을 일부러 깨고 운영합니다. 예약한 사람이 전부 나타나는 날에는 보상금을 주고 다음 편에 태웁니다. 깨진 일관성을 되돌리는 절차를 갖춰두고, 그 절차의 비용보다 빈 좌석을 남긴 채 출발하는 손실이 더 크다고 계산했기 때문입니다.
"Often businesses handle a degree of inconsistency in order to respond quickly to demand, while having some kind of reversal process to deal with mistakes."
사업은 수요에 빠르게 대응하기 위해 어느 정도의 불일치를 감수하는 경우가 많고, 실수를 처리하기 위한 되돌리기 절차를 갖고 있다.
- James Lewis & Martin Fowler, "Microservices"
"The trade-off is worth it as long as the cost of fixing mistakes is less than the cost of lost business under greater consistency."
실수를 바로잡는 비용이 더 강한 일관성 때문에 잃는 사업의 비용보다 작다면 그 거래는 할 만하다.
- James Lewis & Martin Fowler, "Microservices"
즉, 완벽한 동기화를 기다리느라 놓치는 매출보다 엇나간 데이터를 사후에 수습하는 비용이 더 싸다면, 아키텍처는 기꺼이 일관성을 깰 수 있다는 것입니다.
이렇게 보면 분산 시스템에서 마주하는 데이터의 불일치를 무조건 막아야 할 기술적 결함으로만 볼 필요는 없습니다. 오히려 비즈니스가 멈추지 않고 유연하게 흘러가도록 돕기 위해, 우리가 현실과 타협하며 받아들인 자연스러운 결과물에 가깝습니다.
문제를 수습하는 비용 vs 완벽함을 고집하다 잃게 되는 매출
배달 주문의 상황에 대입해 볼까요? 시스템을 약간 어긋나게 두면, 가끔 매장 태블릿이 통신 장애로 꺼져 있어서 끝내 접수되지 않는 주문이 생기곤 합니다. 이때 문제를 수습하는 비용은 고객에게 결제를 취소해 드리고 정중하게 사과드리는 것 정도입니다.
반대로 결제와 매장 접수를 완벽하게 하나로 묶어서 처리한다고 생각해 봅시다. 매장 태블릿이 꺼져 있는 동안 들어오는 모든 주문은, 매장 측에서 알기도 전에 결제 단계에서부터 실패하고 튕겨버리게 됩니다. 이때 우리가 잃는 것은 그 시간대의 매출 전체입니다. 가끔 발생하는 한 건의 실수를 수습하고 되돌리는 편이 비즈니스 관점에서는 훨씬 싸게 먹힙니다. 그래서 우리는 기꺼이 시스템이 조금 어긋나는 것을 허용합니다.
이제 0장의 사고를 다시 떠올려 봅시다. 당시 정산 배치는 환불 대기 중인 주문까지 정상 건으로 판단해 매장에 돈을 잘못 입금했습니다. 하지만 정말 뼈아팠던 부분은 돈이 잘못 나간 '이후'의 상황이었습니다.
잘못된 정산을 한 번 더 걸러낼 검증 단계도 없었고, 이미 나간 돈을 시스템적으로 자동 회수하는 경로도 없었습니다. 남은 방법이라고는 사람이 매장마다 일일이 전화를 돌리며 사정하는 것뿐이었죠. 이처럼 틀어진 것을 되돌릴 안전장치가 없으면, 사소한 틈새 하나가 거대한 사고로 번지게 됩니다.
그렇다면 시스템에 불일치를 허락할 때는, 틀어진 결과를 안전하게 되돌리는 절차도 함께 준비해 두어야 합니다. 수습할 방법도 없이 데이터가 어긋나도록 내버려 두는 것은 유연한 설계라기보다 그저 상황을 방치하는 것에 가깝기 때문입니다.
5장. 같은 사건이 두 번 도착합니다
사건을 알리기로 했으니 코드는 이렇게 생겼을 것입니다.
@Transactional
public void cancelOrder(Long orderId) {
orderRepository.updateStatus(orderId, "CANCELED");
kafkaTemplate.send("order-canceled", orderId); // 이 줄이 문제입니다
}
데이터베이스에 커밋하는 일과 브로커에 메시지를 보내는 일은 서로 다른 장비에서 벌어집니다.
둘을 한꺼번에 확정시키는 방법이 아주 없지는 않습니다. 심판을 하나 세우고, 양쪽에 먼저 "확정할 준비가 됐나"를 물어본 뒤 둘 다 그렇다고 답할 때만 확정시키는 방식입니다. 두 번에 나눠 물어본다고 해서 2단계 커밋이라고 부릅니다.
다만 카프카는 이 방식을 지원하지 않습니다. 지원하는 브로커를 쓰더라도 심판이 흔들리는 순간 양쪽은 서로의 답을 기다리며 멈춰 섭니다. 멈추지 않으려고 나눈 시스템이 도로 멈추는 셈입니다.
위 코드는 메시지를 커밋 전에 내보냅니다. 메시지가 나간 직후 커밋이 실패하면 정산팀은 취소되지 않은 주문을 취소된 것으로 처리합니다. 일어나지 않은 일을 알린 셈입니다. 게다가 send()는 비동기라서 전송이 실패해도 이 줄에서 예외가 튀지 않습니다. 실패를 알아채지도 못한 채 커밋이 지나갑니다.
그러면 send를 커밋 뒤로 옮기면 될까요? 이번에는 커밋이 성공한 직후 서버가 죽는 순간이 생깁니다. 취소된 주문 하나가 아무에게도 알려지지 않은 채 남습니다.
어디에 놓아도 한쪽이 깨집니다. 카프카에서는 두 장비를 하나의 트랜잭션으로 묶을 수 없기 때문입니다.
이런 문제를 보통 '이중 쓰기(Dual Write) 문제'라고 부릅니다. 쉽게 말해, 각기 다른 두 개의 장부에 똑같은 사실을 동시에 적으려다 보니 생기는 피할 수 없는 엇박자입니다. 하나의 장부에 내용을 적고 다른 장부에 마저 적으려는 찰나의 순간에 서버가 멈추거나 네트워크가 끊어지면서 늘 사고가 나는 것이죠.
해법은 3장에서 벗어나려 했던 바로 그 로컬 트랜잭션입니다. 밖으로 쏘는 대신 내 데이터베이스 안에 같이 적으면 어떨까요?
CREATE TABLE outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
aggregate_id BIGINT NOT NULL, -- 사건의 주체가 되는 주문 ID
event_type VARCHAR(40) NOT NULL,
payload JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
BEGIN;
-- 1. 비즈니스 상태를 바꿉니다.
UPDATE orders SET order_status = 'CANCELED' WHERE id = 123;
-- 2. 벌어진 사실을 같은 DB의 다른 테이블에 적습니다.
INSERT INTO outbox (event_id, aggregate_id, event_type, payload)
VALUES ('evt_556677', 123, 'ORDER_CANCELED', '{"eventId":"evt_556677","orderId":123}');
COMMIT;
두 줄이 한 트랜잭션에 들어 있으니 둘 다 남거나 둘 다 없습니다. 잃어버릴 사건이 애초에 생기지 않습니다. 이 구조를 Transactional Outbox 패턴이라고 부릅니다.

event_id가 페이로드에도 들어 있습니다. 이 값이 실려 가야 받는 쪽이 같은 사건인지 판정할 수 있습니다.
발송은 별도 프로세스가 맡습니다. 릴레이가 outbox 테이블을 주기적으로 훑어 새 줄을 브로커로 옮기고, 전송에 성공하면 그 줄을 지웁니다. 브로커가 죽어 있으면 성공할 때까지 다시 시도합니다.
"microservice architectures emphasize transactionless coordination between services, with explicit recognition that consistency may only be eventual consistency and problems are dealt with by compensating operations."
마이크로서비스 아키텍처는 서비스 간 트랜잭션 없는 조정을 강조하며, 일관성이 결과적 일관성에 그칠 수 있고 문제는 보상 연산으로 처리된다는 점을 명시적으로 인정한다.
- James Lewis & Martin Fowler, "Microservices"
이 문장의 뒷부분을 주목해보면 좋을 것 같습니다. 보상 연산이 4장에서 말한 되돌리기 절차입니다. 두 저자는 결과적 일관성을 이야기하면서 그 짝을 같은 문장에 넣어두었습니다. 어긋남을 허용하는 설계와 어긋난 것을 되돌리는 절차는 한 벌입니다.
받는 쪽이 두 번 받아도 한 번만 처리하게 만듭니다
무한 재시도가 새 문제를 만듭니다. 릴레이가 브로커에 메시지를 보내는 데는 성공했는데, 그 사실을 지우기 직전에 죽으면 어떻게 될까요?
다시 깨어난 릴레이는 지워지지 않은 줄을 보고 같은 메시지를 또 보냅니다. 정산팀은 123번 주문의 취소를 두 번 받습니다. 상태를 덮어쓰는 처리라면 두 번째는 무해하지만, 취소 수수료를 차감하거나 환불을 실행하는 것처럼 더하고 빼는 처리가 하나라도 섞여 있으면 그만큼이 두 번 반영됩니다.
이것은 구현을 잘못해서 생기는 문제가 아닙니다. 응답이 안 왔을 때 그것이 유실인지 응답만 끊긴 것인지 보낸 쪽은 구별할 수 없고, 구별할 수 없으면 다시 보내는 편이 안전합니다. 그래서 기대할 수 있는 것은 적어도 한 번까지이고, 중복 방어는 받는 쪽 책임이 됩니다. 카프카가 내세우는 정확히 한 번은 카프카에서 읽어 카프카에 쓰는 구간에 한정된 이야기라, 환불 API 같은 외부 호출은 여기에 들어가지 않습니다.
한 번 처리한 요청을 다시 받아도 결과가 달라지지 않는 성질을 멱등성이라고 부릅니다. 구현은 데이터베이스에 맡깁니다.
CREATE TABLE processed_events (
event_id VARCHAR(64) PRIMARY KEY, -- 이 한 줄이 방어벽입니다
processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
BEGIN;
-- 1. 이 사건을 처음 보는지 기록합니다. 두 번째면 여기서 실패합니다.
INSERT INTO processed_events (event_id) VALUES ('evt_556677');
-- 2. 정산 취소를 실행합니다.
UPDATE settlements SET settlement_status = 'CANCELED' WHERE order_id = 123;
COMMIT;

핵심은 processed_events.event_id를 기본 키로 잡아둔 것입니다. 첫 메시지는 두 줄 다 커밋되고, 같은 메시지가 다시 오면 첫 줄에서 중복 키 에러가 납니다. 데이터베이스는 실패한 문장만 되돌리므로 예외는 삼키지 말고 그대로 던집니다. 트랜잭션이 롤백된 뒤에 잡아 정상 응답을 돌려주면 됩니다.
여기서 실수하기 쉬운 잘못된 구현 하나를 살펴보면 다음과 같습니다.
// 이렇게 하면 안 됩니다
if (processedEventRepository.existsById(eventId)) {
return;
}
settlementService.cancel(orderId);
processedEventRepository.save(new ProcessedEvent(eventId));
읽어서 확인하고 나서 쓰는 방식입니다. 동시성 문제죠. 인스턴스가 하나일 때는 대개 문제가 드러나지 않을 겁니다. 그런데 처리량을 늘리려고 인스턴스를 둘로 늘리면, 리밸런싱 직후처럼 같은 메시지가 두 인스턴스에 겹쳐 전달되는 순간이 생깁니다. 그때 둘 다 existsById에서 거짓을 보고 둘 다 정산 취소를 실행합니다. 검사한 뒤에 기록하면 두 연산이 벌어지고, 벌어진 만큼으로 중복이 들어옵니다.
유니크 제약에는 이 틈이 없습니다. 검사와 기록이 데이터베이스 엔진 안에서 하나의 원자적 연산으로 일어나기 때문입니다. 중복 방어를 애플리케이션 코드에 두지 않고 제약 조건에 맡기는 이유입니다.
멱등 테이블에는 함정이 하나 있습니다. 대상 행이 아직 없으면 UPDATE는 0행을 고치고 조용히 성공합니다. 그런데 같은 트랜잭션의 처리 기록은 커밋되니, 이 사건은 영영 처리 완료로 남고 다시 보내도 걸러집니다. 취소가 소리 없이 증발합니다. 갱신된 행이 0이면 반드시 예외를 던져 통째로 되돌려야 합니다.
다만 로컬 제약은 내 데이터베이스 안에서만 원자적입니다. 결제사 호출이 섞여 있으면 되돌려도 이미 나간 요청은 돌아오지 않으니, 결제사가 제공하는 멱등 키에 같은 event_id를 실어 보내야 합니다.
발신함에도 값이 붙습니다
그런데 아웃박스, 이 발신함 역시 공짜는 아니죠.
지연이 생깁니다. 릴레이가 주기적으로 훑으니 커밋과 발송 사이에 폴링 주기만큼의 시간이 생깁니다. 4장에서 본 그 "언제"가 조금 더 길어질 수 있습니다. (CDC와 같은 방식으로 이걸 보완할 수 있겠구요.)
순서가 깨질 수 있습니다. 처리량을 늘리려고 릴레이를 여러 개 돌리면 같은 주문의 사건이 순서를 바꿔 도착합니다. 파티션 키로는 안 풀립니다. 파티션 키는 같은 파티션으로 들어가는 것까지만 보장하고, 그 안의 순서는 보내는 쪽이 정하기 때문입니다. 주문 ID로 발신함 자체를 갈라서 한 주문은 언제나 같은 릴레이가 맡게 해야 합니다.
이제 두 시스템은 진짜로 분리됐습니다. 주문팀은 정산팀의 사정을 몰라도 되고, 정산팀은 같은 메시지를 몇 번 받아도 흔들리지 않습니다.
그리고 기획팀이 찾아옵니다.
6장. 조회 요구 하나가 경계를 무너뜨립니다
"어드민에서 주문 상태와 정산 상태를 한 표에 같이 보여주세요."
통합 테이블 시절이면 조인 한 줄로 끝났을 요구입니다. 이제 두 데이터는 주인이 다르고, 3장에서 계정을 갈라놓았으니 정산 쪽에서는 주문 스키마를 읽을 수 없습니다. 그래서 애플리케이션 메모리에서 조립합니다.
// 1. 주문 API에서 목록을 가져옵니다.
List<Order> orders = orderApi.getOrders(page, size);
// 2. 주문 ID를 모아 정산 API에 상태를 물어봅니다.
List<Long> orderIds = orders.stream().map(Order::getId).toList();
List<Settlement> settlements = settlementApi.getSettlements(orderIds);
// 3. 메모리에서 엮어 반환합니다.
return merge(orders, settlements);
위와 같이 API 조합을 하면 첫 화면까지는 잘 돌아갑니다.
그런데 다음과 같은 요구라면 어떨까요?
"지금 영업 중인 매장의 정산 보류 건만, 보류 금액이 큰 순서로, 50개씩 넘겨서 보여주세요."

이 요구를 코드로 옮기려고 하면 시작부터 막힙니다.
영업 상태는 계속 바뀌는 값이라 3장의 기준대로 복제하지 않고 물어보기로 한 값입니다. 그래서 정산 쪽에는 없습니다. 보류 금액은 정산이 계산해서 갖고 있으니 주문 쪽에는 없습니다. 필터는 한쪽에, 정렬 기준은 다른 쪽에 있습니다.
정산 API에서 보류 건을 금액순으로 가져오면 그중 몇 개가 영업 중인 매장인지 알 수 없어서 50개를 채울 수가 없습니다. 반대로 영업 중인 매장을 먼저 가져오면 수천 개의 매장 ID를 들고 정산에 물어봐야 하고, 그래도 정렬은 손으로 해야 합니다. 조인이 있으면 데이터베이스가 알아서 하던 일입니다.
어드민 화면 하나가 애써 그은 선을 다시 지웠습니다. 쓰기 경계는 트랜잭션이 정해주는데, 읽기는 원래 경계를 넘나드는 일입니다. 사장님 화면 하나에 주문과 정산과 리뷰가 같이 나와야 하는 것이 사업의 요구이기 때문입니다.
정렬 기준을 복제해서 한 데이터베이스로 끝내는 방법이 가장 싸지만, 여기서는 못 씁니다. 영업 상태는 복제하면 안 되는 값이라고 3장에서 이미 정했기 때문입니다. 그렇다면, 남은 방향은 조회를 위한 데이터를 따로 만드는 것입니다.
"you can use a different model to update information than the model you use to read information"
정보를 갱신할 때 쓰는 모델과 읽을 때 쓰는 모델을 다르게 할 수 있다.
- Martin Fowler, "CQRS"

주문팀과 정산팀이 내보내는 사건을 제3의 프로세스가 구독합니다. 그리고 어드민이 물어볼 형태 그대로, 주문 정보와 정산 정보가 한 줄에 들어 있는 넓은 문서를 만들어 검색 엔진이나 조회 전용 데이터베이스에 적어둡니다. 화면은 이 장부만 봅니다. 필터도 정렬도 페이징도 한 곳에서 끝납니다.
값을 세어봅시다. 통합 테이블 시절에는 테이블 하나였습니다. 지금은 원본 데이터베이스 둘, 브로커 하나, 발신함을 퍼 나르는 릴레이 프로세스 둘, 사건을 받아 장부를 채우는 프로젝터 하나, 조회 전용 저장소 하나입니다. 배포하고 감시하고 새벽에 일어나서 들여다봐야 할 물건이 일곱 개가 됐습니다. 발신함 테이블은 원본 데이터베이스 안에 있으니 따로 세지도 않았는데 이렇습니다.
그리고 그 조회 장부는 항상 조금 늦습니다. 어드민에서 정산 상태를 바꾼 담당자가 새로고침을 눌렀을 때 방금 바꾼 값이 안 보이는 일이 생깁니다. 여기서 4장의 이야기가 되풀이됩니다. 늦는 만큼의 화면을 또 설계해야 합니다.
"For some situations, this separation can be valuable, but beware that for most systems CQRS adds risky complexity."
어떤 상황에서는 이 분리가 값어치를 하지만, 대부분의 시스템에서 CQRS는 위험한 복잡도를 더한다는 점을 유의하라.
- Martin Fowler, "CQRS"
이 경고가 실린 문서를 보면 재미있습니다. 파울러는 같은 글에서 이 패턴을 그렉 영에게 처음 들었다고 적으며 소개하고 있습니다. 패턴을 반대하는 사람이 밖에서 한 말이 아니라, 소개하는 사람이 소개하면서 같은 문단에 적어둔 문장입니다.
화면이 이것 하나뿐이라면 더 싼 길도 있습니다. 두 데이터베이스의 읽기 복제본을 한 서버로 받아 거기서 조인하는 것입니다. 다만 그러면 어드민 쿼리가 양쪽의 물리 스키마에 다시 직접 묶입니다. 3장에서 권한으로 닫았던 문이 조회 쪽으로 다시 열리는 셈이라, 주문팀이 컬럼 이름을 바꾸면 이 화면이 깨집니다.
그렇더라도 단순히 조회가 까다롭다는 이유만으로 이토록 무거운 아키텍처를 짊어지는 것은 득보다 실이 클 수 있습니다. 조회 성능 자체가 비즈니스의 핵심 경쟁력이고, 그 화면이 하루에 수십만 번씩 열려야 하는 상황일 때 비로소 이 커다란 복잡성을 감당할 타당한 명분이 생깁니다.
7장. 그래서 어디까지 자를 것인가
이쯤 되면 설계자는 이런 결론을 내릴지도 모르겠습니다.
그럼 그냥 최대한 많이 쪼게면 되겠군 !
손님이라는 하나의 개념을 계정 서버와 프로필 서버와 인증 서버로 나누고, 서버마다 테이블을 하나씩 쥐여줍니다. 화이트보드에 그려놓은 아키텍처 다이어그램은 무척 깔끔해 보일 것입니다.
하지만 착각입니다. 손님이 탈퇴할 때 무슨 일이 일어나는지 봅시다. 계정 서버가 계정을 지우고, 인증 서버가 토큰을 폐기하고, 프로필 서버가 개인정보를 지웁니다. 여기까지는 4장과 5장의 방법으로 이을 수 있습니다. 사건을 하나 던지고 셋이 각자 받으면 됩니다.
문제는 그다음입니다. 개인정보 보관 기간 정책이 바뀌면 세 서버의 코드를 함께 고치고, 세 번의 배포를 같은 날 동시에 맞춰야 합니다. 정책이 한 번 바뀔 때마다 매번 이 난리를 겪어야 합니다. 서비스 개수는 늘었는데 함께 움직여야 하는 결합도는 그대로입니다. 분산 시스템의 값비싼 대가만 치르고, 정작 그 목적이었던 조직의 자율성은 얻지 못한 빈껍데기 구조입니다.
"These services are built around business capabilities and independently deployable by fully automated deployment machinery."
이 서비스들은 비즈니스 역량을 중심으로 구축되며, 완전히 자동화된 배포 장치에 의해 독립적으로 배포 가능하다.
- James Lewis & Martin Fowler, "Microservices"
independently deployable이 크기 기준을 대신합니다. 마이크로는 작다는 뜻이 아니라 혼자 배포할 수 있다는 뜻으로 이해해야 합니다.
선을 잘못 그었다는 증거는 배포 일지에 있습니다
이 판정에는 좋은 점이 있습니다. 취향 싸움이 되지 않는다는 것입니다.
지난 분기의 배포 기록에서, 한쪽을 배포할 때 다른 쪽도 같이 배포해야만 했던 건수를 셉니다. 같은 변경 요청에 묶여 있었는지, 배포 순서를 맞춰야 했는지, 한쪽만 내보냈다가 되돌린 적이 있는지를 봅니다. 그런 건이 두 서비스를 함께 건드린 배포의 절반을 넘으면 그 선은 이름만 있는 선입니다. 코드는 두 저장소에 나뉘어 있고 서버도 둘인데, 실제로는 하나의 단위로 움직이고 있습니다. 같은 날 배포됐다는 사실만 세면 안 됩니다. 매일 배포하는 팀은 그냥 매일 겹칩니다.
따라서, 경계는 크기로 긋는 것이 아니라 변화의 주기로 긋습니다. 같이 변하는 것은 같이 두고, 따로 변하는 것을 가릅니다. 배포 일지는 무엇이 같이 변하는지를 이미 기록해두고 있습니다.
3장에서 잠시 결정을 미뤄두었던 주문의 나머지 세 가지 축도 똑같은 잣대로 바라보면 됩니다. 배포 일지를 살펴봤을 때 이들이 늘 한 몸처럼 함께 수정되고 있다면, 굳이 서둘러 쪼갤 필요 없이 지금처럼 하나의 덩어리로 두는 것이 안전합니다. 그러다 비즈니스가 뻗어나가며 어느 순간 이들이 서로 다른 속도로 진화하기 시작한다면, 바로 그때가 아키텍처에 기분 좋게 새로운 선을 그어줄 가장 완벽한 타이밍입니다.
경계는 결국 사람이 긋습니다
기술적으로 완벽하게 나눠놓고도 배포에 한 달이 걸리는 경우가 있습니다. 이유는 코드가 아니라 회의실에 있습니다.
주문 데이터를 다루는 로직은 백엔드 팀이 짜고, 어드민 화면은 프론트엔드 팀이 만들고, 스키마 변경 권한은 DBA 팀이 쥐고 있습니다. 아키텍처는 주문과 정산이라는 사업 단위로 나뉘어 있는데 조직은 서버와 화면과 데이터베이스라는 기술 단위로 나뉘어 있습니다. 기능 하나를 내보내려면 세 팀의 일정을 맞춰야 하고, 비동기 메시징이 벌어준 자율은 이 협의 앞에서 전부 사라집니다.
"organizations which design systems (in the broad sense used here) are constrained to produce designs which are copies of the communication structures of these organizations"
시스템을 설계하는 조직은(여기서 시스템은 넓은 뜻이다) 그 조직의 의사소통 구조를 복사한 설계를 만들도록 강제된다.
- Melvin Conway, "How Do Committees Invent?", Datamation 1968년 4월호
1968년 논문의 결론입니다. 널리 인용되는 Any organization that designs a system... 형태는 저자가 나중에 덧붙인 노트의 표현이고, 위가 원문입니다. 원문에서 눈여겨볼 단어는 constrained입니다. 그런 경향이 있다가 아니라 그렇게 하도록 강제된다고 썼습니다.
반대 방향도 같습니다. 조직은 주문팀과 정산팀으로 나뉘었는데 코드가 하나의 저장소에 묶여 있으면, 속도를 목표로 하는 팀과 1원의 오차도 없기를 목표로 하는 팀이 같은 코드를 두고 매번 협상하게 됩니다. 그 협상은 기술 논쟁의 얼굴을 하고 있지만 내용은 우선순위 다툼입니다.
독립적으로 배포하는 시스템을 원한다면 독립적으로 결정하는 팀을 먼저 만들어야 합니다. 선은 다이어그램에서 그어지고 조직도에서 확정됩니다.
이 선택은 되돌리기 어렵습니다
지금까지 우리가 마주했던 복잡도나 수습 비용은 시간과 돈, 그리고 개발팀의 땀방울로 어떻게든 감당할 수 있는 문제들이었습니다. 하지만 마이크로서비스라는 아키텍처를 선택하며 치러야 할 마지막 대가는 성격이 완전히 다릅니다.
"even experienced architects working in familiar domains have great difficulty getting boundaries right at the beginning"
경험 많은 아키텍트가 익숙한 도메인에서 일하더라도 처음부터 경계를 제대로 잡는 데는 큰 어려움을 겪는다.
- Martin Fowler, "MonolithFirst"
"Any refactoring of functionality between services is much harder than it is in a monolith"
서비스들 사이에서 기능을 옮기는 리팩터링은 모놀리스 안에서 하는 것보다 훨씬 어렵다.
- Martin Fowler, "MonolithFirst"
이 두 문장을 나란히 놓고 보면 설계자의 마음은 무거워질 수밖에 없습니다. 아무리 노련한 전문가라도 처음부터 완벽한 경계를 긋는 것은 사실상 불가능에 가까운데, 한 번 잘못 그어버린 선을 나중에 고치는 과정은 너무나도 고통스럽고 험난하다는 것입니다.
하나의 덩어리로 된 시스템 안에서 코드를 옮기는 일은 개발 도구들이 클릭 몇 번으로 알아서 해줍니다. 하지만 이미 물리적으로 분리된 서비스 사이에서 기능을 옮기는 일은 차원이 다릅니다. 데이터를 안전하게 이관해야 하고, 구형 API를 호출하던 모든 곳을 찾아내 수정해야 하며, 두 팀이 머리를 맞대고 이번 분기의 로드맵과 배포 일정까지 촘촘하게 새로 조율해야 합니다. 일반적인 코드 리팩터링이 쾌적한 오후 티타임 전에 끝난다면, 서비스 경계를 수정하는 리팩터링은 꼬박 한 분기를 갉아먹는 거대한 프로젝트가 됩니다.
"When you use microservices you have to work on automated deployment, monitoring, dealing with failure, eventual consistency, and other factors that a distributed system introduces."
마이크로서비스를 쓰면 자동화된 배포, 모니터링, 실패 처리, 결과적 일관성, 그리고 분산 시스템이 도입하는 다른 요소들을 감당해야 한다.
- Martin Fowler, "MicroservicePremium"
분산 시스템이 가져오는 이 무거운 짐을 누구보다 잘 알기에, 파울러는 같은 글에서 이런 단호한 조언을 남깁니다.
"So my primary guideline would be don't even consider microservices unless you have a system that's too complex to manage as a monolith."
그래서 내 첫 번째 지침은 이렇다. 모놀리스로 관리하기에 너무 복잡한 시스템이 아니라면 마이크로서비스는 고려조차 하지 마라.
- Martin Fowler, "MicroservicePremium"
그래서 내일 무엇부터 하나
원칙은 훌륭하지만, 원칙만으로는 당장 다가오는 월요일 출근길의 막막함을 덜어주지 못하죠. 실무에서 아키텍처의 경계를 나눌 때 아래와 같은 접근으로 시작해보면 어떨까 합니다.

하나. 말부터 셉니다. 최근 석 달의 회의록과 스키마를 열어 주문, 상태, 손님 같은 단어가 몇 가지 뜻으로 쓰였는지 적습니다. 뜻이 둘 이상인 단어가 경계 후보입니다. 이 작업에는 서버도 예산도 필요하지 않습니다.
둘. 같이 실패해야 하는 것을 묶습니다. 2장에서 다뤘던 "이 작업이 실패하면 저 작업도 무조건 취소되어야 하나요?"라는 질문을 해보고 표를 채우는 것이죠.
셋. 코드에서 먼저 가릅니다. 데이터베이스를 나누기 전에 패키지와 호출 경로를 먼저 나눕니다. 정산 코드가 주문 리포지토리를 직접 부르지 못하게만 만들어도 대부분의 결합이 드러납니다.
넷. 읽기 요구를 미리 조사합니다. 한 화면에 두 묶음이 같이 나오는 곳을 세어둡니다. 이 화면들의 개수가 바로 6장에서 다뤘던 '분산 시스템의 조회 복잡도'라는 값비싼 청구서의 크기입니다. 데이터베이스를 이미 찢어버린 뒤에 세는 것은 너무 늦습니다.
다섯. 마지막에 물리적으로 나누고, 나누는 날 권한을 같이 겁니다. 계정 분리를 다음 분기로 미루면 또 다른 부수효과가 발생할 수 있으니까요.
이 중에서도 셋째 단계가 특히 중요합니다. 논리적으로만 패키지를 갈라놓고 한두 달 운영해 보면, 잘못 그은 선들이 반드시 드러납니다. 그때는 그저 클래스의 위치를 옮기는 오후의 가벼운 리팩터링만으로 선을 고쳐 그을 수 있습니다. 하지만 물리적으로 서버를 찢어놓은 뒤에 실수를 깨달으면, 거대한 데이터 마이그레이션과 API 폐기라는 악몽이 시작됩니다. 선을 긋는 순서를 '되돌리기 쉬운 것부터' 배치하면, 처음의 실수가 치명적인 상처로 남지 않습니다.
닫는 글
이 시리즈에서 다뤄온 장치들에는 공통점이 있습니다. 무엇을 포기하고 무엇을 얻는지가 분명하다는 것입니다. 경계도 같습니다.
| 포기한 것 | 얻은 것 |
|---|---|
| 항상 일치하는 하나의 진실 | 남의 배포 일정을 기다리지 않을 자유 |
| 조인 한 줄로 답하던 질문들 | 한쪽 장애가 다른 쪽으로 번지지 않는 방화벽 |
| 트랜잭션이 문장 하나로 주던 원자성 | 팀마다 다른 속도로 움직일 권리 |
| 되돌리기 쉬운 구조 | 조직이 커져도 병목이 생기지 않는 구조 |

왼쪽 열은 우리가 마이크로서비스라는 멋진 구조를 위해 기꺼이 지불해야 할 막대한 청구서입니다. 파울러가 2002년 책에 적어둔 묵직한 한 줄이 이 현실을 정확히 요약합니다.
"My First Law of Distributed Object Design: Don't distribute your objects"
내가 말하는 분산 객체 설계의 제1법칙은 이렇다. 객체를 분산시키지 마라.
- Martin Fowler, "FirstLaw". 2002년 『Patterns of Enterprise Application Architecture』에서 온 문장입니다.
이런 이유로, 대부분의 시스템에서는 단일 데이터베이스를 공유하는 모놀리식 구조가 생각보다 아주 오랫동안 훌륭한 정답인 듯 합니다. 모든 것이 한곳에 있는 동안 데이터의 무결성은 트랜잭션 하나로 지켜지고, 복잡한 조회는 조인 한 줄이면 끝나며, 배포는 단 한 번이면 족합니다. 이 압도적인 편의를 버리겠다는 결정은, 그 대가로 얻게 될 조직의 자율성과 속도가 지불할 비용보다 훨씬 클 때만 정당성을 얻습니다.
하지만 비즈니스가 팽창하다 보면 기어이 그 순간이 찾아오죠. 시스템의 일관성을 지키기 위해 팀 전체가 멈춰서 기다리는 시간이, 일관성 자체의 가치를 갉아먹는 임계점입니다. 정산팀이 다른 팀의 DB 컬럼 하나가 추가되기를 2주 넘게 기다리고, 주문팀이 정산 배치를 망가뜨릴까 두려워 변수 이름 하나 마음대로 고치지 못하고, 네 개의 팀이 단 하나의 배포 창을 두고 서로 눈치싸움을 벌이기 시작한다면… 바로 그때가 시스템의 경계를 쪼개야 할 시간입니다.
명심할 것은, 이때 긋는 선이 한 번 정해지면 영원히 변치 않는 국경선이 아니라는 점입니다. 오늘 비즈니스에 완벽하게 들어맞았던 경계도, 내일 사업의 방향이 바뀌면 가장 답답한 장애물이 됩니다. 함께 엮여서 변하기 시작한 개념들은 다시 하나로 합치고, 각자의 길을 가기 시작한 개념들은 과감히 가위질을 해야 합니다. 경계를 긋는 일은 단 한 번의 건축이 아니라, 비즈니스의 맥박에 맞춰 끊임없이 다시 그어가는 기나긴 호흡입니다.
마지막으로 0장에서 던졌던 뼈아픈 질문으로 돌아가 보겠습니다.
그러면 저 값의 뜻은 누가 정하고 있었을까요?
아무도 정하고 있지 않았습니다. status 컬럼에는 진짜 주인이 없었고, 주인이 없으니 지켜야 할 통제 규칙도 없었습니다. 규칙이 없으니 엉뚱한 값 하나가 스리슬쩍 늘어나는 중대한 변화조차 그 누구에게도 통보되지 않았던 것입니다.
경계란 결국 어떤 단어의 뜻을 누가 정하는지 정해두는 일입니다. 그것을 정해두지 않으면 시스템은 컬럼 하나에 네 개의 뜻을 담은 채로 계속 자라고, 어느 밤 조용히 남의 계좌로 돈을 보냅니다.
참고 자료
경계와 모델
- BoundedContext, Martin Fowler, https://martinfowler.com/bliki/BoundedContext.html
- DDD_Aggregate, Martin Fowler, https://martinfowler.com/bliki/DDD_Aggregate.html
- CQRS, Martin Fowler, https://martinfowler.com/bliki/CQRS.html
분리의 값
- Microservices, James Lewis & Martin Fowler, https://martinfowler.com/articles/microservices.html
- MonolithFirst, Martin Fowler, https://martinfowler.com/bliki/MonolithFirst.html
- MicroservicePremium, Martin Fowler, https://martinfowler.com/bliki/MicroservicePremium.html
- First Law of Distributed Object Design, Martin Fowler, https://martinfowler.com/bliki/FirstLaw.html
구현 패턴
- Database per service, Chris Richardson, https://microservices.io/patterns/data/database-per-service.html
- API Composition, Chris Richardson, https://microservices.io/patterns/data/api-composition.html
- Transactional outbox, Chris Richardson, https://microservices.io/patterns/data/transactional-outbox.html
- Idempotent Receiver, Enterprise Integration Patterns, https://www.enterpriseintegrationpatterns.com/patterns/messaging/IdempotentReceiver.html
더 읽어볼 것
- Patterns of Enterprise Application Architecture, Martin Fowler. 제1법칙의 출처입니다. 분산이 왜 마지막 수단인지를 2002년에 이미 정리해두었습니다.
- Domain-Driven Design, Eric Evans. 이 글이 본문 어휘로 쓰지 않은 용어들의 원전입니다. 경계를 언어의 문제로 보는 관점이 여기서 시작합니다.
- How Do Committees Invent?, Melvin Conway, Datamation 1968년 4월호. 저자 본인이 원문을 올려둔 페이지입니다. 널리 인용되는 형태와 원문이 어떻게 다른지 직접 대조해볼 수 있습니다. https://www.melconway.com/Home/Committees_Paper.html

A Note on Graphics
이 글에 사용된 모든 다이어그램과 일러스트 이미지는 개념의 시각화를 위해 생성형 AI 도구를 활용해 제작되었습니다.
'시스템 디자인' 카테고리의 다른 글
| [시스템 설계 - 보상] Ep.7 취소했는데 쿠폰이 돌아오지 않습니다 (0) | 2026.08.06 |
|---|---|
| [시스템 설계 - 격리] Ep.5 하나가 느려지면 전부 멈춥니다 (0) | 2026.08.01 |
| [시스템 설계 - 고유 ID] Ep.4 시계가 거꾸로 갑니다: 고유 ID 만들기가 어려운 진짜 이유 (0) | 2026.07.31 |
| [시스템 설계 - DynamoDB] Ep.3 지운 물건이 되살아납니다: 다이나모 아키텍처 15년과 복잡성 보존의 법칙 (0) | 2026.07.29 |
| [시스템 설계 - 레이트 리미터] Ep.2 방패의 설계도: 트래픽 폭주 속에서 무엇을, 어디서 막을 것인가 (0) | 2026.07.28 |