개발 공부/프로젝트

[사이드 프로젝트] 가상 주식 거래소 만들기 (11) - 내 자산 조회 API 설계와 RESTful 리팩토링

baby-t 2026. 1. 7. 10:29

1. 들어가며: 매수는 했는데, 내 돈은 어디에?

지난 포스팅까지 매수/매도 기능을 구현하여 DB에 데이터가 쌓이는 것까지 확인했습니다. 하지만 사용자 입장에서는 DB를 직접 열어볼 수 없으니, **"내 잔고가 얼마 남았고, 주식이 얼마나 있는지"**를 확인할 방법이 없습니다.

이번 포스팅에서는 사용자의 **'내 자산 현황'**을 조회하는 백엔드 로직을 구현하고, 그 과정에서 기존의 주문 API를 조금 더 RESTful하게 리팩토링한 과정을 정리해 봅니다.


2. 주문 API 리팩토링: RESTful하게 통합하기

자산 조회 기능을 만들기 전에, 기존에 작성했던 주문(Order) 관련 코드를 먼저 다듬기로 했습니다. 처음에는 단순하게 생각해서 **매수(POST /orders/buy)**와 **매도(POST /orders/sell)**를 별개의 API로 만들었습니다. 기능상으로는 잘 돌아갔지만, 곰곰이 생각해보니 아쉬운 점이 있었습니다.

🤔 고민: "주문(Order)이라는 행위는 하나인데, 굳이 URL을 나눠야 할까?"

RESTful API 설계 원칙에 따르면 URL은 **행위(Verb)**가 아닌 **자원(Resource)**을 나타내야 합니다. 따라서 /buy, /sell처럼 URL에 행위를 넣는 것보다는, /api/orders라는 하나의 자원으로 통합하고, **요청 본문(Body)**에 타입을 담아 보내는 것이 훨씬 깔끔하고 확장성이 좋다고 판단했습니다.

Refactoring: Controller는 "접수"만 받자

컨트롤러의 역할을 **"요청을 받아 서비스에 넘기는 접수처"**로 명확히 했습니다. 어떤 주문인지 판단하는 로직도 서비스로 내렸습니다.

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

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<String> createOrder(@RequestBody OrderRequestDto requestDto) {
        // Controller는 if/else 로직을 몰라도 됩니다.
        // 그냥 Service의 createOrder한테 "이거 처리해줘"라고 던지면 끝입니다.
        orderService.createOrder(requestDto);

        return ResponseEntity.ok("주문 접수 완료");
    }
}

Refactoring: Service에서 분기 처리

이제 서비스 계층에서 DTO의 타입을 확인해 적절한 비즈니스 로직(매수/매도)을 수행합니다.

Java
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {

    // ... Repository 의존성 주입 ...

    // [New] 외부에서 들어오는 유일한 창구
    public void createOrder(OrderRequestDto dto) {
        // 1. DTO에서 주문 타입 확인 ("BUY" or "SELL")
        if ("BUY".equalsIgnoreCase(dto.getOrderType())) {
            // 2. 매수 로직 호출
            buy(dto.getUserId(), dto.getCode(), dto.getQuantity());
        } else if ("SELL".equalsIgnoreCase(dto.getOrderType())) {
            // 3. 매도 로직 호출
            sell(dto.getUserId(), dto.getCode(), dto.getQuantity());
        } else {
            // 4. 예외 처리
            throw new IllegalArgumentException("잘못된 주문 타입입니다.");
        }
    }

    // 내부 로직 (외부에선 알 필요 없으니 private으로 변경)
    private void buy(Long userId, String code, Long quantity) {
        // ... (기존 매수 로직과 동일) ...
    }

    private void sell(Long userId, String code, Long quantity) {
         // ... (기존 매도 로직과 동일) ...
    }
}

이렇게 구조를 바꾸니 프론트엔드 입장에서도 API 주소를 하나만 관리하면 되어 훨씬 편해졌습니다.


3. Service 구현: 자산 현황 계산 로직 (StockHoldingService)

