spring_2기[본캠프]/과제

[과제] Spring K사 서버 개발 프로젝트 Day 1

minwoo95 2026. 4. 3. 21:53

## 필수 요구사항

**아래 API를 구현합니다. 단, 기술 제약사항을 사전에 고려하여 설계해주세요.**

1. 커피 메뉴 목록 조회 API
2. 포인트 충전하기 API
3. 커피 주문, 결제하기 API
4. 인기 메뉴 목록 조회 API

### 0. 문제 해결 전략 수립

> 해당 내용은 `README.md`에 필수로 첨부해야 합니다.

- [ ]  설계 내용(ERD, API 명세서)
- [ ]  설계의 의도
- [ ]  선택한 문제해결 전략 및 이 선택을 위해 분석한 내용
- [ ]  기술적 선택 이유

-------------------------------------------------------------------------------------------------------------------------------------

1. 과제 개요 & 핵심 문제 인식
과제는 겉보기엔 단순하다. 메뉴 조회, 포인트 충전, 주문/결제, 인기 메뉴 조회. 네 개의 API다.
그런데 과제 안내에는 이런 문장이 있었다.

"다수 서버 환경에서도 안정적으로 동작하는 커피숍 주문 시스템을 구현해 봅시다."

이 한 문장이 모든 것을 바꾼다. 단순한 CRUD가 아니라 분산 환경에서의 데이터 정합성과 동시성을 고려해야 하는 문제가 된다.
이 과제에서 진짜 어려운 문제는 세 가지라고 판단했다.

포인트 동시성 — 같은 유저가 동시에 충전하거나 주문하면 포인트가 잘못 계산될 수 있다
인기 메뉴 집계 정확성 — 매 요청마다 7일치를 집계하면 성능 문제가 발생한다
주문 + 외부 데이터 전송의 일관성 — 외부 플랫폼 전송 실패 시 주문이 롤백되어야 하는가?

설계의 모든 결정은 이 세 문제를 해결하는 방향으로 이루어졌다.

2. 기술 스택 선택 이유
Spring Boot 3.x + JPA + MySQL + Redis

 

Spring Boot + JPA
JPA를 선택한 이유는 단순히 익숙해서가 아니다. @Lock 어노테이션을 통한 비관적 락 구현, @Transactional을 통한 원자성 보장이 이 과제의 핵심 요구사항과 잘 맞아떨어지기 때문이다.
MyBatis도 충분히 유효한 선택이지만, 동시성 제어 로직을 표현할 때 JPA의 추상화가 의도를 더 명확하게 드러낼 수 있다고 판단했다.

 

MySQL
RDBMS를 선택한 핵심 이유는 트랜잭션이다. 포인트 차감과 주문 생성은 반드시 하나의 트랜잭션으로 묶여야 한다. 어느 하나가 실패하면 전체가 롤백되어야 하고, 이는 ACID를 보장하는 관계형 DB가 가장 안전하게 처리할 수 있다.

 

Redis
인기 메뉴 집계 캐싱을 위해 도입했다. 인메모리 특성상 빠른 읽기가 가능하고, Sorted Set 자료구조를 활용하면 점수 기반 랭킹을 자연스럽게 표현할 수 있다. 상세한 이유는 아래 4번에서 서술한다.

3. 동시성 문제 — 왜 비관적 락(Pessimistic Lock)을 선택했는가
문제 상황
다수 서버 환경에서 같은 유저가 동시에 포인트를 차감하려 한다고 가정한다.
잔액: 1000P
스레드 A: SELECT balance → 1000P 읽음
스레드 B: SELECT balance → 1000P 읽음
스레드 A: 500P 차감 → UPDATE balance = 500P
스레드 B: 500P 차감 → UPDATE balance = 500P  ← 잘못된 결과!
최종 잔액이 0P가 되어야 하는데 500P로 남는 Lost Update 문제다.

 

선택지 비교

방식 장점 단점 적합성
낙관적 락 (Optimistic Lock) 충돌이 없을 때 성능 우수 충돌 시 재시도 로직 필요, 사용자 경험 불안정
비관적 락 (Pessimistic Lock) 데이터 정합성 강력 보장, 구현 단순 락 대기로 처리량 감소 ok
Redis 분산 락 다수 서버에서 글로벌 락 가능 구현 복잡, Redis 장애 시 전체 영향

 

