지정가 PENDING 무제한 요청 가능 + 스케줄러 좀비 SUCCESS
Problem
보유하지 않은 주식종목으로 SELL 지정가를 반복 전송할 수 있었고, /api/trades/pending 응답에 phantom 6건이 쌓여 있었다. 게다가 가격조건 만족 시 스케줄러가 체결을 시도하면 자산은 변하지 않았는데 trade 만 SUCCESS 로 박히는 좀비 데이터까지 발생했다.
Analyze
세 가지가 동시에 깨져 있었다.
(1) PENDING 진입 시 검증 없음 — validateOrderRequest 는 limitPrice/stockAmount 만 본다. 보유/잔액 검증은 asset-service 호출 시점에 일어나는데, PENDING 분기는 asset-service 를 호출조차 안 하고 DB INSERT 만 한다.
(2) 스케줄러가 X-User-Id 누락 — executeAsset(order, currentPrice, null) 로 userId=null 을 넘김. asset-service @RequestHeader("X-User-Id") 가 못 받아서 400. 보유 검증 로직에 도달하기도 전에 거부된다.
(3) SUCCESS 선마킹 — 스케줄러가 executeAsset 호출 전에 updateStatus(updateSuccess) 를 먼저 호출. 외부 호출이 실패해도 catch 가 예외만 삼키고 트랜잭션 롤백을 안 시켜서, 메서드 종료 시 잘못된 SUCCESS 가 DB 로 flush 된다.
Action
셋을 묶어서 한 번에 수정했다.
// (1) 접수 시점 사전 검증
private void validateSufficientFundsOrHoldings(...) {
if (tradeType == TradeType.SELL) {
int held = assetClient.getHoldingQuantity(accountId, stockCode);
if (held < amount) throw new BusinessException(INSUFFICIENT_HOLDINGS);
} else {
long balance = assetClient.getAccountBalance(accountId);
if (balance < required) throw new BusinessException(INSUFFICIENT_BALANCE);
}
}
// (2) Trade 에 userId 필드 추가 → 스케줄러가 trade.userId() 사용
public record Trade(UUID tradeId, UUID accountId, UUID userId, ...) { ... }
// (3) executeAsset 먼저, 성공 시 SUCCESS, 실패 시 FAIL. 건별 독립 트랜잭션.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void executePendingLimitOrder(Trade order, double currentPrice) {
try {
executeAsset(order, currentPrice, order.userId());
} catch (Exception e) {
tradeRepository.updateStatus(Trade.updateFail(order));
}
}
asset-service 에는 X-User-Id 가 필요 없는 내부 엔드포인트도 새로 열었다
(/internal/holdings/quantity, /internal/accounts/balance).
Result
보유 없는 종목 SELL 지정가는 접수 단계에서 400 으로 거부된다. 스케줄러는 trade.userId() 로 asset-service 를 정상 호출하고, 실패해도 trade 가 FAIL 로 정확히 기록된다. 좀비 SUCCESS 가 더 이상 안 생긴다.
REQUIRES_NEW 자기호출과 @Lazy 함정
Problem
위 (3) 을 위해 건별 REQUIRES_NEW 트랜잭션을 적용했는데 Spring 부팅 자체가 실패했다.
APPLICATION FAILED TO START
The dependencies of some of the beans in the application context form a cycle:
limitOrderScheduler
┌─────┐
| tradeServiceImpl
└─────┘
Analyze
@Transactional 은 Spring AOP 프록시로 동작하는데, 같은 클래스 안에서 this.method() 로 직접 호출하면 프록시를 우회하기 때문에 REQUIRES_NEW 가 무시된다. 그래서 자기 자신을 다시 빈으로 주입받아 그 참조(=프록시) 로 호출해야 한다.
그런데 그러면 빈 그래프가 자기 자신을 가리키므로 순환참조가 된다.
그래서 순환을 끊으려고 @Lazy 를 붙였다.
@Lazy
private final TradeService self;
그런데 Lombok @RequiredArgsConstructor 가 만들어주는 생성자는 필드의 @Lazy 를 파라미터로 옮겨주지 않는다.
public TradeServiceImpl(..., TradeService self) { // @Lazy 어디감?
this.self = self;
}
Spring 은 @Lazy 가 생성자 파라미터에 붙어 있어야만 lazy proxy 를 주입한다.
결과적으로 일반 의존성으로 처리돼서 순환참조 검출에 그대로 걸린다.
Action
ObjectProvider<TradeService> 로 갈아탔다. 타입 자체가 lazy lookup 래퍼라 어노테이션 전파 이슈에서 자유롭다.
// 수정 전: @Lazy private final TradeService self;
// 수정 후:
private final ObjectProvider<TradeService> selfProvider;
// 호출 시점에 컨테이너에서 빈 획득
selfProvider.getObject().executePendingLimitOrder(order, currentPrice);
생성자 주입 시점에 ObjectProvider 자체만 주입되고, 실제 TradeService 빈은 getObject() 시점에 조회된다.
그땐 자기 자신의 생성이 이미 끝나 있으니 순환이 발생하지 않는다.
Result
부팅 성공. REQUIRES_NEW 가 정상 작동해서 건별 독립 트랜잭션이 보장된다.
Lombok 환경에서 @Lazy 를 꼭 쓰고 싶다면?
- onConstructor_ 옵션 (모든 파라미터에 적용되는 부작용)
- 수동 생성자 작성
- 필드 주입 + @Autowired @Lazy
셋 다 단점이 있어서 표준 API 인 ObjectProvider 가 가장 깔끔하다는 결론을 냈다.
'내배캠' 카테고리의 다른 글
| [내일배움캠프] 인생에서 목표설정의 중요성 (0) | 2026.05.17 |
|---|---|
| TIL - 지정가 매매시, 검증 허점 보완 (0) | 2026.05.15 |
| TIL - 지정가 매매 시 트러블 슈팅 (0) | 2026.05.14 |
| TIL - DB/Redis/Kafka 분산트랜잭션 대응 (0) | 2026.05.13 |
| TIL - 작업하던 브랜치에서 바로 git pull origin dev를 하면? (0) | 2026.05.08 |