🔌 SPARTA/Assignments

[CH 6 실전] 서버 개발 과제

eunjiom 2026. 5. 11. 00:25

☕ 커피 주문 시스템

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다. 구현 순서는 아래와 같다.

  1. 사용자 조회
  2. 메뉴 조회
  3. 메뉴 존재 여부 검증
  4. 총 금액 계산
  5. 포인트 차감
  6. 주문 생성 및 저장
  7. 실시간 데이터 전송

포인트 차감 → 주문 생성 → 저장을 하나의 @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
    }
]