본문 바로가기

카테고리 없음

프로세스 간 스트림 연결

개발을 하다보면 프로그램 내의 스레드가 아닌 외부 프로세스를 호출해야하는 경우가 간혹가다 존재한다. 이 때 입출력을 어떻게 받고 에러디버깅은 어떻게 하는지 간단하게 소개하고자 한다.

 

컴퓨터 프로그램에서 프로세스(process)는 실행 중인 프로그램을 의미하며, 프로세스는 보통 표준 입력(Standard Input), 표준 출력(Standard Output), 표준 오류(Standard Error)라는 세 가지 기본적인 스트림을 사용한다.

 

표준 입력 (stdin): 프로그램이 외부에서 데이터를 입력받는 통로다. 보통 키보드 입력이나 다른 프로그램의 출력이 표준 입력으로 들어올 수 있다.

표준 출력 (stdout): 프로그램이 실행 결과를 내보내는 통로입니다. 보통 콘솔 창에 출력되는 정보가 여기에 해당한다.

표준 오류 (stderr): 에러 메시지와 같은 오류 정보를 내보내는 통로다. 표준 출력과 분리되어 있기 때문에, 에러 메시지는 다른 출력과 혼동되지 않도록 따로 처리할 수 있다.

 

스트림 연결

 

프로세스의 스트림 연결은 이러한 표준 입력, 출력, 오류 스트림을 다른 프로세스와 연결하여 데이터를 주고받는 방법을 말한다. 이를 통해 프로세스 간에 데이터를 주고받으며 작업을 수행할 수 있다.

 

파이프 (Pipes)

 

파이프는 한 프로세스의 출력 스트림을 다른 프로세스의 입력 스트림으로 연결하는 방식이다. 이 방법을 통해 두 프로세스가 데이터를 주고받을 수 있다.

 

예를 들어, 다음과 같은 명령어를 생각해볼 수 있다.

find . -name '*.java' | xargs cat | wc -l

 

 

위 명령어는 현재 디렉토리에 존재하는 모든 자바 파일의 총 라인수를 출력하는 명령어다.

여기에는 총 3개의 프로세스가 존재하며 프로세스 간 파이프 연결을 통해 입출력을 주고 받았다. find 명령어의 출력을 cat 명령어의 입력에 전달 하고 cat 명령어의 출력을 wc -l 명령어에 전달하여 최종적으로 wc -l 명령어의 결과를 콘솔창에서 확인 가능하다.

 

리다이렉션 (Redirection)

 

리다이렉션은 프로세스의 스트림을 파일이나 다른 출력 대상으로 재지정하는 방식이다. 이를 통해 출력이나 오류 메시지를 파일로 저장하거나, 파일의 내용을 입력으로 사용할 수 있다.

예시로 아래와 같은 셸 커맨드 명령어처럼 사용이 가능하다.

# 이런식이면 덮어 쓰기
ls > output.txt

# 이런식이면 이어 쓰기
ls >> output.txt

# 표준 입력을 파일로부터 가져오기. 명령어의 입력으로 파일의 내용을 사용
sort < input.txt 

# 표준 오류를 덮어쓰기(이어쓰기는 2>>)
ls non_existing_file 2> error.txt

# 표준 오류, 출력 모두 리다이렉션
ls existing_file non_existing_file &> output_and_error.txt

# 표준 출력을 output_and_error.txt에 리다이렉션 하고 오류도 출력과 같은 파일에 리다이렉션 (사실상 위 명령어랑 기능적으로 동일함)
ls existing_file non_existing_file > output_and_error.txt 2>&1

# 오류 무시하기 (/dev/null은 unix 계열 시스템에서 블랙홀이라고 보면 됨)
ls non_existing_file > output.txt 2> /dev/null

#  “here document”라고 불리며, 여러 줄의 입력을 명령어에 전달함
cat << EOF
This is a multiline
text block.
EOF

# 아래와 같이 응용도 가능
cat << EOF > text.txt
This is a multiline
text block.
EOF

 

자바 프로세스에서의 자식 프로세스 호출 (예시로는 노드 프로세스 호출함)

간단하게 셸에서 사용하는 방법을 살펴봤다. 그렇다면 자바 프로세스에서는 다른 프로세스를 어떻게 호출하고 표준 입출력을 어떻게 주고받을 수 있을까?

 

기본적으로 앞서 사용한 셸 커맨드와 개념은 동일하다. 코드는 아래와 같다.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;

public class ProcessBuilderExample {

