Devin.KR

소프트웨어 공학 · 심화

코드 리뷰 실무 - 버그를 잡고 팀을 키우는 읽기

예외와 오류 처리 리뷰

예외 삼키기, 너무 넓은 catch, 오류 메시지의 정보량, 자원 해제(try-with-resources), 실패 시 상태가 반쯤 바뀌는 문제, 완성 코드는 대출 처리 중 예외가 났을 때 재고 상태가 일관된지 전/후 버전을 비교한다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 경계값과 null을 살펴보며 정상적인 입력 밖으로 시선을 넓혔다. 이번에는 입력을 처리하다가 작업이 중단되는 순간을 읽는다. 대출 신청을 받았지만 기록 파일을 쓰지 못했다면, 이용자에게 어떤 결과를 알려야 하는가. 이미 줄인 재고는 어떻게 해야 하는가. 예외가 발생했다는 사실만 확인해서는 이 질문에 답할 수 없다.

예외 처리 리뷰는 catch의 유무를 검사하는 일이 아니다. 실패가 호출자에게 전달되는지, 자원이 닫히는지, 실패한 작업이 남긴 상태를 설명할 수 있는지 확인하는 일이다. 이 장에서는 도서관 대출 서비스의 변경을 읽으며 성공 경로와 실패 경로를 함께 비교한다.

  • 예외를 삼키는 코드와 복구 가능한 예외 처리를 구분한다.
  • catch 범위를 정하고 원인과 작업 맥락을 오류 메시지에 남긴다.
  • try-with-resources로 자원을 닫고, 닫기 실패도 작업 결과에 포함한다.
  • 대출 처리 도중 예외가 발생해도 재고가 일관되게 남도록 수정한다.
  • 같은 입력을 수정 전후 코드에 넣어 유지할 동작과 바꿀 동작을 확인한다.

문제 상황

팀이 관리하는 대출 서비스는 책 한 권을 대출할 때 재고를 하나 줄이고, 대출 확인 파일을 만든다. 한 개발자가 “파일 저장 실패 때문에 화면이 멈추지 않게 했다”라는 설명과 함께 변경을 올렸다. 변경된 메서드는 모든 예외를 잡아 false를 반환한다. 화면에는 처리 실패가 표시되므로 얼핏 문제가 없어 보인다.

그런데 운영 문의를 재현해 보니 다른 결과가 나온다. 대출이 실패한 뒤에도 남은 재고가 하나 줄어 있다. 이용자가 다시 신청하면 또 줄어든다. 이용자는 책을 빌리지 못했지만 시스템은 빌려 간 것처럼 수량을 관리한다. 실패를 알려 주는 반환값과 실제 상태가 서로 어긋난 것이다.

대출 실패를 반환하는 경로에서 재고 감소가 취소되는지 확인해 주기 바란다. 파일 기록 실패를 넣어 보니 시작 재고 2가 1로 남는다. 실패하면 재고를 유지한다는 규칙을 실행으로 확인하면 좋겠다.

리뷰에서는 먼저 이 변경이 지켜야 할 약속을 적는다. 여기서 성공은 확인 파일을 쓰고 닫은 뒤 재고 감소를 확정한 상태다. 실패하면 메모리 안의 재고는 호출 전 수량으로 돌아가야 한다. 파일 전체와 여러 시스템의 상태를 한꺼번에 되돌린다는 약속까지 포함하지는 않는다.

대출 처리의 성공과 실패가 지켜야 할 약속
상황호출자가 받는 결과시작 재고가 2일 때
확인 파일 기록과 닫기 성공대출 성공1
확인 파일 기록 중 실패작업 맥락이 있는 예외2
확인 파일 닫기 중 실패작업 맥락이 있는 예외2
재고 없음대출 불가0 유지

예외를 잡았다는 사실과 처리했다는 사실

예외 삼키기는 실패를 받은 뒤 호출자가 실패를 판단할 수 없게 만드는 처리다. 빈 catch가 대표적이지만, 로그만 남기고 성공 값을 반환하는 코드도 여기에 해당한다. 반대로 예외 대신 false를 돌려준다고 언제나 잘못은 아니다. 반환값의 뜻이 분명하고 호출자가 그 결과를 처리한다면 사용할 수 있다. 문제는 재고 부족과 저장 실패처럼 대응이 다른 상황을 같은 값으로 뭉개는 데 있다.

