Round2 - Docs

Pyro·2025년 11월 6일

Loopers

목록 보기
2/10

이커머스 시스템을 설계하면서 "좋아요를 두 번 누르면?", "재고가 음수가 되면?", "주문 실패는 어디까지 롤백해야 해?", "도메인 간 경계는 어떻게 나눌까?"라는 질문들과 씨름했다.

설계를 시작하며

2주차 과제로 이커머스 시스템 설계를 맡았다. 상품, 브랜드, 좋아요, 주문... 뭔가 익숙한 도메인이라 쉬울 줄 알았다. "ERD 좀 그리고, API 스펙 정리하면 되는 거 아냐?"

근데 막상 시작하니까 손이 안 가더라. 테이블 하나 그리려는데 자꾸 질문이 생겼다.

  • 사용자가 좋아요 버튼을 연타하면?
  • 마지막 재고 1개를 두 명이 동시에 주문하면?
  • 재고 확인 후 차감 전에 다른 주문이 끼어들면?
  • 결제는 성공했는데 외부 배송 시스템 연동이 실패하면?

"일단 돌아가게 만들고 나중에 고치면 되지"라는 생각이 들 때마다, 이미 데이터가 쌓인 후에 구조를 바꾸는 게 얼마나 고통스러운지 떠올랐다. 그래서 처음부터 고민하기로 했다.

좋아요 버튼, 두 번 눌러도 괜찮을까?

처음 마주한 문제

좋아요 기능을 설계하면서 가장 먼저 막혔던 건 "중복 좋아요"였다.

처음엔 "DB에 유니크 제약 걸면 되는 거 아냐?"라고 생각했다. (user_id, product_id) 복합키로 막으면 중복 INSERT가 안 되니까. 근데 요구사항을 다시 읽어보니 "멱등하게 동작해야 한다" 는 조건이 있더라.

멱등성? 용어는 들어봤지만 실제로 깊게 고민해 본 적은 없었다. 찾아보니 "같은 요청을 여러 번 보내도 결과가 동일해야 한다"는 의미였다. 즉, 이미 좋아요한 상품에 다시 좋아요를 눌러도 에러가 아니라 성공이어야 한다.

"왜 에러면 안 돼?"라는 생각이 들었는데, 사용자 입장에서 생각해보니 이해가 됐다. 네트워크가 느려서 좋아요 버튼을 두 번 누르면 어떻게 될까? 첫 번째는 성공하고 두 번째는 에러가 뜨면, 사용자는 혼란스러울 거다. 그냥 조용히 성공 처리하는 게 맞겠더라.

예외를 쓸까, 검증을 쓸까

그럼 어떻게 구현할까 고민했다.

가장 간단한 방법은 일단 INSERT를 시도하고, DB가 중복 에러를 던지면 catch해서 성공으로 바꾸는 거였다:

try {
    likeRepository.save(Like(userId, productId))
} catch (e: DuplicateKeyException) {
    // 에러지만 성공으로 처리
}

근데 뭔가 찝찝했다. 정상적인 흐름(이미 좋아요한 경우)을 예외로 처리하는 게 맞나? 예외는 말 그대로 "예상하지 못한 상황"에 쓰는 거 아닌가?

그래서 사전에 확인하는 방식으로 바꿨다:

if (!likeRepository.existsByUserIdAndProductId(userId, productId)) {
    likeRepository.save(Like(userId, productId))
}

"좋아요가 없으면 추가한다"는 의도가 명확히 드러난다. 물론 확인과 저장 사이에 다른 요청이 끼어들 수 있지만, DB 유니크 제약이 최종 방어선 역할을 해준다.

왜 이게 더 나았을까?

멱등성은 단순히 "중복 방지" 문제가 아니었다. "사용자가 실수하거나 재시도해도 안전한 시스템" 을 만드는 거더라.

좋아요 취소도 마찬가지로 설계했다. 이미 취소된 좋아요를 다시 취소해도 에러 없이 성공. 사용자는 신경 쓸 필요 없이 버튼만 누르면 된다.

처음엔 "이게 꼭 필요한가?" 싶었는데, 실제 서비스에서 네트워크 재시도나 사용자 실수가 얼마나 많은지 생각하니 이해가 됐다.

재고가 음수가 되는 악몽

동시성이라는 벽

주문 기능을 설계하다가 더 큰 문제와 마주했다. 재고 차감이었다.

val stock = stockRepository.findByProductId(productId)
if (stock.quantity >= orderQuantity) {
    stock.quantity -= orderQuantity
    stockRepository.save(stock)
}

