티스토리 뷰

Spring

[Spring] 예외 처리

mandykr 2022. 10. 21. 16:36

목차

1. 자바 예외

2. 자바 예외 활용

3. 예외 포함과 스택 트레이스

4. 예외 추상화

5. JDBC 반복 문제 해결 - JdbcTemplate

6. 서블릿 예외처리

7. 스프링 부트 오류 페이지

8. API 예외처리

 

 

 

 

목표

  • 자바의 체크 예외와 언체크(런타임) 예외의 활용법을 이해하고 스프링에서 제공하는 예외 추상화를 통해 특정 기술에 의존하지 않고 순수한 서비스 계층을 유지하는 방법에 대해 알아본다.
  • 서블릿의 예외처리 과정을 이해하고 스프링에서 예외를 공통으로 처리하는 방법에 대해 알아본다.

 

 

1. 자바 예외

 

1) 예외 계층

(1) Throwable

최상위 예외이다. 하위에 Exception 과 Error 가 있다.

 

(2) Error

메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다.

애플리케이션 개발자는 이 예외를 잡으려고 해서는 안된다.

상위 예외를 catch 로 잡으면 그 하위 예외까지 함께 잡기 때문에

애플리케이션 로직에서는 Throwable 예외도 잡으면 안되는데, Error 예외도 함께 잡을 수 있기 때문에다.

애플리케이션 로직은 이런 이유로 Exception 부터 필요한 예외로 생각하고 잡으면 된다.

참고로 Error 도 언체크 예외이다.

 

(3)  Exception

체크 예외
애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.

Exception 과 그 하위 예외(단, RuntimeException 제외)는 모두 컴파일러가 체크하는 체크 예외이다.

 

(4) RuntimeException

언체크 예외, 런타임 예외

컴파일러가 체크 하지 않는 언체크 예외이다.

RuntimeException 과 그 자식 예외는 모두 언체크 예외이다.

 

2) 체크 예외

Exception을 상속받은 예외는 체크 예외가 된다.

public class MyCheckedException extends Exception {
    public MyCheckedException(String message) {
        super(message);
    }
}

 

체크 예외는 try ~ catch로 잡아서 처리하거나, 또는 thorws로 밖으로 던지도록 선언해야 한다.

그렇지 않으면 컴파일 오류가 발생한다.

public class Service {
    public void callCatch() {
        try {
            repository.call();
        } catch (MyCheckedException e) {
            log.info("예외 처리, message={}", e.getMessage(), e);
        }
    }

    public void callThrow() throws MyCheckedException {
        repository.call();
    }
}

public class Repository {
    public void call() throws MyCheckedException {
        throw new MyCheckedException("ex");
    }
}

log.info("예외 처리, message={}", e.getMessage(), e);

  • 로그를 남길 때 로그의 마지막 인수에 예외 객체를 전달해주면 로그가 해당 예외의 스택 트레이스를 추가로 출력해준다.

Exception 으로 MyCheckedException 을 잡거나 던질 수 있다.

  • catch는 해당 타입과 그 하위 타입을 모두 잡을 수 있다.
  • 예외를 밖으로 던지는 경우에도 해당 타입과 그 하위 타입을 모두 던질 수 있다

 

▶ 체크 예외 장단점

  • 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전 장치이다.
  • 단점: 하지만 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에,  너무 번거로운 일이 된다. 크게 신경쓰고 싶지 않은 예외까지 모두 챙겨야 한다. 추가로 의존관계에 따른 단점도 있다.

 

3) 언체크 예외

RuntimeException 과 그 하위 예외는 언체크 예외로 분류된다.

public class MyUncheckedException extends RuntimeException {
    public MyUncheckedException(String message) {
        super(message);
    }
}

 

언체크 예외는 컴파일러가 예외를 체크하지 않는다.

언체크 예외는 예외를 던지는 throws 를 선언하지 않고 생략할 수 있다. 이 경우 자동으로 예외를 던진다.

public class Service {
    public void callCatch() {
        try {
            repository.call();
        } catch (MyUncheckedException e) {
            log.info("예외 처리, message={}", e.getMessage(), e);
        }
    }

    public void callThrow() {
        repository.call();
    }
}

public class Repository {
    public void call() {
        throw new MyUncheckedException("ex");
    }
}

 

언체크 예외는 throws를 생략해도 되지만 중요한 예외의 경우 선언해두면 해당 코드를 호출하는 개발자가 이런 예외가 발생한다는 점을 IDE를 통해 좀 더 편리하게 인지할 수 있다.

public void callThrow() throws MyCheckedException {
    repository.call();
}

 

▶ 언체크 예외 장단점

  • 장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다.
    체크 예외의 경우 처리할 수 없는 예외를 밖으로던지려면 항상 throws 예외 를 선언해야 하지만, 언체크 예외는 이 부분을 생략할 수 있다. 신경쓰고 싶지 않은 예외의 의존관계를 참조하지 않아도 되는 장점이 있다.
  • 단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 반면에 체크 예외는 컴파일러를 통해 예외 누락을 잡아준다.

 


2. 자바 예외 활용

