FFmpeg으로 영상 -> 음성 변환하기

N’oublie pas de t’aimer·2025년 4월 26일

DIVE

목록 보기
9/10

영상 → 음성 변환 메소드

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 프로세스의 입출력 스트림을 처리하기 위해 스레드를 사용한다.

  1. inputThread - ffmpeg 프로세스에 비디오 데이터를 보내는 스레드. 이 스레드는 process.getOutputStream()을 통해 비디오 바이트를 ffmpeg의 표준 입력으로 전달한다.
  2. errorThread - ffmpeg 프로세스의 오류 출력(stderr)을 읽는 스레드. ffmpeg 실행 중 발생하는 로그나 오류 메시지를 수집한다.

ffmpeg 자체는 별도의 외부 프로세스로 실행되며, Java 코드는 ProcessBuilder를 사용하여 이 프로세스를 시작하고 관리한다. 메인 스레드에서는 ffmpeg의 결과(stdout)를 읽고, 프로세스가 완료될 때까지 기다린 후 결과를 S3에 업로드하는 작업을 수행한다.

이런 구조는 일반적으로 프로세스의 입출력 파이프를 효율적으로 처리하기 위한 방식으로, 교착 상태(deadlock)를 방지하기 위해 사용된다.

이 코드의 구조는 "프로세스 스트림 핸들링 패턴" 또는 "외부 프로세스 IO 스트림 관리 패턴"이라고 할 수 있다.

이 패턴의 핵심 특징은:

  1. 외부 프로세스(여기서는 ffmpeg)를 Java에서 ProcessBuilder로 실행
  2. 외부 프로세스와의 세 가지 스트림(stdin, stdout, stderr)을 별도의 스레드에서 비동기적으로 처리
  3. 각 스트림의 블로킹 IO 작업이 서로를 방해하지 않도록 분리

이런 구조가 필요한 이유는 스트림 IO 작업이 블로킹 방식으로 동작하기 때문이다. 만약 단일 스레드에서 모든 스트림을 처리하려고 하면, 입력 스트림을 모두 보내기 전에 출력 스트림을 읽으려고 할 경우 교착 상태가 발생할 수 있다. 특히 ffmpeg처럼 데이터를 지속적으로 처리하고 출력하는 프로그램에서는 더욱 그렇다.

이 패턴은 명령줄 도구나 외부 프로세스와 안정적으로 통신해야 하는 Java 애플리케이션에서 자주 사용된다.

현재 코드에서는 각 요청마다 ffmpeg 프로세스를 실행하고 그 입출력을 처리하기 위해 스레드를 생성한다. 만약 많은 사용자가 동시에 비디오 변환을 요청한다면, 서버의 스레드 풀이 고갈되어 다른 API 요청을 처리할 수 없는 상황이 발생할 수 있다.

블로킹 방식(Blocking)

블로킹은 특정 작업이 완료될 때까지 코드의 실행이 멈추는 상태를 말한다. 블로킹 방식의 I/O(입출력) 작업에서는:

  • 스레드가 I/O 작업(파일 읽기, 네트워크 통신 등)을 요청하면
  • 해당 작업이 완료될 때까지 스레드가 대기 상태로 들어가며
  • 이 동안 다른 작업을 수행할 수 없다.

예를 들어, InputStream.read()는 데이터를 읽을 수 있을 때까지 스레드가 대기한다.

교착상태(Deadlock)

교착상태는 두 개 이상의 프로세스나 스레드가 서로가 점유한 자원을 기다리며 무한정 대기하는 상황을 말한다. 교착상태의 조건:

  1. 상호 배제(Mutual Exclusion): 자원은 한 번에 하나의 프로세스만 사용 가능
  2. 점유와 대기(Hold and Wait): 자원을 보유한 상태에서 다른 자원을 요청
  3. 비선점(No Preemption): 다른 프로세스가 사용 중인 자원을 강제로 빼앗을 수 없음
  4. 순환 대기(Circular Wait): 프로세스 간에 순환적으로 자원을 기다리는 구조

예시: 프로세스 A가 자원 X를 점유하고 자원 Y를 기다리는 동시에, 프로세스 B가 자원 Y를 점유하고 자원 X를 기다리는 상황

ffmpeg 사용 시의 교착상태 예:

  • 메인 스레드가 ffmpeg의 출력(stdout)을 읽기 위해 대기하는데
  • ffmpeg은 입력(stdin)이 모두 전달되기 전까지 출력을 생성하지 않고
  • 입력 데이터를 보내는 코드가 실행되지 않는 상황

이런 문제를 해결하기 위해 앞서 본 코드에서는 별도의 스레드를 사용해 입력과 출력, 에러 스트림을 독립적으로 처리하고 있다.

profile
매일 1퍼센트씩 나아지기 ୧(﹒︠ ̫ ̫̊ ̫﹒︡)୨

0개의 댓글