20260510 [TIL] - 카페 주문 시스템

2026. 5. 10. 20:36·IL/TIL

이번 과제는 카페 주문 시스템을 구현하는 것이었다.

기능 자체만 보면 메뉴 조회, 포인트 충전, 주문/결제, 인기 메뉴 조회로 단순해 보였다. 하지만 요구사항을 다시 읽어보면 계속 강조되는 부분이 있었다.

  • 다수 서버 환경에서도 안정적으로 동작해야 한다.
  • 동시 요청 상황에서도 정합성이 깨지면 안 된다.
  • 트랜잭션 관점에서 데이터 일관성을 보장해야 한다.
  • 기능과 제약사항에 대한 테스트를 작성해야 한다.

처음에는 API를 하나씩 구현하면 되는 과제라고 생각했는데, 결제는 포인트로만 가능하다는 조건을 보고 생각이 조금 달라짐

포인트는 결국 돈과 비슷한 데이터다. 동시에 여러 요청이 들어왔을 때 잔액이 잘못 계산되면 안 된다. 포인트 결제 정합성을 가장 중요하게 보고 설계했다.

 


1. 핵심 요구사항 정리

구현해야 하는 API는 다음과 같았다.

기능 설명
메뉴 목록 조회 메뉴 ID, 이름, 가격 조회
포인트 충전 사용자 식별값과 충전 금액을 받아 포인트 충전
주문/결제 사용자 식별값과 메뉴 ID를 받아 포인트 차감 후 주문 생성
인기 메뉴 조회 최근 7일간 주문 수 기준 상위 3개 메뉴 조회

추가로 고민해야 할 부분~

  • 여러 서버 인스턴스에서 동작해도 문제가 없어야 한다.
  • 동시에 요청이 들어와도 포인트 잔액이 깨지면 안 된다.
  • 주문, 결제, 주문 이벤트 저장이 일관성 있게 처리되어야 한다.
  • 테스트로 기능과 제약사항을 검증해야 한다.

 


2. 도메인 설계 고민

@Entity
@Getter
@Table(name = "menus")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Menu {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private int price;

    private boolean active;
}

주문 엔티티도 고민이 있었다.

처음에는 Order가 Menu를 연관관계로 가지고 있어도 되지 않을까 생각했다. 하지만 메뉴 이름이나 가격은 나중에 바뀔 수 있다. 예를 들어 아메리카노 가격이 2,800원에서 3,000원으로 변경되더라도, 과거 주문 내역은 주문 당시 가격인 2,800원으로 남아야 한다. 그래서 주문에는 메뉴를 직접 연관관계로 들고 있기보다, 주문 당시의 메뉴 정보를 스냅샷으로 저장하기로.....

@Column(name = "menu_id", nullable = false)
private Long menuId;

@Column(name = "menu_name", nullable = false, length = 50)
private String menuName;

@Column(name = "payment_amount", nullable = false)
private long paymentAmount;

이렇게 하면 메뉴 정보가 나중에 변경되더라도 과거 주문 내역과 인기 메뉴 집계는 주문 당시 데이터를 기준으로 유지할 수 있음..

 


3. 포인트 결제에서 가장 중요한 문제

이번 과제에서 가장 중요하게 본 부분은 포인트 잔액

예를 들어 어떤 사용자의 잔액이 3,000P라고 가정해보자. 그리고 2,800P짜리 아메리카노를 동시에 두 번 주문한다. 락이 없다면 두 요청이 모두 같은 잔액 3,000P를 읽을 수 있다.

요청 A: 잔액 3,000P 확인 -> 결제 가능
요청 B: 잔액 3,000P 확인 -> 결제 가능

실제로는 한 번만 성공해야 하는데 두 요청이 모두 성공하면 주문이 두 번 생성되고 잔액이 음수가 될 수 있다. 이 문제를 막기 위해 포인트 잔액을 변경할 때는 사용자별 포인트 지갑 row를 잠그기로 함

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select w from PointWallet w where w.userId = :userId")
Optional<PointWallet> findByUserIdForUpdate(@Param("userId") String userId);

DB row lock을 선택한 이유는 다중 서버 환경 때문인데... 자바의 synchronized나 ReentrantLock 같은 애플리케이션 메모리 기반 락은 서버 인스턴스가 하나일 때는 동작할 수 있다. 하지만 서버가 여러 대라면 각 서버의 메모리는 공유되지 않는다. 같은 사용자의 요청이 서로 다른 서버로 들어가면 애플리케이션 락만으로는 정합성을 보장하기 어렵다.

