☕ 커피 주문 시스템
Spring Boot + JPA + MySQL을 이용해 포인트 기반 커피 주문 시스템을 만들었다.
메뉴 조회, 포인트 충전, 주문/결제, 인기 메뉴 집계까지 전 과정을 기록한다.
설계 내용
ERD
users
├── id BIGINT PK AUTO_INCREMENT
└── point INT
menus
├── id BIGINT PK AUTO_INCREMENT
├── name VARCHAR
└── price INT
orders
├── id BIGINT PK AUTO_INCREMENT
├── user_id BIGINT FK → users.id
├── total_price INT
└── created_at DATETIME
order_items
├── id BIGINT PK AUTO_INCREMENT
├── order_id BIGINT FK → orders.id
├── menu_id BIGINT FK → menus.id
└── price INT
API 목록
- GET /coffee/menus — 커피 메뉴 목록 조회
- POST /coffee/points/charge — 포인트 충전
- POST /coffee/orders — 커피 주문/결제
- GET /coffee/menus/popular — 인기 메뉴 조회
설계 의도
도메인별로 패키지를 나눴다. menu, order, point, user 각각이 자신의 controller / service / repository / dto / domain을 갖도록 구성했다. 한 도메인의 코드가 다른 도메인을 침범하지 않아서 나중에 수정할 때 영향 범위를 파악하기 쉽다.
order_items 테이블에 주문 당시 가격을 별도 컬럼으로 저장한다. 나중에 메뉴 가격이 변경되어도 과거 주문 금액이 달라지면 안 되기 때문이다.
포인트 충전과 차감 로직은 서비스가 아닌 User 엔티티 안에 메서드로 넣었다. 서비스에서 직접 필드를 건드리는 것보다 도메인 객체가 자신의 상태를 스스로 변경하는 게 객체지향적으로 더 맞다고 판단했다.
RealTimeOrderSender는 주문 완료 후 외부 데이터 수집 플랫폼으로 전송하는 역할을 따로 분리했다. 현재는 Mock(로그 출력)으로 구현되어 있고, 나중에 실제 HTTP Client나 Kafka로 교체할 때 이 클래스만 바꾸면 된다.
기술 선택 이유
- Spring Data JPA — 반복 SQL을 줄이고, JPQL로 복잡한 집계 쿼리도 객체지향적으로 표현할 수 있다.
- Record DTO — 불변 객체로 DTO를 간결하게 표현할 수 있다. getter, 생성자 같은 보일러플레이트 코드가 필요 없다.
- @RestControllerAdvice — 예외 처리를 컨트롤러마다 따로 작성하면 중복이 많아지고 응답 형식도 제각각이 된다. 한 곳에서 모든 예외를 일관된 형식으로 처리하기 위해 사용했다.
- 인기 메뉴 집계 방식 — 별도 집계 테이블 없이 order_items를 GROUP BY로 집계하는 방식을 선택했다. 항상 실시간 주문 이력 기반이라 따로 동기화할 필요가 없고 데이터 일관성이 자연스럽게 보장된다.
1. 프로젝트 세팅
패키지 구조
com.example.coffeeapi
├── common
│ ├── config
│ ├── exception
│ └── response
│
├── menu
│ ├── controller
│ ├── service
│ ├── repository
│ ├── domain
│ └── dto
│
├── point
├── order
├── event
└── user
패키지는 아래 명령어로 한 번에 생성했다.
mkdir -p src/main/java/com/example/coffeeapi/{menu,order,point,user}/{controller,service,repository,dto,domain}
mkdir -p src/main/java/com/example/coffeeapi/common/{config,exception,response}
application.yml
ddl-auto: update로 설정해서 엔티티 변경 시 테이블이 자동으로 업데이트되도록 했다. sql.init.mode: always는 애플리케이션 시작 시 data.sql을 자동 실행하기 위해 추가했다. data.sql에 초기 메뉴와 유저 데이터를 넣어두면 매번 수동으로 INSERT할 필요가 없다.
spring:
sql:
init:
mode: always
datasource:
url: jdbc:mysql://localhost:3306/coffee
username:
password:
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
show-sql: true
DB 초기 데이터
create database coffee;
-- data.sql
INSERT INTO users (point) VALUES (0);
INSERT INTO menus (name, price) VALUES ('아메리카노', 3000), ('카페라떼', 4000), ('콜드브루', 4500);
2. 커피 메뉴 조회 API
GET /coffee/menus
전체 메뉴 목록을 조회하는 API다. 단순 조회라 JPA의 findAll()을 사용했다.
Entity
@Getter
@NoArgsConstructor
@Entity
@Table(name = "menus")
public class Menu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 메뉴 이름
private String name;
// 메뉴 가격
private int price;
public Menu(String name, int price) {
this.name = name;
this.price = price;
}
}
MenuRepository
public interface MenuRepository extends JpaRepository<Menu, Long> {
}
MenuResponse
public record MenuResponse(
Long menuId,
String name,
int price
) {
public static MenuResponse from(Menu menu) {
return new MenuResponse(
menu.getId(),
menu.getName(),
menu.getPrice()
);
}
}
MenuService
@Service
@RequiredArgsConstructor
public class MenuService {
private final MenuRepository menuRepository;
public List<MenuResponse> getMenus() {
return menuRepository.findAll()
.stream()
.map(MenuResponse::from)
.toList();
}
}
MenuController
@RestController
@RequiredArgsConstructor
@RequestMapping("/coffee/menus")
public class MenuController {
private final MenuService menuService;
@GetMapping
public List<MenuResponse> getMenus() {
return menuService.getMenus();
}
}
Postman 테스트
Method: GET | URL: http://localhost:8080/coffee/menus
[
{
"menuId": 1,
"name": "Americano",
"price": 3000
},
{
"menuId": 2,
"name": "Latte",
"price": 4000
}
]
3. 포인트 충전 API
POST /coffee/points/charge
userId와 충전 금액을 받아 포인트를 적립하는 API다.
User Entity
@Getter
@NoArgsConstructor
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 사용자 보유 포인트
private int point;
public User(int point) {
this.point = point;
}
// 포인트 충전
public void chargePoint(int amount) {
this.point += amount;
}
// 포인트 사용
public void usePoint(int amount) {
if (this.point < amount) {
throw new IllegalArgumentException("포인트가 부족합니다.");
}
this.point -= amount;
}
}
PointChargeRequest
Bean Validation으로 입력값을 검증했다. @NotNull로 userId 필수값을 체크하고, @Min(1)로 충전 금액이 1 이상인지 검증한다. 서비스 코드에 검증 로직을 직접 작성하지 않아도 돼서 코드가 깔끔해진다.
public record PointChargeRequest(
@NotNull(message = "사용자 ID는 필수입니다.")
Long userId,
@Min(value = 1, message = "충전 금액은 1 이상이어야 합니다.")
int amount
) {
}
PointService
@Transactional을 붙인 이유는 JPA 더티 체킹 때문이다. 트랜잭션이 열려 있어야 엔티티 변경을 감지해서 커밋 시점에 UPDATE 쿼리를 자동으로 실행한다. 트랜잭션 없이 chargePoint()를 호출하면 DB에 반영되지 않는다.
@Service
@RequiredArgsConstructor
public class PointService {
private final UserRepository userRepository;
@Transactional
public PointResponse charge(PointChargeRequest request) {
// 사용자 조회
User user = userRepository.findById(request.userId())
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
// 포인트 충전
user.chargePoint(request.amount());
return new PointResponse(user.getId(), user.getPoint());
}
}
PointController
@RestController
@RequiredArgsConstructor
@RequestMapping("/coffee/points")
public class PointController {
private final PointService pointService;
@PostMapping("/charge")
public PointResponse charge(
@RequestBody @Valid PointChargeRequest request
) {
return pointService.charge(request);
}
}
Postman 테스트
Method: POST | URL: http://localhost:8080/coffee/points/charge
// Request Body
{
"userId": 1,
"amount": 10000
}
// Response
{
"userId": 1,
"point": 10000
}
4. 주문 및 결제 API
POST /coffee/orders
userId와 menuIds를 받아 포인트를 차감하고 주문을 생성하는 API다. 구현 순서는 아래와 같다.
- 사용자 조회
- 메뉴 조회
- 메뉴 존재 여부 검증
- 총 금액 계산
- 포인트 차감
- 주문 생성 및 저장
- 실시간 데이터 전송
포인트 차감 → 주문 생성 → 저장을 하나의 @Transactional 안에서 처리했다. 중간에 예외가 발생하면 전체가 롤백되기 때문에 포인트만 차감되고 주문이 생성되지 않는 상황을 방지할 수 있다.
CoffeeOrder Entity
테이블명을 orders로 지정했다. order는 SQL 예약어라 그대로 쓰면 에러가 난다. cascade = CascadeType.ALL을 설정해서 주문 저장 시 OrderItem도 함께 저장되도록 했다.
@Getter
@NoArgsConstructor
@Entity
@Table(name = "orders")
public class CoffeeOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private int totalPrice;
private LocalDateTime createdAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
public CoffeeOrder(Long userId, int totalPrice) {
this.userId = userId;
this.totalPrice = totalPrice;
this.createdAt = LocalDateTime.now();
}
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
}
}
OrderItem Entity
price 컬럼을 별도로 저장해서 주문 당시 가격을 스냅샷으로 남긴다. FetchType.LAZY로 지연 로딩을 설정해서 불필요한 JOIN 쿼리가 나가지 않도록 했다.
@Getter
@NoArgsConstructor
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private CoffeeOrder order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "menu_id")
private Menu menu;
// 주문 당시 가격 스냅샷
private int price;
public OrderItem(CoffeeOrder order, Menu menu, int price) {
this.order = order;
this.menu = menu;
this.price = price;
}
}
RealTimeOrderSender
@Slf4j
@Component
public class RealTimeOrderSender {
public void send(Long userId, List<Long> menuIds, int totalPrice) {
// Mock — 실제 환경에서는 HTTP Client 또는 Kafka로 교체
log.info(
"실시간 주문 데이터 전송 완료 userId={}, menuIds={}, totalPrice={}",
userId, menuIds, totalPrice
);
}
}
OrderService
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserRepository userRepository;
private final MenuRepository menuRepository;
private final OrderRepository orderRepository;
private final RealTimeOrderSender realTimeOrderSender;
@Transactional
public OrderResponse order(OrderRequest request) {
// 사용자 조회
User user = userRepository.findById(request.userId())
.orElseThrow(() ->
new IllegalArgumentException("사용자를 찾을 수 없습니다."));
// 메뉴 조회
List<Menu> menus = menuRepository.findAllById(request.menuIds());
// 존재하지 않는 메뉴 검증
if (menus.size() != request.menuIds().size()) {
throw new IllegalArgumentException("존재하지 않는 메뉴가 포함되어 있습니다.");
}
// 총 금액 계산
int totalPrice = menus.stream().mapToInt(Menu::getPrice).sum();
// 포인트 차감
user.usePoint(totalPrice);
// 주문 생성
CoffeeOrder order = new CoffeeOrder(user.getId(), totalPrice);
for (Menu menu : menus) {
order.addOrderItem(new OrderItem(order, menu, menu.getPrice()));
}
// 주문 저장 (cascade로 OrderItem도 함께 저장)
CoffeeOrder savedOrder = orderRepository.save(order);
// 실시간 데이터 전송
realTimeOrderSender.send(user.getId(), request.menuIds(), totalPrice);
return new OrderResponse(savedOrder.getId(), totalPrice);
}
}
OrderController
@RestController
@RequiredArgsConstructor
@RequestMapping("/coffee/orders")
public class OrderController {
private final OrderService orderService;
@PostMapping
public OrderResponse order(@RequestBody @Valid OrderRequest request) {
return orderService.order(request);
}
}
Postman 테스트
Method: POST | URL: http://localhost:8080/coffee/orders
// Request Body
{
"userId": 1,
"menuIds": [1, 2]
}
// Response
{
"orderId": 1,
"totalPrice": 7000
}
전역 예외 처리
예외 처리를 각 컨트롤러마다 따로 작성하면 중복 코드가 많아지고 응답 형식도 제각각이 된다. @RestControllerAdvice를 사용해서 한 곳에서 모든 예외를 일관된 형식으로 처리했다.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 비즈니스 규칙 위반 (포인트 부족, 존재하지 않는 사용자/메뉴 등)
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) {
return new ErrorResponse(e.getMessage());
}
// @Valid 검증 실패 (필수값 누락, 최솟값 위반 등)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldError().getDefaultMessage();
return new ErrorResponse(message);
}
}
public record ErrorResponse(
String message
) {
}
5. 트러블슈팅
문제 1 — 빈 메뉴 목록으로 주문이 생성되는 문제
menuIds를 빈 배열로 보냈을 때 에러가 나지 않고 totalPrice가 0인 주문이 생성되는 문제가 있었다.
// Request
{
"userId": 1,
"menuIds": []
}
// 문제 있는 응답 — 주문이 그냥 생성됨
{
"orderId": 2,
"totalPrice": 0
}
menuIds가 비어있어도 서비스 로직이 그대로 실행되기 때문이었다. OrderRequest에 @NotEmpty를 추가해서 빈 배열이 들어오면 요청 자체를 막도록 했다.
@NotEmpty(message = "메뉴는 최소 1개 이상 선택해야 합니다.")
List<Long> menuIds
// 수정 후 응답
{
"message": "메뉴는 최소 1개 이상 선택해야 합니다."
}
문제 2 — 존재하지 않는 메뉴 ID로 주문이 생성되는 문제
없는 menuId를 요청에 담아 보내면 역시 에러 없이 totalPrice가 0인 주문이 생겼다.
// Request
{
"userId": 1,
"menuIds": [90]
}
// 문제 있는 응답 — 주문이 그냥 생성됨
{
"orderId": 7,
"totalPrice": 0
}
JPA의 findAllById()는 존재하지 않는 ID를 무시하고 찾은 것만 반환하기 때문에 발생한 문제였다. 요청한 menuIds 수와 실제 조회된 menus 수를 비교하는 검증 로직을 서비스에 추가했다.
if (menus.size() != request.menuIds().size()) {
throw new IllegalArgumentException("존재하지 않는 메뉴가 포함되어 있습니다.");
}
// 수정 후 응답
{
"message": "존재하지 않는 메뉴가 포함되어 있습니다."
}
문제 3 — 포인트 부족 시 주문이 생성되는 문제
포인트가 부족한 상태에서 주문을 시도하면 에러 없이 주문이 생성되는 문제가 있었다.
// 포인트가 1000인 상태에서 7000짜리 주문 시도
{
"userId": 1,
"menuIds": [1, 2]
}
// 문제 있는 응답 — 주문이 그냥 생성됨
{
"orderId": 3,
"totalPrice": 7000
}
포인트 차감 전에 잔액 체크 로직이 없었기 때문이었다. User 엔티티의 usePoint() 메서드에 잔액이 부족하면 예외를 던지는 로직을 추가했다.
public void usePoint(int amount) {
if (this.point < amount) {
throw new IllegalArgumentException("포인트가 부족합니다.");
}
this.point -= amount;
}
// 수정 후 응답
{
"message": "포인트가 부족합니다."
}
6. 인기 메뉴 조회 API
GET /coffee/menus/popular
최근 7일간 주문 횟수 기준 상위 3개 메뉴를 반환하는 API다. 별도 집계 테이블 없이 order_items를 GROUP BY로 집계하는 방식을 선택했다. 항상 실시간 주문 이력 기반이라 데이터 일관성이 자연스럽게 보장된다.
JPQL에서 Pageable을 활용하면 LIMIT을 직접 쓰지 않아도 TOP N을 쉽게 가져올 수 있다. JPQL의 new 키워드로 쿼리 결과를 바로 DTO로 매핑했다.
PopularMenuResponse
public record PopularMenuResponse(
Long menuId,
String name,
Long orderCount
) {
}
OrderItemRepository — JPQL 집계 쿼리
public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
@Query("""
SELECT new com.example.coffeeapi.menu.dto.PopularMenuResponse(
m.id,
m.name,
COUNT(oi.id)
)
FROM OrderItem oi
JOIN oi.menu m
JOIN oi.order o
WHERE o.createdAt >= :sevenDaysAgo
GROUP BY m.id, m.name
ORDER BY COUNT(oi.id) DESC
""")
List<PopularMenuResponse> findPopularMenus(
LocalDateTime sevenDaysAgo,
Pageable pageable
);
}
MenuService 수정
private final OrderItemRepository orderItemRepository;
public List<PopularMenuResponse> getPopularMenus() {
// 최근 7일 기준 시각 계산
LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7);
// Pageable로 TOP3만 가져옴
return orderItemRepository.findPopularMenus(
sevenDaysAgo,
PageRequest.of(0, 3)
);
}
MenuController 수정
@GetMapping("/popular")
public List<PopularMenuResponse> getPopularMenus() {
return menuService.getPopularMenus();
}
Postman 테스트
Method: GET | URL: http://localhost:8080/coffee/menus/popular
[
{
"menuId": 1,
"name": "Americano",
"orderCount": 4
},
{
"menuId": 2,
"name": "Latte",
"orderCount": 3
}
]'🔌 SPARTA > Assignments' 카테고리의 다른 글
| [플러스 Spring] 코드 개선 과제 (0) | 2026.04.03 |
|---|---|
| [클라우드 과제] 클라우드_아키텍처 설계 & 배포 (0) | 2026.03.13 |
| [Spring 과제] 코드 개선 (1) | 2026.03.04 |
| [Spring 과제] 일정 관리 앱 Develop (0) | 2026.02.13 |
| [Spring 과제] 일정 관리 앱 만들기 (0) | 2026.02.05 |