재고 부족은 이용자가 다른 책을 선택할 수 있는 결과다. 파일 저장 실패는 작업을 완료하지 못한 상황이다. 둘 다 false이면 호출자는 재고가 없는지, 저장 장치에 문제가 있는지 구분할 수 없다. 처음에는 화면 문구가 조금 부정확한 정도로 보이지만, 나중에는 재시도 판단과 장애 조사에도 영향을 준다.

catch에 쓰인 타입도 읽어야 한다. Exception을 잡으면 예상한 입출력 실패뿐 아니라 NullPointerException 같은 프로그래밍 실수까지 같은 실패로 바뀔 수 있다. 이때 리뷰 질문은 “Exception을 쓰면 안 되는가”가 아니다. “이 범위의 실패를 여기서 같은 방식으로 처리할 수 있는가”다. 애플리케이션의 최상위 경계에서 예상하지 못한 실패를 모아 처리할 수는 있지만, 개별 대출 메서드에서 모두 복구한 것처럼 숨기는 것은 다른 문제다.

이번 수정에서는 파일 처리에서 예상하는 IOException만 잡는다. 이 예외를 대출 처리 실패라는 의미로 바꾸되, 원인 예외를 생성자에 전달한다. 예상하지 못한 실행 중 예외는 그대로 전파한다. 재고 복구는 catch 밖의 finally에 두므로, 예상한 예외를 잡는 범위와 상태를 되돌리는 범위를 따로 읽을 수 있다.

오류 메시지는 다음 행동에 필요한 정보를 담는다

“오류 발생”은 실패했다는 사실만 반복한다. 어떤 작업이 실패했고 어느 책에 대한 요청이었는지 알 수 없다. 반대로 파일의 전체 내용이나 이용자 정보를 모두 붙이면 조사에 필요하지 않은 정보까지 노출된다. 메시지의 길이보다 실패를 구별할 수 있는 정보가 중요하다.

이 예제는 책 식별자와 실패한 작업을 메시지에 넣는다. 저수준 예외의 메시지를 문자열로 이어 붙이는 대신 원인 예외 자체를 보존한다. 그러면 상위 코드가 원인의 타입과 호출 경로를 조사할 수 있다. 화면에 표시할 문구와 내부 조사에 쓸 예외 정보는 목적이 다르므로, 내부 예외의 내용을 그대로 이용자에게 출력한다는 뜻은 아니다.

리뷰에서 구별할 예외 처리와 메시지
발견한 코드의 의도빠진 정보 또는 위험리뷰에서 확인할 것
모든 예외를 잡아 false 반환재고 부족과 저장 실패를 구별하지 못함호출자가 서로 다르게 대응해야 하는가
“저장 실패”만 전달실패한 대상과 원인이 사라짐책 식별자와 원인 예외가 보존되는가
입력 전체를 메시지에 포함필요 이상의 정보가 남음문제 구별에 필요한 항목만 남겼는가
IOException을 업무 예외로 변환변환하면서 원인을 잃을 수 있음생성자에 원인 예외를 전달했는가

자원을 닫는 시점도 실패 경로에 포함한다

파일에 write를 호출했다고 해서 파일 작업 전체가 성공한 것은 아니다. 버퍼에 남은 내용은 닫는 과정에서 기록될 수 있고, close도 실패할 수 있다. 따라서 재고 감소를 확정하는 시점은 write 다음 줄이 아니라 자원을 정상적으로 닫은 다음이어야 한다.

try-with-resources는 자원 선언을 포함한 try 문이다. 선언한 자원은 블록을 벗어날 때 닫힌다. 블록 안에서 예외가 발생해도 닫기를 시도하므로, 정상 경로 마지막 줄에만 close를 두는 코드보다 종료 경로를 읽기 쉽다. 여러 자원을 선언하면 선언의 역순으로 닫는다.

블록 안의 작업과 close가 모두 실패하면 블록 안에서 발생한 예외가 주된 예외가 되고, 닫기 실패는 억제된 예외로 붙는다. 이 정보는 getSuppressed로 확인할 수 있다. 예외를 새로 만들면서 원인을 버리면 이런 조사 정보까지 찾기 어려워진다.

파일 작업의 성공은 기록과 자원 닫기가 모두 끝난 뒤에 확정한다

