본문으로 건너뛰기

Java (Spring Boot) 구현 예제

예제 코드 저장소 링크​

설정​

콜백 응답 생성에 필요한 보안 키와 사용자 키(Custom key)는 소스 코드에 하드코딩하지 않고 application-local.yaml 설정 파일을 통해 주입받도록 구성합니다. 저장소의 application-local.yaml.example 파일을 복사한 뒤, 실제 발급받은 키 값으로 변경하여 사용하세요.

kollus:
security-key: "YOUR_SECURITY_KEY" # Kollus VOD 콘솔 > [설정] > [보안 키]
custom-key: "YOUR_CUSTOM_KEY" # Kollus VOD 콘솔 > [설정] > [사용자 키]

JWT 서명 구현​

별도의 외부 JWT 라이브러리 의존성 없이 표준 Java 보안 API를 활용하여 HS256(HMAC-SHA256) 기반으로 콜백 응답 JWT를 서명하는 서비스입니다. (플레이어 토큰 생성 등 콜백과 직접적인 관련이 없는 메서드는 생략되었습니다.)

package com.example.kollus.service;

import org.springframework.stereotype.Service;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Map;

/**
* Kollus 플레이어 JWT 토큰 생성 및 서명 유틸리티입니다.
* 외부 JWT 라이브러리 없이 HS256(HMAC-SHA256) 알고리즘을 사용해 Kollus 콜백 응답 Payload를 직접 인코딩 및 서명합니다.
*/
@Service
public class KollusTokenService {

/** HMAC-SHA256 서명 알고리즘 식별자 */
private static final String HMAC_ALGORITHM = "HmacSHA256";
/** 고정 JWT 헤더 규격 (typ: JWT, alg: HS256) */
private static final Map<String, Object> JWT_HEADER = Map.of("typ", "JWT", "alg", "HS256");

private final ObjectMapper objectMapper;
private final Base64.Encoder base64Url = Base64.getUrlEncoder().withoutPadding();

public KollusTokenService(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

/**
* Kollus 콜백 응답용 JWT를 HS256 알고리즘으로 서명합니다.
*
* @param payload JWT Payload 객체
* @param securityKey HMAC 서명에 사용할 보안 키
* @return Base64URL로 인코딩된 서명 완료 JWT 문자열
*/
public String signCallbackJwt(Object payload, String securityKey) {
return encodeJwt(payload, securityKey);
}

/**
* {@code header.payload.signature} 형태의 표준 JWT 문자열을 생성합니다.
*/
private String encodeJwt(Object payload, String securityKey) {
String header = base64UrlJson(JWT_HEADER);
String body = base64UrlJson(payload);
String content = header + "." + body;
String signature = sign(content, securityKey);
return content + "." + signature;
}

/**
* 객체를 JSON으로 직렬화한 후 Base64URL(Padding 제외) 형식으로 인코딩합니다.
*/
private String base64UrlJson(Object value) {
try {
byte[] json = objectMapper.writeValueAsBytes(value);
return base64Url.encodeToString(json);
} catch (JacksonException e) {
// 직렬화 실패는 런타임 결함이므로 언체크 예외(IllegalStateException)로 래핑하여 전파
throw new IllegalStateException("JWT payload 직렬화 실패", e);
}
}

/**
* HMAC-SHA256 알고리즘으로 본문을 서명하고 Base64URL 문자열로 반환합니다.
*/
private String sign(String content, String securityKey) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(securityKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
byte[] raw = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
return base64Url.encodeToString(raw);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
// HmacSHA256은 표준 JVM 필수 지원 알고리즘이므로 정상 런타임 환경에서는 발생하지 않음
throw new IllegalStateException("JWT 서명 실패", e);
}
}
}

플레이 콜백 컨트롤러 구현​

Kollus 서버로부터 전송되는 POST /callback/play 요청을 수신하고, 요청 유형(kind)에 따라 적절한 응답 JWT를 생성하여 반환하는 컨트롤러입니다. (DRM, LMS 등 타 콜백 처리 메서드는 생략되었습니다.)

