Spring Boot - 주문/결제 기능 구현

송진우·2026년 1월 5일
post-thumbnail

주요 기능

  • 주문 / 주문 상품 엔티티 분리

  • 주문 상태(Enum) 기반 흐름 관리

  • 재고 차감 및 복구 처리

  • 사용자/관리자 권한 분리

  • JWT 인증 기반 주문 API 설계

주문/결제 전체 흐름


📁 프로젝트 구조

src/main/java/com/onandhome/
├── order/
│   ├── entity/
│   │   ├── Order.java              # 주문 엔티티
│   │   ├── OrderItem.java          # 주문 항목
│   │   └── OrderStatus.java        # 주문 상태 enum
│   ├── dto/
│   │   ├── CreateOrderRequest.java # 주문 생성 요청
│   │   ├── OrderDTO.java           # 주문 응답
│   │   └── OrderItemDTO.java       # 주문 항목 DTO
│   ├── OrderRepository.java        # 주문 DB 접근
│   ├── OrderItemRepository.java    # 주문 항목 DB
│   ├── OrderService.java           # 주문 비즈니스 로직
│   └── OrderRestController.java    # 주문 REST API
└── product/
    └── ProductService.java         # 재고 관리

1. 엔티티 설계

Order.java

@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {

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

    // 주문자 정보
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    // 주문 번호 (고유)
    @Column(unique = true, nullable = false)
    private String orderNumber;

    // 배송 정보
    @Column(nullable = false)
    private String recipientName;

    @Column(nullable = false)
    private String recipientPhone;

    @Column(nullable = false)
    private String shippingAddress;

    @Column(length = 500)
    private String shippingRequest;

    // 결제 정보
    @Column(nullable = false)
    private String paymentMethod;  // CARD, BANK_TRANSFER, VIRTUAL_ACCOUNT

    @Column(nullable = false)
    private int totalPrice;

    // 주문 상태
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderStatus status;

    // 주문 항목 (1:N)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> orderItems = new ArrayList<>();

    // 주문 시간
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime orderedAt;

    // 배송 완료 시간
    @Column
    private LocalDateTime deliveredAt;

    // 취소 시간
    @Column
    private LocalDateTime canceledAt;

    /**
     * 주문 생성 팩토리 메서드
     */
    public static Order create(User user, List<OrderItem> orderItems, 
                               String paymentMethod) {
        Order order = new Order();
        order.setUser(user);
        order.setOrderNumber(generateOrderNumber());
        order.setPaymentMethod(paymentMethod);
        order.setStatus(OrderStatus.PENDING);
        
        // 주문 항목 추가
        for (OrderItem item : orderItems) {
            order.addOrderItem(item);
        }
        
        // 총 금액 계산
        order.calculateTotalPrice();
        
        return order;
    }

    /**
     * 주문 항목 추가
     */
    public void addOrderItem(OrderItem item) {
        orderItems.add(item);
        item.setOrder(this);
    }

    /**
     * 총 금액 계산
     */
    public void calculateTotalPrice() {
        this.totalPrice = orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
    }

    /**
     * 주문 번호 생성
     */
    private static String generateOrderNumber() {
        return "ORD" + LocalDateTime.now().format(
            DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
        ) + (int)(Math.random() * 1000);
    }

    /**
     * 주문 취소
     */
    public void cancel() {
        if (this.status != OrderStatus.PENDING) {
            throw new IllegalStateException("배송 준비 이전 상태만 취소 가능합니다.");
        }
        this.status = OrderStatus.CANCELED;
        this.canceledAt = LocalDateTime.now();
    }
}

OrderItem.java

@Entity
@Table(name = "order_items")
@Getter
@Setter
public class OrderItem {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @Column(nullable = false)
    private int quantity;

    @Column(nullable = false)
    private int price;  // 주문 당시 가격

    /**
     * 주문 항목 생성
     */
    public static OrderItem create(Product product, int quantity) {
        OrderItem item = new OrderItem();
        item.setProduct(product);
        item.setQuantity(quantity);
        item.setPrice(product.getPrice());
        return item;
    }