비관적 락을 선택한 논리
낙관적 락을 사용하지 않은 이유: 낙관적 락은 충돌이 드물다는 전제 아래 효율적이다. 하지만 충돌 발생 시 애플리케이션 레벨에서 재시도를 해야 하는데, 포인트처럼 금전적 가치가 있는 데이터에서 "충돌이 났으니 다시 시도하세요"를 사용자에게 노출하는 설계는 좋지 않다. 재시도 횟수를 제한하면 결국 실패를 반환하게 되고, 과제 요구사항인 "안정적 동작"과 거리가 멀어진다.


Redis 분산 락을 사용하지 않은 이유: Redis 분산 락은 DB 락으로 해결하기 어려운 상황, 즉 여러 DB에 걸친 작업이거나 DB 락 비용이 너무 큰 경우에 유효하다. 이 과제에서는 포인트 연산의 대상이 단일 MySQL row이기 때문에, 굳이 Redis를 추가 의존성으로 도입하여 복잡도를 높일 필요가 없다. Redis가 장애날 경우 포인트 충전 전체가 막히는 단일 장애 지점(SPOF)이 생기는 것도 리스크다.

 

비관적 락은 DB 레벨에서 동작한다. SELECT ... FOR UPDATE는 해당 row를 잠그기 때문에, 서버가 몇 대가 붙어 있어도 DB가 직렬화를 보장한다. 다수 서버 환경이라는 요구사항을 가장 단순하고 확실하게 만족한다.


java// JPA에서의 비관적 락 적용
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.id = :userId")
Optional<User> findByIdWithLock(@Param("userId") Long userId);
포인트 충전과 주문 결제 시 이 메서드로 유저를 조회하면, 트랜잭션이 끝날 때까지 다른 트랜잭션은 해당 row에 접근할 수 없다.

4. 인기 메뉴 집계 — 왜 캐싱 테이블을 별도로 두었는가
문제 상황
인기 메뉴 조회 API는 최근 7일간 주문 횟수 상위 3개를 반환한다.
순진하게 구현하면 이렇게 된다.

 

sqlSELECT menu_id, COUNT(*) as order_count
FROM orders
WHERE ordered_at >= NOW() - INTERVAL 7 DAY
GROUP BY menu_id
ORDER BY order_count DESC
LIMIT 3;

 

이 쿼리는 정확하다. 하지만 주문이 수만 건 쌓이면 매 요청마다 풀 스캔에 가까운 집계를 돌리게 된다. 인기 메뉴 조회는 빈번하게 호출될 수 있는 API인데, 부하가 큰 집계 쿼리를 매번 실행하는 것은 확장성 측면에서 좋지 않다.

 

설계 결정: 집계 테이블 + Redis 캐싱 이중 구조
[주문 발생]
     ↓
[orders 테이블에 저장]
     ↓
[스케줄러: 10분마다 집계]
     ↓
[popular_menu_cache 테이블 갱신]
     ↓
[Redis에 캐싱 (TTL: 10분)]
     ↓
[인기 메뉴 조회 API: Redis에서 즉시 응답]

 

이 구조를 선택한 이유
스케줄러로 주기적 집계를 하는 이유: 인기 메뉴는 실시간으로 1위가 바뀌어야 하는 데이터가 아니다. 10분 전 집계 기준으로 보여줘도 사용자 경험에 전혀 문제가 없다. 이를 "허용 가능한 오래된 데이터(acceptable staleness)"라고 표현한다. 대신 집계 비용을 요청 시점에서 스케줄러 실행 시점으로 분리함으로써, API 응답 시간이 데이터 양에 독립적이 된다.

 

집계 테이블(popular_menu_cache)을 두는 이유: Redis가 재시작되거나 장애가 발생해도 DB에 마지막 집계 결과가 남아 있기 때문에 캐시 워밍업이 가능하다. Redis가 없으면 집계 테이블에서 직접 조회하는 폴백(fallback) 전략도 자연스럽게 가져갈 수 있다.

 

