개발자의 오르막

Java 로 외부 프로세스 실행하기, FFmpeg 활용 (Process Builder) 본문

Java

Java 로 외부 프로세스 실행하기, FFmpeg 활용 (Process Builder)

계단 2022. 8. 18. 09:23

Keyword


FFmpeg , Process , JDK7 , Library , Framework

 

https://www.sedaily.com/NewsView/1HPVE3GABU

 

 

Overview


우리는 개발을 할 때 서비스의 모든 것을 원천 개발하지 않습니다. 코드의 특성 상 재사용이 가능하기 때문에 서비스들간의 공통적인 기능들은 오픈소스로 제공되고 있으며, 이는 많은 개발자들의 시간을 단축시켜줍니다. 여러 오픈소스들은 GitHub 에서 수많은 개발자들의 이슈제기와 해결을 통해 리팩토링과 버전관리가 되고 있습니다.

 

https://mvnrepository.com/

 

위의 사이트는 MVNRepository 로 자바 개발을 할 때 필요한 라이브러리를 찾을 수 있는 사이트입니다. 각 라이브별 버전관리가 되어있으며, Maven, Gradle, Jar 등의 형태로 해당 라이브러리를 제공하고 있습니다. 우리는 dependency 에서 라이브러리 종속성을 선언하는 것만으로 자유롭게 활용할 수 있습니다.

 

 

라이브러리 vs 프레임워크


 

https://cocoon1787.tistory.com/745

 

 

프레임워크란 원하는 기능 구현에 집중하여 개발할 수 있도록 일정한 형태와 필요한 기능을 갖추고 있는 골격, 뼈대를 의미합니다. 가령, Spring Framework 로 개발을 진행할 때에는 우리가 만든 어플리케이션에서 Spring Framework 를 호출 하는 것이 아닌, Spring Framework 안에서 우리가 코드를 작성합니다. 라이브러리의 경우에는 우리가 어플리케이션을 만들고, 해당 기능이 필요할 때, 라이브러리의 메서드를 호출하여 기능을 제공하는 방식입니다.

 

이처럼 제어의 주도권이 개발자가 만든 어플리케이션에 있지 않고, 프레임워크에 있어 우리가 프레임워크 안에서 개발을 진행 하는 것을 제어의 역전 이라는 개념이라고 합니다.

 

 

 

Linux Native Library


위의 오픈소스 라이브러리처럼 리눅스에도 수많은 라이브러리가 존재합니다.

sudo apt-get install {packageName} 을 통해 우리는 수많은 라이브러리를 다운로드 받고 사용합니다. 어디까지나 우리의 서비스는 서버에서 하나의 Process 로 동작하기 때문에 같은 서버에 설치된 라이브러리는 활용이 가능합니다. 이번 포스팅에서는 FFmpeg 라는 멀티 미디어 프레임워크의 라이브러리를 사용해볼 것입니다.

 

 

 

 

FFmpeg


 

FFmpeg 는 비디오, 오디오, 이미지를 쉽게 인코딩(Encoding), 디코딩(Decoding), 먹싱(Muxing), 디먹싱(Demuxing) 할 수 있도록 도움을 주는 오픈소스 멀티미디어 프레임워크입니다.

 

  • 코딩(Coding) : 컴퓨터가 인지할 수 있도록 이진코드(binary code) 를 입력하는 것을 말한다.
  • 인코딩(Encoding) : 특정한 데이터포맷으로 데이터를 가공하는 것을 말한다. 원본 영상 데이터로부터 특정
  • 디코딩(Decoding) : 특정 동영상 코덱을 사용하여 원본 영상 데이터를 얻는 것
  • 먹싱(Muxing) : multiplexing 의 약어로 데이터를 섞는 것이다. 데이터를 종류별로 따로 따로 구분해서 보내는 비효율을 없애기 위해 사용된다.
  • 디먹싱(Demuxing) : demultiplexing 의 약어로 먹싱도니 데이터를 다시 종류별로 뽑아내는 것을 말한다.

 

 

FFmpeg 제공 라이브러리


  • ffmpeg : 미디어 포맷 변환 도구
  • ffserver : 라이브 방송을 하는 멀티미디어 스트리밍 서버
  • libavcodec : 오디오/비디오 코덱 라이브러리
  • libavformat : 멀티미디어 컨테이너의 디먹서/먹서 라이브러리
  • libavdevice : 입출력 장치 제어 라이브러리
  • libavfilter : 미디어 필터 라이브러리
  • libswscale : 이미지 처리 라이브러리
  • libswresample : 오디오 처리 라이브러리

 

 

Ubuntu FFmpeg 설치 명령어


sudo apt-get install ffmpeg

 

FFmpeg 파일 변환 명령어


ffmpeg -i input.mp4 -c copy out.mkv
  • i 는 대상 파일을 지정합니다.
  • -c copy 는 코덱을 진행하지 않고, 파일 변환만을 수행하는 옵션입니다.