리뷰할 때는 try 블록의 중괄호를 경계로 생각하면 도움이 된다. 자원 선언부터 블록 종료 시의 닫기까지 하나의 실패 가능 구간이다. 이 구간 안에서 성공 플래그를 켜면 닫기 실패가 발생했는데도 성공 상태가 남을 수 있다. 완성 코드에서는 try-with-resources 바깥에서 성공 플래그를 켠다.

실패 뒤에 남을 상태를 먼저 정한다

예외가 전파되었다고 이미 바뀐 값까지 돌아오지는 않는다. 재고에서 1을 뺀 다음 파일 기록이 실패하면, Java는 재고를 자동으로 복원하지 않는다. 따라서 실패 시 상태를 유지하려면 복구에 필요한 값을 작업 전에 확보해야 한다.

이 장의 예제는 단일 스레드가 하나의 재고 객체를 다룬다. 호출 전 수량을 저장하고, 전체 작업이 성공하지 못했으면 finally에서 그 수량을 복원한다. 재고 복원은 단순한 필드 대입이며 예외를 던지지 않는다. 복구 코드 자체가 실패할 수 있다면 원래 실패와 복구 실패를 함께 다루는 별도 정책이 필요하다.

finally는 여기서 “무조건 되돌린다”가 아니라 “성공이 확정되지 않았으면 되돌린다”라는 뜻이다. 성공했는데도 복원하면 대출은 이루어졌지만 재고가 줄지 않는 반대 오류가 생긴다. 성공 여부를 나타내는 변수는 이 분기를 드러내기 위해 사용한다.

수정 후에는 기록 실패가 발생하면 재고가 호출 전 수량으로 돌아간다

다만 이것을 여러 저장소에 대한 트랜잭션이라고 부르면 범위를 잘못 이해하게 된다. 확인 파일에는 실패 전에 쓴 일부 내용이 남을 수 있다. 예제에서는 그 파일을 대출 성공의 근거로 읽지 않으며, 실행 마지막에 지운다. 재고 일관성과 파일 내용의 원자적 저장은 서로 다른 약속이다. 실제 서비스에서 파일이나 다른 저장소까지 성공 근거로 쓴다면 어느 기록을 기준으로 확정할지 추가로 정해야 한다.

성공 플래그가 자원을 닫은 뒤에 설정되어 재고 복구 기준을 읽기 쉽다. 이 구현이 보장하는 범위는 단일 호출의 메모리 재고이므로, 파일 일부가 남을 수 있다는 제한도 함께 기록해 주기 바란다.

완성 코드

다음 프로그램은 수정 전 서비스와 수정 후 서비스를 함께 실행한다. 두 서비스에는 각각 별도의 재고 객체와 같은 시작 수량을 준다. 정상 기록과 중간 실패도 같은 기록기 구현으로 주입한다. 시각이나 난수는 사용하지 않으며, 파일은 임시 디렉터리 안에만 만든다.

수정 전 코드도 자원은 닫도록 구성했다. 이렇게 하면 실패 후 재고 차이를 자원 누수와 섞지 않고 관찰할 수 있다. 수정 전의 문제는 넓은 catch가 원인을 숨기고, 재고 감소를 복구하지 않는다는 점이다. 자원 해제 자체의 잘못된 예는 뒤에서 별도로 비교한다.

