개발 공부/프로젝트

[사이드 프로젝트] 가상 주식 거래소 만들기 (8) - 핵심 비즈니스 로직 구현 (매수/매도와 트랜잭션)

baby-t 2025. 12. 9. 10:54

1. 들어가며: 엔진을 조립하다

지난 포스팅에서 우리는 데이터를 담을 그릇인 **Entity(StockHolding, Order)**와 저장소인 Repository를 만들었습니다. 하지만 아직은 정적인 데이터 구조일 뿐, 실제로 돈이 오가고 주식이 거래되는 "기능"은 없습니다.

이번 포스팅에서는 거래소의 심장이라고 할 수 있는 **서비스 계층(Service Layer)**을 구현합니다. 사용자의 주문 요청을 받아서 **[검증 -> 잔고 계산 -> 주식 배분 -> 기록 저장]**으로 이어지는 일련의 과정을 하나의 **트랜잭션(Transaction)**으로 묶어서 처리하는 방법을 정리합니다.

2. DTO 구현: 주문서 양식 만들기

컨트롤러에서 서비스로 데이터를 넘길 때 사용할 "주문서" 역할을 하는 DTO를 먼저 정의합니다. Entity를 직접 노출하지 않고 필요한 데이터만 깔끔하게 전달하기 위함입니다.

dto/OrderRequestDto.java

Java
package com.example.virtual_exchange.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class OrderRequestDto {
    private Long userId;    // 누가
    private String code;    // 어떤 주식을
    private Long quantity;  // 몇 개 살 것인가

    public OrderRequestDto(Long userId, String code, Long quantity) {
        this.userId = userId;
        this.code = code;
        this.quantity = quantity;
    }
}

3. Service 구현: OrderService (매수 로직)

이제 가장 중요한 비즈니스 로직입니다. 매수(Buy)는 단순히 DB에 데이터를 넣는 것이 아니라, "돈은 빼고, 주식은 더하고, 기록은 남기는" 복합적인 작업입니다.

service/OrderService.java

Java
 
@Service
@RequiredArgsConstructor
@Transactional // (중요) 이 메소드 안의 모든 DB 작업은 한 몸이다! (하나라도 실패하면 롤백)
public class OrderService {

    private final UserRepository userRepository;
    private final StockRepository stockRepository;
    private final AccountRepository accountRepository;
    private final StockHoldingRepository stockHoldingRepository;
    private final OrderRepository orderRepository;

    // 매수(Buy) 로직
    public void buy(Long userId, String code, Long quantity) {
        // 1. 조회: 필요한 객체들을 가장 최신 상태로 가져온다.
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("없는 유저입니다."));
        Stock stock = stockRepository.findById(code)
                .orElseThrow(() -> new IllegalArgumentException("없는 종목입니다."));
        Account account = accountRepository.findByUserId(userId)
                .orElseThrow(() -> new IllegalArgumentException("계좌가 없습니다."));

        // 2. 가격 계산 (실수형 오차 방지를 위해 long 형변환)
        long totalPrice = (long) (stock.getCurrentPrice() * quantity);

        // 3. 잔고 확인 (검증)
        if (account.getBalance() < totalPrice) {
            throw new IllegalStateException("잔액이 부족합니다.");
        }

        // 4. 돈 빼기 (Account 변경)
        account.decreaseBalance(totalPrice);

        // 5. 주식 더하기 (StockHolding 변경) - [핵심 로직]
        StockHolding holding = stockHoldingRepository.findByUserAndStock(user, stock)
                .orElse(null); // 없으면 null 반환

        if (holding == null) {
            // (A) 처음 사는 주식이라면: 새로 만들어서 저장 (Insert)
            StockHolding newHolding = new StockHolding(user, stock, quantity, stock.getCurrentPrice());
            stockHoldingRepository.save(newHolding);
        } else {
            // (B) 이미 가진 주식이라면: 물타기 (Update) -> 평단가와 수량 변경
            holding.addQuantity(quantity, stock.getCurrentPrice());
            // JPA 변경 감지(Dirty Checking) 덕분에 save()를 호출하지 않아도 자동 업데이트됨!
        }

