1. 들어가며: 우물 안 개구리 탈출하기
지난 포스팅까지는 회원(User)과 계좌(Account)라는 내부 데이터를 다루는 것에 집중했습니다. 하지만 주식 거래소의 핵심은 **"살 물건(종목)"**이 있어야 한다는 것입니다.
이번 포스팅에서는 업비트(Upbit) Open API를 연동하여 실시간 암호화폐 시세 데이터를 가져오고, 이를 우리 DB의 Stock 테이블에 동기화하는 과정을 다룹니다. 이 과정에서 Blocking 방식(RestTemplate) 대신 **Non-Blocking 방식(WebClient)**을 선택한 이유와 JPA 저장 로직의 트러블 슈팅 과정을 공유합니다.
2. Stock 도메인 설계: PK 전략의 변화
기존 User나 Account는 Long id (Auto Increment)를 PK로 사용했습니다. 하지만 주식 종목은 이미 전 세계적으로 통용되는 고유 코드가 존재합니다.
@Entity
@Getter
@NoArgsConstructor
public class Stock {
@Id // (1) 별도의 숫자 ID 대신 종목 코드를 PK로 사용
private String code; // 예: "KRW-BTC"
private String name; // 예: "비트코인"
private Double currentPrice; // 현재가
// ... 생성자 및 비즈니스 메서드 ...
}
💡 설계 의도
- KRW-BTC 같은 고유 코드가 있는데 굳이 1, 2, 3 같은 숫자 ID를 만들면, 조회할 때마다 **"KRW-BTC가 몇 번이지?"**라고 한 번 더 검색해야 합니다.
- 불필요한 인덱스 생성을 막고 조회 성능을 높이기 위해 종목 코드(String) 자체를 PK로 설정했습니다.
3-1. Upbit API 공식 문서 분석: 데이터를 어디서 가져올까?
구현에 앞서 가장 먼저 한 일은 **업비트 개발자 센터(Upbit Developer Center)**를 분석하여 내가 필요한 데이터가 어디에 있는지 파악하는 것이었습니다.
- 목표: 특정 코인(비트코인 등)의 **'현재 가격'**을 실시간으로 가져오고 싶다.
- 문서 탐색: API Reference -> QUOTATION API -> 시세 Ticker 항목을 찾았습니다.
[분석 결과]
- 요청 URL: https://api.upbit.com/v1/ticker
- 필수 파라미터: markets (반점(,)으로 구분하여 여러 개 요청 가능. 예: KRW-BTC,KRW-ETH)
- 응답 데이터 (JSON): 수많은 필드 중 제게 필요한 핵심 데이터는 딱 두 가지였습니다.
- market: 종목 구분 코드 (예: "KRW-BTC") -> Stock 엔티티의 ID로 사용
- trade_price: 현재 체결가 (Double) -> Stock 엔티티의 가격으로 사용
이 분석을 바탕으로, 외부 데이터를 우리 시스템에 맞게 변환할 그릇(DTO)을 설계했습니다.
3-2. 외부 통신 라이브러리: Why WebClient?
외부 API와 통신하기 위해 Spring Boot Starter WebFlux 의존성을 추가하고 **WebClient**를 도입했습니다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-webflux'
🤔 RestTemplate vs WebClient 과거에는 RestTemplate을 많이 사용했지만, 현재는 유지보수 모드(Maintenance mode)로 전환되어 권장되지 않습니다.
- WebClient: 최신 Spring의 표준이며, 비동기(Asynchronous) & 논블로킹(Non-Blocking) 처리에 강력합니다. 추후 수많은 종목의 시세를 동시에 가져올 때 서버 자원을 훨씬 효율적으로 사용할 수 있습니다.
4-1. DTO 및 Repository 구현
서비스 로직을 짜기 전에, API 응답을 받을 DTO와 DB에 접근할 Repository를 먼저 구현했습니다.
1) DTO (Data Transfer Object) 업비트 API가 보내주는 JSON의 필드명(trade_price)과 변수명을 정확히 일치시켜야 데이터가 자동으로 매핑됩니다.
package com.example.virtual_exchange.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor // 라이브러리(Jackson)가 JSON을 객체로 변환할 때 필수
public class UpbitTickerDto {
private String market; // 종목 코드 (예: KRW-BTC)
private Double trade_price; // 현재 가격
}
2) StockRepository 이번 프로젝트에서 Stock 엔티티는 숫자 ID가 아닌 **종목 코드(String)**를 PK로 사용합니다. 따라서 JpaRepository의 제네릭 타입도 <Stock, String>으로 변경되었습니다.
public interface StockRepository extends JpaRepository<Stock, String> {
// 기본 제공되는 findById(String code)를 사용할 것이므로
// 별도의 메서드 정의가 필요 없습니다.
}
4-2. 데이터 수집 및 저장 로직 (Upsert 구현)
분석한 API 정보와 DTO를 활용해 StockService에 전체 로직을 구현했습니다. 이 코드의 핵심은 "외부에서 비동기로 데이터를 받아오고(WebClient)", **"DB에 없으면 저장, 있으면 수정(Upsert)"**하는 두 가지 과정의 결합입니다.
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@Transactional
public void getStockPrice() {
// 1. 업비트 API 주소 (분석한 URL + 파라미터)
String url = "https://api.upbit.com/v1/ticker?markets=KRW-BTC,KRW-ETH";
// 2. WebClient로 데이터 요청 (비동기)
WebClient.create()
.get()
.uri(url)
.retrieve()
.bodyToFlux(UpbitTickerDto.class) // 여러 개(List)니까 Flux 사용
.subscribe(dto -> {
// 3. 응답이 도착하면 실행될 로직 (Callback)
System.out.println("종목: " + dto.getMarket() + ", 가격: " + dto.getTrade_price());
// [Upsert 로직]
// 3-1. DB에서 종목 코드로 조회
Stock stock = stockRepository.findById(dto.getMarket())
.orElse(null); // 없으면 null 반환
if (stock == null) {
// 3-2. 없으면? -> 신규 생성 및 저장 (Insert)
Stock newStock = new Stock(dto.getMarket(), dto.getMarket(), dto.getTrade_price());
stockRepository.save(newStock);
} else {
// 3-3. 있으면? -> 가격 업데이트 (Update)
// (JPA Dirty Checking에 의해 트랜잭션 종료 시 자동 반영됨)
stock.updatePrice(dto.getTrade_price());
stockRepository.save(stock);
}
});
}
}
3-2 부분에서 현재 업비트 Ticker API에서는 한글 종목명을 제공하지 않아, 우선 종목 코드(Market)를 이름으로 대체했습니다.
💡 기술적 회고: 왜 .block() 대신 .subscribe()를 썼을까?
WebClient 예제들을 찾다 보면 .block()을 쓰는 경우도 종종 보입니다. 하지만 이번 로직에서는 의도적으로 **.subscribe()**를 사용했습니다. 그 이유는 **WebClient의 존재 이유(Non-Blocking)**를 살리기 위해서입니다.
- ⛔ .block() (동기/Blocking):
- 요청을 보내고 응답이 올 때까지 스레드가 멈춰서 기다립니다.
- 마치 식당에서 주문하고 음식이 나올 때까지 카운터 앞에서 꼼짝 않고 서 있는 것과 같습니다. 이렇게 하면 굳이 성능 좋은 WebClient를 쓰는 의미가 퇴색됩니다 (RestTemplate과 다를 게 없어짐).
- ✅ .subscribe() (비동기/Non-Blocking):
- 요청만 보내놓고 스레드는 즉시 다른 일을 하러 갑니다.
- **"데이터 도착하면(구독하면), 이 로직(Lambda)을 실행해줘"**라고 예약만 걸어두는 방식입니다.
- 마치 식당에서 진동벨을 받고 자리에 앉아 있다가, 벨이 울리면 가서 받아오는 것과 같습니다. 서버 자원을 훨씬 효율적으로 사용할 수 있습니다.
5. 기술적 회고 (Deep Dive) 💡
단순한 기능 같지만, 구현 과정에서 JPA의 내부 동작 원리를 깊이 이해할 수 있었습니다.
Q1. 왜 테스트 로그에 SELECT 쿼리가 두 번 찍힐까?
테스트를 실행했을 때, Insert 쿼리가 나가기 전에 의도치 않은 Select 쿼리가 발생하는 것을 발견했습니다.
Hibernate: select ... (우리가 호출한 findById)
Hibernate: select ... (save 메서드 내부에서 발생!)
Hibernate: insert ...
원인: JPA의 save() 메서드는 PK가 null이면 신규 객체(New)로 판단하고 바로 insert를 합니다. 하지만 이번 Stock 엔티티는 PK(code)에 "KRW-BTC"라는 값이 채워져 있습니다. JPA는 "PK가 있으니 이미 DB에 있는 데이터인가?"라고 헷갈려 하며 확인 차원에서 SELECT를 한 번 더 날리는 것이었습니다. (이는 Persistable 인터페이스를 구현하여 해결할 수 있지만, 현재 단계에서는 로직 흐름상 큰 문제가 없어 유지했습니다.)
Q2. @Transactional의 위치 선정
StockService 클래스 전체에는 읽기 전용 트랜잭션(readOnly = true)을 걸지 않았습니다. 하지만 getStockPrice 메서드에는 명시적으로 @Transactional을 붙였습니다. 외부 데이터를 가져와 DB 상태를 변경(Write)하는 작업이므로, 데이터 일관성을 위해 쓰기 가능한 트랜잭션이 필수적이기 때문입니다.
6. 마치며: 데이터가 흐르기 시작했다
이제 우리 서버는 고립된 섬이 아닙니다. 실제 업비트 거래소와 연결되어 실시간으로 시세 데이터를 받아오고 DB에 차곡차곡 쌓기 시작했습니다.
다음 포스팅에서는 이 데이터를 주기적으로 자동 갱신해 주는 **스케줄러(Scheduler)**를 적용하여, 손대지 않아도 스스로 돌아가는 거래소 시스템을 만들어 보겠습니다.
'개발 공부 > 프로젝트' 카테고리의 다른 글
| [사이드 프로젝트] 가상 주식 거래소 만들기 (5) - 스케줄러를 이용한 데이터 자동화와 REST API 구현 (0) | 2025.12.02 |
|---|---|
| 남의 코드 복붙은 그만! 업비트 공식 문서 씹어먹기 (WebClient 연동) (0) | 2025.12.02 |
| [사이드 프로젝트] 가상 주식 거래소 만들기 (3) - 비즈니스 로직 구현과 서비스 계층 테스트 (1) | 2025.11.25 |
| [사이드 프로젝트] 가상 주식 거래소 만들기 (2) - 도메인 설계와 JPA Entity의 기술적 고민들 (0) | 2025.11.24 |
| [사이드 프로젝트] '가상 주식 거래소' 만들기 (1) - 프로젝트 선정 이유와 환경 설정 (0) | 2025.11.20 |