1. 들어가며: "지갑"과 "영수증"은 다르다
지난 포스팅까지 화면(View)을 구성했습니다. 이제 사용자가 "매수" 버튼을 눌렀을 때 일어날 일을 정의할 차례입니다.
가장 먼저 고민해야 할 것은 **"데이터를 어떻게 저장할 것인가?"**입니다. 처음에는 Order 테이블 하나만 있으면 될 것 같지만, 주식 거래소에는 두 가지 성격의 데이터가 필요합니다.
- 현재 상태 (Snapshot): 지금 내가 어떤 주식을 몇 개 가지고 있고, 평단가가 얼마인가? -> 지갑(Holding)
- 과거 기록 (Log): 내가 언제, 얼마에 사고팔았는가? -> 영수증(Order)
이번 포스팅에서는 이 두 가지 데이터를 관리하기 위한 도메인 엔티티(StockHolding, Order)를 설계하고 구현합니다.
2. 기본 설정: Enum 클래스 (OrderType)
가장 먼저 거래의 종류를 구분하기 위한 상수(Enum)를 정의합니다. 문자열("BUY")로 관리하면 오타가 날 수 있으므로 Enum을 사용하는 것이 안전합니다.
domain/OrderType.java
package com.example.virtual_exchange.domain;
public enum OrderType {
BUY, // 매수
SELL // 매도
}
3. 핵심 엔티티 구현: StockHolding (보유 주식)
사용자가 현재 보유 중인 주식 정보를 담는 엔티티입니다. 여기서 주목할 점은 **"평단가(AvgPrice) 계산 로직"**이 엔티티 안에 포함되어 있다는 것입니다.
domain/StockHolding.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
@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
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 로직을 구현해 보겠습니다. 드디어 내 돈이 차감되고 주식이 들어오는 순간입니다!
'개발 공부 > 프로젝트' 카테고리의 다른 글
| [사이드 프로젝트] 가상 주식 거래소 만들기 (9) - 주문을 받는 창구, OrderController와 REST API (0) | 2025.12.10 |
|---|---|
| [사이드 프로젝트] 가상 주식 거래소 만들기 (8) - 핵심 비즈니스 로직 구현 (매수/매도와 트랜잭션) (0) | 2025.12.09 |
| [사이드 프로젝트] 가상 주식 거래소 만들기 (6) - Thymeleaf를 활용한 시세 화면 구현과 SSR (0) | 2025.12.08 |
| [사이드 프로젝트] 가상 주식 거래소 만들기 (5) - 스케줄러를 이용한 데이터 자동화와 REST API 구현 (0) | 2025.12.02 |
| 남의 코드 복붙은 그만! 업비트 공식 문서 씹어먹기 (WebClient 연동) (0) | 2025.12.02 |