[학습기록] Java 개발자가 알아야 할 서비스 거부(DoS) 취약점: (3) 시스템 자원 반환의 중요성
이전 포스팅에서는 과도한 시스템 자원 소비로 인해 발생할 수 있는 DoS 취약점과 이를 예방하기 위한 방법을 살펴봤습니다.
시스템 자원을 과도하게 사용하지 않도록 제한하는 것도 중요하지만, 사용한 자원을 적절하게 반환하는 것 역시 중요합니다.
이번 포스팅에서는 Oracle Secure Coding Guidelines for Java SE의 두 번째 DoS 보안 지침인 DOS-2: Release resources in all cases에 대해 학습한 내용을 정리했습니다.
1. DOS-2: Release resources in all cases
Oracle의 두 번째 DoS 보안 지침인 DOS-2: Release resources in all cases는 시스템 자원을 사용한 후에는 정상적인 상황뿐 아니라 예외가 발생하는 상황에서도 자원을 반환해야 한다는 내용을 담고 있습니다.
애플리케이션은 파일이나 네트워크 연결, 데이터베이스 커넥션 등 다양한 시스템 자원을 사용합니다. 이러한 자원은 사용 가능한 수량에 한계가 있기 때문에 작업이 완료된 후 적절하게 반환하지 않으면 자원 누수(Resource Leak)가 발생할 수 있습니다.
자원 누수가 반복되면 시간이 지남에 따라 사용 가능한 자원이 부족해지고, 결국 새로운 요청을 처리하지 못하는 서비스 거부 상황으로 이어질 수 있습니다.
특히 정상적인 처리 과정에서는 문제가 없더라도 예외가 발생했을 때 자원을 반환하지 못하는 코드가 있다면 동일한 문제가 발생할 수 있습니다.
돌이켜보면 저 역시 코드를 작성하면서 정상적인 처리 과정에만 집중한 나머지, 예외가 발생했을 때 자원을 반환하는 처리를 빠뜨린 적이 많았습니다.
개발 경력이 쌓였다고 해서 이런 실수를 하지 않는 것은 아닌 것 같습니다. 오히려 익숙한 작업일수록 기본적인 원칙을 당연하게 여기고 놓치는 경우도 있습니다.
이번 학습을 통해 자원을 획득한 후에는 작업의 성공 여부와 관계없이 반환될 수 있도록 코드를 작성해야 한다는 기본 원칙을 다시 한번 되새기게 되었습니다.
2. 안전한 자원 반환을 위한 Java 코드 작성 방법
아래 코드는 파일을 열고, 데이터를 읽은 후 close() 메서드를 호출하여 자원을 반환합니다.
public void readFile(Path path) throws IOException {
InputStream in = Files.newInputStream(path);
// 파일 읽기
int data = in.read();
in.close();
}
하지만 파일을 읽는 과정에서 예외가 발생하면 close() 메서드가 실행되지 않을 수 있습니다. 이 경우 파일이 열린 상태로 남아 자원 누수가 발생할 수 있습니다.
2.1. try-with-resources를 이용한 자동 자원 반환
Java 7부터 시스템 자원을 안전하게 반환할 수 있도록 try-with-resources 문법을 제공합니다. try-with-resources는 AutoCloseable 인터페이스를 구현한 객체를 try 구문에서 선언하면, 작업이 완료될 때 자동으로 close() 메서드를 호출하여 자원을 반환하는 기능입니다.
try-with-resources 문법을 적용하면 아래와 같이 코드를 작성할 수 있습니다.
public void readFile(Path path) throws IOException {
try (InputStream in = Files.newInputStream(path)) {
// 파일 읽기
int data = in.read();
}
}
위 코드에서는 파일을 읽는 과정에서 예외가 발생하더라도 try 구문을 빠져나올 때 자동으로 close()가 호출됩니다. 따라서 개발자가 직접 자원 반환 코드를 작성하는 방식보다 자원 누수가 발생할 가능성을 줄일 수 있습니다.
이와 같이 try-with-resources를 활용하면 정상적인 처리뿐 아니라 예외 상황에서도 자원을 안전하게 관리하기 쉬워집니다.
2.2. Execute Around Method 패턴을 이용한 자원 관리
Oracle 가이드라인에서는 여기서 한 걸음 더 나아가 자원을 관리하는 로직과 실제 작업을 처리하는 로직을 분리하는 방법도 소개하고 있습니다. 이를 Execute Around Method 패턴이라고 합니다.
이 패턴은 자원의 획득과 반환을 담당하는 메서드 안에서 외부로부터 전달받은 작업을 실행하는 방식입니다. Java 8부터 지원하는 람다식을 활용하여 이러한 구조를 구현할 수 있습니다.
Execute Around Method 패턴의 개념을 바탕으로 아래와 같이 예제 코드를 간단히 구성해봤습니다.
먼저 파일 처리 작업을 전달받기 위한 인터페이스를 정의합니다.
@FunctionalInterface
interface InputStreamHandler {
long handle(InputStream in) throws IOException;
}
이어서 파일을 열고 반환하는 작업을 담당하는 메서드를 구현합니다.
public long readFileBuffered(
Path path,
InputStreamHandler handler) throws IOException {
try (InputStream in = new BufferedInputStream(
Files.newInputStream(path))) {
return handler.handle(in);
}
}
이 메서드는 파일을 열고 InputStreamHandler에 정의된 작업을 실행한 후 작업이 완료되면 스트림을 닫습니다.
실제 파일을 읽는 로직은 람다식으로 전달할 수 있습니다.
long sum = readFileBuffered(path, in -> {
long current = 0;
int b;
while ((b = in.read()) != -1) {
current += b;
}
return current;
});
람다식으로 전달된 로직은 파일에서 읽은 바이트값을 모두 더해서 반환합니다. 파일 스트림의 생성과 반환은 readFileBuffered() 메서드에서 담당하므로, 람다식에서는 전달받은 스트림을 이용해 파일을 읽는 작업에만 집중할 수 있습니다.
실행 순서를 살펴보면 readFileBuffered() 메서드가 먼저 호출되어 파일 스트림을 생성합니다. 이후 handler.handle(in)을 호출하는 시점에 전달받은 람다식의 코드가 실행됩니다.
람다식의 실행이 완료되면 try-with-resources에 의해 스트림이 닫힌 후 최종 결과가 반환됩니다. 람다식 실행 중 예외가 발생하더라도 스트림을 닫는 처리가 수행된 후 예외가 전파됩니다.
이러한 구조의 장점은 자원 관리 로직을 한곳에 모아 개발자가 자원 반환 처리를 빠뜨릴 가능성을 줄일 수 있다는 것입니다.
3. try-finally를 이용한 자원 관리
앞서 살펴본 try-with-resources는 AutoCloseable 인터페이스를 구현한 객체의 자원을 자동으로 반환하는 방법입니다.
하지만 모든 자원이 AutoCloseable 인터페이스를 구현하는 것은 아닙니다. 예를 들어 Java에서 스레드 간 동시 접근을 제어하는 데 사용하는 ReentrantLock은 AutoCloseable을 구현하지 않으므로 try-with-resources를 직접 사용할 수 없습니다.
이러한 경우에는 try-finally 구문을 활용할 수 있습니다.
다음은 ReentrantLock을 이용해 공유 자원에 대한 동시 접근을 제어하는 코드입니다.
private final Lock lock = new ReentrantLock();
public void process() {
lock.lock();
// 공유 자원 처리
doSomething();
lock.unlock();
}
위 코드는 lock()을 호출하여 잠금을 획득한 후 작업을 수행하고, unlock()을 호출하여 잠금을 해제합니다. 하지만 doSomething() 실행 중 예외가 발생하면 unlock()이 호출되지 않아 잠금이 해제되지 않을 수 있습니다.
잠금이 해제되지 않으면 다른 스레드들이 해당 잠금을 획득하지 못해 작업이 지연되거나 무기한 대기하는 상황이 발생할 수 있습니다. 이는 정상적인 서비스 요청을 처리하지 못하는 DoS 문제로 이어질 수 있습니다.
이를 예방하기 위해 Oracle에서는 다음과 같이 try-finally를 사용하는 방법을 제시합니다.
private final Lock lock = new ReentrantLock();
public void process() {
lock.lock();
try {
// 공유 자원 처리
doSomething();
} finally {
lock.unlock();
}
}
이 코드는 잠금을 획득한 후 try 구문에서 작업을 수행하고, 작업이 종료되면 finally 구문에서 잠금을 해제합니다. try 구문에서 예외가 발생하더라도 finally 구문이 실행되므로 잠금 해제 처리가 누락될 가능성을 줄일 수 있습니다.
여기서 중요한 점은 잠금을 획득한 후 try 구문에 진입하고, finally 구문에서 잠금을 해제하도록 구성하는 것입니다.
잠금을 획득하지 못한 상태에서 unlock()이 호출되지 않도록 하기 위해서입니다.
결국 try-with-resources를 직접 사용할 수 없는 자원이라도 try-finally를 활용하면 정상적인 처리뿐 아니라 예외 상황에서도 자원 반환 코드가 실행되도록 구성할 수 있습니다.
4. 출력 버퍼와 압축 스트림의 자원 관리
Oracle 가이드라인에서는 파일이나 잠금과 같은 자원의 반환뿐 아니라, 출력 버퍼와 압축 스트림을 사용할 때 주의해야 할 사항도 설명하고 있습니다.
4.1. 출력 버퍼의 데이터 처리
Java의 BufferedOutputStream은 데이터를 출력할 때마다 실제 파일에 기록하는 대신, 일정량의 데이터를 버퍼에 모아 한꺼번에 출력하는 기능을 제공합니다.
이러한 방식은 입출력 횟수를 줄여 성능을 개선할 수 있지만, 버퍼에 남아 있는 데이터가 실제 출력 대상에 전달되지 않은 상태로 작업이 종료될 수 있다는 점에 주의해야 합니다.
다음은 BufferedOutputStream을 이용해 파일에 데이터를 기록하는 코드입니다.
public void writeFile(Path path, byte[] data)
throws IOException {
OutputStream rawOut = Files.newOutputStream(path);
BufferedOutputStream out =
new BufferedOutputStream(rawOut);
out.write(data);
}
위 코드는 데이터를 버퍼에 기록하지만, 작업이 끝난 후 스트림을 닫거나 버퍼의 데이터를 출력하는 처리가 없습니다. 따라서 버퍼에 남아 있는 데이터가 파일에 기록되지 않을 수 있으며, 파일 스트림도 반환되지 않아 자원 누수가 발생할 수 있습니다.
이를 예방하기 위해 Oracle에서는 정상적인 출력 작업이 완료된 경우 버퍼의 데이터를 출력하도록 flush()를 호출하는 방법을 제시합니다.
이를 try-with-resources와 함께 적용하면 다음과 같이 작성할 수 있습니다.
public void writeFile(Path path, byte[] data)
throws IOException {
try (BufferedOutputStream out =
new BufferedOutputStream(
Files.newOutputStream(path))) {
out.write(data);
}
}
flush()는 버퍼에 남아 있는 데이터를 하위 출력 스트림으로 전달하는 메서드입니다. 반면 close()는 버퍼에 남아 있는 데이터를 출력하고 관련 자원을 반환하면서 스트림을 종료합니다.
따라서 try-with-resources로 BufferedOutputStream을 관리하는 경우에는 flush()를 명시적으로 호출하지 않더라도 정상적인 종료 과정에서 버퍼의 데이터가 출력됩니다.
다만 스트림을 종료하기 전에 버퍼의 데이터를 출력해야 한다면 flush()를 별도로 호출할 수 있습니다.
여기서 중요한 점은 flush()는 버퍼의 데이터를 출력하는 기능이지, 자원을 반환하는 기능이 아니라는 것입니다.
4.2. 압축 스트림의 자원 반환
Oracle에서는 압축 스트림과 같이 다른 스트림을 감싸는 객체도 별도의 자원을 사용할 수 있으므로 주의해야 한다고 설명합니다.
예를 들어 Java의 GZIPOutputStream은 데이터를 압축하면서 하위 출력 스트림으로 전달하는 기능을 제공합니다. 압축 스트림은 압축 처리 과정에서 별도의 메모리나 내부 자원을 사용할 수 있기 때문에 작업이 완료된 후 적절하게 종료해야 합니다.
다음은 데이터를 GZIP 형식으로 압축하여 파일에 저장하는 코드입니다.
public void writeGzipFile(Path path, byte[] data)
throws IOException {
try (GZIPOutputStream gzipOut =
new GZIPOutputStream(
Files.newOutputStream(path))) {
gzipOut.write(data);
}
}
위 코드에서는 GZIPOutputStream을 try-with-resources로 관리하고 있습니다. 작업이 완료되거나 예외가 발생하여 try 구문을 벗어나면 close()가 호출됩니다.
GZIPOutputStream의 close()는 압축 스트림을 종료하면서 내부 자원을 반환하고, 감싸고 있는 하위 출력 스트림도 닫습니다.
GZIP 형식은 압축 데이터뿐 아니라 압축 종료를 나타내는 정보도 필요하므로, 정상적으로 압축 작업을 마무리하는 것이 중요합니다. GZIPOutputStream에서는 finish()를 호출하면 하위 스트림을 닫지 않고 압축 데이터의 출력을 완료할 수 있으며, close() 역시 필요한 마무리 처리를 수행합니다.
다만, 압축 처리 중 예외가 발생하면 스트림의 종료 처리가 수행되더라도 정상적인 압축 파일이 생성된다고 보장할 수는 없습니다.
이처럼 여러 스트림이 연결된 경우에는 각 스트림의 종료 동작을 이해하고, 사용한 자원이 빠짐없이 반환되도록 관리해야 합니다.
결국 DOS-2 지침은 단순히 close()를 호출하는 것에서 그치지 않고, 자원의 특성에 따라 작업을 안전하게 마무리하고 필요한 자원을 반환하도록 코드를 작성해야 한다는 의미로 이해했습니다.
5. 다음 포스팅
다음 포스팅에서는 세 번째 DoS 보안 지침인 DOS-3: Resource limit checks should not suffer from integer overflow에 대해 알아보겠습니다.
정수 오버플로(Integer Overflow)가 어떻게 자원 사용량 제한을 우회하는 문제로 이어질 수 있는지 살펴보고, 이를 예방하기 위한 Java 코드 작성 방법을 정리해 보려고 합니다.