우리는 FFmpeg 를 사용하여 리눅스에서 input.mp4 파일을 out.mkv 파일로 변환 가능합니다.

 

 

 

요구사항


특정 고객의 경우 FFmepg 를 통해 파일을 변환해주세요.

 

기존 어플리케이션은 멀티 스레드 환경에서 타겟 디렉터리에 파일이 업로드되면, 영상 전처리 과정을 진행 후 트랜스코더 작업 디렉터리로 이동시켜주는 Watcher 미들웨어 입니다.

기존 어플리케이션은 하나의 프로세스로서 특정 주기마다 스레드를 통해 작업을 진행하고 있습니다. 우리는 이 어플리케이션에서 특정 고객의 경우 FFmpeg 명령을 수행하는 자식 프로세스를 생성하여 일을 하게 한 후, 자식 스레드의 종료 시그널을 받은 이후 작업을 진행하도록 로직을 구성합니다.

 

 

 

개발내용


저는 객체가 해야할 일을 먼저 정리해보았습니다.

  • FileExtTransfer 라는 객체가 어떤 정보를 받고 생성될 것인지
  • 해당 고객, 변환하고자 하는 확장자에 해당하는지 Validation
  • ffmpeg -i input.mp4 -c copy out.mkv 을 통해 파일 변환 하는 SubProcess 생성 후 종료
  • mv originfile convertfile 을 통해 본래 파일 이름, 확장자로 Name 변경하는 SubProcess 생성 후 종료

 

 

public class FileExtTransfer {

    private String customerId;
    private String fileExt;
    private TransferFileExtConf[] conf;
    private Process childProcess = null;

    public FileExtTransfer(TransferFileExtConf[] conf, String customerId, String fileExt) {
        this.conf = conf;
        this.customerId = customerId;
        this.fileExt = fileExt;
    }
}
  • 필드 및 생성자 선언
    • customerId : 고객 분류 pk
    • fileExt : 현재 작업 대상 파일의 확장자
    • conf : config 파일에 저장된 객체 배열

 

public void run(String physicalPath) {

        if (!hasText(this.fileExt)) {
            return;
        }

        for (TransferFileExtConf data : this.conf) {
            if (!this.customerId.equals(data.getCustomerId())) {
                continue;
            }

            for (TransferFileExtConf.TransferTargetExt ext : data.getTransferTargetExt()) {
                if (!ext.from.equals(this.fileExt)) {
                    continue;
                }

                String convertPath = physicalPath.replace(ext.from, ext.to);
                executeTransfer(physicalPath, convertPath);
                replaceOriginFile(physicalPath, convertPath);
                return;
            }
        }
    }
  • run 함수를 타기 전에 먼저 isTarget 함수를 통해 실행여부를 결정함 (생략)
  • 고객키가 일치하며, 바꾸고자 하는 확장자가 일치할 때 convert 하고자 하는 대상 확장자로 convertPath 를 생성
  • executeTransfer, replaceOriginFile 함수를 각각 실행

 

private void executeTransfer(String physicalPath, String convertPath) {

        ArrayList<String> array = new ArrayList<String>();
        array.add("ffmpeg");
        array.add("-i");
        array.add(physicalPath);
        array.add("-c");
        array.add("copy");
        array.add(convertPath);

        String[] cmdArray = new String[array.size()];
        array.toArray(cmdArray);

        executeChildProcess(cmdArray);
    }

    private void replaceOriginFile(String physicalPath, String convertPath) {
        ArrayList<String> array = new ArrayList<String>();
        array.add("mv");
        array.add(convertPath);
        array.add(physicalPath);

        String[] cmdArray = new String[array.size()];
        array.toArray(cmdArray);

        executeChildProcess(cmdArray);
    }
  • run 에서 실행할 Command 를 조합하는 함수

 

 

 