반면 DB row lock은 여러 서버가 같은 DB를 바라보는 상황에서도 동일한 사용자 지갑 row를 기준으로 요청을 직렬화 가넝. 물론 같은 사용자의 요청이 몰리면 해당 row에서는 대기가 발생할 수 있다. 하지만 포인트 결제에서는 처리량보다 잔액 정합성을 우선해야 한다고 판단햇는데... 음 ㅋ

 


4. DB row lock을 선택하면서 고민한 점

DB row lock을 선택하면서 병목 가능성도 같이 고민

비관적 락은 같은 사용자의 포인트 지갑 row에 대해 요청을 순서대로 처리하게 만든다. 동일 사용자의 충전이나 주문 요청이 짧은 시간에 많이 몰리면 해당 row에서 대기가 발생할 수 있다

처음에는 이 부분이 조금 걸렸다. 동시성 문제를 해결하려고 락을 걸었는데, 그 락이 성능 병목이 될 수 있기 때문이다.

하지만 포인트 잔액은 주문 성공 여부와 직접 연결되는 데이터다. 잔액이 부족한 주문이 성공하면 안 되고, 동시에 여러 주문이 들어와도 잔액이 음수가 되면 안 된다. 결국 같은 사용자의 하나의 잔액을 정확하게 변경하려면 어떤 방식으로든 순서가 필요하다고 판단했다. DB row lock을 사용하면 그 순서를 DB에서 보장한다. Redis 분산 락을 사용하면 Redis에서 순서를 보장하고, Kafka를 사용하더라도 userId를 key로 잡으면 같은 사용자의 이벤트는 같은 파티션에서 순서대로 처리된다.

병목을 완전히 없애는 문제라기보다는 정합성이 필요한 지점을 어디에서 직렬화할 것인지 선택하는 문제에 가깝다고 생각했다. 이번 과제에서는 다음 이유로 DB row lock을 선택했다.

  • 포인트 잔액은 DB가 최종 원본 데이터다.
  • 다중 서버 환경에서도 DB row lock은 동일하게 동작한다.
  • 구현이 단순하고 테스트로 검증하기 쉽다.
  • 락 범위가 전체 테이블이 아니라 사용자별 지갑 row로 제한된다.
  • 서로 다른 사용자의 주문과 충전은 동시에 처리될 수 있다.

물론 운영 환경에서 특정 사용자에게 요청이 과도하게 몰린다면 이 방식만으로는 한계가 있다. 그 경우에는 조건부 UPDATE로 락 점유 시간을 줄이거나, 낙관적 락과 재시도 전략을 사용할 수 있다. 주문 처리를 비동기로 전환할 수 있다면 Kafka에서 userId를 key로 사용해 사용자별 순서를 보장하는 방식도 검토할 수 있다. 다만 이번 과제에서는 포인트 결제 정합성을 명확하게 보장하고, 그 동작을 테스트로 검증하는 것이 더 중요하다고 판단

 


5. 포인트 충전 동시성 테스트 실패

동시성 테스트를 작성하면서 실제로 문제가 하나 발생..!~ 처음 구현에서는 포인트 충전 요청이 들어올 때마다 먼저 INSERT IGNORE로 지갑 생성을 시도했다. 그 다음 다시 지갑을 비관적 락으로 조회했다.

pointWalletRepository.insertIfAbsent(request.userId());

PointWallet wallet = pointWalletRepository.findByUserIdForUpdate(request.userId())
        .orElseThrow(() -> new BusinessException(ErrorCode.POINT_WALLET_NOT_FOUND));

단일 요청에서는 문제가 없었다. 하지만 이미 지갑이 존재하는 상황에서도 모든 요청이 INSERT IGNORE를 먼저 수행했다. 동시에 여러 충전 요청이 들어오면, 실제로 생성할 필요가 없는 지갑에 대해서도 계속 insert 시도를 하게 된다. 그 결과 불필요한 unique key 경합이 생겼고, 동시 포인트 충전 테스트에서 최종 잔액 검증이 실패했다.

비관적 락을 걸었는데 왜 실패하지?라는 생각이 들었다. 하지만 흐름을 다시 보니 락을 잡기 전에 이미 insert 쿼리가 먼저 실행되고 있었다. 그래서 포인트 충전 흐름을 수정..~

@Transactional
public ChargePointResponse charge(ChargePointRequest request) {
    long amount = request.amount();

    PointWallet wallet = pointWalletRepository.findByUserIdForUpdate(request.userId())
            .orElseGet(() -> createWalletAndLock(request.userId()));

    wallet.charge(amount);
    pointHistoryRepository.save(PointHistory.charge(wallet.getUserId(), amount, wallet.getBalance()));

    return ChargePointResponse.of(wallet, amount);
}

이미 지갑이 있는 경우에는 바로 락으로 조회한다. 지갑이 없을 때만 생성하도록 분기했다.