package com.example.kollus.controller;

import com.example.kollus.config.KollusProperties;
import com.example.kollus.service.KollusTokenService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* Kollus 콜백 처리 컨트롤러입니다.
* 재생(Play), DRM, 다음 회차(Next Episode), LMS 콜백 요청을 수신하고 규격에 부합하는 응답을 반환합니다.
*/
@RestController
@RequestMapping("/callback")
public class CallbackController {

/** 콜백 응답 JWT 유효 시간 (600초 / 10분) — 권한 검증용 토큰은 짧은 만료 시간을 유지 */
private static final int CALLBACK_JWT_EXPIRE_SEC = 600;
/** 콘텐츠 재생 만료 기간 (30일, 초 단위) */
private static final long CONTENT_EXPIRE_SEC = 60L * 60 * 24 * 30;
/** Kollus 콜백 응답 헤더명 (사용자 키 전달용) */
private static final String USERKEY_HEADER = "X-KOLLUS-USERKEY";
/** 콜백 유형 1: 승인 및 만료일 지정 (재생 콜백: 재생 만료 설정, DRM 콜백: 다운로드 승인) */
private static final int KIND_GRANT_WITH_EXPIRATION = 1;
/** 콜백 유형 3: 재생 권한 최종 확인 (재생 콜백: 최종 재생 승인, DRM 콜백: 오프라인 재생 권한 확인) */
private static final int KIND_PLAYBACK_APPROVAL = 3;

private final KollusTokenService tokenService;
private final KollusProperties properties;

public CallbackController(KollusTokenService tokenService,
KollusProperties properties) {
this.tokenService = tokenService;
this.properties = properties;
}

/**
* POST /callback/play — 재생 콜백 요청을 처리합니다.
* kind 1: 재생 만료 시점 설정 (expiration_date 반환), kind 3: 최종 재생 승인 (result 반환)
*
* @param kindParam 콜백 요청 유형 식별값 (1: 재생 만료 설정, 3: 최종 재생 승인)
* @return X-KOLLUS-USERKEY 헤더 및 서명된 JWT 본문(text/plain), 정의되지 않은 kind인 경우 400 Bad Request
*/
@PostMapping("/play")
public ResponseEntity<String> play(@RequestParam(value = "kind", required = false) String kindParam) {
Integer kind = parseIntOrNull(kindParam);
long now = Instant.now().getEpochSecond();

if (kind != null && kind == KIND_GRANT_WITH_EXPIRATION) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("expiration_date", now + CONTENT_EXPIRE_SEC);
data.put("result", 1);
return callbackJwt(payloadWithExp(data, now));
} else if (kind != null && kind == KIND_PLAYBACK_APPROVAL) {
return callbackJwt(payloadWithExp(Map.of("result", 1), now));
}
return ResponseEntity.badRequest().body("Unknown kind");
}

/** payload 데이터와 만료 시각(exp)을 결합하여 콜백 payload 맵을 생성합니다. */
private Map<String, Object> payloadWithExp(Map<String, Object> data, long now) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("data", data);
payload.put("exp", now + CALLBACK_JWT_EXPIRE_SEC);
return payload;
}

/** Kollus 콜백 규격에 따라 X-KOLLUS-USERKEY 헤더와 서명된 JWT 본문으로 구성된 응답을 반환합니다. */
private ResponseEntity<String> callbackJwt(Object payload) {
String jwt = tokenService.signCallbackJwt(payload, properties.securityKey());
return ResponseEntity.ok()
.header(USERKEY_HEADER, properties.customKey())
.contentType(MediaType.TEXT_PLAIN)
.body(jwt);
}

/** 문자열을 정수로 변환하며, 유효하지 않은 형식이거나 실패 시 null을 반환합니다. */
private Integer parseIntOrNull(String value) {
if (value == null) {
return null;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
// 숫자 파싱 실패 시 null을 반환하여 상위 호출부가 '알 수 없는 kind(400 Bad Request)'로 분기하도록 처리
return null;
}
}
}