private void executeChildProcess(String[] cmdArray) {
        ProcessBuilder pb = new ProcessBuilder(cmdArray);

        try {
            childProcess = pb.start();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        InputHandler inputHandler = new InputHandler(childProcess.getInputStream(), "Output Stream");
        inputHandler.start();
        InputHandler errorHandler = new InputHandler(childProcess.getErrorStream(), "Error Stream");
        errorHandler.start();

        try {
            childProcess.waitFor();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        int exitCode = childProcess.exitValue();
        log.debug("exitCode: "+ exitCode);
        if (exitCode != 0) {
            log.debug("exit code is not 0 [ "+ exitCode + "]");
            throw new RuntimeException("exit code is not 0 [ "+ exitCode + "]");
        }

        destroy();
    }

    class InputHandler extends Thread {

        private static final int BUFFER_SIZE = 1024;
        private InputStream input_;
        private int mLines = 0;

        InputHandler(InputStream input, String name) {
            super(name);
            input_ = input;
        }

        public void run() {

            BufferedReader reader = null;
            try
            {
                reader = new BufferedReader(new InputStreamReader(input_), BUFFER_SIZE);
                String line;

                while ((line = reader.readLine()) != null)
                {
                    onNewline(line);
                    mLines ++;

                    /**
                     * 혹시 MAX_VALUE 보다 크다면 0으로 초기화
                     */
                    if(mLines == Integer.MAX_VALUE) mLines = 0;
                }

            }
            catch (IOException e)
            {
            }
            finally
            {
                if (reader != null)
                    try { reader.close(); } catch (IOException e) {}

                stopCatter();
            }
        }

        private void stopCatter()
        {
            if(childProcess != null)
            {
                childProcess.destroy();
            }
        }

        private void onNewline(String line)
        {
            log.debug(this.getName() + " - " + line);
        }

    }

    private void destroy()
    {
        if(childProcess != null)
        {
            childProcess.destroy();
        }
    }
  • Java 의 ProcessBuilder 클래스를 사용하여 SubProcess 를 생성 및 실행
  • SubProcess 의 InputStream, ErrorStream 출력
  • childProcess.waitFor(); 를 통해 해당 SubProcess 가 종료되기 까지 대기
  • Process 종료시 destroy() 를 통해 SubProcess 제거

 

 

 

Java ProcessBuilder Class


위의 객체는 결국, 자바 런타임 환경에서 특정 명령어를 수행할 하위 프로세스를 생성하고 실행, 종료 시키는 역할을 합니다. 기존 실행되고 있는 Watcher 미들웨어가 하나의 프로세스로서 실행되고, 특정 조건에 해당하는 파일이 업로드가 되면 하위 프로세스를 생성하여 작업을 진행하는 것입니다. 위의 개발을 맞게 개발했는지 ProcessBuilder Class 를 통해 검증이 필요하였습니다.

start()

  • Process 객체를 활용해 운영체제 프로세스를 만들며, Process 객체 안의 프로세스를 실행
  • 이 때 대부분의 오류검사를 모두 실행
  • Java 1.5 이상 부터는 Process 클래스로 프로세스를 만드는 것보다 ProcessBuilder 의 start() 를 통해 만드는 것을 권장하고 있습니다.
  • 이 메서드로 시작하면 객체에 등록된 프로세스의 경우만 영향을 끼치지, 이전 프로세스나 Java 프로세스에는 영향이 없음

waitFor()

  • 하위 프로세스가 종료되거나 시간이 초과되지 않는 한 현재 실행 프로세스 스레드를 차단 대기 상태로 둠

exitValue()

  • 하위 프로세스가 성공적으로 종료 된 경우 프로세스의 종료 값이 발생함
  • 하위 프로세스가 종료되지 않은 경우 illegalThreadStateException 을 throw 함

destroy()

  • 프로세스 객체 내에 있는 서브 프로세스를 강제 종료 시킴

redirectErrorStream(boolean)

  • 명령 실행 중 오류 사항을 따로 분류해서 사용자에게 출력할지 결정
  • default 값은 false
  • true 면 getErrorStream(), getOutputStream() 두 가지 스트림으로 나누어서 출력됨
  • false 면 getOutPutStream() 하나의 스트림으로 오류 내용과 정상 출력 내용이 같이 출력됨

 

 

 

주의할 점


  • process.waitFor() 이 결과 값을 리턴하지 않고 행이 걸려서 무한대기 하는 상황이 발생하는 이슈
    • 프로세스가 입력 스트림이나 출력스트림을 사용할 때 실패가 발생하면 교착상태에 빠지는 경우가 발생
    • JDK 1.5 에서 발생하던 이슈
    • JDK 1.7 이상의 경우에는 위와 같은 이슈가 해결되어 반영되어 있음
💡 Linux에서는 JDK 7을, Solaris나 BSD 등에서는 JDK 8 이상을 쓰지 않는다면 fork-exec 시스템 콜로 일시적으로 과도한 메모리를 운영체제에 할당하는 문제를 겪을 수도 있다. JDK 7에서 ProcessBuilder 클래스의 redirectOutput() 메서드 추가나 JDK 8의 posix_spawn 활용 등 JDK에서 이런 문제를 쉽게 해결해주는 개선이 꾸준히 이어지고 있기에 다행이다.
https://d2.naver.com/helloworld/1113548

 

  • exitValue() 를 호출하는 것만으로도 하위 프로세스가 정상적 종료가 되지 않는 경우에 대한 예외처리가 반영되어 있음

 

 

결론


  • 현재 프로젝트는 JDK1.7 이기때문에 SubProcess 의 교착상태를 걱정하지 않아도 된다.
  • exitValue() 호출로 하위 프로세스 정상적 종료되지 않는 경우에 대한 예외처리가 반영되어 있다.

 

Reference


Comments