Redis를 함께 쓰는 이유: 인기 메뉴 조회는 읽기 부하가 집중될 수 있는 API다. Redis의 인메모리 특성으로 DB 부하 없이 빠른 응답이 가능하다. TTL을 스케줄러 주기와 맞춰두면 캐시와 DB의 정합성도 자연스럽게 유지된다.

5. ERD 설계 — 각 테이블의 존재 이유


USERS 테이블에 point_balance를 직접 둔 이유
포인트 잔액을 매번 POINT_HISTORIES를 집계해서 계산하는 방식도 있다. 하지만 이렇게 되면 잔액 확인마다 전체 이력을 SUM하는 비용이 발생한다. 포인트 잔액은 읽기 빈도가 매우 높으므로, USERS 테이블에 비정규화된 필드로 유지하는 것이 실용적이다.

 

대신 이 방식은 USERS.point_balance와 POINT_HISTORIES의 합산이 항상 일치해야 한다는 불변식을 지켜야 한다. 이를 위해 포인트 변경은 반드시 트랜잭션 내에서 두 테이블을 함께 갱신하도록 설계했다.

 

ORDERS에 price를 스냅샷으로 저장하는 이유
menu_id만 저장하고 가격은 MENUS에서 조인해서 가져오면 어떨까. 나중에 메뉴 가격이 변경되면, 과거 주문의 결제 금액이 달라진다. 주문 시점의 금액을 영구적으로 보존하기 위해 주문 시 price를 ORDERS에 복사해서 저장한다.

 

MENUS에 is_available 컬럼을 두는 이유
메뉴를 DB에서 삭제(Hard Delete)하면 해당 메뉴를 참조하는 ORDERS 레코드의 외래 키가 깨진다. is_available = false로 소프트 삭제(Soft Delete)하면 이력 데이터를 보존하면서 사용자에게는 노출하지 않을 수 있다.

6. 주문과 외부 데이터 전송의 일관성
과제 요구사항 중 하나가 이렇다.

"주문 내역을 데이터 수집 플랫폼으로 실시간 전송하는 로직을 추가합니다."

여기서 고민이 생긴다. 외부 플랫폼 전송이 실패하면 주문도 롤백해야 할까?

 

판단 기준
주문 결제는 핵심 비즈니스 로직이다. 외부 데이터 전송은 분석을 위한 부가 로직이다. 부가 로직의 실패가 핵심 비즈니스의 실패를 유발하는 설계는 좋지 않다.

 

카페에서 카드 결제가 완료됐는데, "영수증 출력기가 고장났으니 결제를 취소하겠습니다"라고 하는 것과 같다.

 

설계 결정: 비동기 전송 + 실패 로깅
[주문/결제 트랜잭션 commit]
        ↓
[외부 플랫폼 전송 — 비동기 / @Async]
        ↓
  성공 → 정상 처리
  실패 → 로그 기록 (주문 자체는 유효)


외부 전송을 트랜잭션 밖에서 비동기로 처리한다. 전송 실패는 별도 로그로 기록하여 나중에 재처리할 수 있도록 했다. 이 방식으로 주문의 안정성과 데이터 전송의 best-effort 보장을 분리했다.

 

7.API명세서

커피숍 주문 시스템
다수 서버 환경에서도 안정적으로 동작하는 커피숍 주문 시스템

기술 스택

Java 17 / Spring Boot 3.x
Spring Data JPA / MySQL
Redis
Gradle


ERD
이미지 표시
테이블설명USERS사용자 정보 및 포인트 잔액POINT_HISTORIES포인트 충전/차감 이력MENUS커피 메뉴 목록ORDERS주문 내역 (결제 금액 스냅샷 포함)POPULAR_MENU_CACHE인기 메뉴 집계 캐시

API 명세
1. 메뉴 목록 조회
GET /api/v1/menus
json// Response 200
{
  "menus": [
    { "menuId": 1, "name": "아메리카노", "price": 4500 }
  ]
}

2. 포인트 충전
POST /api/v1/users/{userId}/points/charge
json// Request
{ "amount": 10000 }