이 코드, 언뜻 보면 문제없어 보인다. 근데 두 명이 동시에 마지막 재고 1개를 주문하면? 둘 다 재고 확인을 통과하고, 재고는 -1이 된다.

"설마 그럴 일이 있겠어?"라고 생각할 수 있지만, 실제 서비스에서는 충분히 일어날 수 있는 일이다. 특히 인기 상품의 한정 수량 판매라면.

락을 걸어야 할까?

동시성 문제를 해결하려면 락을 걸어야 한다는 건 알고 있었다. 근데 어떤 락을 써야 할까?

비관적 락 은 확실하다. SELECT FOR UPDATE로 다른 트랜잭션을 아예 막아버리니까. 하지만 성능이 걱정됐다. 모든 주문 요청이 줄을 서서 기다려야 한다면, 트래픽이 많을 때 버틸 수 있을까?

낙관적 락 은 반대다. 일단 진행하고, 충돌나면 재시도한다. 충돌이 드물다면 성능상 유리하지만, 충돌이 잦으면 재시도 지옥에 빠질 수 있다.

처음엔 낙관적 락을 생각했다. "대부분의 경우 충돌이 없을 것 같았으니까." 하지만 더 고민해보니 재고와 포인트 차감은 데이터 정합성이 가장 중요한 영역 이었다.

재고가 음수가 되거나, 포인트가 마이너스가 되는 상황은 절대 일어나면 안 된다. 그리고 충돌이 나면 재시도 로직을 구현해야 하는데, 그것도 복잡도를 높인다.

결국 비관적 락 을 선택했다.

-- 재고 차감 시 (트랜잭션 내)
SELECT product_id, quantity
FROM stocks
WHERE product_id = :productId
FOR UPDATE;

UPDATE stocks
SET quantity = quantity - :quantity
WHERE product_id = :productId
  AND quantity >= :quantity;

물론 완벽한 선택은 아니다. 트랜잭션이 길어지면 락을 오래 잡게 되고, 데드락 위험도 있다. 그래서 트랜잭션 범위를 최소화 하는 게 중요하다. 외부 시스템 연동은 트랜잭션 밖에서 처리하기로 한 것도 이런 이유다.

배운 것

동시성 제어는 정답이 없더라. "무조건 이 방법이 좋다"가 아니라 "이 시스템에서는 이게 더 적합하다" 를 고민해야 했다.

처음엔 "락 하나 걸면 되는 거 아냐?"라고 생각했는데, 락의 종류마다 트레이드오프가 있다는 걸 배웠다. 성능을 택할지, 안정성을 택할지. 그 판단 기준이 결국 "데이터 정합성이 얼마나 중요한가" 였다.

주문 실패, 어디까지 롤백해야 할까?

트랜잭션의 딜레마

주문 생성은 여러 단계로 이뤄진다:

  1. 주문 정보 저장
  2. 재고 차감
  3. 포인트 차감
  4. 외부 배송 시스템에 알림

1~3번은 당연히 함께 성공하거나 실패해야 한다. 재고는 차감됐는데 주문은 저장 안 되면 큰일이니까. 근데 4번은?

처음엔 "당연히 전부 하나의 트랜잭션이지"라고 생각했다. 4번이 실패하면 전부 롤백하는 거지. 깔끔하고 안전해 보였다.

근데 시퀀스 다이어그램을 그리다가 뭔가 이상하다는 걸 느꼈다. 외부 시스템 호출은 우리가 제어할 수 없다. 느릴 수도, 타임아웃날 수도, 일시적으로 죽어있을 수도 있다. 그걸 트랜잭션 안에 넣으면?

트랜잭션이 외부 시스템에 종속된다. 외부 시스템이 느려지면 우리 DB 트랜잭션도 길어지고, 락도 오래 잡히고, 전체 시스템이 느려진다.

안전함과 성능 사이

고민이 깊어졌다.

  • 외부 연동까지 트랜잭션에 넣으면: 안전하지만 느리고, 외부 시스템 장애가 우리를 마비시킬 수 있음
  • 외부 연동을 트랜잭션 밖으로 빼면: 빠르지만, 실패 시 주문은 저장됐는데 배송 시스템은 모르는 상황 발생

결국 "완벽한 실시간 일관성"을 포기 하기로 했다. 대신 "최종적으로는 일관되게 만들 수 있다"는 방향으로.

트랜잭션은 1~3번까지만. 외부 연동은 커밋 후에 시도하고, 실패하면 재시도하거나 관리자에게 알람을 보낸다. 최악의 경우 수동 처리할 수 있는 여지를 남겨둔 거다.

