← 모든 글

우리는 무엇을 만들고 있는가 (공개 가능한 부분만)

HEDVION이 멀티 테넌트 B2B 정산 플랫폼을 만들며 내린 기술적 결정—이벤트 소싱을 버린 이유, 3인 팀의 버스 팩터 관리, 데이터 정합성을 최우선에 두는 이유를 공개 가능한 범위에서 솔직하게 기록한다.

공개할 수 없는 것, 그리고 그럼에도 이야기할 수 있는 것

HEDVION이 만드는 서비스의 세부 내용 대부분은 이 블로그에 쓰지 못한다. 계약상 이유도 있고, 현재 운영 중인 서비스의 특성상 구체적인 구조가 외부에 노출되면 실질적인 리스크가 생기기 때문이다. 그런데 이 제약이 역설적으로 더 쓸모 있는 글을 만든다고 생각한다. "무엇을 만들었는가"가 아니라 "왜 그렇게 만들기로 했는가"—결정의 맥락, 포기한 선택지, 그 결과가 운영에서 어떻게 작동하는지를 쓸 수 있기 때문이다.

기술 블로그 대부분이 결과를 정리한다. 잘 된 것만, 깔끔하게 마무리된 것만. 우리가 쓰려는 건 반대다. 멋있어 보이려고 고른 것이 아니라 살아남기 위해 고른 것, 그리고 실제로 포기한 것들에 대한 기록이다. 이 글은 그 첫 번째다.

멀티 테넌트 정산 플랫폼이 실제로 어떤 문제인가

우리가 만드는 것은 복수의 사업자(테넌트)가 하나의 플랫폼 위에서 각자의 고객에게 서비스를 제공하는 B2B 구조다. 핵심 도메인은 세 가지—정산, 권한 관리, 이력 추적—이고, 이 세 가지를 묶는 공통 특성이 있다. "잘 됐을 때는 아무도 모르지만 잘못되면 바로 티 나는" 도메인이라는 점이다. 정산이 1원이라도 틀리면 고객이 먼저 안다. 권한이 잘못 열리면 데이터 유출 사고가 된다. 이력이 누락되면 감사(audit) 시 불일치가 발생한다. 세 가지 모두 에러가 조용히 누적되다가 한 번에 크게 터지는 구조다.

멀티 테넌트 환경에서 정산이 어려운 이유는 계산 자체가 복잡해서가 아니다. 테넌트 A의 트랜잭션과 테넌트 B의 트랜잭션이 동시에 처리될 때, 두 잔액이 각각 정확하게 반영되어야 하고, 어떤 타이밍에서도 데이터가 섞이지 않아야 한다. 이것이 DB 트랜잭션 격리 수준(isolation level) 선택을 단순한 성능 튜닝이 아닌 비즈니스 리스크 결정으로 만든다. READ COMMITTED만으로는 특정 시나리오에서 잔액 음수가 가능하고, SERIALIZABLE은 처리량을 크게 떨어뜨린다. 우리는 이 트레이드오프에서 REPEATABLE READ + 애플리케이션 레벨 낙관적 락(optimistic lock) 조합을 선택했고, 지금도 엣지 케이스를 계속 발굴하고 있다.

세 명이 운영한다는 것의 기술적 함의

팀이 세 명이라는 사실은 단순한 인력 현황이 아니다. 모든 기술 선택의 가장 강력한 제약 조건이다. 버스 팩터(bus factor) 1—한 명이 빠졌을 때 시스템 이해가 무너지는 상황—은 소규모 팀에서 흔하지만, 정산처럼 실수의 파급이 즉각적인 도메인에서는 치명적이다. 팀원 한 명이 갑작스럽게 2주 자리를 비워도 나머지 두 명이 장애 대응, 정산 오류 수정, 권한 변경을 전부 처리할 수 있어야 한다는 것이 우리의 비기능 요구사항 1번이다.

이 원칙이 코드에 어떻게 반영되는지 구체적으로 말하면: 추상화 레이어를 최소화하고, 어떤 팀원이 어떤 파일을 열어도 3분 안에 흐름을 파악할 수 있어야 한다. "문서보다 코드가 더 잘 설명해야 한다"는 기준이다. 이것 때문에 일부 우아한 패턴을 포기했다. 우아함보다 가독성, 기능보다 운영 조용함이 우선이다. 화려한 아키텍처가 세 명 중 한 명만 이해한다면 그것은 부채다.

이벤트 소싱과 CQRS를 검토하고 버린 이유

