Back to writing

토비의 스프링 - 예외

토비의 스프링 - 예외

자바 개발자가 가장 신경 쓰기 귀찮아하는 것 중의 하나가 바로 예외처리다. 그래서 예외와 관련된 코드는 자주 엉망이 되거나 무성의하게 만들어지기 쉽다.

4.1 사라진 SQLException

3장에서 jdbcTemplate 으로 바꾸고 난 후에 thorws SQLException이 없어졌다.

fun deleteAll() {
    jdbcTemplate.update("delete from users")
}

SQLException은 어디로 간 것일까?

4.1.1 초난감 예외처리

예외 블랙홀

try {
    ...
} catch (Exception e) { }

예외가 발생하면 그것을 catch 블록을 써서 잡아내는 것까지는 좋은데 그리고 아무것도 하지 않고 별문제 없는 것처럼 넘어가 버리는 건 정말 위험한 일이다. 원치 않는 예외가 발생하는 것보다도 훨씬 더 나쁜 일이다. 왜냐하면 프로그램 실행 중에 어디선가 오류가 있어서 예외가 발생했는데 그것을 무시하고 계속 진행해버리기 때문이다. 더 큰 문제는 시스템 오류나 이상한 결과의 원인이 무엇인지 찾아내기가 매우 힘들다는 것이다.

모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운여자 또는 개발자에게 분명하게 통보돼야 한다.

무의미하고 무책임한 thorws

catch 블록으로 예외를 잡아봐야 해결할 방법도 없고 JDK API나 라이브러리가 던지는 각종 이름도 긴 예외들을 처리하는 코드를 매번 throws로 선언하기도 귀찮아지기 시작하면, 매번 throws Exception을 기계적으로 붙이는 개발자도 있다.

예외를 무시해버리는 첫 번째 문제보다는 낫다고 하지만 이런 코드도 매우 안 좋은 예외처리 방법이다.

4.1.2 예외의 종류와 특징

예외처리에 관해서는 자바 개발자들 사이에서도 오랫동안 많은 논쟁이 있었다. 가장 큰 이슈는 Checked Exception 이라고 불리는 명시적인 처리가 필요한 예외를 사용하고 다루는 방법이다.

자바에서 throw를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.

Error

첫째는 java.lang.Error 클래스의 서브클래스들이다. 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다.

그래서 주로 자바 VM에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안 된다.

OutOfMemoryErrorThreadDeath 같은 에러는 catch 블록으로 잡아봤자 아무런 대응 방법이 없기 때문이다.

Excepion과 Checked Exception

java.lang.Exception 클래스와 그 서브클래스로 정의되는 예외들은 에러와 달리 개발자들이 만든 애플리케이션 코드의 작업 중에 예외상황이 발생했을 경우에 사용된다.

  • Exception 클래스는 Checked ExceptionUnchecked Exception으로 나눌 수 있다.
  • Checked ExceptionException 클래스의 서브클래스이면서 RuntimeException 클래스의 서브클래스가 아닌 예외들을 말한다.
  • Unchecked ExceptionRuntimeException 클래스와 그 서브클래스들을 말한다.

사용할 메소드가 Checked Exceptionthrows로 선언하고 있으면 그 메소드를 호출하는 쪽에서는 반드시 try-catch 블록으로 잡아서 처리하거나, throws로 다시 선언해서 호출한 쪽으로 넘겨줘야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

RuntimeException과 Unchecked Exception

java.lang.RuntimeException 클래스와 그 서브클래스들은 명시적인 예외처리를 강제하지 않기 때문에 Unchecked Exception이라고 불린다.

대표적으로 오브젝트를 할당하지 않은 레퍼런스 변수를 사용하려고 시도했을 때 발생하는 NullPointerException이나, 허용되지 않은 값을 사용해서 메소드를 호출할 때 발생하는 IllegalArgumentException 같은 예외들이 있다.

자바 언어를 설계하고 JDK를 개발한 사람들의 이런 설계의도는 현실과 잘 맞지 않았고 비난의 대상이 되기도 했다. 특히 Checked Exception의 불필요성을 주장하는 사람들이 늘어갔다.

Checked Exception가 예외를 강제하는 것 때문에 예외 블랙홀이나 무책임한 throws 선언이 생겨났다는 것이다.

최근에 새로 등장하는 자바 표준 스펙의 API들은 예상 가능한 예외상황을 다루는 예외를 Unchecked Exception으로 설계하는 경우가 많아지고 있다. (kotlin 은 Checked Exception을 지원하지 않는다.)

4.1.3 예외처리 방법

예외 복구

예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려 놓는 것이다.

  • 사용자가 요청할 파일을 읽으려고 시도했는데 해당 파일이 없다거나 다른 문제가 있어서 읽히지가 않아서 IOException이 발생

사용자에게 상황을 알려주고 다른 파일을 이용하도록 안내

  • 네크워크가 불안해서 가끔 서버에 접속이 잘 안되는 열악한 환경에 있는 시스템