(1) 체크 예외 활용

체크 예외는 컴파일러가 예외 누락을 체크해주기 때문에 개발자가 실수로 예외를 놓치는 것을 막아준다.

하지만 다음과 같은 문제점을 갖는다.

public class Controller {
    Service service = new Service();
    public void request() throws SQLException, ConnectException {
        service.logic();
    }
}

public class Service {
    Repository repository = new Repository();
    NetworkClient networkClient = new NetworkClient();
    public void logic() throws SQLException, ConnectException {
        repository.call();
        networkClient.call();
    }
}

public class NetworkClient {
    public void call() throws ConnectException {
        throw new ConnectException("연결 실패");
    }
}

public class Repository {
    public void call() throws SQLException {
        throw new SQLException("ex");
    }
}

 

▶ 복구 불가능한 예외

SQLException 을 예를 들면 데이터베이스에 무언가 문제가 있어서 발생하는 예외이다. 

이런 문제들은 대부분 복구가 불가능하다. 특히나 대부분의 서비스나 컨트롤러는 이런 문제를 해결할 수는 없다. 

따라서 이런 문제들은 일관성 있게 공통으로 처리해야 한다.

오류 로그를 남기고 개발자가 해당 오류를 빠르게 인지하는 것이 필요하다.

서블릿 필터, 스프링 인터셉터, 스프링의 ControllerAdvice 를 사용하면 이런 부분을 깔끔하게 공통으로 해결할 수 있다.

 

▶ 의존 관계에 대한 문제

throws SQLException, ConnectException 처럼 예외를 던지는 부분을 코드에 선언하면 서비스, 컨트롤러에서 java.sql.SQLException 을 의존하기 때문에 문제가 된다.

리포지토리를 JDBC가 아닌 JPA로 변경한다면,
SQLException 에 의존하던 모든 서비스, 컨트롤러의 코드를 JPAException 에 의존하도록 고쳐야 한다.

OCP, DI를 통해 클라이언트 코드의 변경 없이 대상 구현체를 변경할 수 있다는 장점이 사라지게 된다.

 

 

(2) 언체크 예외 활용

체크 예외를 다음과 같이 언체크 예외로 변환해 체크 예외를 사용할 때 발생하는 문제를 해결한다.

  • SQLException → RuntimeSQLException
  • ConnectException → RuntimeConnectException

public class Controller {
    Service service = new Service();
    public void request() {
        service.logic();
    }
}

public class Service {
    Repository repository = new Repository();
    NetworkClient networkClient = new NetworkClient();
    public void logic() {
        repository.call();
        networkClient.call();
    }
}

public class NetworkClient {
    public void call() {
        throw new RuntimeConnectException("연결 실패");
    }
}

public class Repository {
    public void call() {
        try {
            runSQL();
        } catch (SQLException e) {
            throw new RuntimeSQLException(e);
        }
    }
    
    private void runSQL() throws SQLException {
        throw new SQLException("ex");
    }
}

public class RuntimeConnectException extends RuntimeException {
    public RuntimeConnectException(String message) {
        super(message);
    }
}

public class RuntimeSQLException extends RuntimeException {
    public RuntimeSQLException() {
    }
    public RuntimeSQLException(Throwable cause) {
        super(cause);
    }
}

 

▶ 복구 불가능한 예외

시스템에서 발생한 예외는 대부분 복구 불가능 예외이다. 런타임 예외를 사용하면 서비스나 컨트롤러가 이런 복구 불가능한 예외를 신경쓰지 않아도 된다.

물론 이렇게 복구 불가능한 예외는 일관성 있게 공통으로 처리해야 한다.

 

 의존 관계에 대한 문제

런타임 예외는 해당 객체가 처리할 수 없는 예외는 무시하면 된다.

따라서 체크 예외 처럼 예외를 강제로 의존하지 않아도 된다.

 

 

(3) 결론

  • 기본적으로 언체크(런타임) 예외를 사용하자.
  • 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용하자.
    이 경우 해당 예외를 잡아서 반드시 처리해야 하는 문제일 때만 체크 예외를 사용해야 한다.

런타임 예외를 사용하면 중간에 기술이 변경되어도 해당 예외를 사용하지 않는 컨트롤러, 서비스에서는 코드를 변경하지 않아도 된다. 구현 기술이 변경되는 경우, 예외를 공통으로 처리하는 곳에서만 변경하면 되기 때문에 변경의 영향 범위는 최소화 된다.

 

 

(4) 런타임 예외 문서화

런타임 예외는 컴파일 단계에서 체크하지 않기 때문에 문서화를 잘해야 한다.

또는 코드에 throws 런타임예외를 남겨서 중요한 예외를 인지할 수 있게 해야 한다.

 * Make an instance managed and persistent.
 * @param entity entity instance
 * @throws EntityExistsException if the entity already exists.
 * @throws IllegalArgumentException if the instance is not an
 * entity
 * @throws TransactionRequiredException if there is no transaction when
 * invoked on a container-managed entity manager of that is of type
 * <code>PersistenceContextType.TRANSACTION</code>
 */
