본문 바로가기
IT/java by 하루 또다시 하루 2026. 6. 8.
반응형

Java에서 외부 명령을 실행할 때 단순히 프로세스를 호출하고 끝내면 예기치 않은 문제에 직면하기 쉽습니다.

특히 SSH를 통한 Docker 명령 실행이나, 쉘(Shell) 스크립트 실행 결과를 바탕으로 다음 비즈니스 로직을 판단해야 하는 자동화 환경에서는 결과를 대충 처리하면 연쇄적인 장애로 이어질 수 있습니다.

Java에서 외부 명령 실행 결과를 안전하게 받으려면 단순히 "명령이 실행됐다"로 판단해서는 안 됩니다. "명령이 성공했는지, 어떤 출력값을 남겼는지, 실패했다면 구체적인 원인이 무엇인지"를 체계적으로 확인해야 합니다. 실무에서 외부 명령을 다룰 때 반드시 적용해야 할 5단계 구조화 방법을 정리해 드립니다.

외부 명령 실행 시 자주 생기는 문제

외부 명령을 단순히 실행(Runtime.getRuntime().exec() 또는 ProcessBuilder)하고 넘어갈 때 주로 다음과 같은 현상들이 발생합니다.

  • 명령은 실행된 것 같은데 결과 문자열이 비어 있음
  • 분명히 실패한 작업인데 Java 코드에서는 성공한 것으로 처리됨
  • 실패 시 원인을 알려주는 에러 메시지를 완전히 놓침
  • 외부 명령이 끝나지 않아 Java 애플리케이션 스레드까지 무한 대기 상태에 빠짐
  • 결과 문자열의 특정 단어만 보고 성공 여부를 잘못 판단함

자동화 프로그램에서는 한 단계의 외부 명령 결과가 다음 단계의 실행 조건이 되기 때문에, 결과 검증이 누락되면 데이터가 꼬이는 등 치명적인 장애를 유발합니다.

왜 이런 문제가 발생할까?

1) 출력(Output)과 성공(Success)을 동일시하는 경우

명령이 화면에 무언가를 출력했다고 해서 그 작업이 성공한 것은 아닙니다. 반대로 아무런 텍스트를 출력하지 않아도 완벽하게 성공한 명령(예: 단순 파일 이동)도 있습니다. 출력된 문자열의 유무로 성공을 판단하면 실패 상황을 걸러낼 수 없습니다.

2) 에러 출력(stderr)을 분리해서 읽지 않는 경우

운영체제의 프로세스는 정상적인 출력은 표준 출력(stdout)으로, 오류나 경고는 표준 에러(stderr)로 분리해서 내보냅니다. Java 프로세스에서 표준 출력만 읽고 표준 에러 스트림을 무시하면, 명령이 실패했을 때 "왜 실패했는지"에 대한 단서를 영영 잃어버리게 됩니다.

외부 명령 실행 결과를 안전하게 받는 5단계 구조

1단계. 결과를 담을 전용 객체(DTO) 만들기

결과를 단순히 String 하나로 뭉뚱그려 받지 마세요. 성공 여부와 출력 내용을 명확히 분리하여 판단할 수 있도록 별도의 클래스를 만드는 것이 좋습니다.

Java
 
public class CommandResult {
    private int exitCode;
    private String output;
    private String error;

    // 생성자, Getter 등
    public boolean isSuccess() {
        return this.exitCode == 0;
    }
}
  • 왜 필요한가: 출력 문자열(output)만 보고 비즈니스 로직을 분기하면 실패 상황을 놓칠 수 있습니다. 이렇게 객체로 구조화하면 호출하는 쪽에서 result.isSuccess()처럼 직관적으로 제어할 수 있습니다.

2단계. stdout과 stderr 분리해서 읽기

Process 객체에서 제공하는 두 개의 스트림을 모두 읽어들여야 합니다.

Java
 
Process process = processBuilder.start();

