1. 예외처리 어떻게 해야할까?
최근 Notion2WordPress와 Tistory2WordPress를 만들었다.
둘은 상당히 비슷하지만 예외처리 방식은 완전히 다르다.
처음 Notion2WordPress를 만들 당시만 해도 최대한 열심히 예외처리를 했다.
예외에 대한 최대한 많은 정보를 얻겠다는 각오로 하위, 상위 레이어 구분하지 않고 할 수 있는 모든 곳에서 예외처리를 구현했다.
하지만 try-catch문이 지나치게 많이 사용되고, 하위에서 처리한 예외를 다시 상위에서 반복해서 처리하고, 또 예외처리가 프로그램 로직과 결합되어 복잡도가 증가했다.
이에 Tistory2WordPress를 만들 때는 최대한 단순하게 예외처리를 구현했다.
DB, WordPress나 Notion과 연동하는 하위 레이어에선 예외를 처리하지 않고 상위 레이어로 그대로 던지도록 구현했다.
이들을 호출하는 상위 레이어에서 예외처리를 몰아서 수행하도록 구현했다.
훨씬 단순하고 이해하기 쉽지만 버그를 추적하기 어렵다.
이번 기회에 어떻게 예외를 처리하는 것이 좋은지 알아보기로 했다.
2. 좋은 예외처리란?
가장 먼저 “좋은 예외처리란 무엇인가”와 관련된 유튜브 영상을 찾아봤다.
아쉽게도 “예외처리란?”, “try-catch 문법”과 같은 기초적인 내용밖에 없었다.
보다 근본적인 예외처리 전략에 대해 알고 싶어서 블로그와 책을 찾아봤다.
가. 클린코드
- 오류 코드보다 예외를 사용하라
- Try-Catch-Finally 문부터 작성하라
- Unchecked 예외를 사용해라
- 예외에 의미를 제공하라
- 호출자를 고려해 예외 클래스를 정의하라
- 정상 흐름을 정의하라
null을 반환하지 마라null을 전달하지 마라
간단히 목차만 정리했고 아래에 Best Practice를 따로 모아두었다.
자세한 내용은 클린코드 7장 오류 처리와 3장 함수를 읽어보자.
나. 참고 자료


