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);
}
}
}
DRM 다운로드 콜백 컨트롤러 구현
Kollus 서버로부터 전송되는 POST /callback/drm 요청의 items 파라미터(JSON 배열 문자열)를 수신하여 각 항목별 권한 응답 데이터를 구성하고, 이를 data 배열에 담아 서명된 JWT로 반환하는 컨트롤러입니다. (기타 콜백 처리 메서드는 생략되었습니다.)
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 tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Kollus 콜백 처리 컨트롤러입니다.
* 재생(Play), DRM, 다음 회차(Next Episode), LMS 콜백 요청을 수신하고 규격에 부합하는 응답을 반환합니다.
*/
@RestController
@RequestMapping("/callback")
public class CallbackController {
/** DRM 콘텐츠 만료 기간 (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;
/** 콜백 유형 2: 다운로드 완료 통보 (DRM 콜백 전용) */
private static final int KIND_DOWNLOAD_COMPLETED = 2;
/** 콜백 유형 3: 오프라인 재생 권한 최종 확인 */
private static final int KIND_PLAYBACK_APPROVAL = 3;
private final KollusTokenService tokenService;
private final KollusProperties properties;
private final ObjectMapper objectMapper;
public CallbackController(KollusTokenService tokenService,
KollusProperties properties,
ObjectMapper objectMapper) {
this.tokenService = tokenService;
this.properties = properties;
this.objectMapper = objectMapper;
}
/**
* POST /callback/drm — DRM 다운로드 및 오프라인 재생 권한 콜백 요청을 처리합니다.
* 요청 본문의 items 파라미터(JSON 배열 문자열)를 파싱하여 각 콘텐츠별 응답 데이터를 구성하고 JWT로 반환합니다.
*
* @param itemsJson items 파라미터 (kind, media_content_key, start_at 등의 항목을 포함하는 JSON 배열 문자열)
* @return X-KOLLUS-USERKEY 헤더 및 data 배열이 포함된 서명 JWT 본문(text/plain), 파싱 실패 시 400 Bad Request
*/
@PostMapping("/drm")
public ResponseEntity<String> drm(@RequestParam(value = "items", required = false) String itemsJson) {
List> items;
try {
items = objectMapper.readValue(itemsJson, new TypeReference<>() {
});
} catch (JacksonException | IllegalArgumentException e) {
// items 파라미터 역직렬화 실패 시 400 Bad Request 반환
return ResponseEntity.badRequest().body("Invalid items");
}
long now = Instant.now().getEpochSecond();
List> data = new ArrayList<>();
for (Map<String, Object> item : items) {
Integer kind = parseIntOrNull(String.valueOf(item.get("kind")));
Object mediaContentKey = item.get("media_content_key");
if (kind == null) {
continue;
}
Map<String, Object> entry = new LinkedHashMap<>();
if (kind == KIND_GRANT_WITH_EXPIRATION) {
// 콜백 유형 1: 다운로드 승인 및 재생 만료 시점 설정
entry.put("kind", KIND_GRANT_WITH_EXPIRATION);
entry.put("media_content_key", mediaContentKey);
entry.put("expiration_date", now + CONTENT_EXPIRE_SEC);
entry.put("result", 1);
data.add(entry);
} else if (kind == KIND_DOWNLOAD_COMPLETED) {
// 콜백 유형 2: 다운로드 완료 통보 수신 확인
entry.put("kind", KIND_DOWNLOAD_COMPLETED);
entry.put("media_content_key", mediaContentKey);
entry.put("result", 1);
data.add(entry);
} else if (kind == KIND_PLAYBACK_APPROVAL) {
// 콜백 유형 3: 오프라인 재생 권한 최종 확인
entry.put("kind", KIND_PLAYBACK_APPROVAL);
entry.put("media_content_key", mediaContentKey);
entry.put("start_at", item.get("start_at"));
entry.put("result", 1);
data.add(entry);
}
}
return callbackJwt(Map.of("data", data));
}
/** 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을 반환하여 해당 항목 처리를 안전하게 건너뛰도록 처리
return null;
}
}
}