// 스트림을 읽는 유틸리티 메서드를 사용했다고 가정
String output = readStream(process.getInputStream());
String error = readStream(process.getErrorStream());
  • 왜 필요한가: 실패 원인은 십중팔구 stderr(errorStream)에 기록됩니다. 실패 로그가 자꾸 비어 있다면 이 부분을 제대로 읽고 있는지 점검해야 합니다. (주의: 스트림을 읽을 때는 병목이 생기지 않도록 별도의 스레드를 사용하거나 비동기로 처리하는 것이 안전합니다.)

3단계. Exit Code로 성공 여부 판단하기

프로세스 실행이 끝나면 반드시 종료 코드(Exit Code)를 확인해야 합니다.

Java
 
int exitCode = process.waitFor();
  • 왜 필요한가: 유닉스/리눅스 환경의 모든 명령어는 정상 종료 시 0, 오류 발생 시 0이 아닌 값(1, 2, 127 등)을 반환하는 표준을 따릅니다. 따라서 error 문자열이 비어 있더라도 exitCode != 0이라면 반드시 실패로 간주하고 예외 처리를 해야 합니다.

4단계. 무한 대기를 막는 Timeout 설정

서버 상태나 네트워크 문제로 외부 명령이 영원히 응답하지 않을 수 있습니다.

Java
 
// Java 8 이상 지원
boolean finished = process.waitFor(30, TimeUnit.SECONDS);

if (!finished) {
    process.destroyForcibly(); // 타임아웃 시 강제 종료
    throw new RuntimeException("Command execution timed out");
}
  • 왜 필요한가: timeout이 없으면 해당 명령을 실행한 Java 스레드도 무한정 블로킹 상태에 빠져 결국 애플리케이션 전체의 스레드 고갈 장애로 이어집니다. 지정된 시간이 지나면 프로세스를 강제 종료하고 실패로 처리하는 방어 로직이 필수입니다.

5단계. 로깅은 명령과 결과를 세트로 남기기

추후 디버깅과 장애 추적을 위해 실행한 명령의 원문과 결과를 함께 로그로 남겨야 합니다.

Java
 
log.info("Command executed. exitCode={}, output={}", exitCode, output);
if (exitCode != 0) {
    log.error("Command failed. error={}", error);
}
  • 왜 필요한가: 같은 문제가 재발했을 때 "어떤 파라미터가 들어간 명령"이 "어떤 에러를 뱉었는지" 로그 한 줄로 즉시 파악하기 위함입니다.

실전에서 자주 놓치는 부분

  • 출력 문자열 파싱의 함정: Docker Health Check 결과에 healthy라는 단어가 있다고 무조건 성공으로 치부하는 식의 코딩은 위험합니다. 로그 중간에 우연히 해당 단어가 섞여 있을 수도 있기 때문입니다. 항상 exitCode를 1차 판단 기준으로 삼으세요.
  • 명령어 조립 시 공백과 따옴표 처리: 쉘 명령어를 단일 문자열(String)로 조립해서 넘기면 파일 경로나 변수에 공백이 포함되었을 때 인자(Argument)가 깨지게 됩니다. 가급적 ProcessBuilder를 사용할 때 new ProcessBuilder("ls", "-l", "/my folder") 처럼 인자를 List 형태로 분리해서 넘기는 것이 안전합니다.

마무리

Java에서 외부 명령 실행 결과를 다룰 때는 단순히 결과물만 기다려서는 안 됩니다.

명령의 시작부터 끝까지 stdout, stderr, exit code를 완벽히 분리해서 수집하고, timeout을 설정해 시스템의 안정성을 확보해야 합니다. 마지막으로 실패 시 어떤 환경에서 어떤 결과가 나왔는지 명확한 로그를 남기세요.

자동화 스크립트나 배치 프로그램에서는 아주 작은 외부 명령의 실패가 전체 시스템의 오작동으로 번질 수 있습니다. 처음부터 결과를 객체화하여 단단한 구조를 잡아두시길 권장합니다.

반응형