Main.java

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class Main {
    private static final String BOOK_ID = "BK-17";

    static final class Stock {
        private int available;

        Stock(int available) {
            if (available < 0) {
                throw new IllegalArgumentException("음수 재고");
            }
            this.available = available;
        }

        int available() {
            return available;
        }

        void takeOne() {
            if (available == 0) {
                throw new IllegalStateException("재고 없음");
            }
            available--;
        }

        void restore(int previous) {
            available = previous;
        }
    }

    interface SlipWriter {
        void write(BufferedWriter writer, String bookId)
                throws IOException;
    }

    static final class NormalSlipWriter implements SlipWriter {
        @Override
        public void write(BufferedWriter writer, String bookId)
                throws IOException {
            writer.write("loan:" + bookId);
            writer.newLine();
        }
    }

    static final class FailingSlipWriter implements SlipWriter {
        @Override
        public void write(BufferedWriter writer, String bookId)
                throws IOException {
            writer.write("pending:" + bookId);
            throw new IOException("의도한 기록 실패");
        }
    }

    static final class LoanException extends Exception {
        private static final long serialVersionUID = 1L;

        LoanException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    static final class BeforeLoanService {
        private final Stock stock;
        private final SlipWriter slipWriter;

        BeforeLoanService(Stock stock, SlipWriter slipWriter) {
            this.stock = stock;
            this.slipWriter = slipWriter;
        }

        boolean borrow(String bookId, Path file) {
            if (stock.available() == 0) {
                return false;
            }

            try (BufferedWriter writer = Files.newBufferedWriter(
                    file, StandardCharsets.UTF_8)) {
                stock.takeOne();
                slipWriter.write(writer, bookId);
                return true;
            } catch (Exception e) {
                return false;
            }
        }
    }

    static final class AfterLoanService {
        private final Stock stock;
        private final SlipWriter slipWriter;

        AfterLoanService(Stock stock, SlipWriter slipWriter) {
            this.stock = stock;
            this.slipWriter = slipWriter;
        }

        boolean borrow(String bookId, Path file) throws LoanException {
            if (stock.available() == 0) {
                return false;
            }

            int previous = stock.available();
            boolean completed = false;

            try {
                try (BufferedWriter writer = Files.newBufferedWriter(
                        file, StandardCharsets.UTF_8)) {
                    stock.takeOne();
                    slipWriter.write(writer, bookId);
                }
                completed = true;
                return true;
            } catch (IOException e) {
                throw new LoanException(
                        "대출 확인 파일 기록 실패: bookId=" + bookId, e);
            } finally {
                if (!completed) {
                    stock.restore(previous);
                }
            }
        }
    }

    private static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
        if (!condition) {
            throw new AssertionError(name);
        }
    }

    public static void main(String[] args) throws IOException, LoanException {
        Path directory = Files.createTempDirectory("loan-review-");
        Path beforeOk = directory.resolve("before-ok.txt");
        Path afterOk = directory.resolve("after-ok.txt");
        Path beforeFailure = directory.resolve("before-failure.txt");
        Path afterFailure = directory.resolve("after-failure.txt");

        try {
            Stock beforeOkStock = new Stock(2);
            Stock afterOkStock = new Stock(2);
            SlipWriter normal = new NormalSlipWriter();

            boolean beforeResult = new BeforeLoanService(
                    beforeOkStock, normal).borrow(BOOK_ID, beforeOk);
            boolean afterResult = new AfterLoanService(
                    afterOkStock, normal).borrow(BOOK_ID, afterOk);

            check(beforeResult && afterResult,
                    "정상 입력: 전후 모두 성공");
            check(beforeOkStock.available() == 1
                            && afterOkStock.available() == 1,
                    "정상 입력: 전후 재고 1");
            check(Files.readString(beforeOk).equals(
                            Files.readString(afterOk)),
                    "정상 입력: 전후 파일 내용 동일");

            Stock beforeFailureStock = new Stock(2);
            Stock afterFailureStock = new Stock(2);
            SlipWriter failing = new FailingSlipWriter();

            boolean failedResult = new BeforeLoanService(
                    beforeFailureStock, failing)
                    .borrow(BOOK_ID, beforeFailure);

            check(!failedResult,
                    "실패 입력: 수정 전 false 반환");
            check(beforeFailureStock.available() == 1,
                    "실패 입력: 수정 전 재고 1로 불일치 재현");

            LoanException failure = null;
            try {
                new AfterLoanService(afterFailureStock, failing)
                        .borrow(BOOK_ID, afterFailure);
            } catch (LoanException e) {
                failure = e;
            }

            check(failure != null
                            && failure.getCause() instanceof IOException,
                    "실패 입력: 수정 후 원인 예외 보존");
            check(failure != null
                            && ("대출 확인 파일 기록 실패: bookId="
                            + BOOK_ID).equals(failure.getMessage()),
                    "실패 입력: 수정 후 작업 맥락 보존");
            check(afterFailureStock.available() == 2,
                    "실패 입력: 수정 후 재고 2로 복구");
        } finally {
            Files.deleteIfExists(beforeOk);
            Files.deleteIfExists(afterOk);
            Files.deleteIfExists(beforeFailure);
            Files.deleteIfExists(afterFailure);
            Files.deleteIfExists(directory);
        }
    }
}

줄별 해설

Stock의 생성자와 takeOne은 재고 객체가 음수가 되지 않게 한다. available은 현재 수량을 읽고, restore는 저장해 둔 수량을 되돌린다. 이 복원 방식은 다른 호출이 같은 객체를 동시에 변경하지 않는다는 전제에 의존한다. 여러 호출이 겹치는 상황의 해결책으로 그대로 가져가서는 안 된다.