public void persist(Object entity);

/**
 * Issue a single SQL execute, typically a DDL statement.
 * @param sql static SQL to execute
 * @throws DataAccessException if there is any problem
 */
void execute(String sql) throws DataAccessException;

 


3. 예외 포함과 스택 트레이스

예외를 전환할 때는 꼭! 기존 예외를 포함해야 한다.

public void call() {
    try {
        runSQL();
    } catch (SQLException e) {
        throw new RuntimeSQLException(e); //기존 예외(e) 포함
    }
}

public class RuntimeSQLException extends RuntimeException {
    public RuntimeSQLException(Throwable cause) {
        super(cause);
    }
}

throw new RuntimeSQLException(e);

  • 예외를 포함해서 기존에 발생한 java.sql.SQLException 과 스택 트레이스를 확인할 수 있도록 해야한다.

 


4. 예외 추상화

리포지토리에서 SQLException을 MyDbException이라는 RuntimeException으로 변환했을때,

서비스 계층에서는 MyDbException 이라는 예외만 넘어오기 때문에 예외를 구분할 수 없는 단점이 있다.

만약 특정 상황에는 예외를 잡아서 복구하고 싶으면 예외를 어떻게 구분해서 처리할 수 있을까?

예) 키 중복 오류

 

1) 에러 코드

데이터베이스마다 오류별 오류 코드를 제공한다.

같은 오류여도 각각의 데이터베이스마다 정의된 오류 코드가 다르다.

 

키 중복 오류 코드

  • H2 DB: 23505
  • MySQL: 1062
H2 데이터베이스 오류 코드 참고
https://www.h2database.com/javadoc/org/h2/api/ErrorCode.html

 

2) 예외 구분

키 중복 오류에 대해 서비스 계층에서 별도 처리를 하기위해 기존에 사용했던 MyDbException 을 상속받아서 의미있는 계층을 형성한다. 데이터 중복의 경우에 사용하는 MyDuplicateKeyException을 만든다.

public class MyDuplicateKeyException extends MyDbException {
    public MyDuplicateKeyException() {
    }
    public MyDuplicateKeyException(String message) {
        super(message);
    }
    public MyDuplicateKeyException(String message, Throwable cause) {
        super(message, cause);
    }
    public MyDuplicateKeyException(Throwable cause) {
        super(cause);
    }
}

MyDuplicateKeyException은 JDBC나 JPA 같은 특정 기술에 종속적이지 않다.

따라서 이 예외를 사용하더라도 서비스 계층의 순수성을 유지할 수 있다.

 

하지만 리포지토리에서 이런 방식으로 예외를 구분하면 데이터베이스와 수많은 에러 코드에 의존성을 가지게 된다.

try {
    con = dataSource.getConnection();
    pstmt = con.prepareStatement(sql);
    pstmt.setString(1, member.getMemberId());
    pstmt.setInt(2, member.getMoney());
    pstmt.executeUpdate();
    return member;
} catch (SQLException e) {
    //h2 db
    if (e.getErrorCode() == 23505) {
        throw new MyDuplicateKeyException(e);
    }
    throw new MyDbException(e);
}
  • SQL ErrorCode는 각각의 데이터베이스 마다 다르다. 결과적으로 데이터베이스가 변경될 때 마다 ErrorCode도 모두 변경해야 한다.
  • 데이터베이스가 전달하는 오류는 키 중복 뿐만 아니라 락이 걸린 경우, SQL 문법에 오류 있는 경우 등등 수십 수백가지 오류 코드가 있다.

 

3) 스프링 예외 추상화

스프링은 데이터 접근 계층이 데이터베이스와 에러 코드에 의존하는 문제들을 해결하기 위해 데이터 접근과 관련된 예외를 추상화해서 제공한다.

  • 예외의 최고 상위는 org.springframework.dao.DataAccessException 이다.
    런타임 예외를 상속 받았기 때문에 스프링이 제공하는 데이터 접근 계층의 모든 예외는 런타임 예외이다.
  • NonTransient와 Transient
    • Transient 는 일시적이라는 뜻이다. 
      Transient 하위 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다.
      예를 들어서 쿼리 타임아웃, 락과 관련된 오류들이다. 이런 오류들은 데이터베이스 상태가 좋아지거나, 락이 풀렸을 때 다시 시도하면 성공할 수 도 있다.
    • NonTransient 는 일시적이지 않다는 뜻이다. 
      같은 SQL을 그대로 반복해서 실행하면 실패한다.
      SQL 문법 오류, 데이터베이스 제약조건 위배 등이 있다.

 

(1) 예외 변환기

스프링은 데이터베이스에서 발생하는 오류 코드를 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공한다.

