From eec0e7de4a087102f3e12332e1bf2542d57d2e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Thu, 26 Feb 2026 16:12:47 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[CHORE]:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/{transcode => ffmpeg}/FfmpegExecutor.java | 2 +- .../processbuilder/ProcessBuilderFfmpegExecutor.java | 4 ++-- .../com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java | 2 +- .../java/com/ott/transcoder/{ffmpeg => transcode}/.gitkeep | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename apps/transcoder/src/main/java/com/ott/transcoder/{transcode => ffmpeg}/FfmpegExecutor.java (96%) rename apps/transcoder/src/main/java/com/ott/transcoder/{transcode => ffmpeg}/processbuilder/ProcessBuilderFfmpegExecutor.java (97%) rename apps/transcoder/src/main/java/com/ott/transcoder/{ffmpeg => transcode}/.gitkeep (100%) diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java similarity index 96% rename from apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java rename to apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java index 1178e76f..f1c9c461 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java @@ -1,4 +1,4 @@ -package com.ott.transcoder.transcode; +package com.ott.transcoder.ffmpeg; import com.ott.domain.video_profile.domain.Resolution; diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java similarity index 97% rename from apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java rename to apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java index 4ef1dc51..1995ddfa 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -1,7 +1,7 @@ -package com.ott.transcoder.transcode.processbuilder; +package com.ott.transcoder.ffmpeg.processbuilder; import com.ott.domain.video_profile.domain.Resolution; -import com.ott.transcoder.transcode.FfmpegExecutor; +import com.ott.transcoder.ffmpeg.FfmpegExecutor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java index 704155a5..13988c5c 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java @@ -3,7 +3,7 @@ import com.ott.domain.video_profile.domain.Resolution; import com.ott.transcoder.pipeline.CommandPipeline; import com.ott.transcoder.storage.VideoStorage; -import com.ott.transcoder.transcode.FfmpegExecutor; +import com.ott.transcoder.ffmpeg.FfmpegExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/.gitkeep similarity index 100% rename from apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep rename to apps/transcoder/src/main/java/com/ott/transcoder/transcode/.gitkeep From 712453c832e6cbf66f5458eb590d85413151afea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 15:38:08 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[CHORE]:=20=EC=84=A4=EC=A0=95=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=8F=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/config/RabbitConfig.java | 16 ++---------- .../transcoder/pipeline/CommandPipeline.java | 21 +++++---------- .../pipeline/hls/MasterPlaylistGenerator.java | 16 +----------- .../ott/transcoder/queue/MessageListener.java | 2 +- .../queue/rabbit/RabbitTranscodeListener.java | 26 ++++++------------- .../ott/transcoder/storage/VideoStorage.java | 4 --- .../src/main/resources/application.yml | 3 +++ 7 files changed, 22 insertions(+), 66 deletions(-) diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java b/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java index 36e19dd7..0063b1fd 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/config/RabbitConfig.java @@ -13,15 +13,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -/** - * RabbitMQ 설정. - * - * Exchange → Binding → Queue 구조로 메시지 라우팅 - * Producer가 transcode.exchange에 routing key "transcode.request"로 메시지를 발행하면, - * Binding을 통해 transcode.queue로 전달되고, RabbitTranscodeListener가 소비 - * - * transcoder.messaging.provider=rabbit 일 때만 활성화 (SQS 등 전환 시 비활성화) - */ +/** RabbitMQ Exchange/Queue/Binding 설정. transcoder.messaging.provider=rabbit 일 때 활성화. */ @Configuration @ConditionalOnProperty(name = "transcoder.messaging.provider", havingValue = "rabbit") public class RabbitConfig { @@ -49,11 +41,7 @@ public Binding transcodeBinding(Queue transcodeQueue, DirectExchange transcodeEx .with(ROUTING_KEY); } - /** - * JSON 메시지를 TranscodeMessage로 역직렬화할 때 사용할 기본 타입 지정 - * 메시지 헤더에 __TypeId__가 없어도 TranscodeMessage로 변환 - * (Management UI 등 외부에서 직접 발행한 메시지 처리를 위해 필요) - */ + /** __TypeId__ 헤더 없는 메시지도 역직렬화 가능하도록 기본 타입 지정 */ @Bean public DefaultClassMapper classMapper() { DefaultClassMapper classMapper = new DefaultClassMapper(); diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java index d0c13b2a..dabbda49 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/CommandPipeline.java @@ -1,21 +1,14 @@ package com.ott.transcoder.pipeline; +import com.ott.transcoder.inspection.probe.ProbeResult; + +import java.nio.file.Path; + /** - * 미디어 처리 파이프라인 추상화 인터페이스 - * - * 하나의 미디어에 대해 pre → main → post 흐름을 실행하는 커맨드 단위 - * 커맨드 종류에 따라 구현체가 달라진다 - * - * 현재 구현체: HlsTranscodePipeline (HLS 트랜스코딩) - * 향후 구현체: ThumbnailPipeline, SpritePipeline 등 + * 커맨드별 미디어 처리 파이프라인 + * 구현체는 미디어 처리 자체에만 집중 */ public interface CommandPipeline { - /** - * 파이프라인을 실행 - * - * @param mediaId 대상 미디어 ID - * @param originUrl 원본 영상 위치 (로컬 경로 또는 S3 key) - */ - void execute(Long mediaId, String originUrl); + void execute(Long mediaId, Path inputFile, Path workDir, ProbeResult probeResult) throws Exception; } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java index b5331850..003403e7 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/MasterPlaylistGenerator.java @@ -10,21 +10,7 @@ import java.util.List; import java.util.Map; -/** - * HLS 마스터 플레이리스트(master.m3u8) 생성기. - * - * 마스터 플레이리스트는 여러 해상도(variant)를 참조하며, - * HLS 플레이어가 네트워크 상태에 따라 적절한 해상도를 자동 선택(ABR)하게 해준다. - * - * 생성 결과 예시: - * #EXTM3U - * #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360 - * 360p/media.m3u8 - * #EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720 - * 720p/media.m3u8 - * #EXT-X-STREAM-INF:BANDWIDTH=4800000,RESOLUTION=1920x1080 - * 1080p/media.m3u8 - */ +/** HLS 마스터 플레이리스트(master.m3u8) 생성기. ABR variant를 포함한다. */ @Slf4j @Component public class MasterPlaylistGenerator { diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java index a6999bf3..afc300a8 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/MessageListener.java @@ -8,5 +8,5 @@ */ public interface MessageListener { - void listen(TranscodeMessage message); + void listen(TranscodeMessage message) throws Exception; } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java index 97e0cfaf..d59b158d 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/queue/rabbit/RabbitTranscodeListener.java @@ -1,7 +1,7 @@ package com.ott.transcoder.queue.rabbit; +import com.ott.transcoder.JobOrchestrator; import com.ott.transcoder.config.RabbitConfig; -import com.ott.transcoder.pipeline.CommandPipeline; import com.ott.transcoder.queue.MessageListener; import com.ott.transcoder.queue.TranscodeMessage; import lombok.RequiredArgsConstructor; @@ -10,31 +10,21 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -/** - * RabbitMQ 메시지 리스너 (어댑터 역할) - * - * 큐에서 메시지를 소비하여 CommandPipeline에 위임 - * RabbitMQ 전용 로직만 담당, 트랜스코딩 비즈니스 로직은 모르는 상태 - * - * SQS 전환 시 이 클래스 대신 SqsTranscodeListener를 만들면 된다 - */ @Slf4j @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "transcoder.messaging.provider", havingValue = "rabbit") public class RabbitTranscodeListener implements MessageListener { - private final CommandPipeline commandPipeline; + private final JobOrchestrator jobOrchestrator; @Override @RabbitListener(queues = RabbitConfig.QUEUE_NAME) - public void listen(TranscodeMessage message) { - log.info("트랜스코딩 요청 수신 - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); - try { - commandPipeline.execute(message.mediaId(), message.originUrl()); - } catch (Exception e) { - // 예외를 삼켜 requeue를 방지 / DLQ 구성 후 AmqpRejectAndDontRequeueException으로 교체 - log.error("트랜스코딩 처리 실패, 메시지 폐기 - mediaId: {}", message.mediaId(), e); - } + public void listen(TranscodeMessage message) throws Exception { + log.info("작업 요청 수신 - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); + + jobOrchestrator.handle(message); + + log.info("작업 요청 처리 완료 - mediaId: {}, originUrl: {}", message.mediaId(), message.originUrl()); } } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java b/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java index 377b59ae..6417267a 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/storage/VideoStorage.java @@ -4,15 +4,12 @@ /** * 영상 파일 저장소 추상화 인터페이스 - * - * 현재 구현체: LocalVideoStorage (로컬 파일시스템) * S3VideoStorage를 추가하여 실제 AWS S3 연동으로 교체할 것 */ public interface VideoStorage { /** * 원본 영상을 저장소에서 로컬 작업 디렉토리로 가져온다 - * * @param sourceKey 원본 위치 (로컬 경로 또는 S3 key) * @param workDir 다운로드 대상 로컬 디렉토리 * @return 다운로드된 로컬 파일 경로 @@ -21,7 +18,6 @@ public interface VideoStorage { /** * 트랜스코딩 결과물을 저장소에 업로드 - * * @param localDir 업로드할 로컬 디렉토리 (HLS 파일들이 들어있음) * @param destinationPrefix 저장소 내 목적지 경로 (예: "media/1/hls") * @return 업로드된 경로 (DB에 저장할 URL 또는 경로) diff --git a/apps/transcoder/src/main/resources/application.yml b/apps/transcoder/src/main/resources/application.yml index 248c6485..66a1f858 100644 --- a/apps/transcoder/src/main/resources/application.yml +++ b/apps/transcoder/src/main/resources/application.yml @@ -40,6 +40,9 @@ transcoder: path: ${FFMPEG_PATH} temp-dir: ${TRANSCODER_TEMP_DIR} segment-duration: ${TRANSCODER_SEGMENT_DURATION} + ffprobe: + engine: ${TRANSCODER_FFPROBE_ENGINE} + path: ${FFPROBE_PATH} storage: provider: ${STORAGE_PROVIDER} From 5ae8cee24582ece23180bc628b52a847a6148f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 16:22:56 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[FEAT]:=20=EC=9E=91=EC=97=85=20=EC=A0=84=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EA=B2=80=EC=A6=9D=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/inspection/Inspector.java | 33 ++++ .../inspection/probe/ProbeResult.java | 52 ++++++ .../probe/execution/FfprobeExecutor.java | 21 +++ .../ProcessBuilderFfprobeExecutor.java | 165 ++++++++++++++++++ .../inspection/validation/DiskSpaceGuard.java | 26 +++ .../inspection/validation/FileValidator.java | 150 ++++++++++++++++ .../validation/StreamValidator.java | 91 ++++++++++ 7 files changed, 538 insertions(+) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java new file mode 100644 index 00000000..eaadd585 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/Inspector.java @@ -0,0 +1,33 @@ +package com.ott.transcoder.inspection; + +import com.ott.transcoder.inspection.probe.ProbeResult; +import com.ott.transcoder.inspection.probe.execution.FfprobeExecutor; +import com.ott.transcoder.inspection.validation.FileValidator; +import com.ott.transcoder.inspection.validation.StreamValidator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; + +/** + * 입력 파일 검사 + * FileValidator → Probe → StreamValidator 순서로 실행 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class Inspector { + + private final FileValidator fileValidator; + private final FfprobeExecutor ffprobeExecutor; + private final StreamValidator streamValidator; + + public ProbeResult inspect(Path inputFile) { + fileValidator.validate(inputFile); + ProbeResult probeResult = ffprobeExecutor.probe(inputFile); + streamValidator.validate(probeResult); + + return probeResult; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java new file mode 100644 index 00000000..55281fae --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/ProbeResult.java @@ -0,0 +1,52 @@ +package com.ott.transcoder.inspection.probe; + +/** + * ffprobe 실행 결과를 담는 불변 레코드 + * + * @param width 영상 너비 (px) + * @param height 영상 높이 (px) + * @param durationSeconds 전체 재생 시간 (초) + * @param videoCodec 비디오 코덱 (예: h264, hevc, vp9) + * @param audioCodec 오디오 코덱 (예: aac, opus, "none") + * @param fps 프레임레이트 + * @param videoBitrate 비디오 비트레이트 (bps) + * @param audioBitrate 오디오 비트레이트 (bps) + * @param audioChannels 오디오 채널 수 (예: 2=stereo, 6=5.1ch) + * @param pixelFormat 픽셀 포맷 (예: yuv420p, yuv422p) + * @param rotation 회전 각도 (0, 90, 180, 270). 스마트폰 세로 촬영 시 90 또는 270 + */ +public record ProbeResult( + int width, + int height, + double durationSeconds, + String videoCodec, + String audioCodec, + double fps, + long videoBitrate, + long audioBitrate, + int audioChannels, + String pixelFormat, + int rotation +) { + /** + * 회전을 고려한 실제 영상 높이. + * 90° 또는 270° 회전된 영상은 width와 height가 뒤바뀐다. + * 예: 1080x1920(세로 촬영, rotation=90) → 실제 출력은 1920x1080 → effectiveHeight = 1080 + */ + public int effectiveHeight() { + return isRotated() ? this.width : this.height; + } + + public int effectiveWidth() { + return isRotated() ? this.height : this.width; + } + + public boolean isRotated() { + return rotation == 90 || rotation == 270; + } + + // 회전을 고려하여 업스케일 여부 판단 + public boolean isUpscaleFor(int targetHeight) { + return targetHeight > effectiveHeight(); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java new file mode 100644 index 00000000..3f4a6060 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/FfprobeExecutor.java @@ -0,0 +1,21 @@ +package com.ott.transcoder.inspection.probe.execution; + +import com.ott.transcoder.inspection.probe.ProbeResult; + +import java.nio.file.Path; + +/** + * ffprobe 실행 추상화 인터페이스 + * + * 입력 파일의 미디어 메타데이터 추출 + */ +public interface FfprobeExecutor { + + /** + * 입력 파일에 대해 ffprobe를 실행하여 메타데이터 추출 + * + * @param inputFile 분석 대상 파일 경로 + * @return 추출된 메타데이터 + */ + ProbeResult probe(Path inputFile); +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java new file mode 100644 index 00000000..b473548d --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/probe/execution/processbuilder/ProcessBuilderFfprobeExecutor.java @@ -0,0 +1,165 @@ +package com.ott.transcoder.inspection.probe.execution.processbuilder; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ott.transcoder.inspection.probe.execution.FfprobeExecutor; +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * ffprobe를 JSON 출력 모드로 실행하여 미디어 메타데이터를 추출 + * format(컨테이너 정보)과 streams(스트림 정보)를 함께 요청하고, + * 첫 번째 비디오/오디오 스트림에서 필요한 필드를 파싱 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "transcoder.ffprobe.engine", havingValue = "processbuilder") +public class ProcessBuilderFfprobeExecutor implements FfprobeExecutor { + + private final ObjectMapper objectMapper; + + @Value("${transcoder.ffprobe.path:ffprobe}") + private String ffprobePath; + + @Override + public ProbeResult probe(Path inputFile) { + List command = List.of( + ffprobePath, + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + inputFile.toString() + ); + + log.info("ffprobe 실행 - input: {}", inputFile); + + try { + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + Process process = pb.start(); + + String output = new String(process.getInputStream().readAllBytes()); + + boolean finished = process.waitFor(2, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("ffprobe 타임아웃 - input: " + inputFile); + } + if (process.exitValue() != 0) { + throw new RuntimeException( + "ffprobe 실패 - exitCode: " + process.exitValue() + ", output: " + output); + } + + return parseJson(output); + + } catch (IOException | InterruptedException e) { + throw new RuntimeException("ffprobe 실행 실패 - input: " + inputFile, e); + } + } + + private ProbeResult parseJson(String json) throws IOException { + JsonNode root = objectMapper.readTree(json); + JsonNode streamList = root.get("streams"); + + JsonNode videoStream = null; + JsonNode audioStream = null; + + for (JsonNode stream : streamList) { + String codecType = stream.get("codec_type").asText(); + if ("video".equals(codecType) && videoStream == null) { + videoStream = stream; + } else if ("audio".equals(codecType) && audioStream == null) { + audioStream = stream; + } + } + + if (videoStream == null) { + throw new RuntimeException("비디오 스트림을 찾을 수 없음"); + } + + JsonNode format = root.get("format"); // null 가능성 존재 + + double duration = format.has("duration") + ? format.get("duration").asDouble() + : 0.0; + + double fps = parseFps(videoStream.path("r_frame_rate").asText("0/1")); + + long videoBitrate = videoStream.has("bit_rate") + ? videoStream.get("bit_rate").asLong() + : format.path("bit_rate").asLong(0); + + long audioBitrate = (audioStream != null && audioStream.has("bit_rate")) + ? audioStream.get("bit_rate").asLong() + : 0L; + + String audioCodec = (audioStream != null) + ? audioStream.get("codec_name").asText() + : "none"; + + int audioChannels = (audioStream != null) + ? audioStream.path("channels").asInt(0) + : 0; + + String pixelFormat = videoStream.path("pix_fmt").asText("unknown"); + + int rotation = parseRotation(videoStream); + + return new ProbeResult( + videoStream.get("width").asInt(), + videoStream.get("height").asInt(), + duration, + videoStream.get("codec_name").asText(), + audioCodec, + fps, + videoBitrate, + audioBitrate, + audioChannels, + pixelFormat, + rotation + ); + } + + /** side_data_list[].rotation → tags.rotate 순으로 확인 */ + private int parseRotation(JsonNode videoStream) { + // 1. side_data_list에서 rotation 확인 + JsonNode sideDataList = videoStream.path("side_data_list"); + if (sideDataList.isArray()) { + for (JsonNode sideData : sideDataList) { + if (sideData.has("rotation")) { + return Math.abs(sideData.get("rotation").asInt()); + } + } + } + + // 2. tags.rotate 확인 (구버전 호환) + JsonNode tags = videoStream.path("tags"); + if (tags.has("rotate")) { + return Math.abs(tags.get("rotate").asInt()); + } + + return 0; + } + + /** "30/1", "30000/1001" 등 분수 형태 파싱 */ + private double parseFps(String rFrameRate) { + String[] parts = rFrameRate.split("/"); + if (parts.length == 2) { + double numerator = Double.parseDouble(parts[0]); + double denominator = Double.parseDouble(parts[1]); + return denominator > 0 ? numerator / denominator : 0.0; + } + return Double.parseDouble(rFrameRate); + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java new file mode 100644 index 00000000..43a663fb --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/DiskSpaceGuard.java @@ -0,0 +1,26 @@ +package com.ott.transcoder.inspection.validation; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 다운로드 전 디스크 여유 공간 검증 + * 원본 크기 × multiplier만큼의 공간이 있는지 확인 + */ +@Slf4j +@Component +public class DiskSpaceGuard { + + @Value("${transcoder.validation.disk-space-multiplier:5}") + private double multiplier; + + public void check(Path originPath) { + + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java new file mode 100644 index 00000000..34246d81 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/FileValidator.java @@ -0,0 +1,150 @@ +package com.ott.transcoder.inspection.validation; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.Map; + +/** + * probe 전 파일 수준 검증. + * + * ffprobe를 실행하기 전에, 파일 자체가 유효한 미디어 파일인지 기본 방어선을 친다. + * 여기서 걸러지면 ffprobe를 돌릴 필요조차 없다. + * + * 검증 항목: + * 1. 파일 존재 여부 + * 2. 파일 크기 (0 bytes / 상한 초과) + * 3. 읽기 권한 + * 4. 매직 바이트 — 실제 미디어 포맷인지 확인 + * 5. 확장자 vs 매직 바이트 불일치 감지 + */ +@Slf4j +@Component +public class FileValidator { + + /** 파일 크기 상한 (기본 10GB) */ + @Value("${transcoder.validation.max-file-size-bytes:10737418240}") + private long maxFileSizeBytes; + + private static final Map EXTENSION_TO_FORMAT = Map.of( + "mp4", "MP4", + "mov", "MOV", + "mkv", "MKV", + "webm", "WEBM", + "avi", "AVI", + "flv", "FLV", + "ts", "MPEG-TS" + ); + + public void validate(Path inputFile) { + // 1. 파일 존재 + if (!Files.exists(inputFile)) { + throw new IllegalStateException("파일이 존재하지 않음 - " + inputFile); + } + + // 2. 읽기 권한 + if (!Files.isReadable(inputFile)) { + throw new IllegalStateException("파일 읽기 권한 없음 - " + inputFile); + } + + // 3. 파일 크기 + long fileSize; + try { + fileSize = Files.size(inputFile); + } catch (IOException e) { + throw new IllegalStateException("파일 크기 확인 실패 - " + inputFile, e); + } + + if (fileSize == 0) { + throw new IllegalStateException("파일 크기가 0 bytes - " + inputFile); + } + if (fileSize > maxFileSizeBytes) { + throw new IllegalStateException( + "파일 크기 상한 초과 - size: " + fileSize + " bytes, max: " + maxFileSizeBytes + " bytes"); + } + + // 4. 매직 바이트 검증 + String detectedFormat = detectFormatByMagicBytes(inputFile); + if (detectedFormat == null) { + throw new IllegalStateException("알 수 없는 파일 포맷 (매직 바이트 불일치) - " + inputFile); + } + + // 5. 확장자 vs 매직 바이트 불일치 경고 + String extension = getExtension(inputFile); + String expectedFormat = EXTENSION_TO_FORMAT.get(extension); + if (expectedFormat != null && !expectedFormat.equals(detectedFormat)) { + // MOV와 MP4는 동일한 ftyp 계열이므로 호환으로 취급 + if (!isCompatibleFormat(expectedFormat, detectedFormat)) { + log.warn("확장자-포맷 불일치 - file: {}, extension: .{} ({}), detected: {}", + inputFile.getFileName(), extension, expectedFormat, detectedFormat); + } + } + + log.info("파일 검증 통과 - file: {}, size: {} bytes, format: {}", + inputFile.getFileName(), fileSize, detectedFormat); + } + + private String detectFormatByMagicBytes(Path inputFile) { + byte[] header = new byte[12]; + int bytesRead; + + try (InputStream is = Files.newInputStream(inputFile)) { + bytesRead = is.read(header); + } catch (IOException e) { + throw new IllegalStateException("매직 바이트 읽기 실패 - " + inputFile, e); + } + + if (bytesRead < 8) { + return null; + } + + // MP4/MOV: offset 4~7이 "ftyp" + if (header[4] == 0x66 && header[5] == 0x74 && header[6] == 0x79 && header[7] == 0x70) { + return "MP4"; // MP4/MOV/3GP 계열 + } + + // MKV/WebM: EBML 헤더 (0x1A 0x45 0xDF 0xA3) + if (header[0] == 0x1A && header[1] == 0x45 && header[2] == (byte) 0xDF && header[3] == (byte) 0xA3) { + return "MKV"; // MKV/WebM + } + + // AVI: "RIFF" + if (header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F') { + return "AVI"; + } + + // FLV: "FLV" + if (header[0] == 'F' && header[1] == 'L' && header[2] == 'V') { + return "FLV"; + } + + // MPEG-TS: sync byte 0x47 + if (header[0] == 0x47) { + return "MPEG-TS"; + } + + log.debug("매직 바이트 미식별 - hex: {}", HexFormat.of().formatHex(header, 0, bytesRead)); + return null; + } + + private String getExtension(Path file) { + String fileName = file.getFileName().toString(); + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0) return ""; + return fileName.substring(dotIndex + 1).toLowerCase(); + } + + /** MP4/MOV는 동일 ftyp 계열이므로 호환으로 취급 */ + private boolean isCompatibleFormat(String expected, String detected) { + if ("MP4".equals(expected) && "MP4".equals(detected)) return true; + if ("MOV".equals(expected) && "MP4".equals(detected)) return true; + if ("WEBM".equals(expected) && "MKV".equals(detected)) return true; + return false; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java new file mode 100644 index 00000000..c6c796c1 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/inspection/validation/StreamValidator.java @@ -0,0 +1,91 @@ +package com.ott.transcoder.inspection.validation; + +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * probe 후 스트림 수준 검증 + * + * ffprobe 결과(ProbeResult)를 받아, 트랜스코딩을 진행해도 안전한지 판단 + * + * 검증 항목: + * 1. 비디오 스트림 존재 (오디오만 있는 파일 차단) + * 2. 비디오 코덱 지원 여부 + * 3. duration 유효성 (0초, 비정상적으로 긴 영상) + * 4. 해상도 범위 (너무 작거나 너무 큰 영상) + * 5. 프레임레이트 이상 감지 + * 6. 손상 감지 (메타데이터 불완전) + */ +@Slf4j +@Component +public class StreamValidator { + + // FFmpeg이 디코딩 가능한 일반적인 비디오 코덱 + private static final Set SUPPORTED_VIDEO_CODEC_SET = Set.of( + "h264", "hevc", "h265", "vp8", "vp9", "av1", + "mpeg4", "mpeg2video", "mpeg1video", + "wmv3", "vc1", + "theora", "prores", "dnxhd", + "mjpeg", "rawvideo" + ); + + /** 최소 해상도 (이보다 작으면 의미 없는 영상) */ + private static final int MIN_RESOLUTION = 32; + + /** 최대 해상도 (8K 초과는 비정상) */ + private static final int MAX_RESOLUTION = 8192; + + /** 최대 프레임레이트 (이보다 높으면 비정상) */ + private static final double MAX_FPS = 240.0; + + @Value("${transcoder.validation.max-duration-seconds:43200}") + private double maxDurationSeconds; // 기본 12시간 + + public void validate(ProbeResult probeResult) { + // 1. 비디오 코덱 존재 및 지원 여부 + if (probeResult.videoCodec() == null || probeResult.videoCodec().isBlank()) { + throw new IllegalStateException("비디오 코덱 정보 없음"); + } + if (!SUPPORTED_VIDEO_CODEC_SET.contains(probeResult.videoCodec().toLowerCase())) { + throw new IllegalStateException( + "지원하지 않는 비디오 코덱 - codec: " + probeResult.videoCodec()); + } + + // 2. 해상도 범위 + if (probeResult.width() < MIN_RESOLUTION || probeResult.height() < MIN_RESOLUTION) { + throw new IllegalStateException( + "해상도가 너무 작음 - " + probeResult.width() + "x" + probeResult.height()); + } + if (probeResult.width() > MAX_RESOLUTION || probeResult.height() > MAX_RESOLUTION) { + throw new IllegalStateException( + "해상도가 너무 큼 - " + probeResult.width() + "x" + probeResult.height()); + } + + // 3. duration 유효성 + if (probeResult.durationSeconds() <= 0) { + throw new IllegalStateException( + "duration이 유효하지 않음 - " + probeResult.durationSeconds() + "s"); + } + if (probeResult.durationSeconds() > maxDurationSeconds) { + throw new IllegalStateException( + "duration 상한 초과 - " + probeResult.durationSeconds() + "s, max: " + maxDurationSeconds + "s"); + } + + // 4. 프레임레이트 이상 + if (probeResult.fps() <= 0) { + throw new IllegalStateException("프레임레이트가 유효하지 않음 - fps: " + probeResult.fps()); + } + if (probeResult.fps() > MAX_FPS) { + throw new IllegalStateException( + "프레임레이트가 비정상적으로 높음 - fps: " + probeResult.fps() + ", max: " + MAX_FPS); + } + + log.info("스트림 검증 통과 - {}x{}, duration: {}s, codec: {}, fps: {}", + probeResult.width(), probeResult.height(), + probeResult.durationSeconds(), probeResult.videoCodec(), probeResult.fps()); + } +} From 849b453c9ec5c50814e385cf348b2a995f384080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 16:23:31 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[CHORE]:=20FFmpeg=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EA=B8=B0=20=ED=8F=B4=EB=8D=94=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ott/transcoder/ffmpeg/FfmpegExecutor.java | 29 ----- .../ffmpeg/execution/FfmpegExecutor.java | 25 ++++ .../ProcessBuilderFfmpegExecutor.java | 86 +++++++++++++ .../ProcessBuilderFfmpegExecutor.java | 117 ------------------ 4 files changed, 111 insertions(+), 146 deletions(-) delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java deleted file mode 100644 index f1c9c461..00000000 --- a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/FfmpegExecutor.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ott.transcoder.ffmpeg; - -import com.ott.domain.video_profile.domain.Resolution; - -import java.io.IOException; -import java.nio.file.Path; - -/** - * FFmpeg 실행 추상화 인터페이스 - * - * FFmpeg를 호출하는 방식(ProcessBuilder, Jaffree 등)에 독립적으로 - * 단일 해상도에 대한 HLS 트랜스코딩을 수행한다. - * - * 구현체 전환: transcoder.ffmpeg.engine 프로퍼티로 선택 - * - processbuilder: ProcessBuilderFfmpegExecutor (CLI 직접 호출) - * - jaffree: (향후) JaffreeFfmpegExecutor (라이브러리 호출) - */ -public interface FfmpegExecutor { - - /** - * 단일 해상도에 대해 HLS 트랜스코딩을 수행한다. - * - * @param inputFile 원본 영상 파일 경로 - * @param outputDir 출력 디렉토리 (하위에 360p/, 720p/, 1080p/ 폴더가 생성됨) - * @param resolution 대상 해상도 - * @return 생성된 미디어 플레이리스트(media.m3u8) 경로 - */ - Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException; -} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java new file mode 100644 index 00000000..38f1c1cc --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/FfmpegExecutor.java @@ -0,0 +1,25 @@ +package com.ott.transcoder.ffmpeg.execution; + +import com.ott.transcoder.ffmpeg.TranscodeProfile; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * FFmpeg 실행 추상화 인터페이스 + * + * FFmpeg를 호출하는 방식(ProcessBuilder, Jaffree 등)에 독립적으로 + * 단일 해상도에 대한 HLS 트랜스코딩을 수행 + */ +public interface FfmpegExecutor { + + /** + * 단일 프로파일에 대해 HLS 트랜스코딩을 수행 + * + * @param inputFile 원본 영상 파일 경로 + * @param outputDir 출력 디렉토리 (하위에 360p/, 720p/, 1080p/ 폴더가 생성됨) + * @param profile 트랜스코딩 설정 (해상도, 비트레이트, 코덱 등) + * @return 생성된 미디어 플레이리스트(media.m3u8) 경로 + */ + Path execute(Path inputFile, Path outputDir, TranscodeProfile profile) throws IOException, InterruptedException; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java new file mode 100644 index 00000000..4b1730f1 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/execution/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -0,0 +1,86 @@ +package com.ott.transcoder.ffmpeg.execution.processbuilder; + +import com.ott.transcoder.ffmpeg.execution.FfmpegExecutor; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * ProcessBuilder 기반 FFmpeg CLI 래퍼 + * 단일 해상도에 대해 HLS 트랜스코딩을 수행 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") +public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { + + @Value("${transcoder.ffmpeg.path:ffmpeg}") + private String ffmpegPath; + + @Value("${transcoder.ffmpeg.segment-duration:10}") + private int segmentDuration; + + @Override + public Path execute(Path inputFile, Path outputDir, TranscodeProfile profile) throws IOException, InterruptedException { + String resolutionKey = profile.resolution().getKey().toLowerCase(); + + // 해상도별 하위 디렉토리 생성 (예: workDir/360p/) + Path resolutionDir = outputDir.resolve(resolutionKey); + Files.createDirectories(resolutionDir); + + Path playlistPath = resolutionDir.resolve("media.m3u8"); + String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); + + // FFmpeg 명령어 조립 — TranscodeProfile에서 설정값을 가져옴 + // TODO: FFmpeg Filter Chain 구성 로직 추가 필요 + List command = List.of( + ffmpegPath, "-i", inputFile.toString(), + "-vf", "scale=-2:" + profile.height(), + "-c:v", profile.videoCodec(), "-preset", profile.preset(), + "-c:a", profile.audioCodec(), "-b:a", profile.audioBitrate(), + "-b:v", profile.videoBitrate(), + "-f", "hls", + "-hls_time", String.valueOf(segmentDuration), + "-hls_list_size", "0", + "-hls_segment_filename", segmentPattern, + playlistPath.toString() + ); + + log.info("FFmpeg 실행 - resolution: {}, command: {}", resolutionKey, String.join(" ", command)); + + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("[FFmpeg] {}", line); + } + } + + boolean finished = process.waitFor(30, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("FFmpeg 타임아웃 - resolution: " + resolutionKey); + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + throw new RuntimeException("FFmpeg 실패 - resolution: " + resolutionKey + ", exitCode: " + exitCode); + } + + log.info("FFmpeg 완료 - resolution: {}, output: {}", resolutionKey, playlistPath); + return playlistPath; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java deleted file mode 100644 index 1995ddfa..00000000 --- a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/processbuilder/ProcessBuilderFfmpegExecutor.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.ott.transcoder.ffmpeg.processbuilder; - -import com.ott.domain.video_profile.domain.Resolution; -import com.ott.transcoder.ffmpeg.FfmpegExecutor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -/** - * ProcessBuilder 기반 FFmpeg CLI 래퍼 - * - * 시스템에 설치된 FFmpeg 바이너리를 ProcessBuilder로 직접 호출 - * 단일 해상도에 대해 HLS 트랜스코딩을 수행하며, - * 결과물로 media.m3u8 (미디어 플레이리스트) + segment_XXX.ts (세그먼트 파일)를 생성 - * - * FFmpeg 내부 처리 흐름: - * Demux(컨테이너 분리) → Decode(디코딩) → Filter(스케일링) → Encode(재인코딩) → Mux(HLS 패키징) - * 이 전체가 하나의 FFmpeg 명령어로 실행됨 - */ -@Slf4j -@Component -@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") -public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { - - /** 해상도별 출력 높이 (너비는 -2로 자동 계산, 짝수 보장) */ - private static final Map HEIGHT_MAP = Map.of( - Resolution.P360, 360, - Resolution.P720, 720, - Resolution.P1080, 1080 - ); - - /** 해상도별 비디오 비트레이트 */ - private static final Map VIDEO_BITRATE_MAP = Map.of( - Resolution.P360, "800k", - Resolution.P720, "2400k", - Resolution.P1080, "4800k" - ); - - /** 해상도별 오디오 비트레이트 */ - private static final Map AUDIO_BITRATE_MAP = Map.of( - Resolution.P360, "96k", - Resolution.P720, "128k", - Resolution.P1080, "192k" - ); - - @Value("${transcoder.ffmpeg.path:ffmpeg}") - private String ffmpegPath; - - /** HLS 세그먼트 하나의 길이 (초) */ - @Value("${transcoder.ffmpeg.segment-duration:10}") - private int segmentDuration; - - @Override - public Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException { - int height = HEIGHT_MAP.get(resolution); - String videoBitrate = VIDEO_BITRATE_MAP.get(resolution); - String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); - - // 해상도별 하위 디렉토리 생성 (예: workDir/360p/) - Path resolutionDir = outputDir.resolve(resolution.getKey().toLowerCase()); - Files.createDirectories(resolutionDir); - - Path playlistPath = resolutionDir.resolve("media.m3u8"); - String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); - - // FFmpeg 명령어 조립 - List command = List.of( - ffmpegPath, "-i", inputFile.toString(), - "-vf", "scale=-2:" + height, - "-c:v", "libx264", "-preset", "fast", - "-c:a", "aac", "-b:a", audioBitrate, - "-b:v", videoBitrate, - "-f", "hls", - "-hls_time", String.valueOf(segmentDuration), - "-hls_list_size", "0", - "-hls_segment_filename", segmentPattern, - playlistPath.toString() - ); - - log.info("FFmpeg 실행 - resolution: {}, command: {}", resolution.getKey(), String.join(" ", command)); - - ProcessBuilder processBuilder = new ProcessBuilder(command); - processBuilder.redirectErrorStream(true); - - Process process = processBuilder.start(); - - // FFmpeg 출력을 읽어야 프로세스가 블로킹되지 않는다 (버퍼 가득 참 방지) - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - log.debug("[FFmpeg] {}", line); - } - } - - boolean finished = process.waitFor(30, java.util.concurrent.TimeUnit.MINUTES); - if (!finished) { - process.destroyForcibly(); - throw new RuntimeException("FFmpeg 타임아웃 - resolution: " + resolution.getKey()); - } - int exitCode = process.exitValue(); - if (exitCode != 0) { - throw new RuntimeException("FFmpeg 실패 - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); - } - - log.info("FFmpeg 완료 - resolution: {}, output: {}", resolution.getKey(), playlistPath); - return playlistPath; - } -} From 409201a3165f9ecf355f680640fb0727cea79188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 16:24:11 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[FEAT]:=20=ED=8A=B8=EB=9E=9C=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EC=8B=A4=ED=96=89=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transcoder/ffmpeg/TranscodeProfile.java | 36 ++++++ .../pipeline/hls/TranscodePlanner.java | 121 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java new file mode 100644 index 00000000..fc203deb --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/TranscodeProfile.java @@ -0,0 +1,36 @@ +package com.ott.transcoder.ffmpeg; + +import com.ott.domain.video_profile.domain.Resolution; + +/** + * 단일 해상도에 대한 트랜스코딩 설정 묶음 + * + * 현재는 Resolution enum 기반의 고정 프리셋이지만, + * 향후 TranscodePlanner가 ProbeResult를 분석하여 동적으로 생성 + * + * @param resolution 대상 해상도 (DB 저장용) + * @param height 출력 높이 (px). 너비는 FFmpeg -2 옵션으로 자동 계산 + * @param videoBitrate 비디오 비트레이트 (예: "800k", "2400k") + * @param audioBitrate 오디오 비트레이트 (예: "96k", "128k") + * @param videoCodec 비디오 인코더 (예: "libx264") + * @param audioCodec 오디오 인코더 (예: "aac") + * @param preset 인코딩 프리셋 (예: "fast", "medium") + */ +public record TranscodeProfile( + Resolution resolution, + int height, + String videoBitrate, + String audioBitrate, + String videoCodec, + String audioCodec, + String preset +) { + /** 기존 하드코딩 값과 동일한 기본 프리셋 */ + public static TranscodeProfile defaultFor(Resolution resolution) { + return switch (resolution) { + case P360 -> new TranscodeProfile(resolution, 360, "800k", "96k", "libx264", "aac", "fast"); + case P720 -> new TranscodeProfile(resolution, 720, "2400k", "128k", "libx264", "aac", "fast"); + case P1080 -> new TranscodeProfile(resolution, 1080, "4800k", "192k", "libx264", "aac", "fast"); + }; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java new file mode 100644 index 00000000..f9491a74 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/TranscodePlanner.java @@ -0,0 +1,121 @@ +package com.ott.transcoder.pipeline.hls; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import com.ott.transcoder.inspection.probe.ProbeResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * ProbeResult를 분석하여 HLS 트랜스코딩 대상 해상도/비트레이트 결정 + * 업스케일 방지, 원본 비트레이트 상한 적용 등 + */ +@Slf4j +@Component +public class TranscodePlanner { + + /** 해상도별 높이 (Resolution enum에 height 필드가 없으므로 여기서 관리) */ + private static final Map HEIGHT_MAP = Map.of( + Resolution.P360, 360, + Resolution.P720, 720, + Resolution.P1080, 1080 + ); + + /** 해상도별 기본 비디오 비트레이트 (bps) — 원본 비트레이트와 비교용 */ + private static final Map DEFAULT_VIDEO_BITRATE_MAP = Map.of( + Resolution.P360, 800_000L, + Resolution.P720, 2_400_000L, + Resolution.P1080, 4_800_000L + ); + + /** 해상도별 기본 오디오 비트레이트 문자열 */ + private static final Map AUDIO_BITRATE_MAP = Map.of( + Resolution.P360, "96k", + Resolution.P720, "128k", + Resolution.P1080, "192k" + ); + + /** 트랜스코딩 대상 해상도 */ + private static final List CANDIDATE_RESOLUTION_LIST = List.of( + Resolution.P360, Resolution.P720, Resolution.P1080 + ); + + /** + * ProbeResult를 분석하여 트랜스코딩할 프로파일 목록 생성 + * + * @param probeResult ffprobe 결과 + * @return 트랜스코딩 대상 프로파일 목록 (업스케일 해상도 제외) + */ + public List plan(ProbeResult probeResult) { + List profileList = new ArrayList<>(); + + for (Resolution resolution : CANDIDATE_RESOLUTION_LIST) { + int targetHeight = HEIGHT_MAP.get(resolution); + + // 업스케일 방지 + if (probeResult.isUpscaleFor(targetHeight)) { + continue; + } + + String videoBitrate = decideVideoBitrate(probeResult, resolution); + String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); + String audioCodec = decideAudioCodec(probeResult); + + TranscodeProfile profile = new TranscodeProfile( + resolution, + targetHeight, + videoBitrate, + audioBitrate, + "libx264", + audioCodec, + "fast" + ); + + profileList.add(profile); + } + + if (profileList.isEmpty()) { + // 원본이 360p 미만이어도 최소 1개는 생성 (원본 해상도로) + log.warn("모든 해상도가 업스케일 — 최소 프로파일 생성 (360p 기준, 원본 높이: {})", probeResult.height()); + profileList.add(TranscodeProfile.defaultFor(Resolution.P360)); + } + + log.info("트랜스코딩 계획 수립 완료 - 대상 해상도: {}", + profileList.stream().map(p -> p.resolution().getKey()).toList()); + + return profileList; + } + + /** + * 비디오 비트레이트 결정 + * 원본 비트레이트가 기본값보다 낮으면 원본 비트레이트를 상한으로 사용하여 과도한 할당을 방지 + */ + private String decideVideoBitrate(ProbeResult probeResult, Resolution resolution) { + long defaultBitrate = DEFAULT_VIDEO_BITRATE_MAP.get(resolution); + long originBitrate = probeResult.videoBitrate(); + + // 원본 비트레이트 정보가 없으면 기본값 사용 + if (originBitrate <= 0) { + return formatBitrate(defaultBitrate); + } + + // 원본이 기본값보다 낮으면 원본을 상한으로 + long chosen = Math.min(defaultBitrate, originBitrate); + return formatBitrate(chosen); + } + + private String decideAudioCodec(ProbeResult probeResult) { + return "aac"; + } + + private String formatBitrate(long bps) { + if (bps >= 1_000_000) { + return (bps / 1_000) + "k"; + } + return bps + ""; + } +} From 746d6d37414060c25edec1fb315470b1b39529f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Sun, 1 Mar 2026 16:24:27 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[FEAT]:=20=ED=8A=B8=EB=9E=9C=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=98=A4=EC=BC=80?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A0=88=EC=9D=B4=ED=84=B0=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ott/transcoder/JobOrchestrator.java | 80 +++++++++++++++++ .../pipeline/hls/HlsTranscodePipeline.java | 86 +++++-------------- 2 files changed, 101 insertions(+), 65 deletions(-) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java b/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java new file mode 100644 index 00000000..93c258c2 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/JobOrchestrator.java @@ -0,0 +1,80 @@ +package com.ott.transcoder; + +import com.ott.transcoder.inspection.Inspector; +import com.ott.transcoder.inspection.probe.ProbeResult; +import com.ott.transcoder.inspection.validation.DiskSpaceGuard; +import com.ott.transcoder.pipeline.CommandPipeline; +import com.ott.transcoder.queue.TranscodeMessage; +import com.ott.transcoder.storage.VideoStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +/** + * 작업 전체 흐름 조율 + * diskSpaceGuard → workDir 생성 → download → inspect → pipeline 실행 → cleanup + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class JobOrchestrator { + + private final DiskSpaceGuard diskSpaceGuard; + private final VideoStorage videoStorage; + private final Inspector inspector; + private final CommandPipeline pipeline; + + @Value("${transcoder.ffmpeg.temp-dir:#{systemProperties['java.io.tmpdir'] + '/ott-transcode'}}") + private String tempDir; + + public void handle(TranscodeMessage message) throws Exception { + Long mediaId = message.mediaId(); + // TODO: 0. DB 확인 필요 + + Path workDir = Path.of(tempDir, "media-" + mediaId); + + // 1. 디스크 공간 확인 + diskSpaceGuard.check(Path.of(message.originUrl())); + + try { + // 2. workDir 생성 + Files.createDirectories(workDir); + + // 3. 원본 다운로드 + Path inputFile = videoStorage.download(message.originUrl(), workDir); + + // 4. 검사 (FileValidator → Probe → StreamValidator) + ProbeResult probeResult = inspector.inspect(inputFile); + + // TODO: 5. 커맨드 생성 -> 각 커맨드 파이프라인 실행 + + // 6. 파이프라인 실행 + pipeline.execute(mediaId, inputFile, workDir, probeResult); + + } finally { + cleanUp(workDir); + } + } + + private void cleanUp(Path workDir) { + try { + if (Files.exists(workDir)) { + Files.walk(workDir) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { Files.deleteIfExists(path); } catch (IOException ignored) {} + }); + log.info("작업 디렉토리 정리 완료 - {}", workDir); + } + } catch (IOException e) { + log.warn("작업 디렉토리 정리 실패 - {}", workDir, e); + } + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java index 13988c5c..9575c71c 100644 --- a/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java +++ b/apps/transcoder/src/main/java/com/ott/transcoder/pipeline/hls/HlsTranscodePipeline.java @@ -1,92 +1,48 @@ package com.ott.transcoder.pipeline.hls; import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.ffmpeg.TranscodeProfile; +import com.ott.transcoder.ffmpeg.execution.FfmpegExecutor; +import com.ott.transcoder.inspection.probe.ProbeResult; import com.ott.transcoder.pipeline.CommandPipeline; import com.ott.transcoder.storage.VideoStorage; -import com.ott.transcoder.ffmpeg.FfmpegExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.Comparator; import java.util.List; -/** - * HLS 트랜스코딩 파이프라인 - * - * 전체 흐름을 조율: - * 1. 임시 작업 디렉토리 생성 - * 2. 원본 다운로드 (VideoStorage) - * 3. 해상도별 FFmpeg HLS 트랜스코딩 (360p → 720p → 1080p 순차 실행) - * 4. 마스터 플레이리스트 생성 (master.m3u8) - * 5. 결과물 업로드 (VideoStorage) - * 6. 임시 작업 디렉토리 정리 (성공/실패 모두) - */ @Slf4j @Component @RequiredArgsConstructor public class HlsTranscodePipeline implements CommandPipeline { - private static final List TARGET_RESOLUTION_LIST = List.of( - Resolution.P360, Resolution.P720, Resolution.P1080 - ); - - private final VideoStorage videoStorage; + private final TranscodePlanner transcodePlanner; private final FfmpegExecutor ffmpegExecutor; private final MasterPlaylistGenerator masterPlaylistGenerator; - - @Value("${transcoder.ffmpeg.temp-dir:#{systemProperties['java.io.tmpdir'] + '/ott-transcode'}}") - private String tempDir; + private final VideoStorage videoStorage; @Override - public void execute(Long mediaId, String originUrl) { - Path workDir = Path.of(tempDir, "media-" + mediaId); - - try { - Files.createDirectories(workDir); - log.info("트랜스코딩 시작 - mediaId: {}, originUrl: {}", mediaId, originUrl); - - // 1. 원본 영상을 임시 작업 디렉토리로 가져옴 - Path inputFile = videoStorage.download(originUrl, workDir); + public void execute(Long mediaId, Path inputFile, Path workDir, ProbeResult probeResult) throws Exception { + log.info("HLS 트랜스코딩 시작 - mediaId: {}", mediaId); - // 2. 해상도별 HLS 트랜스코딩 (각각 media.m3u8 + segment_XXX.ts 생성) - for (Resolution resolution : TARGET_RESOLUTION_LIST) { - ffmpegExecutor.execute(inputFile, workDir, resolution); - } + // plan + // TODO: Filter Chain 구성 필요 + List profileList = transcodePlanner.plan(probeResult); - // 3. 마스터 플레이리스트 생성 (3개 variant를 참조하는 master.m3u8) - masterPlaylistGenerator.generate(workDir, TARGET_RESOLUTION_LIST); - - // 4. 결과물을 저장소에 업로드 (output-dir/media/{mediaId}/hls/) - String uploadedPath = videoStorage.upload(workDir, "media/" + mediaId + "/hls"); - - log.info("트랜스코딩 완료 - mediaId: {}, uploadedPath: {}", mediaId, uploadedPath); - - } catch (Exception e) { - log.error("트랜스코딩 실패 - mediaId: {}", mediaId, e); - throw new RuntimeException("트랜스코딩 실패 - mediaId: " + mediaId, e); - } finally { - cleanUp(workDir); + // main + for (TranscodeProfile profile : profileList) { + ffmpegExecutor.execute(inputFile, workDir, profile); } - } - /** 임시 작업 디렉토리 삭제. 하위 파일부터 역순으로 삭제. */ - private void cleanUp(Path workDir) { - try { - if (Files.exists(workDir)) { - Files.walk(workDir) - .sorted(Comparator.reverseOrder()) - .forEach(path -> { - try { Files.deleteIfExists(path); } catch (IOException ignored) {} - }); - log.info("임시 디렉토리 정리 완료 - {}", workDir); - } - } catch (IOException e) { - log.warn("임시 디렉토리 정리 실패 - {}", workDir, e); - } + // post + List resolutionList = profileList.stream() + .map(TranscodeProfile::resolution) + .toList(); + masterPlaylistGenerator.generate(workDir, resolutionList); + + String uploadedPath = videoStorage.upload(workDir, "media/" + mediaId + "/hls"); + log.info("HLS 트랜스코딩 완료 - mediaId: {}, uploadedPath: {}", mediaId, uploadedPath); } } From ab3f069d138415872dc720986c47642740e7d16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=8A=B9=EC=9A=B0?= Date: Mon, 2 Mar 2026 11:14:34 +0900 Subject: [PATCH 7/7] [GIT]: Merge Develop --- .../auth/controller/AdminAuthApi.java | 38 +++++- .../auth/controller/AdminAuthController.java | 32 ++--- .../auth/dto/request/AdminLoginRequest.java | 4 +- .../auth/dto/response/AdminLoginResponse.java | 4 +- .../auth/dto/response/AdminTokenResponse.java | 5 + .../ott/api_admin/config/SecurityConfig.java | 40 ++++-- .../auth/controller/AuthController.java | 54 ++------ .../auth/oauth2/CustomOAuth2UserService.java | 2 +- .../oauth2/handler/OAuth2SuccessHandler.java | 32 ++--- .../auth/service/KakaoAuthService.java | 7 +- .../bookmark/controller/BookmarkAPI.java | 2 +- .../ott/api_user/config/SecurityConfig.java | 41 +++--- .../member/service/MemberService.java | 1 + .../transcoder/{transcode => ffmpeg}/.gitkeep | 0 .../transcoder/transcode/FfmpegExecutor.java | 29 +++++ .../ProcessBuilderFfmpegExecutor.java | 117 ++++++++++++++++++ .../java/com/ott/transcoder/validate/.gitkeep | 0 .../java/com/ott/transcoder/worker/.gitkeep | 0 .../ott/common/security/util/CookieUtil.java | 34 +++++ .../com/ott/domain/member/domain/Member.java | 8 ++ .../V3__member_onboarding_completed.sql | 6 + 21 files changed, 320 insertions(+), 136 deletions(-) rename apps/transcoder/src/main/java/com/ott/transcoder/{transcode => ffmpeg}/.gitkeep (100%) create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java create mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep delete mode 100644 apps/transcoder/src/main/java/com/ott/transcoder/worker/.gitkeep create mode 100644 modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java create mode 100644 modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java index 686b105d..38d14fe1 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthApi.java @@ -22,11 +22,24 @@ public interface AdminAuthApi { @Operation( summary = "관리자 로그인", - description = "이메일/비밀번호로 로그인합니다. " - + "Access Token과 Refresh Token은 HttpOnly 쿠키로 세팅됩니다." + description = """ + 이메일/비밀번호로 관리자 로그인을 수행합니다. + + - ADMIN or EDITOR 권한을 가진 계정만 로그인이 가능합니다. + - 응답 Body에는 memberId와 role만 포함됩니다. + """ + ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = AdminLoginResponse.class))), + + @ApiResponse( + responseCode = "400", + description = "요청 값 유효성 검증 실패 (이메일 형식 오류, 필드 누락 등)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), @ApiResponse( responseCode = "401", description = "이메일 또는 비밀번호 불일치", @@ -44,7 +57,13 @@ ResponseEntity> login( @Operation( summary = "토큰 재발급", - description = "refreshToken 쿠키를 사용해 Access Token과 Refresh Token을 재발급합니다." + description = """ + 쿠키의 refreshToken을 검증하여 Access Token과 Refresh Token을 재발급합니다. + + - 요청 시 refreshToken 쿠키가 반드시 포함되어야 합니다. + - 보안을 위해 Access Token과 Refresh Token을 모두 재발급합니다. (Refresh Token Rotation) + - 재발급된 토큰은 기존 쿠키를 덮어씁니다. + """ ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "재발급 성공"), @@ -54,11 +73,18 @@ ResponseEntity> login( content = @Content(schema = @Schema(implementation = ErrorResponse.class)) ) }) - ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response); + ResponseEntity reissue( + HttpServletRequest request, HttpServletResponse response); @Operation( summary = "로그아웃", - description = "DB의 refreshToken을 삭제하고 accessToken/refreshToken 쿠키를 제거합니다." + description = """ + 로그인된 관리자를 로그아웃 처리합니다. + + - DB에 저장된 refreshToken을 삭제합니다. + - accessToken, refreshToken 쿠키를 즉시 만료시킵니다. + - 이후 해당 토큰으로는 인증이 불가능합니다. + """ ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "로그아웃 성공"), diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java index 127aca8f..2207929c 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/controller/AdminAuthController.java @@ -4,6 +4,7 @@ import com.ott.api_admin.auth.dto.response.AdminLoginResponse; import com.ott.api_admin.auth.dto.response.AdminTokenResponse; import com.ott.api_admin.auth.service.AdminAuthService; +import com.ott.common.security.util.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import com.ott.common.web.response.SuccessResponse; @@ -27,6 +28,7 @@ public class AdminAuthController implements AdminAuthApi { private final AdminAuthService adminAuthService; + private final CookieUtil cookie; @Value("${jwt.access-token-expiry}") private int accessTokenExpiry; @@ -43,8 +45,8 @@ public ResponseEntity> login( AdminLoginResponse loginResponse = adminAuthService.login(request); // 둘 다 쿠키로 - addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); + cookie.addCookie(response, "accessToken", loginResponse.getAccessToken(), accessTokenExpiry); + cookie.addCookie(response, "refreshToken", loginResponse.getRefreshToken(), refreshTokenExpiry); // Body에는 memberId, role만 (토큰은 @JsonIgnore) return SuccessResponse.of(loginResponse).asHttp(HttpStatus.OK); @@ -63,8 +65,8 @@ public ResponseEntity reissue( AdminTokenResponse tokenResponse = adminAuthService.reissue(refreshToken); - addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + cookie.addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + cookie.addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); return ResponseEntity.noContent().build(); } @@ -78,8 +80,8 @@ public ResponseEntity logout( Long memberId = (Long) authentication.getPrincipal(); adminAuthService.logout(memberId); - deleteCookie(response, "accessToken"); - deleteCookie(response, "refreshToken"); + cookie.deleteCookie(response, "accessToken"); + cookie.deleteCookie(response, "refreshToken"); return ResponseEntity.noContent().build(); } @@ -93,22 +95,4 @@ private String extractCookie(HttpServletRequest request, String name) { } return null; } - - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true - cookie.setPath("/"); - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } - - private void deleteCookie(HttpServletResponse response, String name) { - Cookie cookie = new Cookie(name, null); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); - } } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java index b840b356..a68bd06e 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/request/AdminLoginRequest.java @@ -13,10 +13,10 @@ public class AdminLoginRequest { @Email @NotBlank - @Schema(description = "관리자 이메일", example = "admin@ott.com") + @Schema(type= "String",description = "관리자 이메일", example = "admin@ott.com") private String email; @NotBlank - @Schema(description = "비밀번호", example = "password123") + @Schema(type="String", description = "비밀번호", example = "password123") private String password; } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java index e0b6d70f..413bb6e2 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminLoginResponse.java @@ -16,9 +16,9 @@ public class AdminLoginResponse { @JsonIgnore // 쿠키로 전달 — JSON 응답에서 제외 private String refreshToken; - @Schema(description = "회원 ID", example = "1") + @Schema(type = "Long", description = "회원 ID", example = "1") private Long memberId; - @Schema(description = "회원 역할", example = "ADMIN") + @Schema(type= "String", description = "회원 역할", example = "ADMIN") private String role; } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java index 4f20127f..dda0cf48 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/auth/dto/response/AdminTokenResponse.java @@ -1,5 +1,6 @@ package com.ott.api_admin.auth.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; @@ -9,6 +10,10 @@ @Getter @AllArgsConstructor public class AdminTokenResponse { + + @Schema(type = "String", description = "accessToken", example = "122Wjxf@djx1jcmxsizkds2fj-dsm2.dzj2") private String accessToken; + + @Schema(type = "String", description = "refreshToken", example = "eym122Wjxf@djx1jcmxsizkds2fj-dsm2.dzj2") private String refreshToken; } \ No newline at end of file diff --git a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java index c9dc2bef..481a7278 100644 --- a/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java +++ b/apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java @@ -14,6 +14,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @RequiredArgsConstructor @@ -30,20 +35,25 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - .cors(AbstractHttpConfigurer::disable) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(e -> e - .authenticationEntryPoint(jwtAuthenticationEntryPoint) - .accessDeniedHandler(jwtAccessDeniedHandler)) + .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 + .accessDeniedHandler(jwtAccessDeniedHandler)) // 403 + .authorizeHttpRequests(auth -> auth + // 인증 불필요 .requestMatchers( "/actuator/health/**", "/actuator/info", "/back-office/login", "/back-office/reissue", - "/back-office/swagger-ui/**", - "/back-office/v3/api-docs/**", - "/back-office/swagger-resources/**" + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ).permitAll() .requestMatchers("/back-office/admin/**").hasRole("ADMIN") .anyRequest().hasAnyRole("ADMIN", "EDITOR") @@ -56,4 +66,20 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://www.openthetaste.cloud")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java index 598ba877..e65fe575 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthController.java @@ -1,7 +1,9 @@ package com.ott.api_user.auth.controller; + import com.ott.api_user.auth.dto.TokenResponse; import com.ott.api_user.auth.service.AuthService; +import com.ott.common.security.util.CookieUtil; import com.ott.common.web.exception.BusinessException; import com.ott.common.web.exception.ErrorCode; import jakarta.servlet.http.Cookie; @@ -11,17 +13,15 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController implements AuthApi { private final AuthService authService; + private final CookieUtil cookieUtil; @Value("${jwt.access-token-expiry}") private int accessTokenExpiry; @@ -44,8 +44,8 @@ public ResponseEntity reissue( TokenResponse tokenResponse = authService.reissue(refreshToken); // 쿠키에 저장 - addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); - addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); + cookieUtil.addCookie(response, "accessToken", tokenResponse.getAccessToken(), accessTokenExpiry); + cookieUtil.addCookie(response, "refreshToken", tokenResponse.getRefreshToken(), refreshTokenExpiry); return ResponseEntity.noContent().build(); } @@ -63,32 +63,13 @@ public ResponseEntity logout( Long memberId = (Long) authentication.getPrincipal(); authService.logout(memberId); - deleteCookie(response, "accessToken"); - deleteCookie(response, "refreshToken"); + cookieUtil.deleteCookie(response, "accessToken"); + cookieUtil.deleteCookie(response, "refreshToken"); return ResponseEntity.noContent().build(); } - // 임시 테스트 코드 -> 추후 프론트 페이지로 변경 예정 - @GetMapping("logincheck") - public ResponseEntity> logincheck( - @RequestParam(value = "isNewMember") boolean isNewMember, - HttpServletRequest request) { - String accessToken = extractCookie(request, "accessToken"); - String refreshToken = extractCookie(request, "refreshToken"); - - return ResponseEntity.ok(Map.of( - "isNewMember", isNewMember, - "accessToken", accessToken, - "refreshToken", refreshToken)); - } - // 인가 테스트용 코드 -> 이렇게 @AuthenticationPrincipal로 쓰시면 됩니다. - // 추후 memberId -> UserDetails로 리팩토링 예정 - @GetMapping("/me") - public Long me(@AuthenticationPrincipal Long memberId) { - return memberId; - } // 쿠키에 대한 접근은 HTTP고 서비스로 내려가면 안되기 때문에 Controller에서 구현 private String extractCookie(HttpServletRequest request, String name) { @@ -96,30 +77,11 @@ private String extractCookie(HttpServletRequest request, String name) { return null; } - for (Cookie cookie : request.getCookies()) { + for (Cookie cookie: request.getCookies()) { if (name.equals(cookie.getName())) { return cookie.getValue(); } } return null; } - - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 - cookie.setPath("/"); - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } - - private void deleteCookie(HttpServletResponse response, String name) { - Cookie cookie = new Cookie(name, null); - cookie.setHttpOnly(true); - cookie.setSecure(false); // 배포 시 true 변경 - cookie.setPath("/"); - cookie.setMaxAge(0); // 즉시 삭제 - response.addCookie(cookie); - } - } diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java index 5f4a8905..53914987 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/CustomOAuth2UserService.java @@ -42,7 +42,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic Member member = kakaoAuthService.findOrCreateMember(kakaoUserInfo); // 신규 회원 판별 - boolean isNewMember = kakaoAuthService.isNewMember(member.getId()); + boolean isNewMember = kakaoAuthService.isNewMember(member); // attribute에 memberId(PK)와 신규 유저 유무를 적재 // payload memberId, isNewMember만 들어감 -> 민감한 정보 적재 x diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java index a4817e79..64441254 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -1,8 +1,8 @@ package com.ott.api_user.auth.oauth2.handler; import com.ott.api_user.auth.service.KakaoAuthService; +import com.ott.common.security.util.CookieUtil; import com.ott.common.security.jwt.JwtTokenProvider; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -12,7 +12,6 @@ import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; import java.util.List; @@ -31,6 +30,7 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final JwtTokenProvider jwtTokenProvider; private final KakaoAuthService kakaoAuthService; + private final CookieUtil cookieUtil; @Value("${app.frontend-url}") private String frontedUrl; @@ -69,31 +69,17 @@ public void onAuthenticationSuccess( kakaoAuthService.saveRefreshToken(memberId, refreshToken); // 쿠키로 저장 - addCookie(response, "accessToken", accessToken, accessTokenExpiry); - addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); + cookieUtil.addCookie(response, "accessToken", accessToken, accessTokenExpiry); + cookieUtil.addCookie(response, "refreshToken", refreshToken, refreshTokenExpiry); - // 리다이렉트에는 쿼리 파라미터로 isNewMember만 전달 - String redirectUri = request.getParameter("redirect_uri"); - if (redirectUri == null || redirectUri.isBlank()) { - redirectUri = frontedUrl + "/auth/logincheck"; // 배포 후 변경 예정 - } - - String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) - .queryParam("isNewMember", isNewMember) - .build() - .toUriString(); + // 리다이렉트에는 isNewMember에 따라서 경로 변경 + String targetUrl = isNewMember + ? frontedUrl + "/auth/userinfo" + : frontedUrl + "/"; getRedirectStrategy().sendRedirect(request, response, targetUrl); } - private void addCookie(HttpServletResponse response, String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(true); // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 - cookie.setSecure(false); // 배포 서버에서는 true로 변경 - cookie.setPath("/"); // 모든 경로에서 전송 - cookie.setMaxAge(maxAge); - response.addCookie(cookie); - } -} +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java index 38daac75..e9b3c365 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java @@ -24,7 +24,6 @@ public class KakaoAuthService { private final MemberRepository memberRepository; - private final PreferredTagRepository preferredTagRepository; // 카카오 사용자 정보로 회원 조회 or 신규 생성 // 기존 회원일 경우 프로필 동기화 필요 @@ -44,9 +43,9 @@ public Member findOrCreateMember(KakaoUserInfo kakaoUserInfo) { kakaoUserInfo.getNickname()))); } - // 신규 회원 판별 -> 태그 소유 유무로 판단 - public boolean isNewMember(Long memberId) { - return !preferredTagRepository.existsByMemberId(memberId); + // 신규 회원 판별 -> 컬럼으로 판별 + public boolean isNewMember(Member member) { + return !member.isOnboardingCompleted(); } // refresh token 저장 diff --git a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java index fc46e4da..b6bb4b98 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java +++ b/apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkAPI.java @@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestBody; -@Tag(name = "Bookmark API", description = "북마크 관련 API입니다.") +@Tag(name = "Bookmark API", description = "북마크 API") public interface BookmarkAPI { @Operation(summary = "북마크 수정", description = "미디어에 대한 북마크를 수정합니다.") @ApiResponses(value = { diff --git a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java index 8bc5552c..2373df74 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java +++ b/apps/api-user/src/main/java/com/ott/api_user/config/SecurityConfig.java @@ -44,13 +44,11 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) // csrf 비활성화, Authorization 헤더로 보냄 - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - -// .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .cors(AbstractHttpConfigurer::disable) - - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증 + .formLogin(AbstractHttpConfigurer::disable) // 카카오 OAtuh2 + JWT기반이라 기본 로그인 폼 안씀 + .httpBasic(AbstractHttpConfigurer::disable) // 카카오 OAtuh2 + JWT기반이라 Basic 인증 안씀 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증이라 세션 유지 x .exceptionHandling(e -> e .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 @@ -61,18 +59,18 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/actuator/health/**", "/actuator/info", - "/auth/**", "/oauth2/**", "/login/oauth2/**", "/auth/reissue", - "/auth/logout", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**" - ).permitAll() + ).permitAll() - // 나머지 url에 대해서는 인증 필요 - .anyRequest().authenticated() + /* + 역할이 MEMBER인 유저만 그 외 EndPoint 접근 가능하도록 설정 + */ + .anyRequest().hasRole("MEMBER") ) // OAuth2 카카오 로그인 @@ -83,7 +81,9 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .failureHandler(oAuth2FailureHandler) ) - // UsernamePasswordAuthenticationFilter 보다 먼저 실행 + // Spring Security보다 먼저 실행 + // 쿠키에서 AccessToken을 꺼내와서 검증 이후 SecurityContext에 인증 정보 박제 + // 해당 과정에서 memberId, ROLE을 context에 넣어줌 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); @@ -93,19 +93,20 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - // allowedOrigins -> 허용할 Origin 내역 + // allowedOrigins -> 허용할 도메인 내역 // allowCredentials -> 브라우저가 요청에 인증정보를 포함하는 것을 허용하겠냐 // credentials가 true일 경우 Allow-origin의 경우 구체적인 경로를 명시해야됨 - config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); -// config.setAllowedOrigins(List.of(frontedUrl)); + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://www.openthetaste.cloud")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - config.setAllowedHeaders(List.of("*")); - config.setAllowCredentials(true); + config.setAllowedHeaders(List.of("*")); // 모든 헤더 다 받는데 우리 서비스에서는 안씀 + config.setAllowCredentials(true); // 쿠키 요청을 포함 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); + source.registerCorsConfiguration("/**", config); // 위 설정을 모든 경로에 적용 return source; } -} +} \ No newline at end of file diff --git a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java index f35df855..bb482250 100644 --- a/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java +++ b/apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java @@ -110,6 +110,7 @@ public void setPreferredTags(Long memberId, SetPreferredTagRequest request) { .toList(); preferredTagRepository.saveAll(preferredTags); + findMember.completeOnboarding(); } } diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep similarity index 100% rename from apps/transcoder/src/main/java/com/ott/transcoder/transcode/.gitkeep rename to apps/transcoder/src/main/java/com/ott/transcoder/ffmpeg/.gitkeep diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java new file mode 100644 index 00000000..1178e76f --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/FfmpegExecutor.java @@ -0,0 +1,29 @@ +package com.ott.transcoder.transcode; + +import com.ott.domain.video_profile.domain.Resolution; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * FFmpeg 실행 추상화 인터페이스 + * + * FFmpeg를 호출하는 방식(ProcessBuilder, Jaffree 등)에 독립적으로 + * 단일 해상도에 대한 HLS 트랜스코딩을 수행한다. + * + * 구현체 전환: transcoder.ffmpeg.engine 프로퍼티로 선택 + * - processbuilder: ProcessBuilderFfmpegExecutor (CLI 직접 호출) + * - jaffree: (향후) JaffreeFfmpegExecutor (라이브러리 호출) + */ +public interface FfmpegExecutor { + + /** + * 단일 해상도에 대해 HLS 트랜스코딩을 수행한다. + * + * @param inputFile 원본 영상 파일 경로 + * @param outputDir 출력 디렉토리 (하위에 360p/, 720p/, 1080p/ 폴더가 생성됨) + * @param resolution 대상 해상도 + * @return 생성된 미디어 플레이리스트(media.m3u8) 경로 + */ + Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException; +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java new file mode 100644 index 00000000..4ef1dc51 --- /dev/null +++ b/apps/transcoder/src/main/java/com/ott/transcoder/transcode/processbuilder/ProcessBuilderFfmpegExecutor.java @@ -0,0 +1,117 @@ +package com.ott.transcoder.transcode.processbuilder; + +import com.ott.domain.video_profile.domain.Resolution; +import com.ott.transcoder.transcode.FfmpegExecutor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * ProcessBuilder 기반 FFmpeg CLI 래퍼 + * + * 시스템에 설치된 FFmpeg 바이너리를 ProcessBuilder로 직접 호출 + * 단일 해상도에 대해 HLS 트랜스코딩을 수행하며, + * 결과물로 media.m3u8 (미디어 플레이리스트) + segment_XXX.ts (세그먼트 파일)를 생성 + * + * FFmpeg 내부 처리 흐름: + * Demux(컨테이너 분리) → Decode(디코딩) → Filter(스케일링) → Encode(재인코딩) → Mux(HLS 패키징) + * 이 전체가 하나의 FFmpeg 명령어로 실행됨 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "transcoder.ffmpeg.engine", havingValue = "processbuilder") +public class ProcessBuilderFfmpegExecutor implements FfmpegExecutor { + + /** 해상도별 출력 높이 (너비는 -2로 자동 계산, 짝수 보장) */ + private static final Map HEIGHT_MAP = Map.of( + Resolution.P360, 360, + Resolution.P720, 720, + Resolution.P1080, 1080 + ); + + /** 해상도별 비디오 비트레이트 */ + private static final Map VIDEO_BITRATE_MAP = Map.of( + Resolution.P360, "800k", + Resolution.P720, "2400k", + Resolution.P1080, "4800k" + ); + + /** 해상도별 오디오 비트레이트 */ + private static final Map AUDIO_BITRATE_MAP = Map.of( + Resolution.P360, "96k", + Resolution.P720, "128k", + Resolution.P1080, "192k" + ); + + @Value("${transcoder.ffmpeg.path:ffmpeg}") + private String ffmpegPath; + + /** HLS 세그먼트 하나의 길이 (초) */ + @Value("${transcoder.ffmpeg.segment-duration:10}") + private int segmentDuration; + + @Override + public Path execute(Path inputFile, Path outputDir, Resolution resolution) throws IOException, InterruptedException { + int height = HEIGHT_MAP.get(resolution); + String videoBitrate = VIDEO_BITRATE_MAP.get(resolution); + String audioBitrate = AUDIO_BITRATE_MAP.get(resolution); + + // 해상도별 하위 디렉토리 생성 (예: workDir/360p/) + Path resolutionDir = outputDir.resolve(resolution.getKey().toLowerCase()); + Files.createDirectories(resolutionDir); + + Path playlistPath = resolutionDir.resolve("media.m3u8"); + String segmentPattern = resolutionDir.resolve("segment_%03d.ts").toString(); + + // FFmpeg 명령어 조립 + List command = List.of( + ffmpegPath, "-i", inputFile.toString(), + "-vf", "scale=-2:" + height, + "-c:v", "libx264", "-preset", "fast", + "-c:a", "aac", "-b:a", audioBitrate, + "-b:v", videoBitrate, + "-f", "hls", + "-hls_time", String.valueOf(segmentDuration), + "-hls_list_size", "0", + "-hls_segment_filename", segmentPattern, + playlistPath.toString() + ); + + log.info("FFmpeg 실행 - resolution: {}, command: {}", resolution.getKey(), String.join(" ", command)); + + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + + // FFmpeg 출력을 읽어야 프로세스가 블로킹되지 않는다 (버퍼 가득 참 방지) + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("[FFmpeg] {}", line); + } + } + + boolean finished = process.waitFor(30, java.util.concurrent.TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new RuntimeException("FFmpeg 타임아웃 - resolution: " + resolution.getKey()); + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + throw new RuntimeException("FFmpeg 실패 - resolution: " + resolution.getKey() + ", exitCode: " + exitCode); + } + + log.info("FFmpeg 완료 - resolution: {}, output: {}", resolution.getKey(), playlistPath); + return playlistPath; + } +} diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/validate/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/transcoder/src/main/java/com/ott/transcoder/worker/.gitkeep b/apps/transcoder/src/main/java/com/ott/transcoder/worker/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java new file mode 100644 index 00000000..8802ab09 --- /dev/null +++ b/modules/common-security/src/main/java/com/ott/common/security/util/CookieUtil.java @@ -0,0 +1,34 @@ +package com.ott.common.security.util; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + public void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) +// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .httpOnly(true) // JS 접근 차단 -> 크로스 사이트 스크립트 공격 대비 + .secure(false) // HTTPS 요청만 허용 + .path("/") // 모든 경로로 전송 + .maxAge(maxAge) + .sameSite("Lax") // 크로스 사이트에 대해서 쿠키 전송 허용 + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "") +// .domain("openthetaste.cloud") // 로컬 테스트 시 주석처리!!! + .httpOnly(true) + .secure(false) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} \ No newline at end of file diff --git a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java index 08b759c7..62d0c805 100644 --- a/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java +++ b/modules/domain/src/main/java/com/ott/domain/member/domain/Member.java @@ -50,6 +50,9 @@ public class Member extends BaseEntity { @Column(name = "refresh_token") private String refreshToken; + @Column(name = "onboarding_completed", nullable = false) + private boolean onboardingCompleted; + public static Member createKakaoMember(String providerId, String email, String nickname) { return Member.builder() .provider(Provider.KAKAO) @@ -84,4 +87,9 @@ public void changeRole(Role targetRole) { public void updateNickname(String nickname) { this.nickname = nickname; } + + // 온보딩 여부 + public void completeOnboarding() { + this.onboardingCompleted = true; + } } diff --git a/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql b/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql new file mode 100644 index 00000000..a062f21e --- /dev/null +++ b/modules/infra-db/src/main/resources/db/migration/V3__member_onboarding_completed.sql @@ -0,0 +1,6 @@ +ALTER TABLE member + ADD COLUMN onboarding_completed BOOLEAN NOT NULL DEFAULT FALSE; + +-- Existing users should not be forced back into onboarding after deployment. +UPDATE member +SET onboarding_completed = TRUE;