    /**
     * 총 가격 계산
     */
    public int getTotalPrice() {
        return price * quantity;
    }
}

OrderStatus.java

public enum OrderStatus {
    PENDING("주문 완료"),           // 주문 완료
    PREPARING("배송 준비중"),       // 배송 준비
    SHIPPED("배송중"),              // 배송중
    DELIVERED("배송 완료"),         // 배송 완료
    CANCELED("주문 취소");          // 취소

    private final String description;

    OrderStatus(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }
}

2. DTO 설계

CreateOrderRequest.java

@Getter
@Setter
public class CreateOrderRequest {

    private Long userId;
    
    // 주문 항목
    private List<OrderItemRequest> orderItems;
    
    // 배송 정보
    private String recipientName;
    private String recipientPhone;
    private String shippingAddress;
    private String shippingRequest;
    
    // 결제 정보
    private String paymentMethod;  // CARD, BANK_TRANSFER, VIRTUAL_ACCOUNT

    @Getter
    @Setter
    public static class OrderItemRequest {
        private Long productId;
        private int quantity;
    }
}

OrderDTO.java

@Getter
@Setter
@Builder
public class OrderDTO {

    private Long id;
    private String orderNumber;
    private String userName;
    private String recipientName;
    private String recipientPhone;
    private String shippingAddress;
    private String shippingRequest;
    private String paymentMethod;
    private int totalPrice;
    private String status;
    private String statusDescription;
    private List<OrderItemDTO> orderItems;
    private LocalDateTime orderedAt;
    private LocalDateTime deliveredAt;
    private LocalDateTime canceledAt;

    /**
     * Entity → DTO 변환
     */
    public static OrderDTO fromEntity(Order order) {
        return OrderDTO.builder()
                .id(order.getId())
                .orderNumber(order.getOrderNumber())
                .userName(order.getUser().getUsername())
                .recipientName(order.getRecipientName())
                .recipientPhone(order.getRecipientPhone())
                .shippingAddress(order.getShippingAddress())
                .shippingRequest(order.getShippingRequest())
                .paymentMethod(order.getPaymentMethod())
                .totalPrice(order.getTotalPrice())
                .status(order.getStatus().name())
                .statusDescription(order.getStatus().getDescription())
                .orderItems(order.getOrderItems().stream()
                        .map(OrderItemDTO::fromEntity)
                        .collect(Collectors.toList()))
                .orderedAt(order.getOrderedAt())
                .deliveredAt(order.getDeliveredAt())
                .canceledAt(order.getCanceledAt())
                .build();
    }
}

OrderItemDTO.java

@Getter
@Setter
@Builder
public class OrderItemDTO {

    private Long id;
    private Long productId;
    private String productName;
    private int quantity;
    private int price;
    private int totalPrice;

    public static OrderItemDTO fromEntity(OrderItem item) {
        return OrderItemDTO.builder()
                .id(item.getId())
                .productId(item.getProduct().getId())
                .productName(item.getProduct().getName())
                .quantity(item.getQuantity())
                .price(item.getPrice())
                .totalPrice(item.getTotalPrice())
                .build();
    }
}

3. Service

OrderService.java