@Transactional
fun createOrder(...): Order {
    // 1. 주문 저장
    // 2. 재고 차감
    // 3. 포인트 차감
    return order
}  // 여기서 커밋

// 트랜잭션 밖에서
fun notifyExternalSystem(order: Order) {
    try {
        externalSystem.send(order)
    } catch (e: Exception) {
        // 재시도 큐에 넣거나 알람
        retryQueue.add(order)
    }
}

실패는 어떻게 처리할까?

외부 시스템 연동 실패에 대한 구체적인 전략도 필요했다:

초기 전략: 재시도 로직

  • 실패한 주문을 재시도 큐에 저장
  • 백그라운드 워커가 주기적으로 재시도 (예: 3회, 지수 백오프)
  • 3회 실패 시 관리자 알람 및 수동 처리 대기열로 이동
// 재시도 로직 (간단한 버전)
class RetryWorker {
    @Scheduled(fixedDelay = 60000)  // 1분마다
    fun processRetryQueue() {
        retryQueue.forEach { order ->
            try {
                externalSystem.send(order)
                retryQueue.remove(order)
            } catch (e: Exception) {
                if (order.retryCount >= 3) {
                    alertManager.notify(order)  // 수동 처리 필요
                    manualQueue.add(order)
                }
            }
        }
    }
}

향후 확장: 보상 트랜잭션

  • 재시도가 계속 실패하면 주문 자체를 취소하는 보상 트랜잭션 고려
  • 재고 복구, 포인트 환불 등의 롤백 로직
  • 현재는 구현하지 않고, 필요시 추가하기로 결정

완벽하지는 않다. 하지만 현실적이다. 외부 시스템 때문에 전체가 멈추는 것보다는, 99% 성공하고 1%는 재시도나 수동 처리하는 게 나았다.

배운 것

트랜잭션 범위를 정하면서 "완벽함"과 "현실" 사이에서 타협해야 한다는 걸 배웠다.

이론적으로는 모든 것을 한 트랜잭션으로 묶는 게 안전하다. 하지만 실제 서비스에서는 외부 시스템, 네트워크, 성능 같은 변수들이 있다. "무조건 롤백"보다는 "재시도와 보상"으로 해결하는 게 더 실용적일 때가 있다.

처음엔 불안했다. "이게 맞나? 데이터가 틀어지면 어쩌지?" 하지만 카프카 같은 메시지 큐나, 재시도 큐를 잘 설계하면 충분히 관리 가능하다는 걸 알게 됐다.

Bounded Context, 경계를 명확히 하다

또 다른 설계 문제

시퀀스 다이어그램을 그리면서 뭔가 이상한 걸 발견했다. 상품 서비스가 좋아요 Repository를 직접 사용하고 있었다.

ProductService → likeRepository.countByProductId()  // 내부 구현 직접 접근

반면 좋아요 서비스는 상품 정보를 가져올 때 인터페이스를 썼다:

LikeService → productReader.getById()  // 인터페이스 사용

일관성이 없었다. 찾아보니 이건 Bounded Context 를 제대로 분리하지 않은 거였다.

Facade 패턴으로 해결

DDD에서는 각 도메인이 명확한 경계를 가져야 한다. 도메인 간 협력은 공개 인터페이스 를 통해서만 이뤄져야 한다.

해결 방법은 Facade 인터페이스 였다. 각 BC는 외부에 공개할 기능만 Facade로 제공한다:

interface LikeFacade {
    fun countByProductId(productId: Long): Long
}

interface ProductFacade {
    fun getById(productId: Long): Product
    fun decreaseStock(productId: Long, quantity: Int)  // 재고도 Product BC 일부
}

이제 BC 간 협력이 명확해진다:

// Product BC
ProductService(likeFacade: LikeFacade)  // ✅ Facade 사용

// Like BC
LikeService(productFacade: ProductFacade)  // ✅ Facade 사용

// Order BC
OrderService(
    productFacade: ProductFacade,  // 상품 + 재고
    pointFacade: PointFacade       // 포인트
)

중요한 결정: Stock을 별도 BC로 분리하지 않고 Product BC의 일부로 봤다. Product와 Stock은 항상 함께 움직이고 같은 트랜잭션에서 관리되어야 하니까.

최종적으로 6개의 BC로 정리:

  • User BC, Brand BC, Product BC (재고 포함), Like BC, Order BC, Point BC

배운 것