재시도, 일정 시간 대기 했다가 다시 접속을 시도해보는 방법

Spring Retry — 서비스 계층에서 “트랜잭션 단위” 재시도

Controller/Service(비즈니스) → Repository(JPA/Hibernate) → JDBC Driver → HikariCP(Connection Pool) → DB

  • 연결 재시도/복구는 드라이버·풀에서 “연결” 수준으로 일부 가능하지만, “쿼리/트랜잭션 재실행”은 애플리케이션 쪽에서 통제해야 안전함.
  • 어떤 작업이 멱등(idempotent) 한지, 재시도 시 부작용/중복이 없는지 판단할 수 있는 곳은 비즈니스 계층뿐.
  • 재시도의 대상 예외, 횟수/백오프, 중단 조건을 도메인 규칙으로 제어 가능.
@EnableRetry
@SpringBootApplication
class App

@Service
class AccountService(
    private val accountUsecase: AccountUsecase // @Transactional 메서드 보유
) {

    // 재시도는 트랜잭션 경계 바깥에서
    @Retryable(
        include = [java.sql.SQLTransientConnectionException::class,
                   org.hibernate.exception.JDBCConnectionException::class],
        maxAttempts = 3,
        backoff = Backoff(delay = 500, multiplier = 2.0, maxDelay = 5_000)
    )
    fun updateBalanceWithRetry(cmd: UpdateBalanceCommand) {
        accountUsecase.updateBalance(cmd) // 내부에서 @Transactional
    }

    @Recover
    fun recover(e: Exception, cmd: UpdateBalanceCommand) {
        // 알림/로깅/대체흐름
        // 예: 실패 이벤트 발행, DLQ, 운영 알람 등
    }
}

@Service
class AccountUsecase(private val repo: AccountRepository) {

    @Transactional
    fun updateBalance(cmd: UpdateBalanceCommand) {
        // 멱등성 확보: unique business key / version / outbox 등 고려
        val account = repo.findByIdForUpdate(cmd.accountId)
        account.increase(cmd.amount)
        // flush는 트랜잭션 경계에서 자동 수행
    }
}

AOP 및 @Retryable를 활용한 낙관적 락 재시도

OpenFeign Retry — 클라이언트 측 재시도

// Feign 설정 클래스
@Configuration
public class FeignConfig {

    // Retryer 빈 등록
    @Bean
    public Retryer retryer() {
        // (초기 대기 간격, 최대 대기 간격, 최대 시도 횟수)
        return new Retryer.Default(100L, TimeUnit.SECONDS.toMillis(1L), 3);
    }

    // ErrorDecoder 커스터마이징
    @Bean
    public ErrorDecoder errorDecoder() {
        return new CustomErrorDecoder();
    }

    public static class CustomErrorDecoder implements ErrorDecoder {
        private final ErrorDecoder defaultDecoder = new Default();

        @Override
        public Exception decode(String methodKey, Response response) {
            int status = response.status();

            // 예: 500 이상이면 재시도 가능 예외로 던지기
            if (status >= 500) {
                return new RetryableException(
                    response.status(),
                    "Retryable status: " + status,
                    response.request().httpMethod(),
                    null,  // 원하는 delay
                    response.request()
                );
            }
            // 그 외는 기본 예외 처리
            return defaultDecoder.decode(methodKey, response);
        }
    }
}

// Feign 클라이언트 선언, 위 설정 연결
@FeignClient(
    name = "my-service-client",
    url = "${my.service.url}",
    configuration = FeignConfig.class
)
public interface MyServiceClient {
    @GetMapping("/some/endpoint")
    MyResponse callSome();
}

// 사용 예 (서비스 계층 등)
@Service
public class MyService {
    private final MyServiceClient client;

    public MyService(MyServiceClient client) {
        this.client = client;
    }

    public MyResponse callWithRetry() {
        return client.callSome();
    }
}

예외처리 회피

예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다. 긴밀한 관계에 있는 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 분명한 확인이 있어야 한다.

예외 전환

좀 더 의미있는 예외로 바꾸어서 던지는 것이다.

예를 들어 SQLException이 발생했을 때 그것을 DuplicateUserIdException 같은 예외로 바꾸어서 던지는 것이다.

fun add(user: User) {
    try {
        jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",
            user.id, user.name, user.password)
    } catch (e: SQLException) {
        if (e.getErrorCode() == SOME_DUPLICATE_ERROR_CODE) {
            throw DuplicateUserIdException(e) // 예외 전환
        }
        throw e // 예외 회피
    }
}

4.1.4 예외처리 전략

런타임 예외의 보편화

수많은 사용자가 동시에 요청을 보내고 각 요청이 독립적인 작업으로 취급된다. 하나의 요청을 처리하는 중에 예외에 발생하면 해당 작업만 중단시키면 그만이다. 독립형 애플리케이션과 달리 서버의 특정 계층에서 예외가 발생했을 때 작업을 일시 중지하고 사용자와 바로 커뮤니케이션하면서 예외상황을 복구할 수 있는 방법이 없다.

