You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
카운터의 QR 코드를 스캔하여 주문 페이지에 접근하고, 포인트를 충전한 뒤 음료를 주문·결제하는 시스템입니다. 수평 확장(Scale-out) 환경에서도 데이터 정합성을 보장하고, 주문 데이터를 실시간으로 데이터 수집 플랫폼에 전송하며, 최근 7일 인기 메뉴 TOP 3를 추천합니다.
핵심 요구사항
#
기능
1
커피 메뉴 목록 조회
2
포인트 충전 (단위 제한 포함)
3
커피 주문 및 포인트 결제
4
주문 데이터 실시간 외부 플랫폼 비동기 전송
5
최근 7일 인기 메뉴 TOP 3 조회
2. 기술 스택
분류
기술
선택 근거
Language
Java 21
Record, LTS 안정성
Framework
Spring Boot 3.5
자동 설정, JPA, 트랜잭션 관리
ORM
Spring Data JPA
트랜잭션 관리, 비관적 락 지원
DB
MySQL 8.0
ACID 보장, SELECT FOR UPDATE 지원
Cache
Redis
메뉴 캐싱, 인기 메뉴 집계 결과 저장
인증
Spring Security + JWT (JJWT)
Stateless 인증으로 멀티 인스턴스 대응
Build
Gradle
빠른 빌드, 의존성 관리
Test
JUnit 5 + Mockito + TestContainers
단위·통합·동시성 테스트
Scheduler
ShedLock
멀티 인스턴스 스케줄러 중복 실행 방지
Container
Docker / Docker Compose
로컬 개발 인프라 단일 명령 실행
CI/CD
GitHub Actions
PR 자동 테스트, Docker 이미지 자동 빌드
3. ERD 설계
users
├── id (PK, BIGINT, AUTO_INCREMENT)
├── uuid (VARCHAR(36), UNIQUE) -- 외부 노출용 식별자
├── phone (VARCHAR(20), UNIQUE) -- 로그인 식별자
├── pin (VARCHAR(60)) -- BCrypt 해싱 저장
├── point (BIGINT, DEFAULT 0) -- 현재 보유 포인트 (비정규화)
├── created_at, updated_at
menus
├── id (PK)
├── name (VARCHAR(100))
├── price (INT)
├── is_available (BOOLEAN, DEFAULT TRUE) -- 소프트 삭제 (품절/비활성)
├── created_at, updated_at
orders
├── id (PK)
├── user_id (FK → users.id)
├── total_price (INT)
├── status (VARCHAR(20), DEFAULT 'COMPLETED')
├── created_at, updated_at
order_items
├── id (PK)
├── order_id (FK → orders.id)
├── menu_id (FK → menus.id)
├── menu_name (VARCHAR(100)) -- 주문 시점 메뉴명 스냅샷
├── menu_price (INT) -- 주문 시점 가격 스냅샷
├── quantity (INT, DEFAULT 1)
point_history
├── id (PK)
├── user_id (FK → users.id)
├── type (VARCHAR(20)) -- CHARGE | USE | REFUND
├── amount (INT)
├── balance_after (INT) -- 트랜잭션 후 잔액
├── reference_id (BIGINT) -- order_id (USE 시) 또는 NULL
├── created_at, updated_at
설계 의도
users.uuid : 내부 PK(id)와 외부 식별자(uuid)를 분리하여 보안성과 JOIN 성능을 모두 확보합니다.
users.point 비정규화 : 매번 point_history를 SUM하는 방식은 성능 문제가 발생하므로 현재 잔액을 별도 컬럼에 보관합니다.
order_items 스냅샷 : menu_name, menu_price를 주문 시점 기준으로 저장하여, 메뉴 가격이 변경되어도 과거 주문 내역의 금액 정합성을 유지합니다.
menus.is_available 소프트 삭제 : 실제 DELETE를 사용하지 않아 과거 주문 내역의 메뉴 참조를 보장합니다.
point_history.balance_after : 특정 시점의 잔액 복원 및 이상 탐지가 가능합니다.
멀티 인스턴스 환경에서 어느 서버에 요청이 도달해도 동일하게 인증 가능 (Stateless)
5-2. 메뉴 목록 캐싱 — Redis @Cacheable
메뉴 목록은 변경 빈도가 낮고 조회 빈도가 높음 → Redis 캐싱 적용
메뉴 추가·변경 시 캐시 무효화 (@CacheEvict)
5-3. 인기 메뉴 집계 — 주기적 갱신 캐시 + ShedLock
7일치 데이터를 매 요청마다 DB 풀스캔하면 응답 지연 발생
1분 주기 스케줄러가 DB 집계 결과를 Redis에 갱신
ShedLock으로 멀티 인스턴스 환경에서 스케줄러가 단 하나의 인스턴스에서만 실행되도록 보장
5-4. 외부 플랫폼 실시간 전송 — @TransactionalEventListener
주문 트랜잭션 커밋 후에만 전송 시도 (AFTER_COMMIT)
롤백된 주문 데이터가 외부에 전송되는 것을 방지
@Async로 비동기 처리 → 전송 실패가 주문 결과에 영향을 주지 않음
6. 동시성 문제 해결 전략
문제 상황
멀티 인스턴스 환경에서 JVM 수준의 synchronized는 프로세스 경계를 넘지 못합니다.
[Load Balancer]
↙ ↘
[App-1] [App-2] ← 각자 다른 JVM, synchronized 공유 불가
↘ ↙
[MySQL]
해결 전략
전략
멀티 인스턴스
성능
복잡도
채택
JVM synchronized
❌
높음
낮음
❌
DB 비관적 락 (FOR UPDATE)
✅
중간
낮음
✅ 1차 채택
Redis 분산 락 (Redisson)
✅
높음
높음
2차 확장
적용 대상
포인트 충전 : 동시에 여러 요청이 와도 최종 포인트 정합성 보장
포인트 차감 (주문 결제) : 잔액 부족 상황에서 중복 차감 방지
포인트 환불 : 동시 환불 요청 시 잔액 음수 방지
// 비관적 락 적용 예시@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.uuid = :uuid")
Optional<User> findByUuidWithLock(@Param("uuid") Stringuuid);