처음엔 "그냥 필요한 Repository 갖다 쓰면 되는 거 아냐?"라고 생각했다. 하지만 그러면 도메인 간 결합도가 높아지고, 나중에 수정하기 어려워진다.

Facade로 경계를 명확히 하니:

  • BC 내부 구현을 자유롭게 변경 가능
  • 테스트하기 쉬움 (Facade를 Mock으로 대체)
  • BC 간 관계를 쉽게 이해

"좋은 설계는 경계가 명확한 설계"라는 말이 이해가 됐다.

도메인 객체가 똑똑해야 하는 이유

Service가 너무 똑똑했던 문제

클래스 다이어그램을 그리면서 또 다른 고민이 생겼다. "로직을 어디에 둘까?"

처음 그린 설계에서는 Service가 모든 걸 했다:

class OrderService {
    fun createOrder(...) {
        val stock = stockRepository.findById(productId)
        if (stock.quantity < orderQuantity) {
            throw InsufficientStockException()
        }
        stock.quantity -= orderQuantity  // Service가 직접 차감
        stockRepository.save(stock)
    }
}

뭔가 이상했다. Stock 클래스는 그냥 데이터만 들고 있고, 모든 로직은 Service에 있다. 이러면 Stock을 쓰는 모든 곳에서 동일한 검증 로직을 반복해야 한다. 누군가 깜빡하면 버그가 된다.

"재고를 관리하는 건 Stock의 책임 아닐까?"라는 생각이 들었다.

책임을 넘기다

Stock에게 책임을 줬다:

class Stock(
    val productId: Long,
    private var quantity: Int  // private!
) {
    fun decrease(requestQuantity: Int) {
        if (quantity < requestQuantity) {
            throw InsufficientStockException()
        }
        quantity -= requestQuantity
    }
}

이제 Service는 단순해진다:

class OrderService {
    fun createOrder(...) {
        val stock = stockRepository.findById(productId)
        stock.decrease(orderQuantity)  // Stock에게 위임
        stockRepository.save(stock)
    }
}

"재고를 차감해줘"라고 부탁만 하면 된다. 어떻게 검증하고 차감하는지는 Stock이 알아서 한다.

처음엔 "굳이 이렇게까지?"라는 생각이 들었는데, 테스트를 작성해보니 차이가 확 느껴졌다. Service를 띄우지 않고도 Stock 단위로 테스트할 수 있었다. "10개 재고에서 5개 차감하면?" 같은 케이스를 간단하게 검증할 수 있었다.

배운 것

"데이터를 가진 쪽이 로직도 가져야 한다" 는 객체지향의 원칙을 몸으로 느꼈다.

처음엔 Service에 로직을 두는 게 편해 보였다. 한곳에 모여있으니까. 근데 프로젝트가 커지면 Service는 비대해지고, 도메인 객체는 텅 비게 된다. 그러면 같은 로직이 여기저기 흩어지고, 수정할 때 놓치는 곳이 생긴다.

도메인 객체에 책임을 주니 코드가 의도를 드러냈다. stock.decrease(5)만 봐도 뭘 하는지 알 수 있다. 테스트하기도, 유지보수하기도 쉬워졌다.

주문 상태, 어떻게 관리할까?

상태가 이렇게 복잡할 줄이야

주문을 설계하면서 또 다른 고민이 생겼다. 주문의 상태를 어떻게 관리할까?

처음엔 "그냥 PENDING, COMPLETED, CANCELLED 정도면 되는 거 아냐?"라고 생각했다. 근데 실제 주문 흐름을 그려보니 상태가 훨씬 복잡했다.

  • 주문을 생성했지만 아직 결제 전 → PENDING
  • 결제가 완료되고 배송 준비 중 → CONFIRMED
  • 배송이 시작됨 → SHIPPED
  • 배송이 완료됨 → DELIVERED
  • 중간에 취소됨 → CANCELLED

각 상태마다 할 수 있는 동작도 다르다. PENDING일 땐 취소가 자유롭지만, SHIPPED 상태에서는 취소가 제한적이어야 한다. DELIVERED 상태에서는 취소가 아예 불가능하고.

상태 다이어그램을 그리면서 "어떤 상태에서 어떤 상태로 전이할 수 있는가" 를 명확히 했다. 그리고 각 전이마다 "어떤 액션이 수반되는가" 도 정리했다.

예를 들어 CONFIRMED → CANCELLED로 갈 때는:

  • 재고 복구
  • 포인트 환불
  • 주문 취소 처리

이런 보상 트랜잭션이 필요하다는 걸 설계 단계에서 미리 파악할 수 있었다.

배운 것

