F-LAB JAVA · 1주차 · Phase 7 · 예외 처리와 자원 관리
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
try-with-resources는 AutoCloseable 자원을 컴파일러가 대신 닫아주는 Java 7+ 문법으로, try-finally가 가진 "장황함 · 예외 마스킹 · close 누락 · NPE 위험" 4가지 문제를 동시에 해결한다.
| 시대 | 동작 | 문제 |
|---|---|---|
| try-finally (Java 6 이전) | 체크아웃 시 손님이 직접 프론트에 키 반납 | 까먹으면 분실 요금. 가방 분실 + 키 반납을 같이 처리하다 둘 중 하나만 처리됨 |
| try-with-resources (Java 7+) | 방을 나서면 NFC가 자동 반납 처리 | 사고가 있어도 모든 자원이 정리되고, 사고 기록은 suppressed에 남음 |
1. 탄생 배경 — try-finally의 비극 (Java 6 이전 코드)
2. AutoCloseable — 모든 것이 시작되는 인터페이스
3. 문법과 규칙 — try-with-resources 정확한 사용법
4. 컴파일러 변환 — javap로 본 진짜 모습 (Java 7 vs 9)
5. 예외 마스킹 — Suppressed Exception의 정체
6. 닫는 순서 — 왜 역순인가
7. ILIC 실무 코드 — JDBC, IO, HTTP, Excel
8. 흔한 실수 9가지 — 안티패턴과 처방
9. 면접 질문 + 자기 점검
JDBC로 데이터 1건 조회. 자원 3개(Connection, PreparedStatement, ResultSet)를 닫아야 한다.
public Shipment findById(Long shipmentId) {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = dataSource.getConnection();
pstmt = conn.prepareStatement(
"SELECT * FROM shipments WHERE id = ?"
);
pstmt.setLong(1, shipmentId);
rs = pstmt.executeQuery();
if (rs.next()) {
return mapToShipment(rs);
}
return null;
} catch (SQLException e) {
throw new DataAccessException(e);
} finally {
// 닫는 순서: 역순 (rs → pstmt → conn)
if (rs != null) {
try { rs.close(); } catch (SQLException ignored) {}
}
if (pstmt != null) {
try { pstmt.close(); } catch (SQLException ignored) {}
}
if (conn != null) {
try { conn.close(); } catch (SQLException ignored) {}
}
}
}
비즈니스 로직(SQL 실행 4줄) vs 자원 정리(15줄).
배보다 배꼽이 더 크다.
try-catch-ignored 블록 1개finally {
rs.close(); // ❌ rs가 null이면 NPE
pstmt.close(); // ❌ 위에서 NPE 나면 여기 못 옴
conn.close(); // ❌ 그래서 conn이 영영 안 닫힘 → Connection Pool 고갈
}
if (rs != null) 체크를 매번 손으로 써야 한다.
한 번이라도 까먹으면 Connection Leak → 운영 장애.
close()도 IOException이나 SQLException을 던질 수 있다.
finally 안에서 또 try-catch를 써야 한다.
finally {
if (rs != null) {
try { // 또 try
rs.close();
} catch (SQLException e) {
// 여기서 뭐 할까? 로그? 그냥 무시?
}
}
// ... ×3
}
본문에서 예외가 발생했고, finally에서도 예외가 발생하면
finally 예외가 본문 예외를 덮어쓴다.
try {
rs = pstmt.executeQuery(); // ❌ SQLException("invalid SQL")
return mapToShipment(rs);
} finally {
rs.close(); // ❌ SQLException("connection reset")
}
// 호출자는 "connection reset"만 보게 됨
// 진짜 원인인 "invalid SQL"은 영영 사라짐
운영에서 가장 디버깅이 어려운 종류의 버그다.
스택트레이스만 보고 엉뚱한 곳을 며칠 헤맨다.
문제가 워낙 심각하다 보니 Apache가 유틸을 만들었다.
import org.apache.commons.io.IOUtils;
InputStream in = null;
try {
in = new FileInputStream("invoice.pdf");
// ... 처리
} finally {
IOUtils.closeQuietly(in); // null 체크 + try-catch 캡슐화
}
하지만 이것도 임시방편.
Java 7은 이 문제를 언어 레벨에서 해결해야 했다.
public interface AutoCloseable {
void close() throws Exception;
}
단 한 줄. 메서드 하나. 그러나 의미는 무겁다.
"나는 닫혀야 할 자원이다. try-with-resources가 알아서 닫아라."
Java 5에 이미 Closeable이 있었지만 한계가 있었다.
// java.io.Closeable (Java 5)
public interface Closeable extends AutoCloseable {
void close() throws IOException; // IOException만
}
// java.lang.AutoCloseable (Java 7) — 더 일반화
public interface AutoCloseable {
void close() throws Exception; // 어떤 예외든 가능
}
| 항목 | Closeable | AutoCloseable |
|---|---|---|
| 패키지 | java.io | java.lang |
| 등장 | Java 5 | Java 7 |
| 예외 | IOException | Exception (더 일반) |
| 멱등성 권장 | ✅ 강하게 권장 | 권장 (강제 아님) |
| 적용 대상 | IO 스트림 위주 | DB · 락 · 트랜잭션 · 모든 자원 |
관계: Closeable extends AutoCloseable
→ 모든 Closeable은 자동으로 AutoCloseable.
→ try-with-resources에서 둘 다 사용 가능.
public void close() {
if (closed) return; // 이미 닫혔으면 무시
closed = true;
// 실제 정리 작업
}
close()가 여러 번 호출돼도 안전해야 한다.
JDK의 모든 Closeable 구현체가 이 규약을 지킨다.
📁 IO 스트림 (java.io)
InputStream · OutputStream · Reader · Writer
FileInputStream · BufferedReader · ObjectOutputStream
PrintWriter · DataInputStream
📊 NIO (java.nio)
FileChannel · SocketChannel · ServerSocketChannel
AsynchronousFileChannel · DirectoryStream
💾 JDBC (java.sql)
Connection · Statement · PreparedStatement
ResultSet · CallableStatement
🌐 네트워크 (java.net)
Socket · ServerSocket · HttpURLConnection
HttpClient (Java 11+)
🔒 동시성 (java.util.concurrent)
ExecutorService (Java 19+에서 AutoCloseable)
Lock 구현체들
🧰 서드파티
apache HttpClient
Apache POI Workbook
Jedis (Redis 클라이언트)
MongoCollection 커서
→ 현대 Java에서 자원이라 부를 수 있는 거의 모든 것이 AutoCloseable.
try (Resource r = new Resource()) {
// r 사용
} // 여기서 r.close() 자동 호출
finally 없다. close() 호출 없다.
괄호 () 안에서 선언된 자원은 try 블록 종료 시 무조건 닫힌다.
try (
Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(SQL);
ResultSet rs = pstmt.executeQuery()
) {
return mapToShipment(rs);
}
// 닫는 순서: rs → pstmt → conn (선언 역순)
세미콜론으로 구분. 줄바꿈은 자유.
Java 7~8까지는 try 괄호 안에서 반드시 새로 선언해야 했다.
// Java 7~8
Connection conn = dataSource.getConnection();
try (Connection c = conn) { // 의미 없는 임시 변수 c
// c.use()
}
Java 9부터는 외부에서 선언한 변수도 사용 가능. 단, final 또는 effectively final.
// Java 9+
Connection conn = dataSource.getConnection();
try (conn) { // 깔끔!
// conn.use()
}
Effectively final: 한 번도 재할당되지 않은 변수. final 키워드를 안 붙여도 그렇게 취급된다.
Connection conn = dataSource.getConnection();
conn = anotherSource.getConnection(); // ❌ 재할당
try (conn) { ... } // 컴파일 에러
try (Connection conn = dataSource.getConnection()) {
// 본문
} catch (SQLException e) {
log.error("DB error", e);
throw new DataAccessException(e);
} finally {
log.info("query finished");
}
순서:
1. 본문 실행
2. (예외 발생 시) 자원 close → catch → finally
3. (정상 종료 시) 자원 close → finally
자원 close가 catch보다 먼저 일어남에 주의.
InputStream in = null;
try (InputStream i = in) {
// ...
}
in == null이어도 close()를 호출하지 않는다. NPE 안 난다.
컴파일러가 자동으로 null 체크 추가해줌.
학습 포인트:
javap -c -p로 디컴파일해서 직접 확인할 것.
// 원본
try (InputStream in = new FileInputStream("a.txt")) {
use(in);
}
// 컴파일러가 만드는 코드 (개념적)
InputStream in = new FileInputStream("a.txt");
Throwable primaryException = null;
try {
use(in);
} catch (Throwable t) {
primaryException = t;
throw t;
} finally {
if (in != null) {
if (primaryException != null) {
try {
in.close();
} catch (Throwable suppressed) {
primaryException.addSuppressed(suppressed);
}
} else {
in.close();
}
}
}
핵심 동작:
addSuppressed()로 첨부Java 9부터는 컴파일러가 더 짧은 바이트코드를 생성한다.
$closeResource 합성 메서드(synthetic method)를 추가로 만들어서 중복 코드를 줄였다.
// Java 9+ 컴파일러가 추가하는 메서드
private static /* synthetic */ void $closeResource(
Throwable primary, AutoCloseable resource
) {
if (primary != null) {
try {
resource.close();
} catch (Throwable suppressed) {
primary.addSuppressed(suppressed);
}
} else {
resource.close();
}
}
자원이 여러 개면 close 로직 중복을 막아준다.
바이트코드 크기 ↓, 메서드 크기 ↓.
# 컴파일
javac Try7.java
# 디컴파일
javap -c -p Try7
# 더 자세히 (LineNumberTable 포함)
javap -c -p -v Try7
📌 실습 과제: 같은 코드를
--release 7과--release 9로 컴파일해 바이트코드를 비교해 보라.
public class Masking {
public static void main(String[] args) {
try {
tryFinally();
} catch (Exception e) {
e.printStackTrace();
}
}
static void tryFinally() throws Exception {
Resource r = new Resource();
try {
r.use(); // 던짐: PRIMARY
} finally {
r.close(); // 던짐: CLOSE → PRIMARY를 덮어씀
}
}
}
출력:
java.lang.RuntimeException: CLOSE
at Resource.close(...)
at Masking.tryFinally(...)
→ PRIMARY는 어디로 갔는가? 영영 사라졌다.
static void tryWithResources() throws Exception {
try (Resource r = new <Resource()) {
r.use(); // PRIMARY
} // close() → suppressed로 첨부
}
출력:
java.lang.RuntimeException: PRIMARY
at Resource.use(...)
at Masking.tryWithResources(...)
Suppressed: java.lang.RuntimeException: CLOSE
at Resource.close(...)
at Masking.tryWithResources(...)
→ PRIMARY가 메인. CLOSE는 Suppressed: 라벨로 첨부됨. 둘 다 살아있다.
public class Throwable {
public final void addSuppressed(Throwable exception);
public final Throwable[] getSuppressed();
}
addSuppressed(t) — 이미 던져진 예외에 t를 첨부getSuppressed() — 첨부된 예외 배열 반환운영 장애 디버깅 시:
| 시나리오 | try-finally | try-with-resources |
|---|---|---|
본문에서 SQLException + rs.close()에서 SocketException | SocketException만 보임 | SQLException(메인) + SocketException(suppressed) |
| 트랜잭션 롤백 실패 + 본문 비즈니스 예외 | 롤백 실패만 보임 | 비즈니스 예외(메인) + 롤백 실패(suppressed) |
→ 원인을 절대 잃지 않는다. 이게 진짜 가치.
try (
A a = new A(); // 1번째 열림
B b = new B(a); // 2번째 열림 (a에 의존)
C c = new C(b) // 3번째 열림 (b에 의존)
) {
// 사용
}
// close 순서: c → b → a (선언 역순)
의존성 그래프 때문이다.
ResultSet ───depends on───► PreparedStatement ───depends on───► Connection
(RS) (PS) (C)
만약 정순으로 닫으면?
1. Connection 닫음 → 그 안의 PS, RS는 좀비
2. PS 닫음 → 이미 죽은 자식
3. RS 닫음 → 이미 죽은 손자
→ 일부 드라이버는 NPE 또는 SQLException("connection is closed")을 던진다.
→ 의존성 역순으로 닫아야 자원이 정상 정리된다.
선언 순서대로 열리고, 역순으로 닫는다. 컴파일러가 보장.
try (A a = new A(); B b = new B(a)) { ... }
// ↑열림1 ↑열림2
// 닫힘1↓ 닫힘2↓
// b.close() → a.close()
국제종합물류 ILIC 코드베이스에서 try-with-resources를 어떻게 쓰고 있는가.
@Repository
public class ShipmentRepository {
private final DataSource dataSource;
public Optional<Shipment> findById(Long id) {
String sql = "SELECT * FROM shipments WHERE id = ?";
try (
Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)
) {
pstmt.setLong(1, id);
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapToShipment(rs));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new DataAccessException("shipment 조회 실패", e);
}
}
}
포인트:
ResultSet은 별도 try로 감쌈 — executeQuery()는 PS 이후에 호출되므로public byte[] readInvoicePdf(String path) {
try (
InputStream in = Files.newInputStream(Path.of(path));
ByteArrayOutputStream out = new ByteArrayOutputStream()
) {
in.transferTo(out); // Java 9+
return out.toByteArray();
} catch (IOException e) {
throw new InvoiceException("PDF 읽기 실패: " + path, e);
}
}
포인트:
Files.newInputStream은 NoSuchFileException 등을 던질 수 있음transferTo()는 Java 9+ 의 편의 메서드 (스트림 → 스트림 복사)private final HttpClient httpClient = HttpClient.newHttpClient();
public TrackingInfo fetchTracking(String trackingNumber) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/tracking/" + trackingNumber))
.header("Authorization", "Bearer " + apiKey)
.GET()
.build();
try {
HttpResponse<String> response = httpClient.send(
request, BodyHandlers.ofString()
);
if (response.statusCode() == 200) {
return parseTracking(response.body());
}
throw new CarrierApiException("status: " + response.statusCode());
} catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
throw new CarrierApiException("API 호출 실패", e);
}
}
포인트:
HttpClient 자체는 닫지 않음 (재사용)HttpClient는 Java 21부터 AutoCloseablepublic List<Cargo> importFromExcel(MultipartFile file) {
List<Cargo> cargoes = new ArrayList<>();
try (
InputStream in = file.getInputStream();
Workbook workbook = WorkbookFactory.create(in)
) {
Sheet sheet = workbook.getSheetAt(0);
for (Row row : sheet) {
if (row.getRowNum() == 0) continue; // 헤더 스킵
cargoes.add(rowToCargo(row));
}
return cargoes;
} catch (IOException e) {
throw new ExcelImportException("Excel 파싱 실패", e);
}
}
포인트:
Workbook은 Closeable (POI 3.10+)Lock 인터페이스 자체는 AutoCloseable이 아니다.
그러나 직접 래퍼를 만들면 try-with-resources 패턴 사용 가능.
public final class CloseableLock implements AutoCloseable {
private final Lock lock;
public CloseableLock(Lock lock) {
this.lock = lock;
lock.lock(); // 생성 시 획득
}
@Override
public void close() {
lock.unlock(); // close에서 해제
}
}
// 사용
public BigDecimal calculateFare(Route route) {
try (var ignored = new CloseableLock(fareLock)) {
// 임계 영역 — 자동으로 unlock 보장
return fareCalculator.compute(route);
}
}
포인트:
try (CloseableLock _ = ...) — 변수명이 의미 없으면 ignored 또는 _ (Java 21+)// ❌ 이중 close — 일부 자원은 멱등성이 없어 에러
try (InputStream in = new FileInputStream(path)) {
// ...
} finally {
in.close(); // 이미 자동 close 됨!
}
// ✅ 자동 close에 맡긴다
try (InputStream in = new FileInputStream(path)) {
// ...
}
// ❌ FileInputStream이 닫히지 않음
new FileInputStream(path).read();
// ✅ try-with-resources로
try (var in = new FileInputStream(path)) {
in.read();
}
// ❌ FileInputStream이 안 닫힐 수 있음
BufferedReader br = new BufferedReader(
new InputStreamReader(
new FileInputStream(path)
)
);
try (br) {
// ...
}
// br.close()는 내부적으로 InputStreamReader → FileInputStream 닫음 (대부분 잘 동작)
// 그러나 BufferedReader 생성자에서 OOM 나면? FileInputStream은 누수!
// ✅ 각각 try에 선언
try (
FileInputStream fis = new FileInputStream(path);
InputStreamReader isr = new InputStreamReader(fis);
BufferedReader br = new BufferedReader(isr)
) {
// ...
}
→ Java 표준 라이브러리는 잘 닫지만, 서드파티 wrapper는 모름.
중요한 자원일수록 명시적으로 선언하는 게 안전.
// ❌
public void close() throws BusinessException {
if (state != COMMITTED) {
throw new BusinessException("not committed");
}
}
// 호출자
try (var tx = new Transaction()) {
tx.execute();
// ❌ close에서 BusinessException 발생 → 마치 본문 예외처럼 보임
}
→ close()는 자원 정리만 해야 한다.
비즈니스 검증은 본문에서 끝내라. close에서는 silent하게 마무리만.
// ❌
InputStream in = maybeNull(); // null 반환 가능
try (in) {
// in이 null이면 NPE는 안 나지만,
// 본문에서 in.read() 호출 시 NPE
}
// ✅ null 체크 후 분기
InputStream in = maybeNull();
if (in == null) {
return;
}
try (in) {
// 안전
}
→ try-with-resources는 자원이 null이어도 close 호출 안 함.
하지만 본문에서 사용하면 NPE. null 체크는 사용 전에 해라.
// ❌
public class Transaction {
public void close() { ... } // close가 있지만 인터페이스 안 구현
}
// 호출 시
try (var tx = new Transaction()) { // ❌ 컴파일 에러
}
close() 메서드가 있다고 try-with-resources에 못 쓴다.
반드시 implements AutoCloseable 필요.
// ❌
public class CountingResource implements AutoCloseable {
private int count = 0;
public void close() {
count++;
if (count > 1) throw new IllegalStateException("already closed");
}
}
대부분의 자원은 직접 close 안 호출하지만, 코드 리뷰 중 또는 디버깅 시 두 번 호출될 수 있다.
→ 멱등성 유지: if (closed) return;
// ❌
try (
PreparedStatement pstmt = conn.prepareStatement(SQL);
Connection conn = dataSource.getConnection() // ❌ conn이 위에서 이미 사용됨
) { ... }
// 컴파일 에러: pstmt 선언 시점에 conn이 아직 선언 안 됨
// ✅
try (
Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(SQL)
) { ... }
선언 순서 = 열림 순서 = 의존성 방향.
// ❌ 람다 안에서 Stream을 닫지 않음
files.stream()
.map(path -> {
try {
return Files.lines(path); // Stream<String> 반환 — 자원!
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.flatMap(Function.identity())
.collect(toList());
// Files.lines로 연 스트림이 어디서 닫히는가? 안 닫힘!
// ✅
files.stream()
.flatMap(path -> {
try (Stream<String> lines = Files.lines(path)) {
return lines.collect(toList()).stream(); // 한 번 모은 뒤 닫음
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.collect(toList());
Files.lines(), Files.list(), Files.walk()는 모두 Stream을 반환하며 자원.
반드시 try-with-resources로 감쌀 것.
| Q | 핵심 답변 |
|---|---|
| try-with-resources는 왜 등장했나? | try-finally의 4가지 문제 (장황 · 예외 마스킹 · close 누락 · NPE) 해결 |
| AutoCloseable과 Closeable의 차이? | 패키지 / 예외 타입 / Closeable extends AutoCloseable |
| Suppressed Exception은 뭔가? | 본문 예외가 메인, close 예외가 첨부. addSuppressed/getSuppressed |
| 자원 close 순서는? | 선언 역순. 의존성 그래프 때문 |
| try-finally로 같은 동작을 흉내내려면? | Throwable 변수로 primary 추적 + addSuppressed 수동 호출 (현실적으로 불가능) |
| Java 7과 Java 9의 차이? | Java 9는 effectively final 변수 사용 가능 + $closeResource 합성 메서드 |
| close()는 멱등해야 하는가? | 강하게 권장. JDK 표준 구현체는 모두 멱등 |
Stream도 자원인가? | Files.lines/list/walk는 자원. 반드시 닫아야 함 |
Files.lines() 같은 Stream 자원을 안전하게 닫을 수 있다1. try-with-resources = AutoCloseable + 컴파일러 마법
try ( ... ) 안에 선언된 자원은 컴파일러가 finally 블록을 자동 생성2. Suppressed Exception — 예외 마스킹 해결
addSuppressed() / getSuppressed() API3. ILIC 전 영역에 적용
Files.lines() 같은 Stream도 자원 — 반드시 닫을 것