SlipWriter의 write 선언은 파일 기록이 IOException으로 실패할 수 있음을 드러낸다. NormalSlipWriter는 확인 내용을 쓰고, FailingSlipWriter는 일부 내용을 쓴 다음 정해진 예외를 던진다. 저장 공간을 실제로 채우거나 운영체제 권한을 바꾸지 않아도 같은 실패 지점을 반복해서 실행할 수 있다.

LoanException의 생성자는 메시지와 원인을 함께 받는다. 메시지는 대출 작업의 맥락을 설명하고, cause는 파일 처리의 원래 실패를 보존한다. serialVersionUID는 예외 클래스가 직렬화 가능한 상위 타입을 물려받는 데 따른 선언이다. 이 예제에서 예외를 파일로 직렬화하지는 않는다.

BeforeLoanService의 try 문은 파일을 열고 재고를 줄인 뒤 내용을 기록한다. try 안의 return도 자원이 닫힌 다음에 완료된다. 닫기에서 예외가 발생하면 catch로 이동할 수 있다. 따라서 여기서 return의 위치 자체가 자원 누수를 만들지는 않는다. 문제는 어느 실패든 false로 바뀌며, 이미 줄인 재고가 그대로 남는다는 데 있다.

AfterLoanService의 previous는 변경 전 수량을 저장한다. completed는 파일을 열고, 기록하고, 닫는 과정이 전부 성공했는지를 나타낸다. 초기값이 false이므로 파일을 여는 단계에서 실패해도 finally는 같은 복구 규칙을 따른다. 아직 재고를 줄이지 않았다면 같은 값을 다시 넣을 뿐이다.

중첩된 try 문 다음의 completed = true가 확정 지점이다. 내부 try-with-resources가 정상 종료했다는 것은 닫기도 정상 완료했다는 뜻이다. 이 줄을 내부 try의 마지막으로 옮기면 close에서 실패해도 completed가 true로 남을 수 있다. 코드 한 줄의 위치가 실패 후 재고를 바꾼다.

catch (IOException e)는 예상한 파일 실패를 LoanException으로 바꾼다. catch가 끝나기 전에 예외를 던져도 finally는 실행된다. 예기치 않은 실행 중 예외가 발생해 이 catch를 지나치더라도 finally는 실행되므로, 성공이 확정되지 않은 재고를 되돌린다. 단, 프로세스 강제 종료까지 복구한다는 보장은 없다.

main의 정상 입력 비교는 반환값, 재고, 파일 내용을 함께 본다. 반환값만 같으면 파일 내용이 달라져도 놓칠 수 있다. 실패 입력 비교에서는 결과가 같기를 요구하지 않는다. 수정 전의 잘못된 상태를 재현하고 수정 후의 상태가 규칙에 맞는지 확인한다. 버그 수정에서는 유지할 결과와 의도적으로 바꿀 결과를 구분해야 한다.

check의 AssertionError는 확인이 실패했을 때 실행을 중단한다. 따라서 앞에서 FAIL이 나왔는데 뒤의 PASS만 보고 성공했다고 오해하지 않는다. 마지막 finally는 정상 종료와 확인 실패 모두에서 임시 파일 정리를 시도한다. 정리 중 삭제 자체가 실패하면 그 예외는 호출자에게 전달된다.

실행 결과

Main.java를 빈 작업 디렉터리에 저장하고 다음 명령을 실행한다. 첫 명령은 요청한 기본 컴파일이며, 마지막 명령은 추가 경고를 확인하는 선택적 검사다. 아래 출력은 코드에서 정해진 확인 순서와 일치하는 예상 출력이다.

javac -d out *.java
java -cp out Main
javac -Xlint:all -Werror -d out *.java
PASS 정상 입력: 전후 모두 성공
PASS 정상 입력: 전후 재고 1
PASS 정상 입력: 전후 파일 내용 동일
PASS 실패 입력: 수정 전 false 반환
PASS 실패 입력: 수정 전 재고 1로 불일치 재현
PASS 실패 입력: 수정 후 원인 예외 보존
PASS 실패 입력: 수정 후 작업 맥락 보존
PASS 실패 입력: 수정 후 재고 2로 복구