상태 관리는 단순히 enum 하나 만드는 게 아니었다. "비즈니스 규칙" 이 녹아있는 중요한 설계였다.

처음엔 "나중에 필요하면 상태 추가하면 되지"라고 생각했는데, 상태 전이 규칙을 미리 정리하니 예외 케이스를 놓치지 않을 수 있었다. "이 상태에서는 이 동작이 불가능해야 한다"는 제약을 코드로 강제할 수 있게 됐다.

근데 완벽하진 않다

아직 애매한 부분들

설계하면서 "이게 맞나?" 싶은 부분들이 있었다.

좋아요 수 집계는 어떻게 할까?

상품 조회 시 좋아요 수를 보여줘야 하는데, 상품마다 COUNT 쿼리를 날리면 N+1 문제가 생긴다. 대안으로 생각한 건:

  1. 상품 테이블에 like_count 컬럼 추가 (비정규화)
  2. JOIN으로 한 번에 조회
  3. Redis 캐싱

초기 구현은 단순하게 가고, 성능 문제가 생기면 개선하기로 했다. 근데 이게 맞는 선택인지는 아직 모르겠다.

ERD 설계할 때 고민했던 것들

  • products 테이블에 price_amountprice_currency를 분리해서 넣었는데, 지금은 KRW만 쓰니까 오버엔지니어링 아닐까?
  • order_items에 주문 시점의 정보를 스냅샷으로 저장하는 게 맞나? product_name, brand_id, brand_name, brand_description, price_at_order를 모두 저장하기로 했는데, 데이터 중복이긴 하다. 하지만 상품이나 브랜드 정보가 바뀌어도 주문 이력은 정확히 유지해야 하니까 필요한 선택이라고 판단했다.

완벽한 답은 없는 것 같다. 지금은 "확장 가능하게"를 우선으로 설계했다.

설계는 정답 찾기가 아니었다

이번 설계 과정에서 가장 많이 했던 질문은 "왜?"였다.

  • 왜 멱등성이 필요한가?
  • 왜 비관적 락을 선택했는가?
  • 왜 외부 연동은 트랜잭션 밖인가?
  • 왜 재시도 로직이 필요한가?
  • 왜 도메인 객체에 로직을 넣는가?
  • 왜 Bounded Context를 분리해야 하는가?
  • 왜 Facade 인터페이스가 필요한가?

정답이 있는 질문은 하나도 없었다. 대신 "이 상황에서는 이게 더 나은 선택 같다" 는 판단을 계속했다.

처음엔 불안했다. "내가 선택한 게 맞나?" "더 좋은 방법이 있는 거 아닐까?" 하지만 모든 선택에는 트레이드오프가 있다는 걸 받아들이니, 선택이 쉬워졌다.

완벽한 설계는 없다. 다만 "왜 이렇게 설계했는지"를 설명할 수 있다면, 그게 좋은 설계의 시작이 아닐까?

다음 라운드에서 해보고 싶은 것

  1. N+1 문제를 실제로 겪어보고 해결해보고 싶다. 지금은 이론만 알고 실제로 얼마나 느린지 체감하지 못했다.

  2. 동시성 테스트를 작성해보고 싶다. 비관적 락이 정말 동작하는지 테스트로 검증할 수 있을까?

  3. 재시도 로직을 실제로 구현해보고 싶다. 재시도 큐, 지수 백오프, 수동 처리 대기열까지 완성도 있게 구현하면 어떤 문제가 생길까?

  4. 이벤트 기반 아키텍처 를 적용해보면 어떨까? 주문 생성 후 이벤트를 발행하고, 리스너가 외부 시스템 연동을 처리하는 방식. 재시도 로직도 이벤트로 처리할 수 있을 것 같다.

  5. Facade와 트랜잭션 경계를 실제 코드로 구현해보고 싶다. 설계는 했지만 실제로 어떻게 구현할지, 특히 Facade를 넘나드는 트랜잭션은 어떻게 관리할지 궁금하다.

이번 라운드는 "구현"보다 "고민"이 많았던 시간이었다. ERD 한 장, 시퀀스 다이어그램 몇 개 그리는 데 한참이 걸렸다. 하지만 그만큼 "왜?"를 생각하는 시간이었고, 설계가 단순히 "어떻게"가 아니라는 걸 배웠다.

다음 주부터는 이 설계를 코드로 구현한다. 설계와 구현 사이의 간극을 어떻게 메꿔갈지, 또 어떤 예상치 못한 문제를 마주할지 궁금하다.

profile
dreams of chronic and sustained passion

0개의 댓글