void exceptionTranslator() {
    String sql = "select bad grammar";
    try {
        Connection con = dataSource.getConnection();
        PreparedStatement stmt = con.prepareStatement(sql);
        stmt.executeQuery();
    } catch (SQLException e) {
        // e.getErrorCode() = 42122        
        SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
		DataAccessException resultEx = exTranslator.translate("select", sql, e);
		// resultEx.getClass(): org.springframework.jdbc.BadSqlGrammarException
    }
}
  • translate()
    • 메서드의 첫번째 파라미터는 읽을 수 있는 설명이고, 두번째는 실행한 sql, 마지막은 발생된 SQLException 을 전달하면 된다
    • 이렇게 하면 적절한 스프링 데이터 접근 계층의 예외로 변환해서 반환해준다.
    • SQL 문법이 잘못되었으므로 BadSqlGrammarException 을 반환

 

(2) sql-error-codes.xml

스프링 SQL 예외 변환기는 SQL ErrorCode를 sql-error-codes.xml 파일에 대입해서 어떤 스프링 데이터 접근 예외로 전환해야 할지 찾아낸다. 따라서 각각의 DB마다 다른 SQL ErrorCode에 대해 같은 예외를 반환할 수 있다.

예를 들어, H2 데이터베이스에서는 42000, MySQL 데이터베이스에서는 1054 가 발생하면 badSqlGrammarCodes 이기 때문에 BadSqlGrammarException 을 반환한다.

 

org.springframework.jdbc.support.sql-error-codes.xml

<bean id="H2" class="org.springframework.jdbc.support.SQLErrorCodes">
    <property name="badSqlGrammarCodes">
        <value>42000,42001,42101,42102,42111,42112,42121,42122,42132</value>
    </property>
    <property name="duplicateKeyCodes">
        <value>23001,23505</value>
    </property>
</bean>
<bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes">
    <property name="badSqlGrammarCodes">
        <value>1054,1064,1146</value>
    </property>
    <property name="duplicateKeyCodes">
        <value>1062</value>
    </property>
</bean>

 

 

스프링 예외 추상화는 데이터베이스 뿐만 아니라 특정 기술에도 종속적이지 않게 설계되어 있다.

따라서 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다. 향후 JDBC에서 JPA로 구현 기술을 변경하더라도, 스프링은 JPA 예외를 적절한 스프링 데이터 접근 예외로 변환해준다.

 


5. JDBC 반복 문제 해결 - JdbcTemplate

스프링이 예외를 추상화해준 덕분에, 서비스 계층은 특정 리포지토리의 구현 기술과 예외에 종속적이지 않게 되었다.

하지만 다음과 같이 리포지토리에서는 예외 변환 코드를 반복해서 작성해야 하고 다른 반복 코드들도 존재한다.

} catch (SQLException e) {
	throw exTranslator.translate("save", sql, e);
}

 

JDBC 반복 문제

  • 커넥션 조회, 커넥션 동기화
  • PreparedStatement 생성 및 파라미터 바인딩
  • 쿼리 실행
  • 결과 바인딩
  • 예외 발생시 스프링 예외 변환기 실행
  • 리소스 종료

 

스프링은 JDBC의 반복 문제를 해결하기 위해 JdbcTemplate 이라는 템플릿을 제공한다.

JdbcTemplate 은 JDBC로 개발할 때 발생하는 반복을 대부분 해결해준다. 그 뿐만 아니라 트랜잭션을 위한 커넥션 동기화는 물론이고, 예외 발생시 스프링 예외 변환기도 자동으로 실행해준다.

public class MemberRepository {
    private final JdbcTemplate template;

    public MemberRepository(DataSource dataSource) {
        template = new JdbcTemplate(dataSource);
    }

    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";
        template.update(sql, member.getMemberId(), member.getMoney());
        return member;
    }

    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";
        return template.queryForObject(sql, memberRowMapper(), memberId);
    }

    @Override
    public void update(String memberId, int money) {
        String sql = "update member set money=? where member_id=?";
        template.update(sql, money, memberId);
    }

    @Override
    public void delete(String memberId) {
        String sql = "delete from member where member_id=?";
        template.update(sql, memberId);
    }

    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> {
            Member member = new Member();
            member.setMemberId(rs.getString("member_id"));
            member.setMoney(rs.getInt("money"));
            return member;
        };
    }
}

 

트랜잭션 커넥션 동기화를 위해 작성해야 했던 다음과 같은 코드도 JdbcTemplate을 사용하면서 불필요해졌다.

private void close(Connection con, Statement stmt, ResultSet rs) {
    JdbcUtils.closeResultSet(rs);
    JdbcUtils.closeStatement(stmt);
    DataSourceUtils.releaseConnection(con, dataSource);
}

private Connection getConnection() {
    Connection con = DataSourceUtils.getConnection(dataSource);
    log.info("get connection={} class={}", con, con.getClass());
    return con;
}

 


정리

  • 서비스 계층의 순수성
    • 트랜잭션 추상화 + 트랜잭션 AOP 덕분에 서비스 계층의 순수성을 최대한 유지하면서 서비스 계층에서 트랜잭션을 사용할 수 있다.
    • 스프링이 제공하는 예외 추상화와 예외 변환기 덕분에, 데이터 접근 기술이 변경되어도 서비스 계층의 순수성을 유지하면서 예외도 사용할 수 있다.
    • 서비스 계층이 리포지토리 인터페이스에 의존한 덕분에 향후 리포지토리가 다른 구현 기술로 변경되어도 서비스 계층을 순수하게 유지할 수 있다.
  • 리포지토리에서 JDBC를 사용하는 반복 코드가 JdbcTemplate 으로 대부분 제거되었다.

 

 


