타임딜 프로젝트에서 배운 Spring 예외 처리 리팩토링 실전
안녕하세요!
커머스 프로젝트를 개발하며 점점 복잡해지는 예외 처리 로직을 개선하고, 클린 코드 원칙을 적용한 리팩토링 경험을 정리해 보았습니다. 이번 글에서는 단순히 try-catch를 줄이는 수준이 아니라, 실제 실무에서 적용 가능한 예외 처리 전략과 리팩토링 전후 코드의 차이점까지 소개해볼게요!
코드 예제는 제가 실제로 리팩토링 하며 사용한 코드들을 가져와 설명해 보도록하겠습니다~.
또한 내용정리에 들어가기에 앞서, 먼저 이 글의핵심...제가 이글을 읽고 설계한 예외처리 전략을 우선 소개해보려합니다.
☄️ 예외도 설계다 – 내 프로젝트에서 수립한 예외 처리 전략
예외 처리... 사실 다들 하긴 하는데,"어떻게 해야 잘했다 소리 듣지?" 이 질문에 답할 수 있는 팀은 생각보다 많지 않더라고요.
저의 목표는 단순합니다.
✔️ 예외를 로직에서 분리하고
✔️ 컨트롤러는 본질만, 예외는 알아서 처리되게
그래서 아래와 같은 4단계 예외 처리 전략을 만들었습니다.
1️⃣ 단순한 예외는 BaseException으로 통일
유효성 검사 실패나 조건 불일치 같은 일반적인 오류는
throw new BaseException(BaseResponseStatus.XXX) 한 줄이면 끝나도록 했습니다!
if (rate < 0 || rate > 100) {
throw new BaseException(BaseResponseStatus.INVALID_DISCOUNT_RATE);
}
2️⃣ 컨텍스트가 필요한 경우 커스텀 예외
예외가 재활용되고, 어떤 값 때문에 오류가 났는지 상세 메시지를 담고 싶은 경우엔 예외 클래스를 따로 만들었습니다.
public class InsufficientStockException extends BaseException {
public InsufficientStockException(Long productId, int req, int avail) {
super(BaseResponseStatus.STOCK_UNAVAILABLE);
}
@Override
public String getDetailMessage() {
return String.format("상품 %d: 요청 %d > 재고 %d", productId, req, avail);
}
}
3️⃣ 전역 예외 핸들러로 응답 통일
예외는 던지기만 하면 끝나야죠! 처리와 응답 포맷은 모두 @RestControllerAdvice에서 책임집니다.
4️⃣ 응답 메시지와 코드도 enum으로 통합 관리
메시지, 코드, HTTP 상태까지 enum 하나로 관리해 프론트와 협업도 쉬워지고 유지보수도 깔끔하도록 구성했어요.
TIMEDEAL_NOT_FOUND(false, HttpStatus.NOT_FOUND, "타임딜을 찾을 수 없습니다."),
INVALID_DISCOUNT_RATE(false, HttpStatus.BAD_REQUEST, "할인율은 0~100 사이여야 합니다.")
⚖️ 예외 처리 책임, 컨트롤러 vs 서비스 누가 맡아야 할까?
예외를 다루는 위치도 중요합니다. 컨트롤러는 요청-응답에 집중하고, 서비스는 도메인 로직과 유효성 검증에 집중해야 합니다.
예외를 잘 설계하면 서비스 흐름이 더 명확해지고, 에러 대응도 체계적이 됩니다.
따라서 저는 아래처럼 설계했어요.
✅ 예외는 서비스에서 던지고, 컨트롤러는 예외를 넘긴다.
✔️ try-catch는 거의(만하면) 쓰지 않는다.
서비스에서 도메인의 규칙 위반, 잘못된 상태, 비즈니스 로직 검증 실패는 모두 서비스 계층에서 throw로 예외를 던집니다.
컨트롤러는 예외가 발생하면 전역 핸들러가 응답을 만들어주기 때문에,자신의 핵심 로직에만 집중하면 됩니다.
재정리하자면, 예외는 서비스에서 던지고, 응답은 @RestControllerAdvice가 만들어줍니다.
이제 클린코드 원칙을 쭉 정리해보겠습니다.
📚 목차
- 리턴 코드 대신 Exceptions를 사용하라
- Try-Catch-Finally문을 먼저 써라
- Unchecked Exceptions를 사용하라
- Exceptions로 문맥을 제공하라
- 사용에 맞게 Exception 클래스를 선언하라
- 정상적인 상황을 정의하라 (Default 값 설정)
- Null을 리턴하지 마라
- Null을 넘기지 마라
- 결론 및 리팩토링 정리
1. 리턴 코드 대신 Exceptions를 사용하라
문제: 아래와 같은 방식은 중첩되고, 에러 상황을 일일이 체크하느라 본래 로직이 묻혀버립니다.
@PostMapping
public ResponseEntity<BaseResponse<TimeDeal>> createTimeDeal(@RequestBody ReqTimeDeal request) {
try {
TimeDeal deal = timeDealService.createTimeDeal(request);
return ResponseEntity.status(HttpStatus.CREATED).body(new BaseResponse<>(deal));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new BaseResponse<>(BaseResponseStatus.ERROR));
}
}
해결: 예외를 던지고 전역 핸들러로 일괄 처리합니다.
@PostMapping
public ResponseEntity<BaseResponse<TimeDeal>> createTimeDeal(@RequestBody ReqTimeDeal request) {
TimeDeal deal = timeDealService.createTimeDeal(request);
return ResponseEntity.status(HttpStatus.CREATED).body(new BaseResponse<>(deal));
}
2. Try-Catch-Finally문을 먼저 써라
예외를 무조건 try-catch로 감싸라는 의미는 아닙니다! 예외가 발생할 가능성이 있는 코드의 범위를 좁게 명시하고, 필요한 경우에만 try-catch-finally를 활용해 로직을 명확히 분리하는 것이 중요합니다.
사실 Spring 환경에서는 대부분의 런타임 예외는 전역 예외 처리기로 처리되기 때문에, 로깅, 리소스 해제 같은 목적이 아니라면 try-catch는 생략해도 괜찮아 보입니다.
try {
InputStream in = new FileInputStream("config.properties");
loadConfig(in);
} catch (FileNotFoundException e) {
logger.warn("기본 설정 파일이 없습니다. 디폴트 설정으로 진행합니다.");
loadDefaultConfig();
}
3. Unchecked Exceptions를 사용하라
이전 글에서 언급했듯, 예전에는 throws가 필요한 Exception을 많이 사용했지만, 스프링은 RuntimeException 기반 언체크 예외 구조를 기본 전제로 설계되어 있습니다.
이렇게 하면 중간 메서드에서 일일이 throws 하지 않아도 돼서 훨씬 간결합니다.
public class BaseException extends RuntimeException {
private final BaseResponseStatus status;
...
}
4. Exceptions로 문맥을 제공하라
단순한 메시지 대신, 의미 있는 Exception 클래스로 의미있는 문맥을 전달합시다.
public class ReviewWithoutPurchaseException extends BaseException {
...
@Override
public String getMessage() {
return String.format("사용자(ID: %d)가 상품(TimeDeal ID: %d)을 구매하지 않아 리뷰를 작성할 수 없습니다.",
userId, timeDealId);
}
}
5. 사용에 맞게 Exception 클래스를 선언하라
복잡한 외부 라이브러리의 예외는 감싸는 것이 좋습니다. 좋은 예외 설계는 외부 교체, 테스트, 유지보수에도 강력한 유연성을 제공합니다!
public class AcmePortAdapter {
public void open() {
try {
port.open();
} catch (DeviceResponseException | ATM1212UnlockedException | GMXError e) {
throw new PortDeviceFailure(e);
}
}
}
6. 정상적인 상황을 정의하라 (Default 객체 사용)
예외를 던지는 대신 스페셜 케이스 객체를 리턴해볼 수도있습니다. 이렇게 하면 호출 측에서는 예외처리를 몰라도됩니다!
public MealExpenses getMeals(int userId) {
return mealDao.findByUserId(userId)
.orElse(new PerDiemMealExpenses());
}
7. Null을 리턴하지 마라
null은 결국 사용자입장에서 한번 더 체크해야합니다. 컬렉션은 빈 리스트, 객체는 Optional or Default 객체를 리턴합시다.
public List<Employee> getEmployees() {
return employeeRepository.findAll()
.orElse(Collections.emptyList());
}
8. Null을 넘기지 마라
아예 메서드가 실행되기 전에 null이 들어오는 걸 막아주는 장치를 미리 설정해두는 게 좋아요. @NonNull, @Validated, 또는assert로 방어 코딩을 적극 활용하는 것이 좋습니다.
✨ 내용을 회고하며...
지금까지 여러 프로젝트를 해오면서도, 예외 처리를 정말 ‘설계의 일부’로 다룬 적은 거의 없었던 것 같습니다.
하지만 이번 리팩토링을 통해 예외도 하나의 로직이고, 예외도 분명한 설계 대상이다. 라는걸 깨달았습니다.😊
'개발프로젝트' 카테고리의 다른 글
[Clean Code] 프로젝트에 '창발성' 원칙을 녹여낸 할인 정책 설계 이야기 (2) | 2025.06.12 |
---|---|
[CleanCode] 실제 프로젝트에서 Clean Code 원칙을 적용한 "좋은 경계" (0) | 2025.06.02 |
[개발프로젝트] 스프링 API 예외 처리, 이제 try-catch는 그만! (1) | 2025.05.26 |
[CleanCode] 형식 맞추기 – 형식 규칙에 대한 깊은 고찰,클린코드를 읽고... (1) | 2025.05.22 |
[CleanCode] 주석은 변명이 아니다 – 네이버 쇼핑 API 응용 코드로 알아보는 클린 코드 (2) | 2025.05.19 |