[학습기록] Java 개발자가 알아야 할 서비스 거부(DoS) 취약점: (5) 안전한 예외 처리와 서비스 안정성
이전 포스팅에서는 정수 오버플로로 인해 자원 사용량 검증이 우회될 수 있다는 사실과 이를 예방하기 위한 방법을 살펴봤습니다.
애플리케이션에서는 잘못된 입력값이나 프로그램의 오류, 외부 시스템의 장애 등 다양한 이유로 예외가 발생할 수 있습니다. 이러한 예외를 적절하게 처리하지 못하면 특정 요청의 실패가 다른 요청에도 영향을 미칠 수 있습니다. 경우에 따라서는 서비스 전체의 장애로 이어질 수도 있습니다.
그렇다고 모든 예외를 발생한 위치에서 처리하는 것이 올바른 방법은 아닙니다. 상황에 따라 예외를 직접 처리해야 할 수도 있고, 상위 호출자로 전파하여 적절한 수준에서 처리하도록 해야 할 수도 있습니다.
이번 포스팅에서는 Oracle Secure Coding Guidelines for Java SE의 네 번째 DoS 보안 지침인 DOS-4: Implement Robust Error/Exceptions handling for services를 통해 예외 처리와 예외 전파의 차이를 알아보고, 예상하지 못한 오류 상황에서도 서비스의 안정성을 유지하기 위한 방법을 살펴보려고 합니다.
1. DOS-4: Implement Robust Error/Exceptions handling for services
Oracle의 네 번째 DoS 보안 지침인 DOS-4: Implement Robust Error/Exceptions handling for services는 애플리케이션에서 발생하는 오류와 예외를 적절하게 처리하여 서비스의 안정성을 유지해야 한다는 내용을 담고 있습니다.
물론 예외가 발생하지 않도록 예방하는 것이 중요하지만, 모든 예외를 사전에 방지하는 것은 현실적으로 어렵습니다. 따라서 예외는 언제든 발생할 수 있다는 전제에서 애플리케이션을 설계해야 합니다.
마침 오늘 팀 세미나에서도 신입 개발자가 작성한 예제 코드에 예외 상황에 대한 고려가 부족한 부분이 있어 이를 짚고 넘어갔습니다.
우리가 개발하고 운영하는 시스템은 불특정 다수의 사용자에게 제공되는 B2C 서비스입니다. 그런 만큼 개발자가 미처 예상하지 못한 다양한 예외 상황이 발생하기도 합니다. 따라서 예제나 테스트 코드를 작성할 때부터 정상적인 상황뿐 아니라 예외가 발생했을 때 어떻게 동작해야 하는지도 함께 고민하는 습관이 중요하다는 점을 강조하고 싶었습니다.
Oracle에서는 예외가 발생했을 때 호출자가 선택할 수 있는 방법을 크게 두 가지로 구분합니다.
- 예외 처리(Handling): 발생한 예외를 잡아서 필요한 복구 작업이나 대체 처리를 수행하고, 해당 위치에서 오류 상황을 마무리하는 방법
- 예외 전파(Propagation): 현재 위치에서 예외를 해결하지 않고 상위 호출자로 전달하여, 해당 호출자가 처리 여부를 결정하도록 하는 방법
여기서 중요한 점은 모든 예외를 발생한 위치에서 처리할 필요는 없다는 것입니다. 현재 메서드에서 복구할 수 없는 오류라면 상위 호출자로 전파하는 것이 적절할 수 있습니다.
따라서 예외를 어디에서 처리하고 어디까지 전파할 것인지 결정하는 것이 서비스 안정성을 유지하는 데 중요한 요소입니다.
2. 예외 처리와 예외 전파의 차이
앞서 살펴본 것처럼 Java에서는 예외가 발생했을 때 현재 메서드에서 직접 처리하거나 상위 호출자로 전파할 수 있습니다.
Oracle 가이드라인에서는 일반적인 애플리케이션 코드나 라이브러리에서 발생하는 대부분의 예외를 상위 호출자로 전파하는 것이 적절할 수 있다고 설명합니다.
예외가 발생한 위치에서 모든 문제를 해결할 수 있는 것은 아니기 때문입니다.
두 방식의 차이를 간단한 Java 예제로 살펴보겠습니다.
2.1. 예외를 직접 처리하는 경우
먼저 파일을 읽는 과정에서 발생한 예외를 현재 메서드에서 처리하는 예제입니다.
public String readMessage(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
return "기본 메시지";
}
}
파일 읽기에 실패하면 IOException이 발생하지만, catch 블록에서 예외를 처리하고 기본 메시지를 반환합니다.
호출자는 파일 읽기에 실패했다는 사실을 직접 알지 못하고 반환된 문자열을 사용하게 됩니다.
이처럼 예외가 발생하더라도 현재 메서드에서 복구하거나 대체할 수 있다면 직접 처리하는 것이 적절할 수 있습니다.
다만 예외를 무조건 잡아서 무시하는 것은 주의해야 합니다. 예를 들어 반드시 읽어야 하는 설정 파일을 읽지 못했는데도 기본값을 반환하면 오류를 감춘 채 애플리케이션이 잘못된 상태로 동작할 수 있습니다.
따라서 예외를 직접 처리할 때는 단순히 예외를 발생시키지 않는 것이 아니라, 오류 이후에도 의도한 동작을 보장할 수 있는지 판단해야 합니다.
2.2. 예외를 상위 호출자로 전파하는 경우
이번에는 파일 읽기 기능에서 예외를 직접 처리하지 않고 상위 호출자로 전파하는 예제입니다.
public String readMessage(Path path) throws IOException {
return Files.readString(path);
}
이 코드에서는 파일 읽기에 실패하면 발생한 IOException이 호출자에게 전달됩니다. 호출자는 전달받은 예외를 처리하거나 다시 상위 호출자로 전파할 수 있습니다.
예외를 전파할 때는 다음과 같이 다른 예외로 감싸서 전달하는 방법도 있습니다.
public String readMessage(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
throw new IllegalStateException("메시지 조회 실패", e);
}
}
위 코드에서는 IOException을 IllegalStateException으로 감싸서 전달합니다. 이처럼 예외를 다른 예외로 감싸서 전달하면 하위 메서드에서 발생한 기술적인 오류를 상위 계층에서 이해할 수 있는 의미로 변환하면서도 원래 오류의 원인을 유지할 수 있습니다.
따라서 현재 메서드에서 오류를 복구할 수 없다면, 예외를 상위 호출자로 전파하여 적절한 위치에서 처리하도록 하는 것이 좋습니다. 하지만 모든 예외를 다른 예외로 변환할 필요는 없으며, 기존 예외가 상위 호출자에게 충분한 의미를 전달한다면 그대로 전파해도 됩니다.
또한 예외를 상위로 전파하더라도 파일이나 잠금과 같이 직접 반환해야 하는 자원이 있다면 반드시 정리해야 합니다. 이 부분은 앞서 DOS-2 지침에서 살펴본 내용과도 연결됩니다.
2.3. 예외는 어디에서 처리해야 할까?
그렇다면 예외는 어느 위치까지 전파하는 것이 적절할까요?
일반적인 애플리케이션 코드에서는 오류를 복구할 수 없다면 예외를 상위로 전파하는 것이 자연스럽습니다. 하지만 여러 요청을 지속해서 처리해야 하는 서버 애플리케이션에서는 조금 다른 관점이 필요합니다.
예를 들어 특정 사용자의 요청을 처리하는 과정에서 예외가 발생했다고 다른 사용자의 요청까지 중단해서는 안 됩니다. 따라서 개별 요청의 실패를 적절하게 처리하고 이후 요청을 계속 처리할 수 있도록 해야 합니다.
중요한 것은 모든 메서드에서 예외를 잡는 것이 아니라, 예외를 처리할 수 있는 적절한 위치와 책임을 정하는 것입니다.
3. 개별 작업의 실패가 서비스 전체에 영향을 미치지 않도록 하는 방법
앞서 살펴본 것처럼 일반적인 애플리케이션 코드에서는 예외를 상위 호출자로 전파하는 것이 적절할 수 있습니다.
하지만 서버와 같이 장시간 실행되면서 여러 요청이나 작업을 반복적으로 처리하는 애플리케이션에서는 예외가 어디까지 전파되는지 주의해야 합니다.
Oracle 가이드라인에서는 요청(Request), 이벤트(Event), 작업(Task) 등을 관리하는 최상위 실행 영역에서 예외가 발생했을 때 어떻게 대응할 것인지 정책을 마련해야 한다고 설명합니다.
3.1. 예외로 인해 작업 처리가 중단되는 경우
예를 들어 큐에 등록된 작업을 순서대로 처리하는 백그라운드 스레드가 있다고 가정해 보겠습니다.
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
Runnable task;
try {
task = queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
task.run();
}
}
위 코드는 queue.take()로 작업을 가져온 후 task.run()을 실행하는 구조입니다.
그런데 작업 실행 중 RuntimeException이 발생하면 어떻게 될까요?
예외를 처리하는 코드가 없기 때문에 해당 예외는 run() 메서드 밖으로 전파됩니다. 그 결과 작업을 처리하던 스레드가 종료되고, 해당 스레드가 담당하던 이후 작업들은 더 이상 처리되지 않을 수 있습니다.
즉, 개별 작업에서 발생한 예외가 작업 처리 스레드의 종료로 이어져 서비스 가용성에 영향을 미칠 수 있는 것입니다.
3.2. 작업 단위로 예외를 처리하는 경우
이러한 문제를 예방하려면 개별 작업에서 발생한 예외가 전체 작업 처리 루프를 중단시키지 않도록 해야 합니다.
앞선 예제를 다음과 같이 변경할 수 있습니다.
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
Runnable task;
try {
task = queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
try {
task.run();
} catch (RuntimeException e) {
logger.error("작업 처리 중 예외 발생", e);
}
}
}
위 코드에서는 task.run()에서 RuntimeException이 발생하더라도 catch 블록에서 오류를 기록한 후 다음 작업을 처리합니다.
따라서 특정 작업이 실패하더라도 작업 처리 스레드가 종료되지 않고 이후 작업을 계속 처리할 수 있습니다. 여기서 주목할 점은 개별 작업을 실행하는 부분에 예외 처리 로직을 두었다는 것입니다.
반면 queue.take()에서 발생하는 InterruptedException은 작업 실패와 구분하여 처리합니다. 인터럽트는 스레드의 종료 요청 등에 사용될 수 있으므로 인터럽트 상태를 복원하고 반복문을 종료하도록 했습니다.
다만 예외를 처리했다고 해서 실패한 작업이 정상적으로 완료된 것은 아닙니다. 실제 서비스에서는 작업 실패 기록과 함께 필요에 따라 재시도, 후속 처리 취소, 자원 정리 등의 정책도 마련해야 합니다.
3.3. 모든 예외를 무조건 처리해야 할까?
Oracle 가이드라인에서는 요청이나 작업을 관리하는 최상위 실행 영역에서 Throwable까지 포함한 광범위한 오류를 처리하는 정책을 고려할 수 있다고 설명합니다.
하지만 이것이 애플리케이션의 모든 메서드에서 Throwable을 잡아 무시하라는 의미는 아닙니다. Java의 Throwable에는 일반적인 예외인 Exception뿐 아니라 OutOfMemoryError와 같은 Error도 포함됩니다.
일부 오류는 현재 작업만 실패시키는 수준에 그치지 않고, 애플리케이션이 안전하게 동작하기 어려운 상태를 만들 수도 있습니다.
따라서 오류의 종류와 시스템 상태를 고려하여 이후 작업을 계속 수행할 수 있는지 판단해야 합니다. 안전한 복구가 어렵다면 프로세스를 종료하고 재시작하는 방식이 더 적절할 수도 있습니다.
결국 DOS-4 지침에서 중요한 것은 예외를 무조건 잡거나 전파하는 것이 아니라, 개별 작업의 실패를 격리하고 시스템이 안전하게 동작할 수 있도록 예외 처리 정책을 설계하는 것입니다.
참고
4. 이해하는 것과 실제로 적용하는 것은 다르다
이번 DOS-4 지침에서 살펴본 내용은 Java 프로그래밍 경험이 있다면 누구나 어렵지 않게 이해할 수 있는 내용이라고 생각합니다.
예외를 처리하거나 상위 호출자로 전파하는 것은 Java의 기본적인 기능이고, 저 역시 이러한 개념이 낯설지는 않습니다. 하지만 개념을 이해하는 것과 실제 서비스에 적절하게 적용하는 것은 다른 문제인 것 같습니다.
실제 서비스를 개발할 때는 상황에 따라 현재 메서드에서 처리해야 할 예외와 상위 호출자로 전파해야 할 예외를 구분할 수 있어야 합니다. 또한 예외가 발생했을 때 작업을 재시도할지, 실패로 처리할지, 이후 작업을 계속 진행할지도 판단해야 합니다.
이러한 판단은 단순히 Java 문법을 알고 있다고 해서 쉽게 내릴 수 있는 것이 아닙니다. 서비스의 특성과 업무 처리 흐름, 장애가 미칠 영향까지 함께 고려해야 하기 때문입니다.
코드를 작성하는 능력뿐 아니라 다양한 예외 상황을 예측하고 대응 방안을 설계하는 능력도 중요하다는 점을 다시 한번 느낄 수 있었습니다.
5. 다음 포스팅
다음 포스팅에서는 다섯 번째 DoS 보안 지침인 DOS-5: Avoid using user input as hash keys에 대해 알아보겠습니다.
Java에서 사용하는 HashMap과 같은 자료구조는 데이터를 효율적으로 저장하고 조회하기 위해 해시 함수를 사용합니다. 하지만 외부에서 전달받은 입력값을 해시 키로 사용하는 과정에서 해시 충돌이 과도하게 발생하면 처리 성능이 저하될 수 있습니다.
Oracle 가이드라인을 통해 해시 충돌이 어떻게 DoS 취약점으로 이어질 수 있는지 살펴보고, 외부 입력값을 안전하게 처리하기 위한 방법을 알아보려고 합니다.