이력 추적이 핵심 요구사항이라는 점에서 이벤트 소싱(Event Sourcing)은 자연스러운 후보였다. 모든 상태 변화를 이벤트로 기록하면 시점 조회(point-in-time query)가 쉬워지고, 이력 자체가 단일 소스 오브 트루스(source of truth)가 된다. CQRS와 결합하면 읽기/쓰기 부하를 분리한다는 이론적 장점도 있다. 실제로 3주간 프로토타입을 만들었다.

결론적으로 버렸고, 이유는 명확했다. 첫째, 운영 복잡도가 예상을 훨씬 초과했다. 이벤트 스토어 별도 관리, 프로젝션(projection) 재생성 로직, 이벤트 스키마 버전 관리—이 세 가지만으로 현재 팀의 운영 여력이 소진된다. 둘째, 현재 트래픽 규모에서 CQRS의 이점이 실제로 발생하지 않는다. 초당 100건 미만의 트랜잭션에서 별도 읽기 모델을 유지하는 비용 대비 이득이 없다. 우리는 대신 단순한 append-only 이력 테이블과 명시적인 스냅샷 전략을 선택했다. 덜 우아하지만, 세 명 모두가 이해하고 고칠 수 있다.

데이터 정합성에 가장 많은 에너지를 쓰는 이유

지금 우리의 모드는 "기능을 얼마나 빠르게 추가했는가"보다 "지금 있는 것이 어떤 상황에서도 정확한가"를 먼저 묻는 것이다. 정산 도메인에서 데이터 정합성 문제는 두 형태로 나타난다. 계산 오류(잔액이 실제와 다름)와 격리 실패(테넌트 A가 테넌트 B의 데이터를 볼 수 있거나, 없어야 할 집계에 포함됨)다. 둘 다 조용히 누적되다가 고객이 먼저 발견한다.

공개 가능한 범위에서 실제 사례를 하나 이야기하면: 정산 배치 작업과 실시간 트랜잭션이 겹치는 시간대에서 잔액 집계가 특정 조건에서 중복 계산되는 버그가 있었다. 월말 정산이 실행되는 23:50~00:10 사이에 신규 트랜잭션이 들어오는 경우였다. 이 케이스를 재현하는 테스트를 만드는 데만 이틀이 걸렸다. 발견하지 못했다면 소수의 고객에게만, 월 한 번만 나타나는 오류로 수개월을 보냈을 것이다. 규모가 작을 때 이런 엣지 케이스를 정리하지 않으면, 고객이 수백 명이 된 시점의 데이터 복구 비용은 기하급수적으로 커진다.

지금 당장 써먹을 수 있는 시사점

정산·권한·이력 추적을 다루는 팀이라면, 규모에 무관하게 지금 바로 점검할 수 있는 것들이 있다.

1. 잔액 계산 경로를 단일화하라. 잔액을 계산하는 코드가 두 군데 이상 존재하면 반드시 불일치가 생긴다. 어드민 뷰, API 응답, 정산 배치가 각각 다른 쿼리로 잔액을 계산하고 있다면 지금 당장 하나로 통일해야 한다. 차이가 생겼을 때 어느 쪽이 맞는지 알 수 없게 되기 전에.

2. 테넌트 격리를 ORM 레벨에만 의존하지 마라. ORM 필터(WHERE tenant_id = ?)는 실수로 누락될 수 있다. PostgreSQL의 Row Level Security(RLS) 또는 스키마 분리로 DB 레벨에서 한 번 더 강제하면 실수 하나가 데이터 유출 사고가 되는 경로를 차단할 수 있다. 초기 설정 비용은 하루 미만이다.

3. 이력은 처음부터 append-only로 설계하라. UPDATE로 이력을 수정하는 구조가 되는 순간 감사 추적이 불가능해진다. 수정이 필요하다면 기존 레코드를 무효화(invalidate)하는 새 레코드를 추가하는 방식이 맞다. 나중에 바꾸는 것은 마이그레이션 비용이 크다.

4. 버스 팩터를 분기마다 실제로 시뮬레이션하라. "이 모듈을 처음 보는 팀원이 혼자 장애 대응을 할 수 있는가?"를 실제로 해보라. 대답이 "아마도"라면 문서화가 아니라 코드 단순화가 필요하다는 신호다. 문서는 코드가 바뀌면 틀려지지만, 읽기 쉬운 코드는 스스로를 설명한다.


— by mings

* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

📚 추천 강의
한 입 크기로 잘라먹는 바이브코딩 (with Claude Code)
Claude Code로 바이브코딩, 개발자라면 꼭 들어야 할 필수 강의
강의 보러가기 →

* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.