@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {

    private final OrderRepository orderRepo;
    private final OrderItemRepository orderItemRepo;
    private final UserRepository userRepo;
    private final ProductRepository productRepo;
    private final CartService cartService;

    /**
     * 주문 생성
     */
    public OrderDTO createOrder(CreateOrderRequest request) {
        // 1. 사용자 조회
        User user = userRepo.findById(request.getUserId())
                .orElseThrow(() -> new IllegalArgumentException(
                    "사용자를 찾을 수 없습니다."));

        // 2. 주문 항목 생성 및 재고 확인
        List<OrderItem> orderItems = new ArrayList<>();
        
        for (CreateOrderRequest.OrderItemRequest itemReq : request.getOrderItems()) {
            Product product = productRepo.findById(itemReq.getProductId())
                    .orElseThrow(() -> new IllegalArgumentException(
                        "상품을 찾을 수 없습니다: " + itemReq.getProductId()));

            // 재고 확인
            if (product.getStock() < itemReq.getQuantity()) {
                throw new IllegalStateException(
                    product.getName() + " 상품의 재고가 부족합니다.");
            }

            // 주문 항목 생성
            OrderItem orderItem = OrderItem.create(product, itemReq.getQuantity());
            orderItems.add(orderItem);
        }

        // 3. 주문 생성
        Order order = Order.create(user, orderItems, request.getPaymentMethod());
        
        // 배송 정보 설정
        order.setRecipientName(request.getRecipientName());
        order.setRecipientPhone(request.getRecipientPhone());
        order.setShippingAddress(request.getShippingAddress());
        order.setShippingRequest(request.getShippingRequest());

        // 4. 재고 차감
        for (OrderItem item : orderItems) {
            Product product = item.getProduct();
            product.setStock(product.getStock() - item.getQuantity());
            productRepo.save(product);
        }

        // 5. 주문 저장
        Order savedOrder = orderRepo.save(order);

        // 6. 장바구니에서 주문한 상품 삭제
        try {
            for (CreateOrderRequest.OrderItemRequest itemReq : request.getOrderItems()) {
                cartService.removeFromCart(user.getUserId(), itemReq.getProductId());
            }
        } catch (Exception e) {
            // 장바구니 삭제 실패는 주문에 영향 없음
            log.warn("장바구니 삭제 실패: {}", e.getMessage());
        }

        return OrderDTO.fromEntity(savedOrder);
    }

    /**
     * 사용자 주문 목록 조회
     */
    @Transactional(readOnly = true)
    public List<OrderDTO> getUserOrders(Long userId) {
        User user = userRepo.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException(
                    "사용자를 찾을 수 없습니다."));

        return orderRepo.findByUserOrderByOrderedAtDesc(user)
                .stream()
                .map(OrderDTO::fromEntity)
                .collect(Collectors.toList());
    }

    /**
     * 주문 상세 조회
     */
    @Transactional(readOnly = true)
    public OrderDTO getOrderDetail(Long orderId, Long userId) {
        Order order = orderRepo.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException(
                    "주문을 찾을 수 없습니다."));

        // 본인 주문만 조회 가능
        if (!order.getUser().getId().equals(userId)) {
            throw new IllegalArgumentException("권한이 없습니다.");
        }

        return OrderDTO.fromEntity(order);
    }

    /**
     * 주문 취소
     */
    public void cancelOrder(Long orderId, Long userId) {
        Order order = orderRepo.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException(
                    "주문을 찾을 수 없습니다."));

        // 본인 주문만 취소 가능
        if (!order.getUser().getId().equals(userId)) {
            throw new IllegalArgumentException("권한이 없습니다.");
        }

        // 주문 취소
        order.cancel();

        // 재고 복구
        for (OrderItem item : order.getOrderItems()) {
            Product product = item.getProduct();
            product.setStock(product.getStock() + item.getQuantity());
            productRepo.save(product);
        }

        orderRepo.save(order);
    }

    /**
     * 주문 상태 변경 (관리자)
     */
    public void updateOrderStatus(Long orderId, OrderStatus newStatus) {
        Order order = orderRepo.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException(
                    "주문을 찾을 수 없습니다."));

        order.setStatus(newStatus);

        if (newStatus == OrderStatus.DELIVERED) {
            order.setDeliveredAt(LocalDateTime.now());
        }

        orderRepo.save(order);
    }

    /**
     * 전체 주문 목록 (관리자)
     */
    @Transactional(readOnly = true)
    public List<OrderDTO> getAllOrders() {
        return orderRepo.findAllByOrderByOrderedAtDesc()
                .stream()
                .map(OrderDTO::fromEntity)
                .collect(Collectors.toList());
    }
}

4. REST API

