포스트

전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어

전략 보호가 필요한 이유

Quant Lab에는 전략 마켓이 있다. 사용자 A가 만든 전략을 B가 구독하면 A는 수익을 얻는다.

그런데 전략의 조건식이 클라이언트에 노출되면 문제가 생긴다.

  1. B가 조건식을 복사해서 자기 전략으로 등록한다.
  2. B가 조건식을 분석해서 유사한 전략을 만든다.
  3. C가 많은 구독으로 인기 전략의 조건식을 역공학으로 분석한다.

전략 판매자의 지적재산권이 보호되지 않으면 전략 마켓이 존재하기 어렵다.


설계 원칙

1
2
전략 조건식(rule_definition)은 절대 클라이언트에 반환하지 않는다.
서버가 조건식을 평가하고, 결과(신호)만 반환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
클라이언트 ─────────────────────────────────────────────► API 서버
              GET /api/strategies/42/signal

API 서버 ─────────────────────────────────────────────────────────
              rule_definition 로드 (DB에서)
              IndicatorEngine.compute()
              RuleEvaluator.evaluate()
                                ◄─────────────────────────────
              {
                "hasSignal": true,
                "direction": "BUY",
                "stockCount": 3,
                "triggeredAt": "2025-07-14T10:00:00Z"
              }
                                ← rule_definition 없음

rule_sets 테이블 설계

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE rule_sets (
    id                          BIGSERIAL    PRIMARY KEY,
    user_id                     BIGINT       NOT NULL REFERENCES users(id),
    name                        VARCHAR(100) NOT NULL,
    rule_definition             JSONB        NOT NULL,          -- 조건식 원문
    rule_definition_encrypted   BYTEA,                          -- AES-256 암호화본 (향후)
    rule_set_fingerprint        CHAR(64)     NOT NULL UNIQUE,   -- SHA-256 해시
    version                     INTEGER      NOT NULL DEFAULT 1,
    status                      VARCHAR(20)  NOT NULL DEFAULT 'DRAFT',
    is_public                   BOOLEAN      NOT NULL DEFAULT false,
    created_at                  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

rule_set_fingerprint

조건식의 SHA-256 해시다. 두 가지 목적이 있다.

  1. 무결성 검증: 저장 후 조건식이 변조되었는지 확인
  2. 중복 탐지: 같은 전략이 다른 이름으로 재등록되는 것을 방지
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun generateFingerprint(ruleDefinition: JsonNode): String {
    // 정규화: 키 순서 정렬, 공백 제거 → 같은 조건식은 항상 같은 해시
    val normalized = objectMapper.writeValueAsString(
        sortedKeys(ruleDefinition)
    )
    return MessageDigest.getInstance("SHA-256")
        .digest(normalized.toByteArray())
        .joinToString("") { "%02x".format(it) }
}

private fun sortedKeys(node: JsonNode): Any = when {
    node.isObject -> TreeMap<String, Any>().also { map ->
        node.fields().forEach { (key, value) ->
            map[key] = sortedKeys(value)
        }
    }
    node.isArray  -> node.map { sortedKeys(it) }
    else          -> node
}

정규화 과정에서 키를 알파벳 순으로 정렬해야 한다. {"a":1,"b":2}{"b":2,"a":1}은 같은 조건식이지만 단순 직렬화하면 다른 문자열이 된다.


신호 서비스 — rate limiting

구독자가 신호 API를 너무 자주 호출하면 응답 패턴 분석으로 조건식을 역공학할 수 있다. 예를 들어 특정 조건에서만 신호가 발생한다는 것을 반복 호출로 파악할 수 있다.

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
27
28
29
30
31
32
33
34
35
36
37
@Service
class StrategySignalService(
    private val ruleSetRepository: RuleSetRepository,
    private val ruleEvaluator: RuleEvaluator,
    private val redisTemplate: StringRedisTemplate,
) {
    private val rateLimitWindow = Duration.ofHours(1)
    private val maxCallsPerHour = 60

    fun getSignal(userId: Long, strategyId: Long): StrategySignal {
        // Rate limit 체크
        val rateLimitKey = "signal:ratelimit:$userId:$strategyId"
        val calls = redisTemplate.opsForValue().increment(rateLimitKey) ?: 1L
        if (calls == 1L) redisTemplate.expire(rateLimitKey, rateLimitWindow)
        if (calls > maxCallsPerHour) {
            throw RateLimitException("신호 조회가 너무 잦습니다 (한도: ${maxCallsPerHour}회/시간)")
        }

        // 구독 확인
        val subscription = subscriptionRepository.findActive(userId, strategyId)
            ?: throw ForbiddenException("구독하지 않은 전략입니다")

        // 전략 로드 및 평가 (rule_definition 은 서버에서만 사용)
        val ruleSet = ruleSetRepository.findById(strategyId)
        val signals = evaluateForUniverse(ruleSet)

        // 반환: 신호만 포함, 조건식 내용 없음
        return StrategySignal(
            strategyId  = strategyId,
            hasSignal   = signals.isNotEmpty(),
            direction   = signals.firstOrNull()?.direction,
            stockCount  = signals.size,
            stocks      = signals.map { it.symbol },  // 종목명만, 조건 내용 없음
            triggeredAt = Instant.now(),
        )
    }
}

API 응답에서 rule_definition 제거

Spring에서 JSON 응답에서 특정 필드를 제외하는 방법이다.

1
2
3
4
5
6
7
8
9
data class RuleSetResponse(
    val id          : Long,
    val name        : String,
    val status      : String,
    val version     : Int,
    val fingerprint : String,
    val isPublic    : Boolean,
    // rule_definition 없음 — 응답 DTO에 아예 포함하지 않는다
)

엔티티와 응답 DTO를 분리하는 것이 가장 단순하고 확실한 방법이다. @JsonIgnore를 쓰는 방법보다 명시적이다.


전략 버전 관리

전략을 수정하면 새 버전이 생성된다. 과거 버전의 신호와 현재 버전의 신호를 비교할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun updateRuleSet(userId: Long, id: Long, newDefinition: JsonNode): RuleSet {
    val existing = ruleSetRepository.findByIdAndUserId(id, userId)
    val newFingerprint = generateFingerprint(newDefinition)

    // 조건식이 실제로 변경됐는지 확인
    if (existing.ruleSetFingerprint == newFingerprint) {
        return existing  // 변경 없음
    }

    // 새 버전 생성 (기존 버전은 보존)
    return ruleSetRepository.save(existing.copy(
        ruleDefinition   = newDefinition,
        ruleSetFingerprint = newFingerprint,
        version          = existing.version + 1,
        updatedAt        = Instant.now(),
    ))
}

버전 카운트가 많으면 신뢰도 점수가 낮아진다. 파라미터를 과도하게 조정한(과최적화) 흔적이기 때문이다.


정리

  • 조건식(rule_definition)은 서버에서만 실행되고 클라이언트에 반환되지 않는다.
  • SHA-256 지문으로 무결성 검증과 중복 탐지를 동시에 해결한다.
  • 신호 API에 rate limiting을 적용해 패턴 분석을 통한 역공학을 방지한다.
  • 응답 DTO에 rule_definition 필드 자체를 포함하지 않는 것이 @JsonIgnore보다 안전하다.

다음 시리즈(Series 6)에서는 보유 포트폴리오를 수학적으로 최적화하는 Markowitz 포트폴리오 최적화를 다룬다.

  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 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다