강의를 들으며… Exception에 대해서 정리할겸 글을 작성하게 됐습니다.
Exception이란 뭘까요..?
1. Java 예외 계층 구조

기본 계층 구조
- Object : 모든 객체의 최상위 부모는 Object 입니다.
- Throwable : 최상위 예외이다. 하위에 Exception 과 Error 가 있습니다.
- Error
- 메모리 부족, 심각한 시스템 오류
- 애플리케이션에서 복구 불가능
- 개발자가 잡으면 안됨
- OutOfMemoryError, StackOverFlowError, VirtualMachineError …
- Exception :
- 애플리케이션 로직에서 사용하는 실질적 최상위 예외입니다. Checked Exception과 RuntimeException으로 구분합니다.
- Checkd Exception: 컴파일 시점에 예외 처리 강제를 요구합니다. 반드시
try-catch
또는throws
필요합니다. IOException
,SQLException
,ClassNotFoundException
- UnChecked Exception( RuntimeException ) : 컴파일러가 예외 처리를 강제하지 않는 예외입니다. RuntimeException과 상속받은 Child Class들이 이 예외들이 모두 해당하며,주로 프로그래밍 오류나 논리적 오류를 나타냅니다.
NullPointException
,IllegalArgumentException
등
중요한 원칙
- 상위 예외를 catch하면 하위 예외도 함께 잡힘
- Throwable을 잡으면 안됨 (Error까지 잡기 때문)
- 애플리케이션 로직은 Exception부터 처리
2. Spring Exception의 핵심 특성
- Throwable의 핵심 속성들
모든 예외의 최상위인 Throwable에서 제공하는 주요 속성들은 다음과 같습니다.
public abstract class Throwable {
private String detailMessage; // 예외 메시지
private Throwable cause; // 원인 예외 (예외 체이닝)
private StackTraceElement[] stackTrace; // 스택 트레이스
// 주요 메서드들
public String getMessage() { return detailMessage; }
public Throwable getCause() { return cause; }
public void printStackTrace() { /* 스택 트레이스 출력 */ }
public StackTraceElement[] getStackTrace() { return stackTrace; }
}
- 예외 체이닝 (Exception Chaining)
Throwable cause를 파라미터로 받아서 super()로 cause를 상속하면 상위 Exception의 에러를 상속받아 다음으로 넘겨줄 수 있습니다. 왜 발생했는지 이전 예외를 가져올 수 있게 됩니다.
try {
// SQLException 발생
connection.prepareStatement("INVALID SQL");
} catch (SQLException e) {
// 원본 예외 정보를 유지하면서 새로운 예외로 감싸기
throw new CustomDatabaseException("데이터베이스 오류 발생", e);
}
// 나중에 원인 추적
public void handleException(CustomDatabaseException e) {
Throwable rootCause = e.getCause(); // 원본 SQLException 획득
while (rootCause.getCause() != null) {
rootCause = rootCause.getCause(); // 최종 원인까지 추적
}
}
- Spring Boot 기본 예외 지원
ResponseStatusException
: HTTP 상태 코드와 함께 예외 처리HttpClientErrorException
: 4xx 클라이언트 오류HttpServerErrorException
: 5xx 서버 오류DataAccessException
: 데이터 액세스 관련 예외ValidationException
: 입력 검증 관련 예외
Spring Boot에서는 다양한 기본 Exception을 지원합니다:
3. 실제 사용할 때 예외 처리 전략
- CustomException
- 기본 예외
- 도메인별 예외
- 구체적인 예외
- 명확한 의미 전달
- 타입 안전성 보장
- 특정 예외에 대한 세밀한 처리 가능
특정 상황에 맞춘 응답이 필요하다면, CustomException을 사용하는 것도 좋은 방법입니다. 반드시 사용해야 하냐는 물음에는 아니오지만, 많은 사람들이 CustomException을 만들어서 사용하고 있는 것도 사실입니다.
3단계로 구분할 수 있을 것 같은데,
public abstract class CustomException extends RuntimeException {
private final String errorCode;
// 모든 비즈니스 예외가 공통으로 가져야 할 속성/메서드 정의
}
실질적으로 사용하진 않고, 앞으로 사용될 모든 CustomException에 공통 인터페이스를 제공합니다.
public class UserException extends CustomException {
public UserException(String message) {
super("USER_ERROR", message);
}
}
public class OrderException extends CustomException {
public OrderException(String message) {
super("ORDER_ERROR", message);
}
}
구체적인 예외 클래스를 정의하지 않을 때 사용하고, 일반적인 오류 상황으로도 설명될 수 있습니다. 혹은 구체적인 예외 클래스를 정하는 것이 개발의 복잡성을 올린다고 판단될때도 큰 카테고리를 커버하는 도메인별 예외를 사용할 수 있습니다.
다만, 일반적으로는 Exception의 Layer를 구성할 때 가장 많이 사용되는 것 같습니다.
public class UserNotFoundException extends UserException {
public UserNotFoundException(Long userId) {
super(String.format("사용자를 찾을 수 없습니다. ID: %d", userId));
}
}
public class DuplicateEmailException extends UserException {
public DuplicateEmailException(String email) {
super(String.format("이미 사용중인 이메일입니다: %s", email));
}
}
명확하고 세부적인 처리가 필요할 때 사용하는데, 직접 정의할 때 쓴다면 보통은 이렇게 세부적으로 나눠놓고 응답하는 것이 특정 예외에 대한 세밀한 처리 가능해져 실제 개발 시 디버깅에 유리하게 작용합니다.
결국 위와 같은 CustomException 사용 시 장점으로는
등을 꼽을 수 있습니다.
다만 너무 많은 Exception은 개발의 복잡성과 생산성을 떨어트리기 때문에 지양할 필요가 있는 것 같습니다. (예를들면, 소규모 프로젝트에서 100개의 CustomException을 만들 필요는 없습니다.)
- Exception을 RuntimeException으로
- 사용하면 좋은 경우:
- 결제 관련
- 인증 관련
- 1: 복구 불가능한 예외
- 2: 의존 관계 문제
- 1. 의존성 제거
- 2. 기술 변경시 영향 최소화
- 3. 예외 전환 패턴
- 순수 JDBC 사용시
- Hibernate 사용시
Checked Exception의 사용은 사용전 한 번 고민해볼 필요가 있습니다. 반드시 처리해야 하는 비즈니스 중요 상황에만 사용하는 것이 좋은데 굳이 사용해야 하는 경우라면,
가 있고 이런 예외들은 개발자가 실수로 놓치면 안 되는 중요한 비즈니스 로직이므로, 컴파일러의 도움을 받아 강제로 처리하도록 만드는 것이 의미가 있습니다. 하지만 체크 예외에는 심각한 문제점들이 있는데,
대부분의 예외는 실제로 복구가 불가능합니다.
SQLException
이나 ConnectException
같은 경우 서비스나 컨트롤러 레벨에서 해결할 방법이 없습니다. 하지만 체크 예외이기 때문에 어쩔 수 없이 throws
로 선언해야 합니다.// 체크 예외 사용시
public void method() throws SQLException, ConnectException {
// 처리할 수 없어도 강제로 선언해야 함
}
// 기술 변경시 모든 곳을 수정
public void method() throws JPAException, NetworkException {
// JDBC → JPA 변경시 모든 메서드 시그니처 변경 필요
}
이는 OCP(개방-폐쇄 원칙)을 위배하고 DI의 장점이 퇴색됩니다.
따라서, 기본적으로 RuntimeException을 사용하는 것이 권장됩니다. 현대의 대부분 라이브러리들이 이 방식을 채택하고 있습니다. RuntimeException의 장점으로는,
public void method() {
// throws 선언 불필요// 처리할 수 없으면 자연스럽게 상위로 전파
}
구현 기술이 JDBC에서 JPA로 바뀌어도 서비스나 컨트롤러 코드는 전혀 수정할 필요가 없습니다. 공통 예외 처리 부분만 수정하면 됩니다.
try {
// JDBC 코드 실행
} catch (SQLException e) {
// Checked Exception을 RuntimeException으로 변환
throw new RuntimeSQLException("데이터베이스 오류 발생", e);
}
기존 예외 정보는 유지하면서 RuntimeException으로 감싸서 던져집니다.
Checked Exception을 RuntimeException으로 변환하는 실제 구현
실제 예시를 보면서 어떻게 Checked Exception이 RuntimeException으로 감싸서 던져지는지 보면
StatementPreparerImpl.class
파일의 hibernate
의 SQL 문을 미리 컴파일하여 실행 준비를 하는 JDBC 메서드인 prepareStatement를 통해 간단하게 살펴볼 수 있습니다.public PreparedStatement prepareStatement() {
try {
.....
PreparedStatement preparedStatement;
try {
observer.jdbcPrepareStatementStart();
// 여기서 JDBC의 PreparedStatement 생성 - SQLException 발생 가능
preparedStatement = this.doPrepare();
this.setStatementTimeout(preparedStatement);
} finally {
eventManager.completeJdbcPreparedStatementCreationEvent(jdbcPreparedStatementCreation, this.sql);
observer.jdbcPrepareStatementEnd();
}
this.postProcess(preparedStatement);
return preparedStatement;
} catch (SQLException var10) {
SQLException e = var10;
// SQLException(Checked Exception)을 RuntimeException으로 변환
throw StatementPreparerImpl.this.sqlExceptionHelper().convert(e, "could not prepare statement", this.sql);
}
}
// SqlExceptionHelper.java
public JDBCException convert(SQLException sqlException, String message, String sql) {
// 예외 로깅
this.logExceptions(sqlException, message + " [" + sql + "]");
// SQLException을 JDBCException(RuntimeException)으로 변환
return this.sqlExceptionConverter.convert(
sqlException,
message + " [" + sqlException.getMessage() + "]",
sql
);
}
여기서 SQLException이 발생하면, SQLException은StatementPreparerImpl.this.sqlExceptionHelper()에 의해 convert되는데, SQLExceptionHelper는 SQLException을 JDBCException으로 convert합니다.
public JDBCException convert(SQLException sqlException, String message, String sql) {
this.logExceptions(sqlException, message + " [" + sql + "]");
return this.sqlExceptionConverter.convert(sqlException, message + " [" + sqlException.getMessage() + "]", sql);
}
이 JDBCException은 아래와 같은 흐름으로 상속을 받고 있는데
JDBCException
→ HibernateException
→ PersistenceException
→ RuntimeException
→ Exception
public class JDBCException extends HibernateException {
// JDBC 관련 예외를 나타내는 RuntimeException
}
public class HibernateException extends PersistenceException {
// Hibernate 프레임워크의 기본 예외
}
public class PersistenceException extends RuntimeException {
// JPA 표준 지속성 예외
}
public class RuntimeException extends Exception {}
이러한 변환을 통해 Checked Exception을 RuntimeException으로 처리할 수 있게 되어 다음과 같은 이점을 얻습니다.
public void saveUser(User user) throws SQLException { // throws 강제 선언
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("INSERT INTO users...");
ps.executeUpdate(); // SQLException 발생 가능
}
@Service
@Transactional
public class UserService {
@PersistenceContext
private EntityManager em;
public void saveUser(User user) { // throws 선언 불필요!
em.persist(user); // 내부적으로 SQLException → JDBCException 변환
}
}
이러한 방식을 통해 개발자는 복구 불가능한 인프라 예외에 신경쓰지 않아도 되고, 비즈니스 로직에만 집중 가능하며, 메서드 시그니처가 깔끔해지고, 기술 변경시 상위 계층 코드 수정 불필요해지는 결과를 얻을 수 있습니다.
- 공통 예외 처리 구조
- Spring Servlet Filter
- Spring Interceptor
@ControllerAdvice
복구 불가능한 예외들은 다음과 같은 방식으로 일관되게 처리합니다:
ControllerAdvice는 Exception들을 ExceptionHandler에 따라, 공통으로 처리가능한 로직을 구성하게 해줍니다.
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e) {
log.error("Runtime Exception 발생", e);
ErrorResponse errorResponse = ErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Internal Server Error")
.message("서버 내부 오류가 발생했습니다.")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorResponse);
}
// 특정 예외 처리
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
IllegalArgumentException e) {
log.warn("잘못된 요청 파라미터: {}", e.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST.value())
.error("Bad Request")
.message(e.getMessage())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
}
등과 같은 공통 예외 처리 컴포넌트를 사용하여 오류 로그를 남기고, 개발자에게 Notification을 전달하며, 사용자에게는 적절한 메시지를 보여주도록 작성해야 합니다.
- Transaction 롤백 정책
Checked Exception이 발생하면 롤백이 되지 않을 수도 있습니다. Spring Boot 공식에서 Rolling Back a Declarative Transaction에 대해서 정의하고 있는데 여기서
In its default configuration, the Spring Framework’s transaction infrastructure code marks a transaction for rollback only in the case of runtime, unchecked exceptions. That is, when the thrown exception is an instance or subclass ofRuntimeException
. (Error
instances also, by default, result in a rollback). 기본 설정에서 Spring Framework의 트랜잭션 인프라 코드는 런타임, 언체크 예외의 경우에만 트랜잭션을 롤백하도록 표시합니다. 즉, 던져진 예외가 RuntimeException의 인스턴스이거나 하위 클래스인 경우입니다. (Error 인스턴스들도 기본적으로 롤백을 발생시킵니다).
즉, RuntimeException과 Error일 경우에만 기본적으로 롤백을 발생시키기 때문에 Checked Exception의 경우 별도 지정이 필요합니다.
@Transactional
public void transactionExample() {
try {
// 1. RuntimeException → 자동 롤백
throw new RuntimeException("런타임 예외");
// 2. Error → 자동 롤백
throw new OutOfMemoryError("메모리 부족");
// 3. Checked Exception → 롤백 안됨
throw new IOException("파일 읽기 실패");
} catch (Exception e) {
// 예외 처리
}
}
// 특정 Checked Exception도 롤백하도록 설정
@Transactional(rollbackFor = {IOException.class, SQLException.class})
public void customRollbackPolicy() throws IOException {
throw new IOException("이제 롤백됨");
}
// 특정 RuntimeException은 롤백하지 않도록 설정
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void noRollbackForSpecific() {
throw new IllegalArgumentException("롤백 안됨");
}
또 외부 API를 사용할 때도, Exception이 발생했을 때 rollback 정책에 대해서 고민해볼 필요가 있습니다.
@Service
@Transactional
public class PaymentService {
public void processPayment(PaymentRequest request) {
try {
// 핵심 비즈니스 로직
validatePayment(request);
executePayment(request);
} catch (InsufficientBalanceException e) {
// 비즈니스 예외 - 롤백됨 (RuntimeException)
throw e;
} catch (ExternalApiException e) {
// 외부 API 오류 - 상황에 따라 결정
if (e.isRetryable()) {
throw new PaymentRetryException("결제 재시도 필요", e);
} else {
throw new PaymentFailedException("결제 실패", e);
}
}
}
}
Exception에 따라 롤백처리할지, 재시도 정책을 구성할지에 대한 고민은 언제나 의미가 있습니다.
- 예외 메시지에 민감 정보 포함
// Worst Case
throw new AuthException("로그인 실패: 비밀번호가 틀렸습니다. 입력값: " + password);
// 적어도 이정도가 좋다.
throw new AuthException("로그인 실패: 인증 정보를 확인해주세요");
- 문서화
RuntimeException은 컴파일러가 체크하지 않으므로 놓칠 수 있습니다. 따라서 중요한 예외는 반드시 문서화해야 합니다. 또는 코드에
throws RuntimeException
을 명시해서 중요한 예외임을 알려줄 수도 있습니다. 문서화는 주석이 될 수 있고, 실제 노션이나 Confluence같은 공유 페이지가 될 수도 있으며 테스트 코드가 될 수도 있습니다./**
* 사용자를 저장합니다.
* @param user 저장할 사용자 정보
* @throws UserDuplicateException 중복된 사용자인 경우
* @throws DatabaseException 데이터베이스 오류 발생시
*/
public void saveUser(User user) {
// 구현 로직
}
3. 좋은 예외 처리 원칙에 대해서
해당 부분은 향로님의 글을 읽어보시면 더 좋을 것 같습니다. 많은 글을 참고했지만, 향로님 글만한 내용이 없었습니다.
내가 보기위해 간단한 정리
1. 복구 가능한 오류 vs 복구 불가능한 오류 구분
복구 가능한 오류
특징: 시스템 외적 요소로 발생하는 치명적이지 않은 오류
예시:
- 사용자의 오입력 (잘못된 전화번호)
- 네트워크 오류
- 서비스적으로 중요하지 않은 작업의 오류
처리 방법:
- 사용자에게 문제 원인을 알려주고 재입력 요청
- 로그 레벨:
warn
- 임계치 초과 시 모니터링 알람 (복구 불가능한 오류보다 높은 기준)
복구 불가능한 오류
특징: 시스템이 자동으로 복구할 수 없는 오류
예시:
- 메모리 부족 (Out of Memory)
- 스택오버플로우 (StackOverflow)
- 시스템 레벨 오류
- 개발자의 잘못 구현된 코드
처리 방법:
- 로그 레벨:
error
- 에러 트레이스 남기기
- 빠른 개발자 알람
2. 특수값 대신 예외 사용
❌나쁜 예시
typescript
function divideWrong(a: number, b: number): number {
if (b === 0) {
return -1;// 특수값 반환
}
return a / b;
}
✅ 좋은 예시
typescript
function divideRight(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero is not allowed.");
}
return a / b;
}
예외 사용의 장점:
- 정확한 문제 표현
- 상세 메시지 포함 가능
- Stack Trace 제공
- 더 깔끔한 코드
3. 예외 객체 사용 (문자열 throw 금지)
❌ 나쁜 예시
throw '유저 정보를 받아오는데 실패했습니다.';
✅ 좋은 예시
throw new Error('유저 정보를 받아오는데 실패했습니다.');
throw new NotFoundResourceException('유저 정보를 받아오는데 실패했습니다.');
4. 추적 가능한 예외
예외에 포함되어야 할 정보:
- 어떤 값을 사용하다가 실패했는지
- 실패한 작업의 이름과 실패 유형
❌ 나쁜 예시
throw new IllegalArgumentException('잘못된 입력입니다.');
✅ 좋은 예시
throw new IllegalArgumentException(`사용자 ${userId}의 입력(${inputData})가 잘못되었다.`);
5. 의미 있는 예외 이름
❌ 나쁜 예시
class CustomException extends Error {}
function connectToDatabase() {
throw new CustomException("Connection failed because of invalid credentials.");
}
✅ 좋은 예시
class InvalidCredentialsException extends Error {}
function connectToDatabase() {
throw new InvalidCredentialsException("Failed to connect due to invalid credentials.");
}
6. Layer에 맞는 예외
각 계층별로 적절한 예외 정의:
// Data Access Layer
function fetchUserData(userId: string): any {
throw new DataAccessException("Failed to fetch user data from database.");
}
// Business Logic Layer
function getUserProfile(userId: string): any {
try {
const userData = fetchUserData(userId);
} catch (error) {
if (error instanceof DataAccessException) {
throw new BusinessLogicException("Error processing user profile.");
}
}
}
// Presentation Layer
function displayUserProfile(userId: string): void {
try {
const profile = getUserProfile(userId);
} catch (error) {
if (error instanceof BusinessLogicException) {
throw new PresentationException("Error displaying user profile.");
}
}
}
7. 예외 계층 구조
❌ 나쁜 예시
class DuplicatedException extends Error {}
class UserAlreadyRegisteredException extends Error {}
✅ 좋은 예시
class ValidationException extends Error {}
class DuplicatedException extends ValidationException {}
class UserAlreadyRegisteredException extends ValidationException {
8. 외부 예외 감싸기
외부 SDK/API 예외를 내부 예외로 변환:
❌ 나쁜 예시
function order() {
const pay = new Pay();
try {
pay.billing();
database.save(pay);
} catch (e) {
logger.error(`pay fail`, e);
}
✅ 좋은 예시
function billing() {
try {
pay.billing();
} catch (e) {
if (e instanceof PayNetworkException) {
// 구체적 처리
}
throw new BillingException(e);
}
}
function order() {
const pay = new Pay();
pay.billing();
try {
database.save(pay);
} catch (e) {
pay.cancel();
}
}
9. 불필요한 catch 금지
❌ 나쁜 예시
function something() {
try {
// 비즈니스 로직
} catch (e) {
throw e;// 불필요한 catch
}
}
✅ 좋은 예시
function something() {
// 비즈니스 로직
}
10. 정상 흐름에서 catch 금지
❌ 나쁜 예시 (예외를 흐름 제어로 사용)
function fetchDataFromAPI() {
if (/* 데이터가 없는 경우 */) {
throw new NoDataFoundError();
}
return data;
}
function display() {
try {
const data = fetchDataFromAPI();
process(data);
} catch (error) {
if (error instanceof NoDataFoundError) {
displayEmptyState();
}
}
}
✅ 좋은 예시
function fetchDataFromAPI() {
if (/* 데이터가 없는 경우 */) {
return null;
}
return data;
}
function display() {
const data = fetchDataFromAPI();
if (!data) {
displayEmptyState();
return;
}
process(data);
}
11. 가능한 늦게 예외 처리
원칙: 가능한 가장 최상위 계층에서 예외 처리
❌ 나쁜 예시 (이른 처리)
function calculate() {
try {
divide();
} catch (e) {
if (error instanceof DivideZeroError) {
// 매번 처리 필요
}
}
}
✅ 좋은 예시 (글로벌 핸들러)
function calculate() {
divide();// 예외 처리 없이 진행
}
// 글로벌 예외 핸들러
@Catch()
export class DivideZeroExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
// 통합된 예외 처리
}
}
장점:
- 가독성: 코드가 더 깔끔함
- 재사용성: 함수를 다른 상황에서 쉽게 재사용
- 통일성: 예외 처리 방법 통일
Share article