OrderRestController.java

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderRestController {

    private final OrderService orderService;
    private final JWTUtil jwtUtil;

    /**
     * 주문 생성
     * POST /api/orders
     */
    @PostMapping
    public ResponseEntity<Map<String, Object>> createOrder(
            @RequestBody CreateOrderRequest request,
            @RequestHeader("Authorization") String authHeader) {
        try {
            String userId = getUserIdFromToken(authHeader);
            request.setUserId(Long.parseLong(userId));
            
            OrderDTO order = orderService.createOrder(request);

            Map<String, Object> response = new HashMap<>();
            response.put("success", true);
            response.put("message", "주문이 완료되었습니다.");
            response.put("data", order);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            Map<String, Object> response = new HashMap<>();
            response.put("success", false);
            response.put("message", e.getMessage());
            return ResponseEntity.badRequest().body(response);
        }
    }

    /**
     * 내 주문 목록 조회
     * GET /api/orders
     */
    @GetMapping
    public ResponseEntity<Map<String, Object>> getMyOrders(
            @RequestHeader("Authorization") String authHeader) {
        try {
            Long userId = Long.parseLong(getUserIdFromToken(authHeader));
            List<OrderDTO> orders = orderService.getUserOrders(userId);

            Map<String, Object> response = new HashMap<>();
            response.put("success", true);
            response.put("data", orders);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            Map<String, Object> response = new HashMap<>();
            response.put("success", false);
            response.put("message", e.getMessage());
            return ResponseEntity.badRequest().body(response);
        }
    }

    /**
     * 주문 상세 조회
     * GET /api/orders/{id}
     */
    @GetMapping("/{id}")
    public ResponseEntity<Map<String, Object>> getOrderDetail(
            @PathVariable Long id,
            @RequestHeader("Authorization") String authHeader) {
        try {
            Long userId = Long.parseLong(getUserIdFromToken(authHeader));
            OrderDTO order = orderService.getOrderDetail(id, userId);

            Map<String, Object> response = new HashMap<>();
            response.put("success", true);
            response.put("data", order);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            Map<String, Object> response = new HashMap<>();
            response.put("success", false);
            response.put("message", e.getMessage());
            return ResponseEntity.badRequest().body(response);
        }
    }

    /**
     * 주문 취소
     * PUT /api/orders/{id}/cancel
     */
    @PutMapping("/{id}/cancel")
    public ResponseEntity<Map<String, Object>> cancelOrder(
            @PathVariable Long id,
            @RequestHeader("Authorization") String authHeader) {
        try {
            Long userId = Long.parseLong(getUserIdFromToken(authHeader));
            orderService.cancelOrder(id, userId);

            Map<String, Object> response = new HashMap<>();
            response.put("success", true);
            response.put("message", "주문이 취소되었습니다.");

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            Map<String, Object> response = new HashMap<>();
            response.put("success", false);
            response.put("message", e.getMessage());
            return ResponseEntity.badRequest().body(response);
        }
    }

    /**
     * 주문 상태 변경 (관리자)
     * PUT /api/orders/{id}/status
     */
    @PutMapping("/{id}/status")
    public ResponseEntity<Map<String, Object>> updateOrderStatus(
            @PathVariable Long id,
            @RequestBody Map<String, String> request) {
        try {
            OrderStatus status = OrderStatus.valueOf(request.get("status"));
            orderService.updateOrderStatus(id, status);

            Map<String, Object> response = new HashMap<>();
            response.put("success", true);
            response.put("message", "주문 상태가 변경되었습니다.");

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            Map<String, Object> response = new HashMap<>();
            response.put("success", false);
            response.put("message", e.getMessage());
            return ResponseEntity.badRequest().body(response);
        }
    }

    /**
     * 전체 주문 목록 (관리자)
     * GET /api/orders/admin/all
     */
    @GetMapping("/admin/all")
    public ResponseEntity<Map<String, Object>> getAllOrders() {
        try {
            List<OrderDTO> orders = orderService.getAllOrders();

            Map<String, Object> response = new HashMap<>();
            response.put("success", true);
            response.put("data", orders);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            Map<String, Object> response = new HashMap<>();
            response.put("success", false);
            response.put("message", e.getMessage());
            return ResponseEntity.badRequest().body(response);
        }
    }

    /**
     * JWT에서 userId 추출
     */
    private String getUserIdFromToken(String authHeader) {
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new RuntimeException("유효하지 않은 토큰입니다.");
        }
        String token = authHeader.substring(7);
        Map<String, Object> claims = jwtUtil.validateToken(token);
        return String.valueOf(claims.get("userId"));
    }
}