3. 모범 사례
클린코드와 블로그 글을 종합해서 참고할 수 있는 Best Practice 정리.
1) 오류 코드보다 예외를 사용하라
// BAD
if (deletePage(page) == E_OK) {
if (registry.deleteReference(page.name) == E_OK) {
if (configKeys.deleteKey(page.name.makeKey()) == E_OK) {
logger.log("page deleted");
} else {
logger.log("configKey not deleted");
}
} else {
logger.log("deleteReference from registry failed");
}
} else {
logger.log("delete failed");
return E_ERROR;
}
Code language: JavaScript (javascript)
- 명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 미묘하게 위반한다.
- 여러 단계로 중첩되는 코드를 야기한다.
- 오류 코드를 반환하면 호출자는 오류 코드를 곧바로 처리해야 한다는 문제에 부딪힌다.
// GOOD
try {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
} catch (Exception e) {
logger.log(e.getMessage());
}
Code language: PHP (php)
2) 오류 처리만 수행하는 함수를 만들어라
// GOOD
public void delete(Page page) {
try {
deletePageAndAllReferences(page);
} catch (Exception e) {
logError(e);
}
}
private void deletePageAndAllReferences(Page page) throws Exception {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}
private void logError(Exception e) {
logger.log(e.getMessage());
}
Code language: PHP (php)
- 실제 페이지를 제거하는 함수는
deletePageAndAllReferences다. - 실제 오류를 처리하는 함수는
delete다. - 오류 처리도 한 가지 작업이다. 함수는 한 가지 작업만 해야 한다.
3) Unchecked 예외를 사용하라
// BAD
// 상위 계층
class FileController {
void save(File f) {
try {
service.save(f);
} catch (DiskFullException e) {
// ...
} catch (PermissionDeniedException e) {
// ...
}
}
}
// 중간 계층
class FileService {
void save(File f)
throws DiskFullException, PermissionDeniedException {
repo.save(f);
}
}
// 하위 계층
class DiskFullException extends Exception {}
class PermissionDeniedException extends Exception {}
class FileRepository {
void save(File f)
throws DiskFullException, PermissionDeniedException {
}
}
Code language: JavaScript (javascript)
- 개방 폐쇄 원칙을 위반한다.
- 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다.
// GOOD
// 상위 계층
abstract class FileSaveException extends RuntimeException {}
class FileController {
void save(File f) {
try {
service.save(f);
} catch (FileSaveException e) {
handleSaveFailure(e);
}
}
}
// 중간 계층
class FileService {
void save(File f) {
repo.save(f); // 선언부 오염 없음
}
}
// 하위 계층
class DiskFullException extends FileSaveException {}
class PermissionDeniedException extends FileSaveException {}
class FileRepository {
void save(File f) {
// 실패 시 FileSaveException 계열 발생
}
}
Code language: JavaScript (javascript)
| 언어 | Checked Exception | 기본 에러 모델 |
|---|---|---|
| Java | 있음 | 예외 (throw/catch) |
| JavaScript | 없음 | 런타임 예외 |
| Python | 없음 | 런타임 예외 |
| Go | 없음 | (value, error) 반환 |
| Rust | 없음 (대신 Result) | 타입 기반 에러 |
참고로 많은 언어들이 보통 Checked Exception 개념이 없다.
4) 예외에 의미를 제공하라
// BAD
if (user == null) {
throw new RuntimeException("user error");
}
Code language: JavaScript (javascript)
// GOOD
if (user == null) {
throw new UserNotFoundException(
userId,
"UserRepository.findById"
);
}
// 예외 정의
class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String userId, String source) {
super("User not found. userId=" + userId + ", source=" + source);
}
}
Code language: JavaScript (javascript)
- 예외를 던질 때는 원인, 맥락과 위치를 파악하기 쉽도록 정보를 충분히 덧붙인다.
- 커스텀 예외 클래스를 정의하는 것도 좋다.
5) 예외도 계층적으로 처리하라
| 계층 | 처리 책임 | 예시 |
|---|---|---|
| 하위 기술 계층 | 기술적 예외 | DB, API 연동, 파일 I/O, 네트워크 등 |
| 비즈니스 계층 | 도메인 관점에서 의미 있는 예외 | 주문 조회 실패, 결제 처리 실패, 재고 부족, 권한 없음 등 |
계층별 예외의 성격이 분리되어야 한다.
각 계층에서 발생한 예외는 각 계층에서 처리하는 것이 가장 좋다.
다만 상위 계층으로 전파해야 하는 경우, 상위 계층의 관점에서 맞는 예외로 변환해야 한다.
// GOOD
// 하위 계층
abstract class ServiceException extends RuntimeException {
protected ServiceException(Throwable cause) {
super(cause);
}
}
class OrderQueryFailedException extends ServiceException {
OrderQueryFailedException(Throwable cause) {
super(cause);
}
}
class OrderService {
private final OrderRepository repository = new OrderRepository();
Order getOrder(String orderId) {
try {
return repository.findOrder(orderId);
} catch (DatabaseException e) {
// 상위 계층에 맞춰 예외 변환
throw new OrderQueryFailedException(e);
}
}
}
// 상위 계층
class OrderController {
private final OrderService service = new OrderService();
void request(String orderId) {
try {
Order order = service.getOrder(orderId);
} catch (ServiceException e) { // 추상화된 하위 계층 예외
// 적절히 예외 처리
}
}
}
Code language: PHP (php)
하위 계층의 예외를 상위 계층으로 그대로 전파하면 상위 계층이 하위 계층의 구현 세부사항을 알게 된다.
예외 변환은 이 의존성을 끊기 위한 수단이다.
상위 계층에 맞는 예외로 변환해야 의존성을 줄일 수 있고, 모듈이 온전히 독립된 모률로 동작할 수 있다.
6) 외부 라이브러리와 API는 Wrapper 클래스로 추상화하라
// BAD
ACMEPort port = new ACMEPort(12);
try {
port.open();
} catch (DeviceResponseException e) {
reportPortError(e);
logger.log("Device response excepion", e);
} catch (ATM1212UnlockedException e) {
reportPortError(e);
logger.log("Unlock exception", e);
} catch (GMXError e) {
reportPortError(e);
logger.log("Device reponse exception");
} finally {
// ...
}
Code language: JavaScript (javascript)
- 외부 라이브러리가 던질 예외를 모두 잡아낸다.
- 다수의 중복 발생
// GOOD
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
// ...
}
// ACMEPort의 예외만 잡아 변환하는 Wrapper class
public class LocalPort {
private ACMEPort innerPort;
public LocalPort(int portNumber) {
innerPort = new ACMEPort(portNumber);
}
public void open() {
try {
innerPort.open();
} catch (DeviceResponseException e) {
throw new PortDeviceFailure(e);
} catch (ATM1212UnlockedException e) {
throw new PortDeviceFailure(e);
} catch (GMXError e) {
throw new PortDeviceFailure(e);
} finally {
// ...
}
}
// ...
}
Code language: PHP (php)
- 외부 라이브러리와 API를 사용할 때 의존성이 크게 줄어든다.
- 테스트하기 쉽다.
7) 특수 사례는 정상 흐름으로 정의하라
// BAD
try {
MealExpense expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
m_total += getMealPerDiem();
}
class ExpenseReportDAO {
MealExpenses getMeals(String employeeId) throws MealExpensesNotFound {
// DB에 Meal 정보가...
// 있으면 MealExpenses 반환
// 없으면 MealExpensesNotFound 던짐
}
}
Code language: JavaScript (javascript)
- 예외 처리와 계산 작업을 동시에 수행한다. 두 가지 작업을 동시에 처리함.
- 예외 처리를 프로그램 논리와 분리할 수 없다.
// GOOD
MealExpense expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
class ExpenseReportDAO {
MealExpenses getMeals(String employeeId) throws MealExpensesNotFound {
// DB에 Meal 정보가...
// 있으면 ActualMealExpenses 반환
// 없으면 PerDiemMealExpenses 반환
// MealExpensesNotFound를 던지는 대신에 PerDiemMealExpenses를 반환하도록 수정
}
}
// 인터페이스
interface MealExpenses {
int getTotal();
}
// 특수 사례 객체
class PerDiemMealExpenses implements MealExpenses {
@Override
public int getTotal() {
// 기본값으로 일일 기본 식비를 반환한다.
}
}
// 실제 값 객체
class ActualMealExpenses implements MealExpenses {
@Override
public int getTotal() {
// 실제 식비를 반환한다.
}
}
Code language: PHP (php)
- 특수 사례 패턴을 활용해 Try-Catch-Finally 문을 줄였다.
- 특수 사례의 예외는 정상 흐름으로 포함시킬 수 있다.
- 클라이언트 코드가 예외 처리할 필요가 없음.
- 클래스나 객체가 예외적인 상황을 캡슐화하여 처리.
8) null을 반환하지 마라
null을 반환하고 싶다면 그 대신 예외를 던지거나 빈 리스트 또는 특수 사례 객체를 구현하여 반환한다.
| 단수형 반환값 | 복수형 반환값 | |
|---|---|---|
| 기본값 있음 | 특수 사례 객체 구현 및 반환 | 특수 사례 객체 구현 및 반환 |
| 기본값 없음 | 예외를 던진다 | 빈 리스트 반환 |
// BAD
List<Employee> employees = getEmployees();
if (employees != null) {
for (Employee e : employees) {
totalPay += e.getPay();
}
}
Code language: PHP (php)
// GOOD
List<Employee> employees = getEmployees();
for (Employee e : employees) {
totalPay += e.getPay();
}
Code language: PHP (php)
9) null을 전달하지 마라
// BAD 1
public class MetricsCalculator
{
public double xProjection(Point p1, Point p2) {
if (p1 == null || p2 == null) {
throw InvalidArgumentException(
"Invalid argument for MetricsCaculator.xProjection");
}
return (p2.x - p1.x) * 1.5;
}
}
// BAD 2
public class MetricsCalculator
{
public double xProjection(Point p1, Point p2) {
assert p1 != null : "p1 should not be null";
assert p2 != null : "p2 should not be null";
return (p2.x - p1.x) * 1.5;
}
}
Code language: PHP (php)
- 인수로
null이 전달되는 경우를 확인하고 있다. - 누군가
null을 전달하면 여전히 실행 오류가 발생한다. - 문서화가 잘 되어 코드를 읽기 편하지만 여전히 문제를 해결하지 못한다.
// GOOD
public class MetricsCalculator
{
public double xProjection(Point p1, Point p2) {
return (p2.x - p1.x) * 1.5;
}
}
Code language: PHP (php)
- 애초에 메서드로
null을 전달하지 못하도록 금지한다.
10) 가능한 늦게 처리하라
공통으로 사용되는 함수의 예외를 호출부마다 처리하기 시작하면 중복과 불일치가 발생한다.
예외는 책임 있는 계층에서 가능한 한 늦게 한 번만 처리하라.
물론 무조건적으로 글로벌 핸들러에서 처리하는 것을 의미하지 않는다.
해당 예외를 처리해야하는 여러 계층 중 가장 최상위 계층에서 처리하라는 것이다.
// BAD
class Calculator {
public int divide(int a, int b) {
if (b == 0) throw new ArithmeticException();
return a / b;
}
}
// 서비스 1: 통계 서비스
class StatService {
private Calculator calc = new Calculator();
public void calculateAverage(int total, int count) {
try {
int result = calc.divide(total, count);
System.out.println("평균: " + result);
} catch (ArithmeticException e) {
// 중복되는 예외 처리 로직 1
System.err.println("[오류] 0으로 나눌 수 없습니다: " + e.getMessage());
}
}
}
// 서비스 2: 정산 서비스
class SettlementService {
private Calculator calc = new Calculator();
public void calculateShare(int amount, int persons) {
try {
int result = calc.divide(amount, persons);
System.out.println("1인당 정산 금액: " + result);
} catch (ArithmeticException e) {
// 중복되는 예외 처리 로직 2
System.err.println("[오류] 0으로 나눌 수 없습니다: " + e.getMessage());
}
}
}
Code language: PHP (php)
- 이렇게 되면 해당 함수를 호출해야하는 모든 곳에서는 항상 예외처리를 해야만 한다.
- 너무 빨리 예외를 처리하게 되면 같은 예외 처리 코드를 중복해서 추가해야 한다.
// GOOD
class Calculator {
public int divide(int a, int b) {
if (b == 0) throw new ArithmeticException();
return a / b;
}
}
// 서비스들은 예외 처리를 하지 않고 밖으로 던집니다 (Logic에만 집중)
class StatService {
private Calculator calc = new Calculator();
public void calculateAverage(int total, int count) {
int result = calc.divide(total, count);
System.out.println("평균: " + result);
}
}
class SettlementService {
private Calculator calc = new Calculator();
public void calculateShare(int amount, int persons) {
int result = calc.divide(amount, persons);
System.out.println("1인당 정산 금액: " + result);
}
}
// 최상위 계층 (Application Entry Point / Controller)
public class MainApp {
public static void main(String[] args) {
StatService statService = new StatService();
SettlementService settlementService = new SettlementService();
try {
// 서비스 호출 - 어떤 서비스에서 에러가 나든 여기서 관리합니다.
statService.calculateAverage(100, 0);
settlementService.calculateShare(50000, 0);
} catch (ArithmeticException e) {
// [GOOD] 단 한 곳에서 일관된 방식으로 예외 처리
handleGlobalException(e);
}
}
private static void handleGlobalException(Exception e) {
System.err.println("[시스템 공통 알림] 처리 중 오류가 발생했습니다: " + e.getMessage());
}
}
Code language: PHP (php)
- 예외를 한 곳에서 처리하면 중복된 코드를 제거할 수 있어 유지보수의 효율성이 올라간다.
4. 실제 적용
Notion2WordPress을 리팩터링하면서 적용한 사례다.
예외처리를 잘하기 위해서 리팩터링을 시작했다.
하지만 끝나고 보니 전체적으로 더 좋은 구조를 갖게 되었다.
5. 결론
몇가지 방법만 익히면 충분히 예외 처리를 프로그램 논리와 분리할 수 있다.
깨끗한 코드를 작성하면서도 안정성 높은 코드를 작성할 수 있다.