개발 공부/프로젝트

[사이드 프로젝트] 가상 주식 거래소 만들기 (7) - 거래를 위한 도메인 설계와 JPA Entity

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

1. 들어가며: "지갑"과 "영수증"은 다르다

지난 포스팅까지 화면(View)을 구성했습니다. 이제 사용자가 "매수" 버튼을 눌렀을 때 일어날 일을 정의할 차례입니다.

가장 먼저 고민해야 할 것은 **"데이터를 어떻게 저장할 것인가?"**입니다. 처음에는 Order 테이블 하나만 있으면 될 것 같지만, 주식 거래소에는 두 가지 성격의 데이터가 필요합니다.

  1. 현재 상태 (Snapshot): 지금 내가 어떤 주식을 몇 개 가지고 있고, 평단가가 얼마인가? -> 지갑(Holding)
  2. 과거 기록 (Log): 내가 언제, 얼마에 사고팔았는가? -> 영수증(Order)

이번 포스팅에서는 이 두 가지 데이터를 관리하기 위한 도메인 엔티티(StockHolding, Order)를 설계하고 구현합니다.

2. 기본 설정: Enum 클래스 (OrderType)

가장 먼저 거래의 종류를 구분하기 위한 상수(Enum)를 정의합니다. 문자열("BUY")로 관리하면 오타가 날 수 있으므로 Enum을 사용하는 것이 안전합니다.

domain/OrderType.java

Java
package com.example.virtual_exchange.domain;

public enum OrderType {
    BUY,  // 매수
    SELL  // 매도
}

3. 핵심 엔티티 구현: StockHolding (보유 주식)

사용자가 현재 보유 중인 주식 정보를 담는 엔티티입니다. 여기서 주목할 점은 **"평단가(AvgPrice) 계산 로직"**이 엔티티 안에 포함되어 있다는 것입니다.

domain/StockHolding.java

Java
@Entity
@Getter
@NoArgsConstructor
public class StockHolding {

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

    @ManyToOne(fetch = FetchType.LAZY) // (1) 유저 한 명은 여러 종목을 가질 수 있다.
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY) // (2) 한 종목은 여러 유저가 가질 수 있다.
    @JoinColumn(name = "stock_code")
    private Stock stock;

    private Long quantity;   // 보유 수량
    private Double avgPrice; // 평단가 (매수 평균가)

    // 생성자
    public StockHolding(User user, Stock stock, Long quantity, Double avgPrice) {
        this.user = user;
        this.stock = stock;
        this.quantity = quantity;
        this.avgPrice = avgPrice;
    }

    // (3) 비즈니스 로직: 추가 매수 시 평단가/수량 업데이트 (물타기)
    public void addQuantity(Long amount, Double price) {
        // 공식: ((기존수량 * 기존평단) + (새수량 * 새가격)) / 전체수량
        double totalCost = (this.quantity * this.avgPrice) + (amount * price);
        this.quantity += amount;
        this.avgPrice = totalCost / this.quantity;
    }

    // (4) 비즈니스 로직: 매도 시 수량 감소
    public void decreaseQuantity(Long amount) {
        if (this.quantity < amount) {
            throw new IllegalStateException("보유 수량이 부족하여 매도할 수 없습니다.");
        }
        this.quantity -= amount;
    }
}
  • 핵심 로직 (addQuantity): 데이터를 수정하는 로직을 Service에 두지 않고 Entity에 두었습니다. 데이터가 있는 곳(Entity)에서 계산을 처리하는 것이 객체지향적이기 때문입니다.

4. 핵심 엔티티 구현: Order (거래 내역)

거래가 체결될 때마다 쌓이는 로그(Log) 성격의 데이터입니다.

domain/Order.java

Java
 