API 엔드포인트 정리

MethodEndpoint설명권한
POST/api/orders주문 생성사용자
GET/api/orders내 주문 목록사용자
GET/api/orders/{id}주문 상세사용자
PUT/api/orders/{id}/cancel주문 취소사용자
PUT/api/orders/{id}/status주문 상태 변경관리자
GET/api/orders/admin/all전체 주문 목록관리자

실제 구현 화면

  1. 주문/결제 페이지
  1. 주문 완료 페이지
  1. 주문 내역 목록 페이지

Postman 테스트

1. 주문 생성

설정

Method: POST
URL: http://localhost:8080/api/orders
Headers:
  Content-Type: application/json
  Authorization: Bearer {your_jwt_token}

Body (raw JSON)

{
  "orderItems": [
    {
      "productId": 10,
      "quantity": 2
    },
    {
      "productId": 15,
      "quantity": 1
    }
  ],
  "recipientName": "김철수",
  "recipientPhone": "010-1234-5678",
  "shippingAddress": "서울특별시 강남구 테헤란로 123",
  "shippingRequest": "문 앞에 놓아주세요",
  "paymentMethod": "CARD"
}

응답

{
  "success": true,
  "message": "주문이 완료되었습니다.",
  "data": {
    "id": 42,
    "orderNumber": "ORD20250129143500123",
    "userName": "testuser",
    "recipientName": "김현수",
    "totalPrice": 150000,
    "status": "PENDING",
    "statusDescription": "주문 완료",
    "orderItems": [
      {
        "productId": 10,
        "productName": "냉장고",
        "quantity": 2,
        "price": 50000,
        "totalPrice": 100000
      },
      {
        "productId": 15,
        "productName": "세탁기",
        "quantity": 1,
        "price": 50000,
        "totalPrice": 50000
      }
    ],
    "orderedAt": "2025-01-29T14:35:00"
  }
}

2. 내 주문 목록 조회

설정

Method: GET
URL: http://localhost:8080/api/orders
Headers:
  Authorization: Bearer {your_jwt_token}

응답

{
  "success": true,
  "data": [
    {
      "id": 42,
      "orderNumber": "ORD20250129143500123",
      "totalPrice": 150000,
      "status": "PENDING",
      "statusDescription": "주문 완료",
      "orderedAt": "2025-01-29T14:35:00"
    }
  ]
}

3. 주문 상세 조회

설정

Method: GET
URL: http://localhost:8080/api/orders/42
Headers:
  Authorization: Bearer {your_jwt_token}

응답

{
  "success": true,
  "data": {
    "id": 42,
    "orderNumber": "ORD20250129143500123",
    "recipientName": "김현수",
    "recipientPhone": "010-1234-5678",
    "shippingAddress": "서울특별시 강남구 테헤란로 123",
    "shippingRequest": "문 앞에 놓아주세요",
    "paymentMethod": "CARD",
    "totalPrice": 150000,
    "status": "PENDING",
    "orderItems": [...]
  }
}

4. 주문 취소

설정

Method: PUT
URL: http://localhost:8080/api/orders/42/cancel
Headers:
  Authorization: Bearer {your_jwt_token}

응답

{
  "success": true,
  "message": "주문이 취소되었습니다."
}

재조회 확인

GET /api/orders/42
→ status: "CANCELED", canceledAt: "2025-01-29T15:00:00"

5. 주문 상태 변경 (관리자)

설정

Method: PUT
URL: http://localhost:8080/api/orders/42/status
Headers:
  Content-Type: application/json
  Authorization: Bearer {admin_jwt_token}

Body

{
  "status": "SHIPPED"
}

응답

{
  "success": true,
  "message": "주문 상태가 변경되었습니다."
}

0개의 댓글