Spring Boot 웹훅: HMAC-SHA256 서명 검증하기

Spring Boot 웹훅은 본문을 byte[]로 받아 타임스탬프와 원본 바이트에 대한 HMAC-SHA256을 계산하고, 각 sume-v1 항목을 상수 시간으로 비교합니다.

읽는 시간 6분Sume
전체 글

Spring Boot 웹훅 엔드포인트는 @PostMapping으로 매핑한 @RestController 메서드입니다. 이 메서드는 본문을 @RequestBody byte[]로 받으므로 Spring이 파싱된 객체 대신 원본 바이트를 넘겨 주며, 서명 헤더는 @RequestHeader로 읽습니다. Sume 웹훅이라면 javax.crypto.Mac으로 <timestamp>.<raw_body>에 대한 HMAC-SHA256을 계산하고, MessageDigest.isEqual로 비교해 sume-v1= 항목 중 하나라도 일치하면 전달을 수락한 뒤, 빠르게 204로 응답하세요.

Spring 관련 내용은 Spring Boot와 Spring Framework 레퍼런스 문서에서, Java 관련 내용은 Java SE 21 API 문서에서 가져왔으며 모두 출처에 나열했습니다. Sume 관련 내용은 Run 웹훅 (영문), 웹훅 (영문), 웹훅 검증에서 가져왔습니다. 모두 2026-09-27에 확인했습니다. Sume의 SDK인 @sume-com/sdk는 TypeScript용이므로, Java 수신기는 공개된 서명 방식을 직접 구현합니다. 다른 언어로 같은 검증을 하는 방법은 Go, PHP와 Laravel, Python 글에 있습니다.

Spring Boot 웹훅 엔드포인트에는 무엇이 필요한가요?

여섯 가지이며, 모두 JDK와 Spring MVC에 들어 있습니다. Spring은 서버 쪽에 바이트 배열 컨버터를 기본으로 등록하고, 이 컨버터는 모든 미디어 타입에 대해 바이트 배열을 읽으므로, byte[] 매개변수는 보낸 그대로의 본문을 받습니다.

Spring의 @RequestBody, 메시지 변환, @RequestHeader 문서, Java SE 21의 Mac, MessageDigest, HexFormat 문서, Sume의 Run 웹훅 (영문), 웹훅 (영문) 기준, 2026-09-27 확인.
단계Sume 규칙Spring 또는 Java
원본 본문JSON을 파싱하기 전에 원본 바이트를 검증@RequestBody byte[] body
헤더x-sume-webhook-timestamp, x-sume-webhook-signature@RequestHeader("x-sume-webhook-timestamp") String timestamp
MAC<timestamp>.<raw_body>에 대한 HMAC-SHA256Mac.getInstance("HmacSHA256"). 모든 Java 플랫폼이 지원해야 하는 알고리즘
서명sume-v1=<hex>. 시크릿 교체 중에는 유효한 시크릿마다 항목 하나씩, 쉼표로 구분쉼표로 나눈 뒤 HexFormat.of().parseHex(...). hex 숫자를 대소문자 구분 없이 읽음
비교상수 시간. 일치하는 항목이 하나라도 있으면 수락모든 항목에 MessageDigest.isEqual(expected, candidate)
재전송 허용 시간벗어난 타임스탬프는 거부. 오 분이 무난한 기본값파싱한 타임스탬프를 현재 Unix 시간과 비교

Java에서 HMAC-SHA256 서명은 어떻게 검증하나요?

MAC은 한 번 계산하고, 모든 sume-v1= 항목을 확인하세요. 시크릿을 교체한 뒤 24시간 동안 Sume는 두 시크릿으로 모두 서명해 최신 것부터 보내므로, 헤더 전체를 비교하는 검증은 그 기간의 모든 전달에서 실패합니다. Java 문서에 따르면 MessageDigest.isEqual은 첫 번째 인수의 모든 바이트를 검사하며, 걸리는 시간은 내용이 아니라 그 인수의 길이에만 좌우됩니다. 그러니 예상 MAC을 첫 번째 인수로 넘기세요. parseHex는 hex가 아닌 입력에 IllegalArgumentException을 던지며, HexFormat에는 Java 17 이상이 필요합니다. 이 클래스에는 Mac, SecretKeySpec, MessageDigest, StandardCharsets, Instant도 필요합니다.

