포스트

MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험

왜 테스트가 어려운가

monticker의 백엔드 서비스는 대부분 JdbcTemplate을 직접 사용한다. Spring Data JPA 리포지토리 대신 복잡한 SQL을 직접 작성해야 하는 금융 도메인 특성 때문이다.

JdbcTemplate을 테스트하기 어려운 이유:

  1. SQL 문자열 비교 취약성query(), queryForObject(), update() 인자가 긴 SQL 문자열
  2. 람다 인자RowMapper, ResultSetExtractor가 람다로 전달됨
  3. 가변 인자(vararg) — SQL 파라미터가 vararg Any?로 전달됨

기본 패턴: SQL 부분 문자열 매칭

정확한 SQL 문자열 전체를 매처로 쓰면 테스트가 깨지기 쉽다. 공백 하나, 줄바꿈 하나에 실패한다.

대신 SQL의 핵심 키워드로 부분 매칭한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// BAD: 정확한 SQL 전체 매칭 → 공백/줄바꿈 하나에 실패
every { jdbc.queryForObject(
    "SELECT COALESCE(SUM(realized_pnl), 0)\n    FROM fills f\n    ...",
    BigDecimal::class.java, userId
) } returns BigDecimal.ZERO

// GOOD: SQL 핵심 키워드로 부분 매칭
every {
    jdbc.queryForObject(
        match<String> { it.contains("FROM fills") },
        BigDecimal::class.java,
        any()
    )
} returns BigDecimal.ZERO

match<String> { it.contains("FROM fills") } — SQL이 FROM fills를 포함하면 매칭된다. 서비스 코드의 SQL 형식이 바뀌어도 핵심 테이블명이 있으면 테스트가 통과한다.


JdbcTemplate.query() + RowMapper 목킹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 서비스 코드
fun getHoldings(userId: Long): List<Holding> {
    return jdbcTemplate.query("""
        SELECT h.stock_id, h.quantity, h.avg_cost, s.symbol
        FROM holdings h
        JOIN stocks s ON s.id = h.stock_id
        WHERE h.user_id = ?
        ORDER BY h.stock_id
    """, { rs, _ ->
        Holding(
            stockId  = rs.getLong("stock_id"),
            quantity = rs.getInt("quantity"),
            avgCost  = rs.getBigDecimal("avg_cost"),
            symbol   = rs.getString("symbol"),
        )
    }, userId)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 테스트
@Test
fun `보유 종목이 있을  Holdings 반환한다`() {
    val expectedHoldings = listOf(
        Holding(stockId = 1L, quantity = 10, avgCost = BigDecimal("73000"), symbol = "005930"),
        Holding(stockId = 2L, quantity = 5,  avgCost = BigDecimal("120.0"), symbol = "AAPL"),
    )

    every {
        jdbc.query(
            match<String> { it.contains("FROM holdings") },
            any<RowMapper<Holding>>(),
            userId
        )
    } returns expectedHoldings

    val result = walletService.getHoldings(userId)

    assertThat(result).hasSize(2)
    assertThat(result[0].symbol).isEqualTo("005930")
}

any<RowMapper<Holding>>()으로 람다를 무시한다. 람다 내부 로직은 별도의 단위 테스트로 검증한다.


queryForObject() 반환 타입별 목킹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Int 반환
every {
    jdbc.queryForObject(
        match<String> { it.contains("COUNT(*)") },
        Int::class.java,
        any()
    )
} returns 3

// BigDecimal 반환
every {
    jdbc.queryForObject(
        match<String> { it.contains("SUM(realized_pnl)") },
        BigDecimal::class.java,
        userId
    )
} returns BigDecimal("1200000")

// String 반환
every {
    jdbc.queryForObject(
        match<String> { it.contains("emotion_tag") },
        String::class.java,
        orderId
    )
} returns "PLANNED"

update() 반환값 목킹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// INSERT/UPDATE: 영향받은 행 수 반환
every {
    jdbc.update(
        match<String> { it.contains("INSERT INTO ledger_events") },
        *anyVararg<Any?>()  // vararg 파라미터
    )
} returns 1

// 0 반환 = 중복으로 무시됨 (ON CONFLICT DO NOTHING)
every {
    jdbc.update(
        match<String> { it.contains("INSERT INTO stock_events") },
        *anyVararg<Any?>()
    )
} returns 0

*anyVararg<Any?>() — vararg 파라미터를 임의 값으로 매칭한다. MockK에서 vararg는 *anyVararg()로 spread operator와 함께 사용해야 한다.


@AuthenticationPrincipal 처리

컨트롤러 테스트에서 @AuthenticationPrincipal Long userId 파라미터를 처리하려면 커스텀 ArgumentResolver가 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private val authPrincipalResolver = object : HandlerMethodArgumentResolver {
    override fun supportsParameter(parameter: MethodParameter): Boolean =
        parameter.parameterType == Long::class.java &&
        parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java)

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?,
    ): Any = 1L  // 고정 userId

}