@Entity
@Getter
@NoArgsConstructor
@Table(name = "orders") // (중요) 테이블 이름 변경!
public class Order {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "stock_code")
    private Stock stock;

    @Enumerated(EnumType.STRING) // DB에 숫자(0) 대신 "BUY"로 저장
    private OrderType orderType;

    private Double price;            // 체결 가격
    private Long quantity;           // 주문 수량
    private LocalDateTime orderDate; // 주문 시간

    public Order(User user, Stock stock, OrderType orderType, Double price, Long quantity) {
        this.user = user;
        this.stock = stock;
        this.orderType = orderType;
        this.price = price;
        this.quantity = quantity;
        this.orderDate = LocalDateTime.now(); // 생성 즉시 현재 시간 기록
    }
}
  • @Table(name = "orders"): SQL에서 ORDER는 정렬(ORDER BY)을 위한 예약어입니다. 테이블 이름을 그대로 Order로 쓰면 에러가 발생하므로, 반드시 orders로 이름을 바꿔줘야 합니다.

5. Repository 구현

DB에 접근하기 위한 인터페이스입니다.

repository/StockHoldingRepository.java

Java
public interface StockHoldingRepository extends JpaRepository<StockHolding, Long> {
    // (1) "이 유저"가 "이 주식"을 가지고 있는지 확인하는 메서드
    Optional<StockHolding> findByUserAndStock(User user, Stock stock);
}

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

Q1. 왜 StockHolding과 Order를 분리했는가?

Order 테이블만 있어도 현재 보유량을 계산할 수는 있습니다. 과거의 모든 BUY 수량에서 SELL 수량을 빼면 되니까요. 하지만 거래 내역이 수백만 건 쌓인다면? 잔고를 확인할 때마다 수백만 건을 계산해야 하므로 성능이 심각하게 저하됩니다. 따라서 **현재 상태(Snapshot)**를 빠르게 조회하기 위해 StockHolding을 별도로 두고, 거래가 일어날 때마다 갱신하는 방식을 택했습니다. 이는 데이터베이스 정규화/비정규화 전략 중 성능을 위한 중복 데이터 허용에 해당합니다.

Q2. FetchType.LAZY는 무엇인가?

코드의 @ManyToOne 옆에 FetchType.LAZY를 붙였습니다. 이를 지연 로딩이라고 합니다.

  • EAGER (즉시 로딩): 주문 내역을 가져올 때 회원 정보와 주식 정보를 무조건 다 조인(Join)해서 가져옵니다. 불필요한 데이터를 가져오느라 성능이 떨어집니다.
  • LAZY (지연 로딩): 주문 내역만 일단 가져오고, 회원 정보나 주식 정보는 진짜로 필요할 때(get 메서드 호출 시) 쿼리를 날려 가져옵니다. 실무에서는 예상치 못한 쿼리 폭탄(N+1 문제)을 막기 위해 기본적으로 모든 연관 관계에 LAZY를 사용하는 것이 원칙입니다.

Q3. 왜 User와 Stock을 직접 연결하지 않고 StockHolding을 만들었나? (연관관계 매핑)

설계를 하다 보니 **유저(User)**와 **주식(Stock)**은 서로 다대다(N:M) 관계라는 것을 알았습니다.

  • 한 명의 유저는 여러 종목을 보유할 수 있습니다.
  • 하나의 종목(삼성전자 등)은 여러 유저에게 팔릴 수 있습니다.

하지만 관계형 데이터베이스(RDB)는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없습니다. 그래서 이 두 테이블을 연결해 주는 **중간 다리(연결 테이블)**가 필요합니다.

그것이 바로 StockHolding입니다. 단순히 연결만 하는 것이 아니라, "몇 개를(quantity)", "얼마에(avgPrice)" 가지고 있는지에 대한 추가 정보가 필요했기 때문에, @ManyToMany를 쓰지 않고 StockHolding이라는 별도의 엔티티로 승격시켜서 구현했습니다.

결과적으로 구조는 다음과 같이 풀립니다.

  • User (1) : (N) StockHolding (유저 입장에서 내 주식들)
  • Stock (1) : (N) StockHolding (주식 입장에서 주주 명부)

그래서 StockHolding 코드에 두 개의 @ManyToOne이 등장하게 된 것입니다.

 

7. 마치며

이제 데이터를 담을 그릇(Entity)과 저장소(Repository)가 준비되었습니다. 다음 포스팅에서는 이 재료들을 활용해 실제 매수(Buy) 기능을 담당하는 Service 로직을 구현해 보겠습니다. 드디어 내 돈이 차감되고 주식이 들어오는 순간입니다!