정상 입력에서는 수정 전후가 같은 성공 결과와 같은 재고를 만든다. 확인 파일의 내용도 같다. 실패 입력에서는 차이가 드러난다. 수정 전은 실패를 반환하면서 재고를 1로 남기고, 수정 후는 원인을 전달하면서 재고를 2로 되돌린다. 여기서 “불일치 재현”의 PASS는 수정 전 코드가 올바르다는 뜻이 아니라, 고치려는 현상을 확인했다는 뜻이다.

임시 디렉터리 이름은 실행마다 달라질 수 있지만 출력하지 않는다. 파일 줄바꿈은 실행 환경에 맞춰 기록되며 같은 실행 안에서 전후 내용을 비교한다. 출력에는 현재 시각, 임시 경로, 예외의 호출 경로를 넣지 않아 제시한 결과를 일정하게 유지한다.

실무에서 자주 틀리는 것

잡은 예외를 성공 값으로 바꾼다

다음 클래스의 before는 재고 문자열을 읽지 못해도 0을 반환한다. 호출자는 “재고가 없다”와 “재고를 읽지 못했다”를 구별할 수 없다. 넓은 catch 때문에 null 같은 다른 실수도 같은 결과가 된다. after는 숫자 형식 실패만 잡아 입력의 뜻을 설명하고 원인을 전달한다. 각 예제 클래스는 완성 코드와 별도로 컴파일할 수 있는 Java 코드다.

class StockTextExample {
    static int before(String text) {
        try {
            return Integer.parseInt(text);
        } catch (Exception e) {
            return 0;
        }
    }