private PointWallet createWalletAndLock(String userId) {
    pointWalletRepository.insertIfAbsent(userId);

    return pointWalletRepository.findByUserIdForUpdate(userId)
            .orElseThrow(() -> new BusinessException(ErrorCode.POINT_WALLET_NOT_FOUND));
}

동시성 문제는 단순히 락을 사용햇다로 끝나는 게 아님 락을 어디에서 잡는지, 락을 잡기 전에 어떤 쿼리가 먼저 실행되는지도 중요하지.. . . . .

 


6. 주문 결제 트랜잭션 설계

주문 결제는 하나의 트랜잭션으로 묶었다.

@Transactional
public CreateOrderResponse createOrder(CreateOrderRequest request) {
    Menu menu = menuRepository.findByIdAndActiveTrue(request.menuId())
            .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND));

    PointWallet wallet = pointWalletRepository.findByUserIdForUpdate(request.userId())
            .orElseThrow(() -> new BusinessException(ErrorCode.POINT_WALLET_NOT_FOUND));

    wallet.use(menu.getPrice());
    pointHistoryRepository.save(PointHistory.use(wallet.getUserId(), menu.getPrice(), wallet.getBalance()));

    Order order = orderRepository.save(Order.paid(request.userId(), menu));
    orderEventService.saveAndSendAfterCommit(order);

    return CreateOrderResponse.from(order);
}

이 트랜잭션 안에서 처리되는 작업은 다음과 같다.

  1. 주문 가능한 메뉴 조회
  2. 포인트 지갑 조회 및 row lock 획득
  3. 잔액 검증
  4. 포인트 차감
  5. 포인트 사용 이력 저장
  6. 주문 저장
  7. 주문 이벤트 outbox 저장

중간에 하나라도 실패하면 주문이 성공했다고 볼 수 없다. 그래서 이 작업들은 하나의 트랜잭션 안에서 처리하는 것이 맞다고 판단했다. 특히 포인트 차감은 주문 생성과 분리되면 안 된다.

포인트 차감 성공 + 주문 저장 실패
주문 저장 성공 + 포인트 차감 실패

이런 상태가 생기면 데이터 일관성이 깨진다. 그래서 주문과 포인트 변경은 같은 트랜잭션 경계 안에 두었다.

 


7. 주문 이벤트 전송과 Outbox 패턴

요구사항에는 주문 내역을 데이터 수집 플랫폼으로 실시간 전송하는 로직이 있었다. 처음에는 주문이 생성된 직후 Mock API를 바로 호출하면 되지 않을까 생각했다. 하지만 외부 시스템 호출은 실패할 수 있다.

예를 들어 이런 상황이 생길 수 있다.

주문 DB 저장 성공
외부 데이터 플랫폼 전송 실패

이 경우 주문은 존재하지만 외부 플랫폼에는 주문 데이터가 전달되지 않는다. 반대 상황도 생각해야 됨

외부 데이터 플랫폼 전송 성공
주문 트랜잭션 롤백

이 경우 실제로는 존재하지 않는 주문 이벤트가 외부로 나가게 된다. 그래서 주문 이벤트를 먼저 DB의 outbox 테이블에 저장하고, 주문 트랜잭션이 커밋된 이후에 전송하도록 함

public void saveAndSendAfterCommit(Order order) {
    OrderEventPayload payload = OrderEventPayload.from(order);

    OrderOutbox outbox = orderOutboxRepository.save(
            OrderOutbox.pending(order.getId(), toJson(payload))
    );

    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                orderOutboxSender.send(outbox.getId());
            }
        });
        return;
    }

    orderOutboxSender.send(outbox.getId());
}

이렇게 하면 주문 트랜잭션이 정상적으로 커밋된 뒤에만 외부 전송이 ~

전송 결과는 주문 트랜잭션과 분리해서 기록

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void send(Long outboxId) {
    OrderOutbox outbox = orderOutboxRepository.findById(outboxId)
            .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_EVENT_NOT_FOUND));

    try {
        OrderEventPayload payload = objectMapper.readValue(outbox.getPayload(), OrderEventPayload.class);
        dataPlatformClient.send(payload);
        outbox.markSent();
    } catch (JacksonException e) {
        outbox.markFailed();
        throw new BusinessException(ErrorCode.EVENT_PAYLOAD_CONVERT_FAILED);
    }
}

현재는 Mock 데이터 플랫폼으로 전송하지만, 실제 운영 환경에서는 DataPlatformClient 구현체를 Kafka Producer로 교체할 수 잇듬 Kafka를 바로 붙이지 않은 이유는 이번 과제의 핵심이 메시지 브로커 사용 자체라기보다, 주문 데이터와 이벤트 저장의 일관성을 보장하는 것이라고 생각했기 때문이다.