6. 서블릿 예외처리

애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다.

애플리케이션에서 예외가 발생했는데, 어디선가 try ~ catch로 예외를 잡아서 처리하면 아무런 문제가 없다.

그런데 애플리케이션에서 예외를 잡지 못하면, 다음과 같은 경로로 WAS까지 예외가 전달된다.

WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)

 

1) 톰캣이 제공하는 오류 페이지

스프링 부트가 제공하는 기본 예외 페이지 설정을 비활성화하면 톰캣에서 제공하는 오류 페이지를 확인할 수 있다.

 

application.properties

server.error.whitelabel.enabled=false

 

(1) 500

톰캣은 예외를 받으면 서버 내부에서 처리할 수 없는 오류가 발생한 것으로 생각해서 HTTP 상태 코드 500을 반환한다.

HTTP Status 500 – Internal Server Error

 

(2) 404

HTTP Status 404 – Not Found

 

2) 서블릿 컨테이너에게 오류를 전달하는 방법

(1) 예외 발생

@Controller
public class ServletExController {
    @GetMapping("/error-ex")
    public void errorEx() {
        throw new RuntimeException("예외 발생!");
    }
}

 

(2) response.sendError(HTTP 상태 코드, 오류 메시지)

HttpServletResponse 가 제공하는 sendError 라는 메서드를 사용해 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.

@GetMapping("/error-404")
public void error404(HttpServletResponse response) throws IOException {
     response.sendError(404, "404 오류!");
}

@GetMapping("/error-500")
public void error500(HttpServletResponse response) throws IOException {
     response.sendError(500);
}

서블릿 컨테이너는 고객에게 응답 전에 response 에 sendError() 가 호출되었는지 확인한다.
그리고 호출되었다면 설정한 오류 코드에 맞추어 기본 오류 페이지를 보여준다.

 

3) 서블릿 오류 페이지 제공 방법

서블릿 컨테이너가 제공하는 오류 페이지가 아닌 직접 오류 처리 화면을 등록할 수 있다.

오류가 전달되면 서블릿 컨테이너는 오류 정보를 확인해 오류 페이지를 재요청한다.

 

(1) 오류 페이지 등록

 

에러 페이지 등록

  • /templates/error-page/404.html
  • /templates/error-page/500.html

에러 페이지 컨트롤러 등록

  • ErrorPageController
    • @RequestMapping("/error-page/404")
    • @RequestMapping("/error-page/500")
@Slf4j
@Controller
public class ErrorPageController {
    @RequestMapping("/error-page/404")
    public String errorPage404(HttpServletRequest request, HttpServletResponse
            response) {
        log.info("errorPage 404");
        return "error-page/404";
    }
    
    @RequestMapping("/error-page/500")
    public String errorPage500(HttpServletRequest request, HttpServletResponse
            response) {
        log.info("errorPage 500");
        return "error-page/500";
    }
}

 

에러 발생시 재요청 경로 설정

  • WebServerCustomizer
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
        factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
    }
}

new ErrorPage(RuntimeException.class, "/error-page/500");

  • RuntimeException이 발생하면 WAS는 "/error-page/500" 경로로 재요청 한다.

 

 

(2) 오류 페이지 작동원리

서블릿은 Exception(예외)이 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError() 가 호출 되었을 때 설정된 오류 페이지를 찾는다. 오류 페이지를 출력하기 위해 오류 페이지를 재요청한다.

1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/errorpage/500) -> View
  • RuntimeException 예외가 WAS까지 전달되면, WAS는 오류 페이지 정보를 확인한다.
    • new ErrorPage(RuntimeException.class, "/error-page/500"
  • 확인해보니 RuntimeException 의 오류 페이지로 /error-page/500 이 지정되어 있다. WAS는 오류 페이지를 출력하기 위해 /error-page/500 를 다시 요청한다.

 

 

(3) request.attribute

WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 request 의 attribute 에 추가해서 넘겨준다

  • javax.servlet.error.exception : 예외
  • javax.servlet.error.exception_type : 예외 타입
  • javax.servlet.error.message : 오류 메시지
  • javax.servlet.error.request_uri : 클라이언트 요청 URI
  • javax.servlet.error.servlet_name : 오류가 발생한 서블릿 이름
  • javax.servlet.error.status_code : HTTP 상태 코드

 

정리

1. 예외가 발생해서 WAS까지 전파된다.

2. WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다.

3. WAS는 오류 페이지와 함께 오류 정보를 request 의 attribute 에 추가해서 넘겨준다

 

 

4) 필터와 인터셉터 재요청 구분

오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 한번 호출이 발생한다.

이때 필터, 인터셉터도 모두 다시 호출된다. 따라서 오류 페이지를 출력하기 위한 내부 요청인지 구분해 필터와 인터셉터에서 비효율적으로 동작하지 않도록 해야한다.