    private static final Logger logger = LoggerFactory.getLogger(ProcessBuilderExample.class);

    public static void main(String[] args) {
        try {
            // 스크립트 파일의 경로
            String scriptPath = "./plus.js";

            // ProcessBuilder를 사용해 외부 프로세스 호출
            List<String> command = List.of(
                    "node", scriptPath,
                    "--input=5",
                    "--input=10"
            );
            ProcessBuilder processBuilder = new ProcessBuilder();
            processBuilder.redirectInput(ProcessBuilder.Redirect.INHERIT)
                    .redirectOutput(ProcessBuilder.Redirect.PIPE)
                    .redirectError(ProcessBuilder.Redirect.PIPE);


            // 프로세스 시작
            Process process = processBuilder
                    .command(command)
                    .start();

            // 프로세스의 표준 출력 로그를 읽음
            try (BufferedReader outputReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
                 BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {

                // 출력 로그 읽기
                String line;
                while ((line = outputReader.readLine()) != null) {
                    logger.info(line);
                }
                // 에러 로그 읽기
                while ((line = errorReader.readLine()) != null) {
                    logger.error(line);
                }
            }

            // 프로세스 종료 코드 확인
            int exitCode = process.waitFor();
            logger.info("프로세스 종료 코드: {}", exitCode);
        } catch (Exception e) {
            // 스크립트 오류 나면 여기서 에러 스트림 받아서 로그 찍기
        }
    }
}

 

스크립트는 간단히 --input 문자열을 파싱해서 계속 더하기 연산을 하는 스크립트다.

function parseArgv() {
    const args = {};
    process.argv.forEach(v => {
        if (v.startsWith('--input=')) {
            const value = parseInt(v.split('=')[1], 10);
            if (!isNaN(value)) {
                args.input = (args.input || 0) + value;
            }
        }
    });
    return args;
}

// 명령줄 인수 파싱 및 합계 계산
const args = parseArgv();

// 결과를 출력
console.log(`표준 출력 스트림 Result: ${args.input || 0}`);
console.error(`에러 스트림 Result: ${args.input || 0}`);

 

위 프로세스 호출 코드는 아래의 셸 명령어와 동일한 기능을 수행한다.

node ./plus.js --input=5 \
--input=10

 

이 때 자바 프로세스에서 에러 스트림과 출력 스트림을 받기 위해 리다이렉션 모드를 PIPE 로 설정한 것이다. 다른 모드도 있는데 INHERIT이면 부모 프로세스의 표준 입출력을 상속받는다는 의미(같은 콘솔 쓰겠다는 뜻)이고 PIPE 로 설정한다면 부모프로세스 내부로 직접 받는다는 의미이다.

헷갈릴수도 있는 부분이 자식 프로세스의 결과 스트림은 부모 프로세스의 인풋 스트림에 연결되기 때문에 process.getInputStream()을 해야 자식 프로세스의 결과 스트림을 얻을 수 있다.

 

설명하다보니 글이 길어져 어떤 느낌인지 감이 안올 수 있으니 아래 사진을 참고하면 이해가 금방 될 것이다.

 

먼저 아래는 표준출력, 표준 에러를 PIPE로 설정했을 때의 결과 로그다.

 

 

process.getInputStream()에 노드 프로세스의 표준 출력이 잘 전달되고 있는 것을 확인할 수 있다.

 

다음으로 아래는 표준 출력, 표준 에러를 INHERIT으로 설정했을 때의 결과이다.

 

보는바와 같이 process.getInputStream(), errorStream() 에 노드 프로세스의 표준 출력, 표준 에러를 전달받지 못하고 콘솔에만 보이는 것을 알 수 있다. 보통 배포했을 때 로그는 콘솔로 보지 않고 로그 파일이나 다른 인프라를 써서 보기 때문에  INHERIT 보다는 파이프로 전달받아서 직접 sl4fj의 로그를 찍는 것이 나을 것이다.

 

이런식으로 api 호출이 아닌 프로세스 호출로 외부 프로세스를 호출할 수 있다. 이를 응용하면 aws sdk 에서 제공하지 않는 기능을 aws cli를 통해 호출이 가능할수도 있고 자바에서 구현하기 쉽지 않은 코드를 자바스크립트, 파이썬 스크립트, 셸 스크립트 등을 이용해서 구현할수도 있다. (물론 프로세스 간에는 컨텍스트 스위칭 비용이 좀 크기도 하고 혹시 모를 좀비 프로세스나 고아 프로세스 문제를 발생할 가능성도 생각해보긴 해야 하지만..)