// MockMvc 설정
val mockMvc = MockMvcBuilders
    .standaloneSetup(controller)
    .setCustomArgumentResolvers(authPrincipalResolver)
    .build()

항상 참/거짓인 진입 조건 만들기

백테스트 엔진 테스트에서 “무조건 진입” 시나리오가 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// GOOD: MA(period=1) = 현재 종가. 종가 >= 자기 자신은 항상 true
private fun alwaysTrueEntryCondition() = RuleCondition(
    indicator  = "CLOSE_VS_MA",
    comparator = "GTE",
    params     = mapOf("period" to 1),
)

// BAD: PROFIT_RATE는 진입 조건 평가 시 처리되지 않는 지표 → 항상 false
private fun alwaysFalseEntryCondition() = RuleCondition(
    indicator  = "PROFIT_RATE",
    comparator = "GTE",
    params     = mapOf("threshold" to -999.0),
)

ObjectMapper + JavaTimeModule

날짜 타입(LocalDate, Instant)을 포함하는 DTO를 직렬화할 때 JavaTimeModule이 없으면 직렬화 오류가 난다.

1
2
3
4
5
6
7
// BAD: JavaTimeModule 없음 → LocalDate 직렬화 실패
val mapper = ObjectMapper()
val json = mapper.writeValueAsString(patternMatch)  // 예외 발생

// GOOD
val mapper = ObjectMapper().registerModule(JavaTimeModule())
val json = mapper.writeValueAsString(patternMatch)  // 정상

테스트에서 ObjectMapper()를 직접 생성할 때 놓치기 쉬운 설정이다.


311개 테스트 중 핵심 카테고리

모듈테스트 수핵심 테스트
Matching Engine45부분체결, 슬리피지, 가격우선 매칭
Risk Limit385가지 규칙 각각 BLOCKED/PASSED
Quant Backtest52look-ahead 없음, 비용 반영, 국면별 성과
Investment Wallet40잔고 재구성, 감정 태그 분석
Quant Analytics55Kelly 음수 케이스, 패턴 미탐지, ADX 임계값
Market Data35EMA 워밍업, 이벤트 중복 방지

정리

  • match<String> { it.contains("FROM table_name") }으로 SQL을 유연하게 목킹한다.
  • *anyVararg<Any?>()로 vararg SQL 파라미터를 처리한다.
  • 컨트롤러 테스트의 @AuthenticationPrincipal은 커스텀 HandlerMethodArgumentResolver로 처리한다.
  • 백테스트 “항상 진입” 조건은 CLOSE_VS_MA, period=1 — MA(1) = 현재 종가이므로 종가 ≥ MA는 항상 참.

다음 편에서는 OpenTelemetry + Jaeger로 시세 파이프라인 전체 지연을 추적하는 방법을 다룬다.

  1. 1 가격이 아니라 이벤트를 팔자 — monticker 설계 철학
  2. 2 모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기
  3. 3 TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계
  4. 4 Go goroutine으로 202개 종목 동시 수집하기 — Market Gateway 설계
  5. 5 Kafka로 시세 파이프라인 분리하기 — 토픽 설계와 at-least-once
  6. 6 Netty로 수만 연결에 시세 브로드캐스트하기 — NioEventLoopGroup 리액터 패턴
  7. 7 EMA 기반 이상 탐지 — 가격 급등과 거래량 서지 실시간 감지
  8. 8 TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지
  9. 9 주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙
  10. 10 잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계
  11. 11 감정 태그 × 수익률 — 투자 습관을 데이터로 기록하기
  12. 12 룰 엔진: RSI·MACD 조건식을 JSON DSL로 — Quant Lab 설계
  13. 13 백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산
  14. 14 전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어
  15. 15 Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법
  16. 16 Kelly Criterion: 수학적 파산 방지 베팅 비율 — Half Kelly와 백테스트 연동
  17. 17 ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥
  18. 18 ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL
  19. 19 손익통산으로 세금 줄이기 — Tax-Loss Harvesting 시뮬레이션
  20. 20 MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험
  21. 21 OpenTelemetry + Jaeger로 분산 추적 — 시세 파이프라인 지연 측정
  22. 22 Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다