public static String uploadToS3(AwsCredentialsProvider credentialsProvider, byte[] videoBytes) {
try {
log.info("uploadToS3 시작 - videoBytes 크기: {}", videoBytes.length);
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg",
"-i", "pipe:0", // stdin에서 webm
"-vn", // 비디오 제거
"-acodec", "libmp3lame", // mp3 인코딩
"-ar", "44100", // 표준 mp3 샘플링
"-ac", "2", // 스테레오
"-f", "mp3", // WAV 포맷
"pipe:1" // stdout으로 출력
);
pb.redirectErrorStream(true);
Process process = pb.start();
// 비디오 데이터를 쓰는 스레드
Thread inputThread = new Thread(() -> {
try (OutputStream ffmpegStdin = process.getOutputStream()) {
ffmpegStdin.write(videoBytes);
ffmpegStdin.flush();
} catch (IOException e) {
log.error("ffmpeg stdin 처리 중 오류", e);
}
});
inputThread.start();
// 오류 출력을 읽는 스레드
StringBuilder errorOutput = new StringBuilder();
Thread errorThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
errorOutput.append(line).append("\n");
}
} catch (IOException e) {
log.error("ffmpeg stderr 읽기 중 오류", e);
}
});
errorThread.start();
// 2. stdout에서 오디오 bytes 읽기
byte[] audioBytes;
try (InputStream ffmpegStdout = process.getInputStream()) {
audioBytes = IOUtils.toByteArray(ffmpegStdout);
}
// 모든 스레드가 완료될 때까지 대기
inputThread.join();
errorThread.join();
// 프로세스 완료 대기 (타임아웃 설정)
boolean completed = process.waitFor(60, TimeUnit.SECONDS);
if (!completed) {
process.destroyForcibly();
log.error("ffmpeg 처리 타임아웃");
throw new RuntimeException("ffmpeg 처리 타임아웃");
}
int exitCode = process.exitValue();
if (exitCode != 0 || audioBytes.length == 0) {
log.error("ffmpeg 음성 추출 실패. 종료 코드: {}, 추출된 바이트: {}, 오류: {}", exitCode, audioBytes.length, errorOutput);
throw new RuntimeException("ffmpeg 음성 추출 실패. 종료 코드: " + exitCode);
}
log.info("ffmpeg 음성 추출 성공. 추출 크기: {} bytes", audioBytes.length);
// 3. S3에 업로드
S3Client s3Client = S3Client.builder()
.region(REGION)
.credentialsProvider(credentialsProvider)
.build();
String audioFileName = "extracted-audio-" + UUID.randomUUID() + ".mp3";
String s3Key = "audio/" + audioFileName;
PutObjectRequest request = PutObjectRequest.builder()
.bucket(BUCKET_NAME)
.key(s3Key)
.contentType("audio/mpeg")
.build();
s3Client.putObject(request, RequestBody.fromBytes(audioBytes));
String fileUri = "s3://" + BUCKET_NAME + "/" + s3Key;
log.info("오디오 파일 S3 업로드 완료: {}", fileUri);
return fileUri;
} catch (IOException | InterruptedException e) {
log.error("uploadToS3 처리 실패", e);
throw new RuntimeException("오디오 업로드 실패", e);
}
}
ffmpeg 프로세스의 입출력 스트림을 처리하기 위해 스레드를 사용한다.
inputThread - ffmpeg 프로세스에 비디오 데이터를 보내는 스레드. 이 스레드는 process.getOutputStream()을 통해 비디오 바이트를 ffmpeg의 표준 입력으로 전달한다.errorThread - ffmpeg 프로세스의 오류 출력(stderr)을 읽는 스레드. ffmpeg 실행 중 발생하는 로그나 오류 메시지를 수집한다.ffmpeg 자체는 별도의 외부 프로세스로 실행되며, Java 코드는 ProcessBuilder를 사용하여 이 프로세스를 시작하고 관리한다. 메인 스레드에서는 ffmpeg의 결과(stdout)를 읽고, 프로세스가 완료될 때까지 기다린 후 결과를 S3에 업로드하는 작업을 수행한다.
이런 구조는 일반적으로 프로세스의 입출력 파이프를 효율적으로 처리하기 위한 방식으로, 교착 상태(deadlock)를 방지하기 위해 사용된다.
이 코드의 구조는 "프로세스 스트림 핸들링 패턴" 또는 "외부 프로세스 IO 스트림 관리 패턴"이라고 할 수 있다.
이 패턴의 핵심 특징은:
ProcessBuilder로 실행이런 구조가 필요한 이유는 스트림 IO 작업이 블로킹 방식으로 동작하기 때문이다. 만약 단일 스레드에서 모든 스트림을 처리하려고 하면, 입력 스트림을 모두 보내기 전에 출력 스트림을 읽으려고 할 경우 교착 상태가 발생할 수 있다. 특히 ffmpeg처럼 데이터를 지속적으로 처리하고 출력하는 프로그램에서는 더욱 그렇다.
이 패턴은 명령줄 도구나 외부 프로세스와 안정적으로 통신해야 하는 Java 애플리케이션에서 자주 사용된다.
현재 코드에서는 각 요청마다 ffmpeg 프로세스를 실행하고 그 입출력을 처리하기 위해 스레드를 생성한다. 만약 많은 사용자가 동시에 비디오 변환을 요청한다면, 서버의 스레드 풀이 고갈되어 다른 API 요청을 처리할 수 없는 상황이 발생할 수 있다.

블로킹은 특정 작업이 완료될 때까지 코드의 실행이 멈추는 상태를 말한다. 블로킹 방식의 I/O(입출력) 작업에서는:
예를 들어, InputStream.read()는 데이터를 읽을 수 있을 때까지 스레드가 대기한다.
교착상태는 두 개 이상의 프로세스나 스레드가 서로가 점유한 자원을 기다리며 무한정 대기하는 상황을 말한다. 교착상태의 조건:
예시: 프로세스 A가 자원 X를 점유하고 자원 Y를 기다리는 동시에, 프로세스 B가 자원 Y를 점유하고 자원 X를 기다리는 상황
ffmpeg 사용 시의 교착상태 예:
이런 문제를 해결하기 위해 앞서 본 코드에서는 별도의 스레드를 사용해 입력과 출력, 에러 스트림을 독립적으로 처리하고 있다.