개발 공부/프로젝트

[사이드 프로젝트] 가상 주식 거래소 만들기 (14) - 내 거래 기록은 어디에? (N+1 문제 해결과 REST API)

baby-t 2026. 1. 8. 17:57

1. 들어가며: 스냅샷이 아니라 '로그'가 필요해

지난 포스팅에서 우리는 StockHoldingDto를 도입해 **"현재 내가 가진 자산(스냅샷)"**을 안전하게 조회하는 데 성공했습니다. 무한 참조 에러도 잡고, 로직도 깔끔해졌죠.

하지만 사용자는 현재 잔고만큼이나 "내가 언제, 얼마에 샀는지(로그)"도 궁금해합니다. 이번에는 주문 내역을 조회하는 기능을 구현합니다. 특히 이번 구현에서는 Order와 Stock 사이의 연관관계 때문에 발생할 수 있는 **성능 문제(N+1)**를 잡는 데 집중했습니다.

2. DTO 설계: 화면에 보여줄 데이터만 쏙

지난번과 마찬가지로 Entity를 직접 노출하지 않습니다. 특히 주문 내역에는 "매수/매도" 같은 타입이나 "날짜 포맷" 같은 화면용 로직이 필요한데, 이를 DTO 생성자에서 처리하면 관리가 매우 편해집니다.

dto/OrderHistoryDto.java

Java
@Getter
@NoArgsConstructor
public class OrderHistoryDto {
    private Long orderId;
    private String stockName;
    private String orderType;   // ENUM(BUY) -> String("매수") 변환
    private Long quantity;
    private Double price;       // 1주당 가격
    private Double totalAmount; // 총 거래 금액
    private String orderDate;   // 보기 좋은 날짜 형식

    public OrderHistoryDto(Order order) {
        this.orderId = order.getId();
        this.stockName = order.getStock().getName(); // 여기서 Stock 정보 필요
        
        // ENUM -> 한글 변환 로직
        this.orderType = (order.getOrderType() == OrderType.BUY) ? "매수" : "매도";
        
        this.quantity = order.getQuantity();
        this.price = order.getPrice();
        this.totalAmount = order.getPrice() * order.getQuantity();
        
        // 날짜 포맷팅 (2024-01-06 15:30)
        this.orderDate = order.getOrderDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
    }
}

3. Repository: 성능 최적화의 열쇠 (Fetch Join)

주문 내역(Order)을 조회하려면 반드시 어떤 종목(Stock)인지 정보를 함께 가져와야 합니다. 여기서 JPA의 고질적인 문제인 N+1 문제가 발생할 수 있습니다. 이를 막기 위해 join fetch를 사용했습니다.

repository/OrderRepository.java

Java
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    // 일반 join이 아닌 'fetch join'을 사용하여 연관된 Stock 데이터를 한 번에 가져옴
    @Query("select o from Order o join fetch o.stock where o.user.id = :userId order by o.orderDate desc")
    List<Order> findAllByUserIdWithStock(@Param("userId") Long userId);
}

4. Service & Controller: 데이터 흐름 연결

서비스 계층에서 데이터를 가져와 DTO로 변환하고, 컨트롤러는 이를 JSON 형태로 프론트엔드에 반환합니다.

service/OrderService.java

Java
public List<OrderHistoryDto> getOrderLists(Long userId){
    // Fetch Join으로 최적화된 쿼리 실행
    return orderRepository.findAllByUserIdWithStock(userId).stream()
            .map(OrderHistoryDto::new) // Entity -> DTO 변환
            .toList();
}

controller/OrderController.java

Java
@RestController // 데이터(JSON) 반환
@RequiredArgsConstructor
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    // ... (주문 생성 로직 생략) ...

    // 주문 목록 조회 API
    @GetMapping("/list")
    public ResponseEntity<List<OrderHistoryDto>> getOrderLists(@RequestParam Long userId){
        List<OrderHistoryDto> orderList = orderService.getOrderLists(userId);
        return ResponseEntity.ok(orderList);
    }
}

5. Frontend 구현: Fetch API로 데이터 그리기

백엔드 API가 완성되었으니 프론트엔드(HTML/JS)에서 이 데이터를 받아 표(Table)에 뿌려줍니다. fetch 함수를 이용해 비동기로 데이터를 요청합니다. 추가로 이전 화면들에 다른 화면으로 이동하는 버튼들도 추가했습니다.

JavaScript
// 페이지 로드 시 실행
function loadOrderHistory() {
    // 임시 userId = 1 (나중에 로그인 연동 시 변경)
    fetch('/api/orders/list?userId=1')
        .then(response => response.json())
        .then(data => {
            const tableBody = document.getElementById('order-history-body');
            tableBody.innerHTML = ''; // 초기화

            data.forEach(order => {
                // 매수/매도에 따라 색상 클래스 지정 (CSS)
                const typeClass = order.orderType === '매수' ? 'text-danger' : 'text-primary';
                
                const row = `
                    <tr>
                        <td>${order.orderDate}</td>
                        <td class="fw-bold">${order.stockName}</td>
                        <td class="${typeClass}">${order.orderType}</td>
                        <td>${order.quantity}주</td>
                        <td>${order.price.toLocaleString()}원</td>
                        <td>${order.totalAmount.toLocaleString()}원</td>
                    </tr>
                `;
                tableBody.insertAdjacentHTML('beforeend', row);
            });
        });
}

6. 기술적 회고 (Deep Dive) 💡

이번 구현에서 가장 중요하게 고민했던 부분은 DB 조회 성능입니다.

Q. 왜 그냥 findAll을 쓰지 않고 @Query와 Fetch Join을 썼나요? (N+1 문제) OrderHistoryDto를 만들 때 order.getStock().getName()을 호출합니다. 즉, 주문 내역마다 종목 정보가 필요합니다. 만약 일반적인 조회(Lazy Loading)를 사용했다면 다음과 같은 참사가 벌어집니다.

  1. 주문 목록 조회: SELECT * FROM order (1번 실행 -> 결과 10개)
  2. 종목 정보 조회: 가져온 주문 10개 각각에 대해 SELECT * FROM stock WHERE id = ? (10번 실행)
  3. 결과: 총 **11번(1+N)**의 쿼리가 나갑니다. 주문 내역이 100개라면 101번이 나가겠죠.

하지만 join fetch o.stock을 사용하면, JPA가 **"Order 가져올 때 Stock이랑 조인해서 데이터 한 번에 다 들고 와!"**라고 인식하여 단 1번의 쿼리로 모든 데이터를 가져옵니다.

Q. DTO 변환 위치? Service 계층에서 stream().map()을 사용해 변환했습니다. Transaction 안에서 변환이 이루어지므로, 혹시 모를 지연 로딩 이슈(LazyInitializationException)에서도 안전하고, Controller는 순수하게 데이터 전달 역할만 하게 되어 구조가 깔끔해졌습니다.

7. 마치며

이제 사용자는 본인의 자산 현황뿐만 아니라, 과거의 거래 내역까지 상세하게 확인할 수 있게 되었습니다.

  • Backend: REST API + Fetch Join 최적화
  • Frontend: Fetch API를 이용한 비동기 데이터 렌더링

현재는 userId=1과 같이 하드코딩된 ID를 사용하고 있습니다. 다음 포스팅에서는 대망의 Spring Security를 이용한 로그인/회원가입을 구현하여, 진짜 '나만의' 투자 데이터를 관리할 수 있도록 만들어 보겠습니다.