        // 6. 기록 남기기 (Order 저장)
        Order order = new Order(user, stock, OrderType.BUY, stock.getCurrentPrice(), quantity);
        orderRepository.save(order);
    }
}

4. Service 구현: OrderService (매도 로직)

매도(Sell)는 매수의 역순입니다. "주식은 빼고, 돈은 더하고, 기록은 남기는" 과정입니다.

Java
 
    // 매도(Sell) 로직
    public void sell(Long userId, String code, Long quantity) {
        // 1. 기본 조회 (User, Stock, Account)
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("없는 유저입니다."));
        Stock stock = stockRepository.findById(code)
                .orElseThrow(() -> new IllegalArgumentException("없는 종목입니다."));
        Account account = accountRepository.findByUserId(userId)
                .orElseThrow(() -> new IllegalArgumentException("계좌가 없습니다."));

        // 2. 보유 주식 조회 (없으면 매도 불가)
        StockHolding stockHolding = stockHoldingRepository.findByUserAndStock(user, stock)
                .orElseThrow(() -> new IllegalArgumentException("매도할 주식이 없습니다."));

        // 3. 주식 차감 (내부 로직에서 수량 부족 시 예외 발생)
        stockHolding.decreaseQuantity(quantity); 

        // 4. 가격 계산 및 입금
        long totalPrice = (long) (stock.getCurrentPrice() * quantity);
        account.increaseBalance(totalPrice);

        // 5. 기록 저장
        Order order = new Order(user, stock, OrderType.SELL, stock.getCurrentPrice(), quantity);
        orderRepository.save(order);
    }

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

Q1. @Transactional은 왜 필수인가?

주식을 매수하는 과정을 상상해 봅시다.

  1. 내 통장에서 100만 원이 빠져나감 (성공)
  2. (갑자기 서버 전원 꺼짐 🔌)
  3. 내 주식 계좌에 주식이 안 들어옴 (실패)

만약 트랜잭션이 없다면, 돈은 사라졌는데 주식은 없는 끔찍한 상황이 발생합니다. @Transactional 어노테이션을 붙이면, 이 메서드 안의 작업들은 "모두 성공하거나, 아니면 아예 없던 일로 하거나(Rollback)" 둘 중 하나만 실행되도록 보장합니다. 이를 데이터베이스의 **원자성(Atomicity)**이라고 합니다.

Q2. save() 없이 어떻게 DB가 업데이트되었나? (변경 감지)

buy 메서드의 (B) 물타기 로직과 sell 메서드의 decreaseQuantity를 보면 stockHoldingRepository.save()를 호출하지 않았습니다. 그런데도 DB에는 값이 수정되어 있습니다. 이것이 JPA의 가장 강력한 기능 중 하나인 **변경 감지(Dirty Checking)**입니다. 트랜잭션 안에서 조회해온 Entity(holding)의 값을 변경하면, 트랜잭션이 끝나는 시점에 JPA가 알아서 변경된 내용을 감지하고 UPDATE 쿼리를 날려줍니다. 덕분에 코드가 훨씬 간결해집니다.

Q3. Entity에 비즈니스 로직을 넣은 이유?

보통 service에서 계산을 다 하고 entity.setQuantity(...) 처럼 값을 집어넣는 경우가 많습니다. 하지만 저는 holding.addQuantity(...) 처럼 엔티티에게 계산을 시켰습니다. 데이터를 가지고 있는 녀석(Entity)이 그 데이터를 관리하는 로직도 가지고 있는 것이 **객체지향적인 설계(Rich Domain Model)**에 가깝기 때문입니다. 덕분에 Service 코드는 "무엇을 할지"만 명시하고, "어떻게 계산할지"는 몰라도 되는 깔끔한 구조가 되었습니다.

6. 마치며

이제 엔진(Service)이 완성되었습니다. 사용자 요청이 들어오면 안전하게 자산을 검증하고 거래를 체결시킬 수 있게 되었습니다.

다음 포스팅에서는 이 엔진을 사용자가 직접 조작할 수 있도록 컨트롤러(Controller)와 화면(View)에 연결하는 작업을 진행해 보겠습니다. 드디어 버튼을 눌러서 거래를 해볼 시간입니다!