이 코드는 HMAC을 계산하기 전에 빈 시크릿을 거부하므로, 설정되지 않은 변수 때문에 위조된 전달이 검증을 통과하는 일은 없습니다. 이 검사가 없으면 빈 키에 대해 SecretKeySpec이 IllegalArgumentException을 던집니다.

public final class SumeSignature {
    public static boolean verify(byte[] body, String ts, String header, byte[] secret)
            throws Exception {
        long t;
        try { t = Long.parseLong(ts); } catch (NumberFormatException e) { return false; }
        long now = Instant.now().getEpochSecond();
        if (secret.length == 0 || Math.abs(now - t) > 300) return false; // 5-minute window
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
        byte[] expected = mac.doFinal(body); // the raw bytes, never re-encoded JSON
        boolean ok = false;
        for (String entry : header.split(",")) { // two entries during a rotation
            String e = entry.trim();
            if (!e.startsWith("sume-v1=")) continue;
            try {
                byte[] got = HexFormat.of().parseHex(e.substring("sume-v1=".length()));
                if (MessageDigest.isEqual(expected, got)) ok = true; // check them all
            } catch (IllegalArgumentException notHex) { /* not a match */ }
        }
        return ok;
    }
}

Spring Boot 컨트롤러에서 웹훅은 어떻게 받나요?

Spring Boot는 Spring MVC를 자동 구성하며, Spring MVC에서는 @RestController 빈이 들어오는 HTTP 요청을 처리합니다. 서명 시크릿은 대시보드의 웹훅 탭에서 확인하거나 account:read가 있는 키로 GET /v1/webhooks/signing-secret을 호출해 읽고, SUME_COM_WEBHOOK_SIGNING_SECRET으로 저장하세요. 204는 ResponseEntity.noContent()로, 거부 응답은 ResponseEntity.status(401)로 만듭니다.

@RestController
public class SumeWebhookController {
    private final byte[] secret = System.getenv()
            .getOrDefault("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
            .getBytes(StandardCharsets.UTF_8);

    @PostMapping("/hooks/sume")
    public ResponseEntity<Void> receive(
            @RequestBody byte[] body, // raw bytes, not a bound object
            @RequestHeader("x-sume-webhook-timestamp") String timestamp,
            @RequestHeader("x-sume-webhook-signature") String signature) throws Exception {
        if (!SumeSignature.verify(body, timestamp, signature, secret)) {
            return ResponseEntity.status(401).build();
        }
        recordOnce(body); // your store: parse these verified bytes, dedupe, queue work
        return ResponseEntity.noContent().build(); // 204 inside Sume's 10 s
    }
}

왜 모든 서명 검증이 실패하나요?

Spring에서는 검증하기 전에 본문이 객체에 바인딩되었다가 다시 직렬화된 경우가 가장 흔합니다. 파싱했다가 다시 직렬화한 본문은 검증되지 않습니다. 키 순서와 공백도 Sume가 서명한 대상의 일부이기 때문입니다. byte[] 매개변수를 유지하고, verify를 통과한 바이트만 파싱하세요. 다른 시크릿이나 어긋난 시계 같은 나머지 원인은 Sume 웹훅이 도착하지 않나요?에서 차례로 점검합니다.

검증한 뒤 엔드포인트는 무엇을 해야 하나요?

이벤트를 내구성 있게 기록하고 응답하세요. 어떤 2xx든 인정되고, 시도마다 10초가 주어지며, Sume는 최대 10회 시도합니다. Run 웹훅은 request_id로, 생성 Job 웹훅은 job_id로 중복을 제거하세요. 1 MiB를 넘는 영수증은 payload: null과, 영수증을 가져올 error.result_url을 담아 도착합니다. 재시도와 다시 보내기(Redeliver)는 Sume 영상 실행용 서명된 웹훅에서 다룹니다.

출처

관련 글

연동 카테고리의 다른 글

연동 글 전체 보기

작성자 Sume