// Response 200
{
  "userId": 1,
  "chargedAmount": 10000,
  "currentBalance": 15000
}

3. 커피 주문 / 결제
POST /api/v1/orders
json// Request
{ "userId": 1, "menuId": 2 }

// Response 200
{
  "orderId": 101,
  "menuName": "카페라떼",
  "price": 5000,
  "remainingBalance": 10000,
  "orderedAt": "2025-04-05T10:23:00"
}

4. 인기 메뉴 조회
GET /api/v1/menus/popular
json// Response 200
{
  "popularMenus": [
    { "rank": 1, "menuId": 2, "name": "카페라떼", "orderCount": 142 },
    { "rank": 2, "menuId": 1, "name": "아메리카노", "orderCount": 98 },
    { "rank": 3, "menuId": 5, "name": "바닐라라떼", "orderCount": 77 }
  ],
  "aggregatedAt": "2025-04-05T10:20:00"
}

설계 의도 및 기술적 선택
동시성 제어 — 비관적 락 (Pessimistic Lock)
포인트 충전/차감 시 SELECT ... FOR UPDATE로 row를 잠근다.
낙관적 락은 충돌 발생 시 재시도 로직이 필요하고, 포인트처럼 금전적 가치가 있는 데이터에서 재시도 실패를 사용자에게 노출하는 것은 좋지 않다고 판단했다. 비관적 락은 DB 레벨에서 직렬화를 보장하기 때문에 다수 서버 환경에서도 서버 수에 관계없이 정합성을 지킬 수 있다.


인기 메뉴 집계 — 스케줄러 + Redis 캐싱
매 요청마다 7일치 ORDERS를 집계하면 데이터가 쌓일수록 응답 시간이 늘어난다. 인기 메뉴는 실시간성보다 안정적인 응답 속도가 중요하다고 판단했다. 스케줄러로 10분마다 집계 결과를 POPULAR_MENU_CACHE 테이블에 저장하고, Redis에 TTL 10분으로 캐싱한다. Redis 장애 시 DB 테이블로 fallback하여 단일 장애 지점을 방지한다.


주문과 외부 데이터 전송 분리 — @Async 비동기 처리
데이터 수집 플랫폼 전송은 핵심 비즈니스(주문/결제)와 분리해서 비동기로 처리한다. 외부 전송 실패가 주문 트랜잭션 롤백으로 이어지면 안 된다고 판단했기 때문이다. 전송 실패는 로그로 기록하여 재처리 가능하도록 했다.


주문 금액 스냅샷 저장
ORDERS 테이블에 price를 저장 시점에 복사한다. 이후 메뉴 가격이 변경되어도 과거 주문 금액이 보존된다.


소프트 삭제 (Soft Delete)
MENUS 테이블의 is_available 컬럼으로 메뉴를 비활성화한다. Hard Delete 시 ORDERS의 외래 키 참조가 깨지기 때문이다.

공통 에러 응답
json{
  "code": "INSUFFICIENT_POINT",
  "message": "포인트 잔액이 부족합니다.",
  "timestamp": "2025-04-05T10:23:00"
}


HTTP 상태                                              코드                                                    설명

400                                        INVALID_AMOUNT                                 충전 금액이 0 이하

400                                        INSUFFICIENT_POINT                          포인트 잔액 부족

404                                        USER_NOT_FOUND                               존재하지 않는 사용자

404                                        MENU_NOT_FOUND                              존재하지 않는 메뉴

409                                        MENU_UNAVAILABLE                            판매 중단된 메뉴

500                                        INTERNAL_SERVER_ERROR               서버 내부 오류

8. 설계하며 배운 것
이번 과제를 통해 가장 크게 배운 점은 선택에는 항상 트레이드오프가 있다는 것이다.
비관적 락은 정합성을 얻는 대신 처리량을 희생한다. 캐싱 집계 구조는 응답 속도를 얻는 대신 약간의 최신성을 포기한다. 

포인트를 비정규화해서 저장하면 읽기 성능을 얻는 대신 일관성 유지 책임이 생긴다.
앞으로 구현 단계에서 실제 코드와 테스트로 이 설계를 검증해 나갈 예정이다.