서블릿은 이런 문제를 해결하기 위해 request.attribute에 DispatcherType 이라는 추가 정보를 제공한다.

 

DispatcherType

  • REQUEST : 클라이언트 요청
  • ERROR : 오류 요청
  • FORWARD : MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때
  • RequestDispatcher.forward(request, response);
  • INCLUDE : 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
  • RequestDispatcher.include(request, response);
  • ASYNC : 서블릿 비동기 호출 

 

(1) 필터에서 구분

필터는 filterRegistrationBean.setDispatcherTypes() 으로 DispatcherType에 따라 필터를 적용할 수 있다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new
                FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
        return filterRegistrationBean;
    }
}
  • filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
    • 클라이언트 요청은 물론이고, 오류 페이지 요청에서도 필터가 호출된다.
  • 아무것도 넣지 않으면 기본 값이 DispatcherType.REQUEST 이다.

 

(2) 인터셉터에서 구분

인터셉터는 excludePathPatterns()를 이용해 오류 페이지 경로를 제외시킬 수 있다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/css/**", "/*.ico"
                        , "/error", "/error-page/**" //오류 페이지 경로
                );
    }
}

 

 


7. 스프링 부트 오류 페이지

스프링 부트는 WebServerCustomizer에 예외 종류에 따라서 ErrorPage를 추가하는 과정과

예외 처리용 컨트롤러 ErrorPageController를 만드는 과정을 자동화해준다.

따라서 개발자는 특정 경로에 에러 페이지만 만들어 두면 된다.

 

1) 기본 오류 페이지

스프링 부트는 ErrorPage 를 자동으로 등록한다.

상태코드와 예외를 설정하지 않으면 모든 오류는 /error 를 호출하게 된다.

new ErrorPage("/error")

 

ErrorMvcAutoConfiguration

ErrorMvcAutoConfiguration는 /error에 오류 페이지를 자동으로 등록해준다.

private static class StaticView implements View {
	
    public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
 
        builder.append("<html><body><h1>Whitelabel Error Page</h1>").append(
                "<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>")
                .append("<div id='created'>").append(timestamp).append("</div>")
                .append("<div>There was an unexpected error (type=").append(htmlEscape(model.get("error")))
                .append(", status=").append(htmlEscape(model.get("status"))).append(").</div>");

    }
}

 

2) BasicErrorController

스프링 부트는 BasicErrorController를 스프링 빈으로 등록한다.

BasicErrorController는 "/error " 경로를 받아 오류 페이지를 매핑한다.

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
	@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
	public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
		HttpStatus status = getStatus(request);
		Map<String, Object> model = Collections
				.unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
		response.setStatus(status.value());
		ModelAndView modelAndView = resolveErrorView(request, response, status, model);
		return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
	}
}

 

BasicErrorController가 제공하는 기본 정보

BasicErrorController 컨트롤러는 다음 정보를 model에 담아서 뷰에 전달한다.

timestamp, path, status, message, error, exception, errors, trace

 

오류 정보 포함 여부 설정

application.properties에 오류 정보를 model 에 포함할지 여부 선택할 수 있다.

server.error.include-exception=false : exception 포함 여부( true , false )
server.error.include-message=never : message 포함 여부
server.error.include-stacktrace=never : trace 포함 여부(never, always, on_param)
server.error.include-binding-errors=never : errors 포함 여부(never, always, on_param)

실무에서는 이것들을 노출하면 안된다! 사용자에게는 오류 화면과 고객이 이해할 수 있는 간단한 오류 메시지를 보여주고 오류는 서버에 로그로 남겨서 로그로 확인해야 한다.

 

3)  뷰 선택 우선순위

뷰 템플릿이 정적 리소스보다 우선순위가 높고, 404, 500처럼 구체적인 것이 5xx처럼 덜 구체적인 것 보다 우선순위가 높다. 5xx, 4xx 라고 하면 500대, 400대 오류를 처리해준다.

 

1. 뷰 템플릿

  • resources/templates/error/500.html
  • resources/templates/error/5xx.html

2. 정적 리소스( static , public )

  • resources/static/error/400.html
  • resources/static/error/404.html
  • resources/static/error/4xx.html

3. 적용 대상이 없을 때 뷰 이름( error )

  • resources/templates/error.html

 


8. API 예외처리

오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 하기 때문에 생각할 내용이 더 많다.

 

1) JSON 응답

컨트롤러에서 예외가 발생하거나 response.sendError()로 응답하면 WAS는 기본 경로인 "/error" 를 요청해 BasicErrorController에서 받아 처리한다. HTML이 아닌 JSON으로 응답하기 위해 WebServerFactoryCustomizer를 구현하고 컨트롤러에서 WAS의 재요청을 받아 JSON으로 응답한다.

 

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {        
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
        factory.addErrorPages(errorPageEx);
    }
}

// controller
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {
    Map<String, Object> result = new HashMap<>();
    Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
    result.put("status", request.getAttribute(ERROR_STATUS_CODE));
    result.put("message", ex.getMessage());
    Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    return new ResponseEntity(result, HttpStatus.valueOf(statusCode));
}
  • produces = MediaType.APPLICATION_JSON_VALUE
    • 클라이언트가 요청하는 HTTP Header의 Accept 의 값이 application/json 일 때 해당 메서드가 호출된다.
  • ResponseEntity 를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다.

 

2) BasicErrorController

@RequestMapping의 produces 옵션으로 HTTP Header의 Accept에 따라 호출될 컨트롤러를 구분할 수 있었는데

BasicErrorController도 "/error"라는 동일한 경로에 HTML을 반환할지 JSON을 반환할지 구분해 두 개의 메소드가 정의되어 있다.

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
  • errorHtml() : produces = MediaType.TEXT_HTML_VALUE : 클라이언트 요청의 Accept 해더 값이 text/html 인 경우에는 errorHtml() 을 호출해서 view를 제공한다.
  • error() : 그외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환한다.

 

BasicErrorController 는 HTML 페이지를 제공하는 경우에는 매우 편리하지만,

API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다.

따라서 API 오류 처리는 @ExceptionHandler 를 사용해야한다.

 

3) HandlerExceptionResolver

컨트롤러에서 예외가 발생하거나 request.sendError()를 사용했을 때 컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver를 사용할 수 있다. (ExceptionResolver)

직접 HandlerExceptionResolver를 구현할 수도 있고 스프링 부트가 제공하는 3가지 HandlerExceptionResolver를 사용할 수도 있다.

참고: ExceptionResolver 로 예외를 해결해도 postHandle() 은 호출되지 않는다.

 

(1) ExceptionResolver 활용

 

예외 상태 코드 변환

  • 예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
  • 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어서 스프링 부트가 기본으로 설정한 /error 가 호출됨

 

WAS 재요청 생략

  • 뷰 템플릿 처리나 API 응답 처리처럼 WAS에서 에러 페이지를 재요청할 필요가 없는 경우에 response.sendError()를 호출하지 않으면 불필요한 과정을 생략하고 깔끔하게 응답할 수 있다.
  • 뷰 템플릿 처리
    • ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면을 뷰 렌더링 해서 고객에게 제공
  • API 응답 처리
    • response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다. 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다.

 

 

(2) 사용자 정의 ExceptionResolver 

HTTP 요청 해더의 ACCEPT 값이 application/json 이면 JSON으로 오류를 내려주고, 그 외 경우에는 error/500에 있는 HTML 오류 페이지를 보여준다.

 

UserHandlerExceptionResolver

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof UserException) {
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

                if ("application/json".equals(acceptHeader)) {
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());

                    String result = objectMapper.writeValueAsString(errorResult);
                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);
                    
                    return new ModelAndView();
                } else {
                    //TEXT/HTML
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}
  • response.sendError()를 호출하지 않았기 때문에 WAS는 내부 요청을 다시 하지 않는다.
  • 빈 ModelAndView: new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다. JSON 응답에 사용한다.
  • ModelAndView 지정: ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
  • null: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

 

WebConfig에 UserHandlerExceptionResolver 추가

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new UserHandlerExceptionResolver());
    }
}

 

 

4) 스프링이 제공하는 ExceptionResolver

스프링 부트가 기본으로 제공하는 ExceptionResolver 는 다음과 같다.

1. ResponseStatusExceptionResolver : HTTP 응답 코드 변경

2. DefaultHandlerExceptionResolver : 스프링 내부 예외 처리

3. ExceptionHandlerExceptionResolver : @ExceptionHandler 을 처리

 

예외 발생시 동작 우선 순위

  • 3 → 1 → 2

 

(1) ResponseStatusExceptionResolver

ResponseStatusExceptionResolver 는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.

response.sendError(statusCode, resolvedReason) 를 호출해 지정한 코드로 변경한다.

public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver implements MessageSourceAware {
    protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response)
            throws IOException {

        if (!StringUtils.hasLength(reason)) {
            response.sendError(statusCode);
        }
        else {
            String resolvedReason = (this.messageSource != null ?
                    this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
                    reason);
            response.sendError(statusCode, resolvedReason);
        }
        return new ModelAndView();
    }
}

 

 

[ @ResponseStatus ]

//@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}

BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST (400)으로 변경하고, 메시지도 담는다.

 

messages.properties

error.bad=잘못된 요청 오류입니다.

reason 을 MessageSource 에서 찾는 기능도 제공한다.

 

 

[ ResponseStatusException ]

@ResponseStatus 는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. (애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.) 추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
     throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}

 

 

(2) DefaultHandlerExceptionResolver 

DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결한다.

public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionResolver {
    protected ModelAndView doResolveException(
            HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {

        } else if (ex instanceof TypeMismatchException) {
            return handleTypeMismatch(
                    (TypeMismatchException) ex, request, response, handler);
        }
    }

    protected ModelAndView handleTypeMismatch(TypeMismatchException ex,
            HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {

        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
        return new ModelAndView();
    }
}
  • 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.
  • 그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP 에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다.
  • DefaultHandlerExceptionResolver 는 handleTypeMismatch()에서 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.
  • sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다.
  • HTTP Header의 Accept에 따라 BasicErrorController가 응답한다.
{
  "status": 400,
  "error": "Bad Request",
  "exception": "org.springframework.web.method.annotation.MethodArgumentTypeMismatchException",
  "message": "Failed to convert value of type 'java.lang.String' to required
type 'java.lang.Integer'; nested exception is java.lang.NumberFormatException:
For input string: \"hello\"",
  "path": "/api/default-handler-ex"
}

 

 

(3) ExceptionHandlerExceptionResolver 

API는 컨트롤러, 예외 등에 따라 응답에 세밀한 제어가 필요한데, @ExceptionHandler를 사용하면 편리하게 예외를 처리할 수 있다.

@ExceptionHandler을 사용하면 ExceptionHandlerExceptionResolver가 동작하고 ExceptionHandlerExceptionResolver는 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다.

 

[ Controller ]

@RestController
public class ApiExceptionController {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        return new ErrorResult("EX", "내부 오류");
    }
}

@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.

 

실행 흐름

1. 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.

2. 예외가 발생했으로 ExceptionResolver 가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행된다.

3. ExceptionHandlerExceptionResolver 는 해당 컨트롤러에 IllegalArgumentException 을 처리할 수 있는 @ExceptionHandler 가 있는지 확인한다.

4. illegalExHandle() 를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.

5. @ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드 400으로 응답한다.

 

다양한 예외

다양한 예외를 한번에 처리할 수 있다.

@ExceptionHandler({AException.class, BException.class})

 

예외 생략

@ExceptionHandler 에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

 

파리미터와 응답

@ExceptionHandler 에는 마치 스프링의 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정할 수 있다.

스프링 공식 문서

 

 

5) @ControllerAdvice

@ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 둘을 분리할 수 있다.

 

[ @ControllerAdvice ]

Controller 코드에 있는 @ExceptionHandler 모두 제거

@RestControllerAdvice
public class ExControllerAdvice {
    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }
}

 

대상 컨트롤러 지정 방법

@ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. 스프링 공식 문서

// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}

 

정리

@ExceptionHandler 와 @ControllerAdvice 를 조합하면 예외를 깔끔하게 해결할 수 있다.

 


결론

자바 예외 활용

체크 예외는 예외를 반드시 잡거나 던지도록 처리해야 하는데

복구 불가능한 예외를 잡아 처리할 수 없고 던지면 다른 계층에서의 의존관계 문제가 발생한다.

따라서, 체크 예외를 런타임 예외로 변환해 사용하고 런타임 예외가 발생할 수 있는 코드는 문서로 남겨서 중요한 예외를 인지할 수 있게 해야 한다.

그리고 예외를 변환할 때는 기존 예외를 포함해야 발생 원인을 쉽게 파악할 수 있다.

 

스프링 예외 추상화

스프링은 데이터 접근 계층이 데이터베이스와 에러 코드에 의존하는 문제들을 해결하기 위해 예외 변환기, sql-error-codes.xml을 사용해  데이터 접근과 관련된 예외를 추상화해서 제공한다.

 

서블릿 오류 처리 과정

먼저, 오류 페이지, 오류 페이지 경로 매핑(WebServerCustomizer), 오류 페이지 컨트롤러를 정의한다.

1. 예외가 발생해서 WAS까지 전파된다.

2. WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다.

3. WAS는 오류 페이지와 함께 오류 정보를 request 의 attribute 에 추가해서 넘겨준다.

 

스프링 부트 오류 페이지

스프링 부트는 오류 페이지, 오류 페이지 경로 매핑(WebServerCustomizer), 오류 페이지 컨트롤러를 정의하는 과정을 자동으로 해준다.

  • 오류 페이지 생성: ErrorMvcAutoConfiguration
  • 오류 페이지 경로: "/error"
  • 오류 페이지 컨트롤러: BasicErrorController

뷰 선택 우선순위를 고려해 커스텀 오류 페이지를 생성할 수 있다.

 

API 예외처리

HandlerExceptionResolver를 활용하면 예외 상태 코드를 변환하거나, WAS 재요청을 생략할 수 있다.

1. ResponseStatusExceptionResolver : HTTP 응답 코드 변경
2. DefaultHandlerExceptionResolver : 스프링 내부 예외 처리
3. ExceptionHandlerExceptionResolver : @ExceptionHandler 을 처리
예외 발생시 동작 우선 순위: 3 → 1 → 2

 

@ExceptionHandler를 활용해 예외를 깔끔하게 처리할 수 있고 @ControllerAdvice를 활용해 정상 코드와 예외 처리 코드를 분리할 수 있다.

 

 

 

 

 

출처

스프링 DB 1편 - 데이터 접근 핵심 원리(김영한)

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

728x90

'Spring' 카테고리의 다른 글

[Spring] 트랜잭션  (0) 2022.10.19
[Spring] 3. 스프링 빈  (0) 2022.04.13
[Spring] 2. 컴포넌트 스캔과 의존관계 자동 주입  (0) 2022.03.21
[Spring] 1. 스프링 컨테이너  (0) 2022.03.04