애플리케이션 예외

시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외들을 일반적으로 애플리케이션 예외라고 한다.

은행계좌 출금 기능 예시

사용자가 요청한 금액을 은행계좌에서 출금하는 기능을 가진 메소드가 있다. 이런 메소드를 설계하는 방법이 두 가지 있다.

정상적인 출금처리를 했을 경우와 잔고 부족이 발생했을 경우에 각각 다른 종류의 리턴 값을 돌려주는 것이다. (kotlin Result)

fun withdraw(accountId: String, amount: Double): Result {
    val account = accountRepository.findById(accountId)
        ?: return Result.Failure("Account not found for id: $accountId")

    return if (account.balance >= amount) {
        account.balance -= amount
        accountRepository.save(account)
        Result.Success(account.balance)
    } else {
        Result.Failure("Insufficient funds for account id: $accountId, requested: $amount, available: ${account.balance}")
    }
}

정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외 상황에서는 비즈니스적인 의미를 띤 예외를 던지도록 만드는 것이다.

class InsufficientFundsException(message: String) : RuntimeException(message)

fun withdraw(accountId: String, amount: Double) {
    val account = accountRepository.findById(accountId)
        ?: throw IllegalArgumentException("Account not found for id: $accountId")
    if (account.balance < amount) {
        throw InsufficientFundsException("Insufficient funds for account id: $accountId, requested: $amount, available: ${account.balance}")
    }
    account.balance -= amount
    accountRepository.save(account)
}

4.2 예외 전환

스프링의 JdbcTemplate이 던지는 DataAccessException은 런타임 예외로 SQLException을 포장해주는 역할을 한다.

그래서 대부분 복구가 불가능한 예외인 SQLException에 대해 애플리케이션 레벨에서는 신경 쓰지 않도록 해주는 것이다.

또한 SQLException에 담긴 다루기 힘든 상세한 예외정보를 의미 있고 일관성 있는 예외로 전환해서 추상화해주려는 용도로 쓰이기도 한다.

4.2.1 JDBC의 한계

비표준 SQL

  • SQL은 어느 정도 표준화된 언어이고 몇 가지 표준 규약(ANSI SQL)을 따르고 있지만, 각 DBMS마다 고유한 확장 기능이 있고 표준을 완벽하게 따르지 않는 경우도 많다.
  • 해당 DB의 특별한 기능을 사용하거나 최적화된 SQL을 만들 때 유용하기 때문이다.

호환성 없는 SQLException의 DB 에러 정보

  • DB를 사용하다가 발생할 수 있는 예외의 원인은 다양하다. SQL문법 오류, 제약조건 위반, 커넥션 문제, 타임아웃, 리소스 부족 등등.
  • DB마다 SQL만 다른 것이 아니라 에러의 종류와 원인도 제각각이다.
  • DB 벤더마다 getErrorCode()getSQLState() 메소드로 제공하는 에러코드와 SQL 상태 코드도 다르고, 같은 에러코드라도 그 의미가 다를 수 있다.

4.2.2 DB 에러 코드 매핑을 통한 전환

  • JDBC 템플릿은 SQLException을 단지 런타임 예외인 DataAccessException으로 포장하는 것이 아니라 DB의 에러 코드를 DataAccessException 계층 구조의 클래스 중 하나로 매핑해준다.
  • 드라이버나 DB 메타정보를 참고해서 DB 종류를 확인하고 DB별로 미리 준비된 매핑정보를 참고해서 적절한 예외 클래스를 선택하기 때문에 DB가 달라져도 같은 종류의 에러라면 동일한 예외를 받을 수 있는 것이다.
  • DataAccessException은 JDBC의 SQLException뿐만 아니라 JPA, Hibernate, MyBatis 등 다양한 퍼시스턴스 기술에서 발생하는 예외들을 일관성 있게 추상화해서 표현해준다.

DAO 인터페이스와 분리

데이터 엑세스 기술의 API는 자신만의 독자적인 예외를 던지기 때문에 DAO 메소드 내에서 런타임 예외로 포장해 던저준다면 아래와 같이 DAO 인터페이스에서는 특정 기술에 종속되지 않는 깔끔한 선언이 가능하다.

interface UserDao {
    fun add(user: User)
}

데이터 엑세스 예외 추상화와 DataAccessException 계층구조

  • DataAccessException은 자바의 주요 데이터 엑세스 기술에서 발생할 수 있는 대부분의 예외를 추상화하고 있다.
  • 데이터 엑세스 기술에 상관없이 공통적인 예외도 있지만 일부 기술에서만 발생하는 예외도 있다.

인터페이스 적용

=> 기존에 UserDao 클래스의 테스트 코드를 굳이 UserDaoJdbc 로 바꿀필요는 없다. 구현 기술에 상관없이 DAO 기능이 동작하는 데만 관심 있다면, UserDao 인터페이스로 받아서 테스트 하는 편이 낫다.