그래서 지금은 outbox 구조를 유지하면서, 전송 구현체만 나중에 바꿀 수 있도록 분리했다.

 


8. 인기 메뉴 조회 설계

인기 메뉴는 최근 7일간 결제 완료된 주문만 대상으로 집계했다.

@Query("""
        select
            o.menuId as menuId,
            o.menuName as name,
            o.paymentAmount as price,
            count(o.id) as orderCount
        from Order o
        where o.status = :status
          and o.orderedAt >= :from
        group by o.menuId, o.menuName, o.paymentAmount
        order by count(o.id) desc, o.menuId asc
        """)
List<PopularMenuProjection> findPopularMenus(
        @Param("status") OrderStatus status,
        @Param("from") LocalDateTime from,
        Pageable pageable
);

조건은 다음과 같이 정했다.

기준 기간: 현재 시각 기준 최근 7일
대상 주문: PAID 상태 주문
정렬 조건: 주문 수 내림차순, 메뉴 ID 오름차순
조회 개수: 상위 3개

동일한 주문 수일 경우 메뉴 ID 기준으로 정렬했다. 이렇게 하지 않으면 같은 데이터여도 DB 실행 계획에 따라 응답 순서가 달라질 수 있다고 생각했다.

 


9. 테스트로 검증한 내용

테스트는 크게 API 통합 테스트와 동시성 테스트로 나눔 (시간 없음 이슈)

API 통합 테스트

  • 활성화된 메뉴 목록을 조회한다.
  • 포인트를 충전한다.
  • 충전 금액이 0 이하이면 실패한다.
  • 주문 결제 시 포인트를 차감하고 주문 이벤트를 저장한다.
  • 포인트 잔액이 부족하면 주문에 실패한다.
  • 최근 7일간 주문 수 기준 인기 메뉴 3개를 조회한다.

동시성 테스트에서는 여러 스레드를 같은 시점에 출발~

assertThat(readyLatch.await(5, TimeUnit.SECONDS)).isTrue();

startLatch.countDown();

assertThat(doneLatch.await(10, TimeUnit.SECONDS)).isTrue();

포인트 충전 테스트에서는 동시에 여러 번 충전해도 최종 잔액이 정확한지 확인했다. 주문 테스트에서는 동시에 여러 주문이 들어와도 포인트 잔액이 음수가 되지 않는지 확인했다.

테스트는 Testcontainers를 사용해서 MySQL 환경에서 실행

 


10. 앞으로 개선하고 싶은 점

현재 구현은 과제 범위 안에서 정합성과 동시성 검증에 집중했다. 확장한다면 더 개선해 볼만한 부분,...!

  • outbox 전송 실패 이벤트 재시도 스케줄러 추가
  • Kafka Producer 기반 주문 이벤트 발행
  • 주문 중복 방지를 위한 idempotency key 검토
  • 운영 DB 관리를 위한 migration 도구 도입

다음에는 기능 구현을 시작하기 전에 어떤 데이터가 가장 중요하고 어떤 상황에서 정합성이 깨질 수 있는지를 먼저 더 깊게 생각해봐야겟슨... 최프 어뜩하지

저작자표시 비영리 (새창열림)

'IL > TIL' 카테고리의 다른 글

최종프로젝트에서 flyway 쓰는 이유  (0) 2026.05.21
외부 AI Provider 호출 보호를 위한 Redis 기반 Rate Limit 적용  (0) 2026.05.20
20260404 [TIL] - 플러스 스프링 과제  (0) 2026.04.02
20260310 [TIL] 클라우드 아키텍처 설계 & 배포  (0) 2026.03.10
20260304 [TIL] - 코드 개선  (0) 2026.03.03
'IL/TIL' 카테고리의 다른 글
  • 최종프로젝트에서 flyway 쓰는 이유
  • 외부 AI Provider 호출 보호를 위한 Redis 기반 Rate Limit 적용
  • 20260404 [TIL] - 플러스 스프링 과제
  • 20260310 [TIL] 클라우드 아키텍처 설계 & 배포
견지
견지
개발로 개발하는지 새발로 개발하는지 내가 개인 건지 새인 건지 사람인 건지
  • 견지
    개발새발
    견지
  • 전체
    오늘
    어제
    • 분류 전체보기 (34) N
      • ... (0)
      • IL (34) N
        • TIL (30) N
        • WIL (4)
        • MIL (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    JavaScript
    oracle
    HTML
    java
    DB
    JSP
    git
    CSS
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
견지
20260510 [TIL] - 카페 주문 시스템
상단으로

티스토리툴바