들어가며
Java 프로그래밍에서 리소스 관리와 예외 처리는 안정적인 애플리케이션 개발의 핵심 요소입니다. 특히 파일, 네트워크 연결, 데이터베이스 커넥션 등의 시스템 리소스를 다룰 때는 올바른 관리가 필수적입니다. 이번 포스팅에서는 Java의 리소스 관리 방법과 효과적인 예외 처리 기법에 대해 자세히 알아보겠습니다.
1. 리소스(Resource)의 이해
리소스란?
리소스는 데이터를 제공하는 객체로, 시스템의 한정된 자원을 사용하는 모든 객체를 의미합니다. 주요 리소스 유형은 다음과 같습니다:
- 파일 시스템: FileInputStream, FileOutputStream, BufferedReader 등
- 네트워크: Socket, ServerSocket, URLConnection 등
- 데이터베이스: Connection, Statement, ResultSet 등
- 입출력 스트림: InputStream, OutputStream, Reader, Writer 등
리소스와 관련된 핵심 객체들
Java에서 데이터 처리 시 자주 사용되는 객체 패턴들을 살펴보겠습니다:
- DAO (Data Access Object): 데이터 처리 객체로, 데이터베이스나 파일 시스템에 접근하는 로직을 캡슐화
- DTO (Data Transfer Object): 계층 간 데이터 전송을 위한 객체
- VO (Value Object): 값을 표현하는 불변 객체
리소스 사용의 기본 원칙
리소스를 안전하게 사용하기 위한 필수 규칙입니다:
- 열기(Open): 리소스 사용 전 반드시 열어야 함
- 사용(Use): 필요한 작업 수행
- 닫기(Close): 사용 완료 후 반드시 닫아야 함
// 전통적인 방식의 위험성
FileInputStream fis = null;
try {
fis = new FileInputStream("example.txt"); // 열기
// 파일 읽기 작업
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fis != null) {
try {
fis.close(); // 닫기
} catch (IOException e) {
e.printStackTrace();
}
}
}
위 코드의 문제점:
- 코드가 복잡하고 가독성이 떨어짐
- close() 메서드에서도 예외가 발생할 수 있음
- 실수로 close()를 빼먹을 가능성
- 리소스 누수(Resource Leak) 위험
2. try-with-resources 구문
try-with-resources의 등장 배경
Java 7에서 도입된 try-with-resources 구문은 위와 같은 문제점들을 해결하기 위해 만들어졌습니다. 이 구문을 사용하면 리소스의 자동 해제가 보장됩니다.
AutoCloseable 인터페이스
try-with-resources를 사용하려면 해당 리소스가 AutoCloseable 인터페이스를 구현해야 합니다.
public class MyResource implements AutoCloseable {
private String name;
public MyResource(String name) {
this.name = name;
System.out.println("[MyResource(" + name + ") 열기]");
}
public String read1() {
System.out.println("[MyResource(" + name + ") 읽기]");
return "100";
}
public String read2() {
System.out.println("[MyResource(" + name + ") 읽기]");
return "abc";
}
@Override
public void close() throws Exception {
System.out.println("[MyResource(" + name + ") 닫기]");
}
}
try-with-resources 사용 예제
public class TryWithResourcesExample {
public static void main(String[] args) {
// 1. 정상 실행 케이스
try (MyResource resource = new MyResource("AAA")) {
String data = resource.read1();
int value = Integer.parseInt(data);
System.out.println("결과: " + value);
} catch (Exception e) {
System.out.println("예외처리: " + e.getMessage());
}
// 출력:
// [MyResource(AAA) 열기]
// [MyResource(AAA) 읽기]
// 결과: 100
// [MyResource(AAA) 닫기]
System.out.println("===================");
// 2. 예외 발생 케이스
try (MyResource resource = new MyResource("BBB")) {
String data = resource.read2();
int value = Integer.parseInt(data); // NumberFormatException 발생!
System.out.println("결과: " + value);
} catch (Exception e) {
System.out.println("예외처리: " + e.getMessage());
}
// 출력:
// [MyResource(BBB) 열기]
// [MyResource(BBB) 읽기]
// [MyResource(BBB) 닫기] <- 예외 발생 후에도 자동으로 닫힘!
// 예외처리: For input string: "abc"
System.out.println("===================");
// 3. 다중 리소스 처리
MyResource resource1 = new MyResource("CCC");
MyResource resource2 = new MyResource("DDD");
try (resource1; resource2) {
String data1 = resource1.read1();
String data2 = resource2.read1();
System.out.println("결과1: " + data1 + ", 결과2: " + data2);
} catch (Exception e) {
System.out.println("예외 처리: " + e.getMessage());
}
// 출력:
// [MyResource(CCC) 열기]
// [MyResource(DDD) 열기]
// [MyResource(CCC) 읽기]
// [MyResource(DDD) 읽기]
// 결과1: 100, 결과2: 100
// [MyResource(DDD) 닫기] <- 나중에 생성된 것부터 먼저 닫힘
// [MyResource(CCC) 닫기]
}
}
3. try-with-resources 동작 원리
예외 발생 시 실행 순서
try-with-resources에서 예외가 발생했을 때의 정확한 실행 순서는 다음과 같습니다:
- try() 블록 내에서 리소스 생성 및 초기화
- try {} 블록 내에서 비즈니스 로직 실행 중 예외 발생
- 예외가 catch 블록으로 전달되기 전에 리소스의 close() 메서드가 자동 호출
- catch 블록이 실행되어 예외를 처리
- finally 블록이 있다면 실행
Suppressed Exception 처리
만약 try 블록과 close() 메서드에서 모두 예외가 발생하면 어떻게 될까요?
public class ProblematicResource implements AutoCloseable {
@Override
public void close() throws Exception {
throw new RuntimeException("close() 메서드에서 예외 발생!");
}
public void doSomething() {
throw new RuntimeException("비즈니스 로직에서 예외 발생!");
}
}
public class SuppressedExceptionExample {
public static void main(String[] args) {
try (ProblematicResource resource = new ProblematicResource()) {
resource.doSomething(); // 첫 번째 예외 발생
} catch (Exception e) {
System.out.println("주 예외: " + e.getMessage());
// 억제된 예외들 확인
Throwable[] suppressed = e.getSuppressed();
for (Throwable t : suppressed) {
System.out.println("억제된 예외: " + t.getMessage());
}
}
}
}
출력 결과:
주 예외: 비즈니스 로직에서 예외 발생!
억제된 예외: close() 메서드에서 예외 발생!
try-with-resources는 원래 예외를 유지하면서, close() 과정에서 발생한 예외를 억제된 예외로 처리합니다. 이를 통해 Throwable.getSuppressed() 메서드로 모든 예외 정보에 접근할 수 있습니다.
코드 실행 순서
- try 블록 진입:
try (ProblematicResource resource = new ProblematicResource()
) 문을 통해ProblematicResource
객체가 생성되고,resource
변수에 할당됩니다. - doSomething() 메서드 호출 및 예외 발생:
resource.doSomething()
; 메서드가 호출됩니다. 이 메서드는 "비즈니스 로직에서 예외 발생!"이라는 메시지를 가진RuntimeException
을 발생시킵니다. - 리소스 자동 닫기: try 블록에서 예외가 발생했기 때문에, Java는 catch 블록으로 넘어가기 전에 try-with-resources 규칙에 따라
resource.close()
메서드를 자동으로 호출합니다. - close() 메서드에서 예외 발생: close() 메서드가 호출되지만, 이 메서드도 "close() 메서드에서 예외 발생!"이라는 메시지를 가진
RuntimeException
을 발생시킵니다. - 예외 처리: Java는
doSomething()
에서 발생한 첫 번째 예외를 주 예외(primary exception)로 설정하고,close()
메서드에서 발생한 두 번째 예외를 주 예외에 **억제된 예외(suppressed exception)**로 추가합니다. - catch 블록 실행:
catch (Exception e)
블록이 실행됩니다. 여기서 e는 주 예외인 "비즈니스 로직에서 예외 발생!" 예외입니다. - 출력:
System.out.println("주 예외: " + e.getMessage());
가 실행되어 "주 예외: 비즈니스 로직에서 예외 발생!"이 출력됩니다.e.getSuppressed()
를 통해 억제된 예외를 가져와 반복문으로 출력합니다. 따라서 "억제된 예외:close()
메서드에서 예외 발생!"이 출력됩니다.
핵심 규칙은 try-with-resources 문에서 try
블록 내부와 close()
메서드에서 둘 다 예외가 발생할 경우, try 블록의 예외가 주 예외가 되고 close()
메서드의 예외는 억제된 예외로 처리된다는 점입니다. 이 규칙을 통해 개발자는 비즈니스 로직의 예외가 리소스 닫기 과정의 예외로 인해 덮어쓰여지는 것을 방지하고, 두 예외를 모두 추적할 수 있습니다.
4. 예외 떠넘기기 (Exception Throwing)
throws 키워드의 활용
메서드 내부에서 발생한 예외를 직접 처리하지 않고, 해당 메서드를 호출한 곳으로 예외 처리 책임을 전가하는 방법입니다.
public class ExceptionThrowingExample {
public static void main(String[] args) {
ExceptionThrowingExample example = new ExceptionThrowingExample();
example.method1();
}
public void method1() {
String className = "java.lang.String2"; // 존재하지 않는 클래스명
try {
method2(className);
System.out.println("클래스 로딩 성공!");
} catch (ClassNotFoundException e) {
System.err.println("클래스를 찾을 수 없습니다: " + e.getMessage());
e.printStackTrace();
}
}
public void method2(String className) throws ClassNotFoundException {
// ClassNotFoundException을 method1()으로 떠넘김
Class.forName(className);
System.out.println("method2에서 클래스 로딩 완료");
}
}
다중 예외 떠넘기기
하나의 메서드에서 여러 종류의 예외를 떠넘길 수 있습니다:
public void complexMethod() throws IOException, SQLException, ClassNotFoundException {
// 파일 처리 중 IOException 발생 가능
// 데이터베이스 처리 중 SQLException 발생 가능
// 클래스 로딩 중 ClassNotFoundException 발생 가능
}
public void callerMethod() {
try {
complexMethod();
} catch (IOException e) {
System.out.println("파일 처리 오류: " + e.getMessage());
} catch (SQLException e) {
System.out.println("데이터베이스 오류: " + e.getMessage());
} catch (ClassNotFoundException e) {
System.out.println("클래스 로딩 오류: " + e.getMessage());
}
}
5. 실제 활용 예제
파일 처리 예제
public class FileProcessingExample {
public void processFile(String fileName) throws IOException {
// try-with-resources로 안전한 파일 처리
try (BufferedReader reader = Files.newBufferedReader(Paths.get(fileName),
StandardCharsets.UTF_8)) {
String line;
int lineNumber = 1;
while ((line = reader.readLine()) != null) {
System.out.println(lineNumber + ": " + line);
lineNumber++;
}
} // reader가 자동으로 close됨
}
public static void main(String[] args) {
FileProcessingExample example = new FileProcessingExample();
try {
example.processFile("sample.txt");
} catch (IOException e) {
System.err.println("파일 처리 중 오류 발생: " + e.getMessage());
}
}
}
데이터베이스 처리 예제
public class DatabaseExample {
public List<String> getUsers() throws SQLException {
List<String> users = new ArrayList<>();
String sql = "SELECT name FROM users";
// 다중 리소스를 try-with-resources로 관리
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:test");
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
users.add(rs.getString("name"));
}
} // 모든 리소스가 자동으로 close됨
return users;
}
}
6. 모범 사례와 주의사항
👍 모범 사례
- 항상 try-with-resources 사용: AutoCloseable을 구현한 리소스는 반드시 try-with-resources로 관리
- 구체적인 예외 처리: 가능한 한 구체적인 예외 타입으로 처리
- 리소스 순서 고려: 다중 리소스 사용 시 의존성을 고려한 순서로 배치
- 로깅 활용: 예외 정보를 적절히 로깅하여 디버깅에 활용
⚠️ 주의사항
- null 리소스 처리: 리소스가 null일 수 있는 경우를 고려
- 예외 은닉 방지: 중요한 예외 정보가 억제되지 않도록 주의
- 성능 고려: 리소스 생성 비용이 큰 경우 풀링(Pooling) 고려
- 스레드 안전성: 멀티스레드 환경에서의 리소스 공유 시 동기화 필요
마무리
Java의 리소스 관리와 예외 처리는 안정적인 애플리케이션 개발의 핵심입니다. try-with-resources 구문을 활용하면 복잡한 리소스 관리 코드를 간소화하면서도 안전성을 보장할 수 있습니다. 또한 적절한 예외 떠넘기기를 통해 책임을 분리하고 코드의 가독성을 향상시킬 수 있습니다.
이러한 기법들을 적절히 활용하여 메모리 누수 없는 안정적인 Java 애플리케이션을 개발하시기 바랍니다. 특히 파일, 네트워크, 데이터베이스 등의 외부 리소스를 다룰 때는 반드시 이러한 원칙들을 준수하는 것이 중요합니다.