UseCase, Domain, Repository 설계부터 동시성까지.
Spring·Kotlin 백엔드 구조를 다시 설계하며 UseCase, Domain, Repository의 책임과 상태 저장, 트랜잭션 경계를 정리한 기록입니다.
최근 Spring/Kotlin으로 백엔드 구조를 다시 잡으면서 기존에 NestJS에서 사용하던 아키텍처를 다시 바라보게 됐다. 내가 기존에 사용하던 구조는 대략 Request → UseCase → Engine → Repository 형태였고, UseCase는 요청을 받아 여러 Engine을 조합해 상태를 변경한 뒤 결과를 반환하는 역할을 담당했다. Engine끼리 직접 참조하는 것은 엄격하게 금지했고, 여러 도메인이 필요한 행위라면 RoomEngine ← UseCase → UserEngine처럼 UseCase가 상위에서 이를 조율했다. 이 방식은 도메인 간 순환 참조를 막고 각 도메인 내부 구현을 Engine 뒤로 숨기는 데 효과적이었지만, 사용하면서 한 가지 아쉬운 점이 있었다. Engine에서 상태를 만들면 그 상태를 거의 즉시 저장해야 했다는 점이다.
상태 변경과 상태 기록이 너무 가까웠다
기존 Engine은 상태 복원, 비즈니스 규칙 실행, 상태 변경, 상태 저장을 하나의 operation 안에서 처리하는 경우가 많았다. 예를 들어 MessageEngine.create()를 호출하면 단순히 Message 상태를 만드는 것이 아니라 Repository를 통해 실제 저장까지 끝내는 식이었다. 이렇게 하면 UseCase가 persistence의 세부사항을 몰라도 된다는 장점이 있었고, 호출자 입장에서는 messageEngine.create()라는 높은 수준의 operation만 사용하면 됐다. 하지만 반대로 상태를 메모리상에서 잠시 유지하면서 여러 도메인의 상태를 조합하거나 모든 계산이 끝난 뒤 한 번에 기록하기 어려웠다. Room, Membership, Message를 하나의 UseCase 안에서 모두 만든 뒤 마지막에 전체 상태를 기록하고 싶어도 각 Engine이 생성 즉시 저장한다면 중간 상태를 자유롭게 다루기 어려웠다.
이 문제를 생각하면서 Application과 Domain의 역할을 좀 더 명확하게 나눠보게 됐다. 최상위 아키텍처는 presentation, application, domain, infrastructure와 같은 레이어 단위로 구분하고, Presentation은 HTTP나 WebSocket 등의 입출력을 담당하며, Application의 UseCase는 상태를 복원하고 여러 Domain을 조합하고 상태 변경의 흐름을 만든 뒤 결과를 기록하고 외부 Side Effect를 조율하도록 한다. Domain은 이미 주어진 상태를 기반으로 Side Effect 없이 비즈니스 규칙을 계산하고, Infrastructure는 DB, 메시지 브로커, 외부 API와 같은 실제 구현을 담당한다.
presentation/
application/
domain/
infrastructure/
이렇게 보면 기존의 UseCase → Engine → Repository 구조 역시 조금 달라진다. Engine이 Repository를 통해 필요한 상태를 직접 가져오던 구조에서, UseCase가 Repository를 이용해 상태를 복원하고 이를 Engine에 전달하는 구조로 이동한다.
Repository
↑
UseCase
↓
Engine
Engine은 더 이상 roomId를 받아 Room을 조회한 뒤 판단하는 객체가 아니라, 이미 복원된 Room을 받아 그 상태에 대해 계산하는 객체가 된다. 즉 Engine의 입력이 식별자나 요청에서 이미 복원된 Domain State로 바뀌는 셈이다.
Domain은 가능한 한 순수하게 유지한다
처음에는 "이 사용자가 이 방에 메시지를 보낼 수 있는가?" 같은 질문도 순수한 Domain 규칙이라고 생각했다. 하지만 실제로 이 질문에 답하려면 Room, Membership, User, Ban 상태 등 여러 상태를 조회해야 할 수 있고, 그렇다면 이 질문 자체가 이미 하나의 Application 수준 상황을 포함하고 있다. 어떤 데이터를 어디에서 가져오고 어떤 순서로 조합할지는 Application의 책임으로 두고, Domain은 "Room은 현재 메시지를 받을 수 있는 상태인가?", "Membership은 메시지 작성 권한이 있는가?", "Message의 내용은 유효한가?"처럼 이미 주어진 상태에 대해 더 작은 규칙을 판단하는 편이 자연스럽다. 이를 한 문장으로 표현하면 Application은 상황을 구성하고 Domain은 주어진 상황에 대한 규칙을 판단한다.
테스트는 훨씬 단순해진다
Domain에서 I/O를 제거하면 테스트는 거의 순수 함수 테스트에 가까워진다. 이미 준비된 Domain State를 입력하고 규칙을 실행한 뒤 결과만 확인하면 되기 때문에 DB도 필요 없고 Spring Context도 필요 없으며 Repository Mock 역시 대부분 사라진다. 반대로 UseCase 테스트에서는 복잡한 비즈니스 계산 자체보다 필요한 상태를 제대로 가져왔는지, 올바른 Domain 규칙을 실행했는지, 계산된 결과를 기록했는지, 필요한 Side Effect를 실행했는지를 검증하면 된다.
Domain Test
State → Rule → Result
UseCase Test
Read → Orchestrate → Write → Side Effect
이렇게 구조를 나누면 테스트 난이도 자체가 설계에 대한 피드백이 되기도 한다. Domain 테스트를 하기 위해 Repository Mock이 여러 개 필요하다면 Domain이 너무 많은 책임을 가지고 있는 것은 아닌지 의심할 수 있고, 반대로 UseCase 내부에 수많은 조건문과 비즈니스 판단이 들어가기 시작한다면 Domain으로 내려가야 할 규칙이 남아 있는 것은 아닌지 의심할 수 있다.
여기까지만 보면 상태 복원과 계산, 상태 기록을 분리하는 것이 거의 무조건 좋아 보이지만, 이 구조를 생각하다가 자연스럽게 동시성 문제가 다시 보이기 시작했다.
상태 복원과 상태 저장 사이에는 다른 요청이 들어올 수 있다
UseCase가 상태를 복원하고 Domain에서 계산한 뒤 결과를 저장하는 구조를 가진다고 해보자. 재고가 10개인 상황에서 두 요청이 동시에 1개씩 차감하면 두 요청 모두 stock = 10을 읽은 뒤 각각 9를 계산할 수 있고, 둘 다 9를 저장하면 실제로는 두 번 차감되었음에도 최종 상태가 9가 된다.
Request A
READ stock = 10
계산 → 9
Request B
READ stock = 10
계산 → 9
WRITE 9
Request A
WRITE 9
여기서 중요한 것은 단순히 Transaction을 사용한다고 해서 이 문제가 자동으로 해결되는 것은 아니라는 점이다. Transaction의 All-or-Nothing과 여러 Transaction 사이에서 읽기와 쓰기가 어떻게 직렬화되는가는 다른 문제다. 즉 상태 복원과 상태 저장을 분리하면 테스트와 책임 분리는 좋아지지만, 그 사이에 다른 상태 복원과 상태 변경이 개입할 수 있는 시간적 공간도 함께 만들어진다.
이 때문에 모든 비즈니스 규칙을 반드시 메모리상의 Domain Model까지 끌어올리는 것이 최선이라고 볼 수는 없다. 어떤 규칙은 DB가 가지고 있는 최신 상태와 조건 판단과 상태 변경이 분리되어서는 안 된다.
최소 invariant는 SQL statement 안에서 원자화한다
재고 차감의 경우 우리가 정말 지키고 싶은 것은 "재고가 충분한가?"라는 특정 UseCase의 질문보다는 quantity >= 0****이라는 invariant에 가깝다. 이 규칙은 다음과 같이 하나의 SQL statement 안에서 조건 검사와 상태 변경을 함께 수행할 수 있다.
UPDATE stock
SET quantity = quantity - :amount
WHERE product_id = :id
AND quantity >= :amount;
이 statement는 현재 DB에 저장된 최신 상태를 기준으로 조건을 판단하고 바로 상태를 변경하기 때문에 READ → 계산 → WRITE 사이에 다른 요청이 개입하면서 발생하는 Lost Update 문제를 피할 수 있다. Repository API 역시 purchaseProduct()처럼 특정 UseCase를 그대로 표현하기보다는 tryDecrease()와 같이 Stock이라는 상태가 제공하는 최소 원자적 operation에 가까운 형태가 더 적합하다.
fun tryDecrease(
productId: ProductId,
amount: Quantity,
): Boolean
이 operation의 계약은 간단하다. 성공하면 quantity -= amount, 실패하면 아무 상태도 변경하지 않으며 어떤 경우에도 quantity >= 0이라는 invariant가 유지된다. 여기서 중요한 것은 UseCase 전체를 SQL 안으로 내려보내는 것이 아니라 동시성 상황에서도 반드시 지켜져야 할 최소 invariant만 DB에 위임하는 것이다.
UseCase는 Engine을 반드시 거쳐야 하는 것이 아니다
기존에는 대부분의 상태 변경을 UseCase → Engine → Repository라는 흐름으로 처리했지만, 어떤 책에서는 UseCase가 Engine을 건너뛰고 Repository를 직접 호출할 수 있다고 설명하고 있었다. 처음에는 이 예외가 왜 필요한지 명확하지 않았지만, 지금 생각해보면 하나의 이유를 이해할 수 있을 것 같다. 모든 상태 변경을 Domain Engine까지 끌어올리는 것보다 DB가 제공하는 원자적 상태 전이를 그대로 사용하는 편이 더 정확한 경우가 존재하기 때문이다.
┌→ Engine
Request → UseCase
└→ Repository atomic operation
가격 계산처럼 이미 주어진 상태에서 복잡한 계산을 해야 한다면 Engine을 사용하는 것이 적합하고, 재고 차감이나 선착순 Claim처럼 현재 저장된 상태와 조건 판단과 mutation이 붙어 있어야 한다면 Repository의 atomic operation을 직접 호출하는 편이 더 자연스러울 수 있다. 결국 Engine은 반드시 통과해야 하는 계층이라기보다 Domain computation이 필요한 경우 UseCase가 선택하는 도구 중 하나라고 볼 수 있다.
SQL 원자화를 너무 강하게 밀어도 문제가 된다
반대로 원자 SQL을 너무 적극적으로 사용하면 Repository가 특정 UseCase의 비즈니스 로직을 흡수하기 시작할 수 있다. 처음에는 tryDecrease(), claimIfAvailable() 같은 작은 operation이 들어가지만, 이를 계속 밀어붙이면 createRoomAndMembershipIfUserEligibleAndBelowLimit()처럼 하나의 사용자 시나리오 전체가 Repository API로 내려갈 수 있다. 이렇게 되면 Persistence Layer가 사실상 UseCase를 구현하게 되고, Domain과 Application의 책임 구분이 다시 흐려진다.
그래서 여기서 하나의 기준을 잡을 수 있다. UseCase를 원자화하지 않고 invariant를 원자화한다. PurchaseUseCase 전체를 SQL 하나로 만들 필요는 없지만 "재고는 음수가 될 수 없다"와 같이 concurrent한 변경에서도 반드시 지켜져야 하는 최소 규칙은 DB statement 안에서 원자적으로 보호할 수 있다. 반면 여러 상태를 읽고 판단한 뒤 여러 도메인의 상태를 함께 변경해야 하는 더 큰 비즈니스 규칙은 하나의 SQL statement로 원자화하기 어렵다. 이런 경우에는 UseCase 전체를 무리하게 Repository나 SQL 안으로 밀어 넣기보다, 필요한 범위에서 Lock을 사용해 상태를 읽고 판단하고 변경하는 구간을 보호할 수 있다. 나머지 사용자 시나리오와 여러 도메인의 orchestration은 계속 UseCase가 소유한다.
여기서 Lock의 의미도 다시 보이기 시작했다
이 흐름을 따라가다 보니 Optimistic Lock과 Pessimistic Lock을 단순히 "경쟁이 적으면 낙관락, 경쟁이 많으면 비관락"이라고 이해하는 것도 충분하지 않다는 생각이 들었다. 두 전략의 더 중요한 차이는 충돌이 발생했을 때 그 요청이 비즈니스적으로 어떤 의미를 가지는가에 있다.
Optimistic Lock에서는 여러 요청이 일단 같은 상태를 읽고 각자 계산할 수 있도록 허용한 뒤 저장 시점에 내가 읽었던 상태가 여전히 유효한지를 확인한다. 예를 들어 PENDING 상태의 주문을 보고 A는 주문 취소를 시도하고 B는 배송 시작을 시도했다고 하자. A가 먼저 상태를 CANCELLED로 바꿨다면 B가 배송 시작을 결정할 때 사용했던 PENDING이라는 전제 자체가 이미 깨진 것이다.
READ Order(version=7)
A:
UPDATE ... WHERE version=7
→ 성공
→ version=8
B:
UPDATE ... WHERE version=7
→ affected rows = 0
이때 B의 실패는 단순한 기술적 실패라기보다 "네가 판단했던 세계가 이미 바뀌었다"라는 신호로 해석할 수 있다. 따라서 최신 상태를 다시 읽고 동일한 요청이 지금도 유효한지를 다시 판단해야 한다. 현재 상태가 이미 CANCELLED라면 배송 시작은 더 이상 유효한 행동이 아니다. 이런 경우에 충돌했다고 무조건 자동 retry를 수행하는 것은 비즈니스 의미를 무시하는 행동이 될 수도 있다.
반대로 Pessimistic Lock이 자연스러운 상황은 충돌이 발생하더라도 두 요청 모두 여전히 정당하고 결국 수행되어야 하며, 문제는 "누가 수행될 것인가?"가 아니라 "어떤 순서로 수행할 것인가?"인 경우다. 예를 들어 동일한 상태에 대한 두 개의 독립적인 변경이 모두 반드시 반영되어야 한다면 한 요청을 실패시킨 뒤 다시 실행하기보다는 처음부터 경쟁 요청을 대기시키고 순서대로 실행시키는 것이 비즈니스 의미와 더 가까울 수 있다.
A 실행
B 대기
A 완료
B 실행
물론 기술적으로는 이런 경우에도 Optimistic Lock과 자동 retry를 사용할 수 있고, 반대로 많은 상황을 Pessimistic Lock으로 처리하는 것도 가능하다. 하지만 "가능하다"는 것은 좋은 설계 기준이 아니다. 그렇게 따지면 대부분의 동시성 문제를 어떤 방식으로든 억지로 해결할 수 있기 때문이다. 더 좋은 기준은 충돌한 요청이 최신 상태에서도 반드시 수행되어야 하는지, 아니면 충돌이 발생한 순간 기존 판단의 전제가 깨졌기 때문에 다시 비즈니스 판단을 해야 하는지에 있다.
Atomic Statement, Optimistic Lock, Pessimistic Lock
현재까지의 생각을 정리하면 동시성 문제를 다음과 같이 나누어 볼 수 있다.
첫째, 현재 상태에 대한 작은 invariant와 mutation을 하나의 statement로 표현할 수 있다면 Atomic SQL을 우선 고려한다. "재고는 음수가 될 수 없다", "아직 Claim되지 않은 항목만 Claim할 수 있다", "Counter를 1 증가시킨다"와 같은 문제가 여기에 가깝다.
둘째, 충돌이 발생했을 때 내가 판단했던 상태 자체가 더 이상 유효하지 않을 가능성이 있다면 Optimistic Lock이 자연스럽다. "PENDING 상태의 주문을 취소한다", "특정 version의 문서를 수정한다"처럼 특정 상태를 전제로 비즈니스 결정을 내리는 경우 충돌은 최신 상태에서 다시 판단해야 한다는 신호가 된다.
셋째, 충돌하더라도 모든 요청이 여전히 유효하고 결국 실행되어야 하며 단지 실행 순서만 정하면 된다면 Pessimistic Lock이나 다른 형태의 serialization을 고려할 수 있다. 이 경우 여러 요청을 경쟁시킨 뒤 실패한 요청을 다시 수행하는 것보다 애초에 순서대로 처리하는 것이 문제의 의미에 더 잘 맞는다.
동시 상태 변경
│
├─ 최소 invariant를 단일 statement로 표현 가능
│ → Atomic SQL
│
└─ 상태를 읽고 비즈니스 판단 필요
│
├─ 충돌하면 기존 판단의 전제가 깨짐
│ → Optimistic Lock + 최신 상태에서 재판단
│
└─ 충돌해도 모든 요청이 계속 유효함
→ Pessimistic Lock / Serialization 고려
아키텍처와 동시성은 결국 같은 질문으로 이어진다
처음에는 단순히 Controller, Service, Repository를 어떤 디렉토리에 넣을지, UseCase와 Engine의 역할을 어떻게 나눌지를 고민하고 있었다. 하지만 상태를 누가 복원하고, 누가 계산하고, 누가 기록할지를 구분하기 시작하면서 자연스럽게 동시성 문제까지 이어졌다. Repository → UseCase → Domain으로 상태의 생명주기를 끌어올리면 Domain은 훨씬 순수해지고 테스트도 쉬워지지만, 상태 복원과 기록 사이에 다른 요청이 개입할 수 있다는 문제가 생긴다. 반대로 모든 변경을 Repository의 atomic SQL로 밀어 넣으면 동시성 문제는 단순해질 수 있지만 Persistence Layer가 Application의 비즈니스 로직을 흡수하기 시작한다.
결국 중요한 것은 어느 한쪽을 절대적인 규칙으로 정하는 것이 아니라 각각의 책임을 명확하게 구분하는 것이다. Application의 UseCase는 상태를 조합하고 여러 Domain의 행위를 orchestration하며, Domain은 이미 주어진 상태를 기반으로 비즈니스 규칙을 계산한다. Repository는 상태를 복원하고 기록하지만 필요하다면 저장된 최신 상태에 밀착되어 있어야 하는 최소 atomic invariant를 제공할 수 있다. 그리고 Lock 전략 역시 단순히 경쟁량을 기준으로 선택하는 성능 옵션이라기보다 충돌한 요청이 비즈니스적으로 어떤 의미를 가지는가를 표현하는 수단으로 볼 수 있다.
현재까지의 결론을 압축하면 다음과 같다.
Application은 행위를 소유하고 Domain은 규칙을 소유한다.
DB에는 UseCase 전체가 아니라 동시성 상황에서도 보호해야 하는 최소 invariant를 내려보낸다.
Optimistic Lock의 충돌은 "네가 판단했던 세계가 바뀌었다"는 신호이고, Pessimistic Lock은 "이 요청들은 모두 여전히 유효하니 순서대로 실행하자"에 가깝다.
아키텍처를 나누는 목적은 결국 디렉토리를 예쁘게 정리하는 것이 아니었다. 상태를 누가 읽고, 누가 판단하고, 누가 변경하며, 그 판단이 동시에 여러 요청이 들어오는 상황에서도 어떤 의미를 가져야 하는지를 명확하게 만드는 일에 더 가까웠다.
READ NEXT
같은 주제의 글 더 보기
제 얼굴을 본뜬 SVG 프레젠터를 만들고, 프로그래머블 API와 TTS를 연동해 릴스 자동화 시스템에 넣었습니다. 팔과 손을 움직이던 첫 프로토타입부터 얼굴의 인상, 안경과 옷깃, 음성에 맞춘 동작을 다듬기까지 AI와 함께 겪은 시행착오를 정리했습니다.
Read Entry
댓글 0
로그인 없이 익명으로 남길 수 있습니다.