이제 본격적으로 '내 자산 조회' 기능을 구현합니다. 화면에 뿌려줄 정보는 단순한 DB 조회가 아니라, 계산된 파생 데이터들이 필요합니다.

  • 총 자산: 예수금 + (보유 주식의 현재 가치 총합)
  • 총 평가 손익: (현재 가치 총합) - (매수 금액 총합)
  • 수익률: (평가 손익 / 매수 금액) * 100

이 로직을 StockHoldingService에 구현했습니다.

Java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StockHoldingService {

    private final StockHoldingRepository stockHoldingRepository;
    private final AccountRepository accountRepository;

    public MyAssetDto getMyAssetStatus(Long userId) {

        // 1. [자산] 예수금 가져오기
        Account account = accountRepository.findByUserId(userId)
                .orElseThrow(() -> new IllegalArgumentException("계좌 없음"));
        Long balance = account.getBalance();

        // 2. [주식] 리스트 가져오기
        List<StockHolding> myStocks = stockHoldingRepository.findAllByUserId(userId);

        // 3. [계산] 합산 로직
        long totalPurchaseAmount = 0;   // 총 매수 금액
        long totalEvaluationAmount = 0; // 총 평가 금액

        // 미리 DTO 리스트를 준비합니다.
        List<StockHoldingDto> holdingDtos = myStocks.stream()
            .map(StockHoldingDto::new) // 생성자 참조를 통해 변환
            .toList(); 

        for (StockHolding holding : myStocks) {
            // ★ 도메인 활용 포인트! holding 안에서 바로 Stock을 꺼내 가격 확인
            Double currentPrice = holding.getStock().getCurrentPrice();
            Double avgPrice = holding.getAvgPrice();
            Long quantity = holding.getQuantity();

            // 전체 자산 계산
            totalPurchaseAmount += (long) (quantity * avgPrice);
            totalEvaluationAmount += (long) (quantity * currentPrice);
        }

        // 4. [최종] 파생 데이터(수익률 등) 계산
        long totalAssetAmount = balance + totalEvaluationAmount;
        long totalProfitLoss = totalEvaluationAmount - totalPurchaseAmount;
        double returnRate = 0.0;

        if (totalPurchaseAmount > 0) {
            returnRate = ((double) totalProfitLoss / totalPurchaseAmount) * 100;
        }

        // 5. 결과 반환 (DTO로 포장)
        return new MyAssetDto(
                totalAssetAmount,
                balance,
                totalPurchaseAmount,
                totalEvaluationAmount,
                totalProfitLoss,
                returnRate,
                holdingDtos
        );
    }
}

여기서 중요한 점은 Entity를 그대로 반환하지 않고, MyAssetDto와 StockHoldingDto로 변환해서 반환한다는 점입니다. (이게 왜 중요한지는 다음 포스팅에서 뼈저리게 느끼게 됩니다...😅)


4. API 테스트: 브라우저로 날것의 데이터 확인하기

Postman 같은 거창한 도구까지 쓸 필요도 없습니다. GET 요청은 브라우저 주소창이 최고의 테스트 도구니까요. 서버를 켜고 크롬 주소창에 http://localhost:8080/api/my-assets?userId=29를 입력해 보았습니다.

(확인 결과)

 

JSON 데이터가 아주 예쁘게(?) 잘 넘어오는 것을 확인했습니다. 백엔드 로직은 완벽하게 준비되었습니다.

5. 마치며

이제 백엔드 API는 준비되었습니다. 남은 건 이 JSON 데이터를 받아 HTML 화면에 예쁘게 뿌려주는 일뿐입니다. "그냥 HTML 만들어서 타임리프나 JS로 연결하면 끝나겠지?"라고 가볍게 생각했지만, 다음 단계에서 Spring Boot JPA 개발자들이 한 번쯤 겪는다는 '그 에러'를 마주하게 됩니다.

다음 포스팅에서는 my_asset.html 화면 구현 과정과, 그 과정에서 발생한 500 에러(순환 참조 문제) 해결기를 다뤄보겠습니다.