    static int after(String text) {
        try {
            return Integer.parseInt(text);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                    "재고 수량은 정수여야 한다", e);
        }
    }

    public static void main(String[] args) {
        String input = "two";
        System.out.println(before(input));
        try {
            after(input);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}

같은 “two”를 넣으면 before는 0을 출력하고 after는 “재고 수량은 정수여야 한다”를 출력한다. 이 차이는 의도한 수정이다. 정상 입력 “2”에는 두 메서드 모두 2를 반환한다. 오류를 드러내는 변경을 하면서 모든 입력의 결과가 같아야 한다고 요구하면 수정 목적을 놓친다.

메시지만 새로 만들고 원인은 버린다

예외의 문구를 바꾸는 과정에서 cause를 빠뜨리기 쉽다. before도 저장 실패를 알리지만 어디서 발생한 IOException인지 조사할 연결이 끊긴다. after는 작업 대상과 원인을 함께 보존한다. 메시지에는 예제의 책 식별자만 넣으며 이용자 입력 전체를 붙이지 않는다.

class ErrorMessageExample {
    static IllegalStateException before(java.io.IOException cause) {
        return new IllegalStateException("저장 실패");
    }

    static IllegalStateException after(
            String bookId, java.io.IOException cause) {
        return new IllegalStateException(
                "대출 확인 파일 기록 실패: bookId=" + bookId, cause);
    }

    public static void main(String[] args) {
        java.io.IOException cause =
                new java.io.IOException("의도한 기록 실패");
        System.out.println(before(cause).getCause() == cause);
        System.out.println(after("BK-17", cause).getCause() == cause);
    }
}

같은 원인 객체를 전달하면 before의 확인 결과는 false이고 after는 true다. 단순히 오류 문구가 출력되는지만 확인하면 이 차이를 놓친다. 리뷰에서는 호출자가 메시지를 읽을 수 있는지와 원인 예외를 추적할 수 있는지를 따로 확인한다.

마지막 줄에서만 close를 호출한다

before는 action이 실패하면 close에 도달하지 못한다. after는 action이 실패하더라도 자원을 닫는다. 예제는 실제 파일 대신 종료 여부를 기억하는 작은 자원을 사용하므로, 확인 과정에서 파일 핸들을 남기지 않는다. close가 필요하다는 구조는 파일 기록기와 같다.

class ResourceExample {
    static final class Probe implements AutoCloseable {
        private boolean closed;

        @Override
        public void close() {
            closed = true;
        }
    }

    static void action() {
        throw new IllegalStateException("의도한 작업 실패");
    }

    static void before(Probe resource) {
        action();
        resource.close();
    }

    static void after(Probe resource) {
        try (Probe opened = resource) {
            if (opened.closed) {
                throw new IllegalStateException("이미 닫힌 자원");
            }
            action();
        }
    }

    public static void main(String[] args) {
        Probe first = new Probe();
        Probe second = new Probe();
        try {
            before(first);
        } catch (IllegalStateException e) {
            System.out.println(first.closed);
        }
        try {
            after(second);
        } catch (IllegalStateException e) {
            System.out.println(second.closed);
        }
    }
}

같은 작업 실패를 넣으면 before는 false, after는 true를 출력한다. 예외를 전파한다는 점은 같지만 종료 상태가 다르다. 자원을 닫는 코드가 보인다는 이유만으로 충분하다고 판단하지 말고, 그 줄에 도달하지 않는 경로를 따라가야 한다.

한눈에 보기

예외와 오류 처리 리뷰에서 따라갈 질문
검사 대상핵심 질문이번 코드의 선택
예외 삼키기호출자가 실패를 구별할 수 있는가재고 부족은 false, 파일 실패는 예외
catch 범위이 위치에서 처리할 수 있는 실패인가IOException만 업무 예외로 변환
오류 정보작업과 대상을 알고 원인을 추적할 수 있는가책 식별자, 작업 설명, cause 보존
자원 해제기록 실패와 닫기 실패를 모두 고려했는가try-with-resources 종료 후 성공 확정
중간 상태실패하면 어떤 값이 남는가finally에서 이전 재고 복원
보장 범위되돌리는 상태와 남을 수 있는 상태는 무엇인가메모리 재고 복원, 파일 일부는 남을 수 있음

리뷰 의견에는 문제가 드러나는 순서와 기대 상태를 함께 적는다. “예외 처리가 부족하다”보다 “재고 감소 뒤 기록이 실패하면 수량이 줄어든 채 남는다”가 수정 대상을 분명하게 만든다. 그다음 같은 실패를 넣은 실행 결과를 확인하면, catch를 추가했다는 사실보다 실패 후 약속이 지켜지는지를 근거로 판단할 수 있다.

연습 문제

  1. AfterLoanService에서 completed = true를 내부 try-with-resources의 마지막 줄로 옮겼다. write는 성공하고 close는 실패한다면 재고와 호출 결과가 어떻게 되는지 설명하라.
  2. 수정 전 서비스의 catch에서 false 대신 LoanException을 던지도록 바꾸기만 했다. 시작 재고가 2이고 기록 중 예외가 발생하면 문제가 해결되는지 설명하라.
  3. 시작 재고가 0인 경우를 완성 코드의 main에 추가하라. 수정 전후가 모두 false를 반환하고 재고가 0이며 확인 파일이 생기지 않았는지 check로 확인하라. 새 파일 경로도 임시 디렉터리 안에 두고 정리 대상에 포함하라.
  4. FailingSlipWriter가 일부 내용을 쓴 뒤 실패했다. 수정 후 재고가 2로 돌아왔다는 확인만으로 확인 파일도 비어 있다고 결론 내려도 되는지 설명하라. 이 예제의 보장 범위를 한 문장으로 작성하라.

정답과 해설

  1. close에서 IOException이 발생하면 LoanException이 호출자에게 전달된다. 그러나 completed는 이미 true이므로 finally가 재고를 복원하지 않는다. 시작 재고가 2라면 1로 남는다. 성공 플래그는 닫기까지 정상 종료한 뒤에 설정해야 한다.
  2. 해결되지 않는다. 호출자는 실패 원인을 알 수 있게 되지만 이미 감소한 재고는 1로 남는다. 실패 전달과 상태 복구는 별개의 요구 사항이다. 변경 전 수량을 저장하고 성공하지 못한 경로에서 복원해야 한다.
  3. 새 Stock(0)을 서비스마다 준비하고, 아직 생성하지 않은 별도 경로로 borrow를 호출한다. 두 반환값의 부정, 두 재고가 0이라는 조건, Files.exists 결과의 부정을 각각 확인하면 된다. 현재 구현은 파일을 여는 문장보다 먼저 재고를 검사하므로 파일이 생성되지 않는다. 성공 입력 비교처럼 두 서비스에 독립된 재고를 주어야 확인이 서로 영향을 주지 않는다.
  4. 결론 내릴 수 없다. 실패 전에 기록한 내용이 닫는 과정에서 파일에 남을 수 있다. 보장 범위는 “단일 스레드의 대출 호출이 정상 완료하지 못하면 메모리 재고를 호출 전 수량으로 복원한다”다. 파일 내용까지 되돌린다는 보장은 이 코드에 없다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.