[ 김영한 스프링 DB 1편 - 데이터 접근 핵심 원리 #6 ] 스프링과 문제 해결 - 예외 처리, 반복 (1)

김수호·2023년 10월 14일
0
post-thumbnail

이번 섹션에서는 [스프링과 문제 해결 - 예외 처리, 반복]에 대해서 알아보자.

👉 목차는 다음과 같다.

1) 체크 예외와 인터페이스
2) 런타임 예외 적용
3) 데이터 접근 예외 직접 만들기

4) 스프링 예외 추상화 이해
5) 스프링 예외 추상화 적용
6) JDBC 반복 문제 해결 - JdbcTemplate
7) 정리

이번 섹션은 1) ~ 3), 4) ~ 7) 로 나눠서 포스팅하고자 한다.

바로 하나씩 확인해보자.


1) 체크 예외와 인터페이스

서비스 계층은 가급적 특정 구현 기술에 의존하지 않고, 순수하게 유지하는 것이 좋다. 이렇게 하려면 예외에 대한 의존도 함께 해결해야한다.

예를 들어서 서비스가 처리할 수 없는 SQLException 에 대한 의존을 제거하려면 어떻게 해야할까?
서비스가 처리할 수 없으므로 리포지토리가 던지는 SQLException 체크 예외를 런타임 예외로 전환해서 서비스 계층에 던지면 된다. 이렇게 하면 서비스 계층이 해당 예외를 무시할 수 있기 때문에, 특정 구현 기술에 의존하는 부분을 제거하고 서비스 계층을 순수하게 유지할 수 있다.

지금부터 코드로 이 방법을 적용해보자.

인터페이스 도입
먼저 MemberRepository 인터페이스도 도입해서 구현 기술을 쉽게 변경할 수 있게 해보자.

  • 인터페이스 도입 그림 참고)
    • 이렇게 인터페이스를 도입하면 MemberServiceMemberRepository 인터페이스에만 의존하면 된다.
    • 이제 구현 기술을 변경하고 싶으면 DI를 사용해서 MemberService 코드의 변경 없이 구현 기술을 변경할 수 있다.
  • MemberRepository 인터페이스 참고)
    • 특정 기술에 종속되지 않는 순수한 인터페이스이다. 이 인터페이스를 기반으로 특정 기술을 사용하는 구현체를 만들면 된다.

 

체크 예외와 인터페이스

잠깐? 기존에는 왜 이런 인터페이스를 만들지 않았을까? 사실 다음과 같은 문제가 있기 때문에 만들지 않았다.
왜냐하면 SQLException 이 체크 예외이기 때문이다. 여기서 체크 예외가 또 발목을 잡는다. 체크 예외를 사용하려면 인터페이스에도 해당 체크 예외가 선언되어 있어야 한다.
예를 들면 다음과 같은 코드가 된다.

  • 체크 예외 코드에 인터페이스 도입시 문제점 - 인터페이스
    • 인터페이스의 메서드에 throws SQLException 이 있는 것을 확인할 수 있다.
  • 체크 예외 코드에 인터페이스 도입시 문제점 - 구현 클래스
    • 인터페이스의 구현체가 체크 예외를 던지려면, 인터페이스 메서드에 먼저 체크 예외를 던지는 부분이 선언되어 있어야 한다. 그래야 구현 클래스의 메서드도 체크 예외를 던질 수 있다.
      • 쉽게 이야기 해서 MemberRepositoryV3throws SQLException 를 하려면 MemberRepositoryEx 인터페이스에도 throws SQLException 이 필요하다.
    • 참고로 구현 클래스의 메서드에 선언할 수 있는 예외는 부모 타입에서 던진 예외와 같거나 하위 타입이어야 한다.
      • 예를 들어서 인터페이스 메서드에 throws Exception 를 선언하면, 구현 클래스 메서드에 throws SQLException 는 가능하다. SQLExceptionException 의 하위 타입이기 때문이다.

특정 기술에 종속되는 인터페이스

구현 기술을 쉽게 변경하기 위해서 인터페이스를 도입하더라도 SQLException 과 같은 특정 구현 기술에 종속적인 체크 예외를 사용하게 되면 인터페이스에도 해당 예외를 포함해야 한다. 하지만 이것은 우리가 원하던 순수한 인터페이스가 아니다. JDBC 기술에 종속적인 인터페이스일 뿐이다. 인터페이스를 만드는 목적은 구현체를 쉽게 변경하기 위함인데, 이미 인터페이스가 특정 구현 기술에 오염이 되어 버렸다. 향후 JDBC가 아닌 다른 기술로 변경한다면 인터페이스 자체를 변경해야 한다.

런타임 예외와 인터페이스

런타임 예외는 이런 부분에서 자유롭다. 인터페이스에 런타임 예외를 따로 선언하지 않아도 된다. 따라서 인터페이스가 특정 기술에 종속적일 필요가 없다.


2) 런타임 예외 적용

실제 코드에 런타임 예외를 사용하도록 적용해보자.

  • MemberRepository 생성: src > main > java > hello > jdbc > repository 패키지 내부에 MemberRepository 인터페이스를 생성하자.
  • MyDbException 생성 - 런타임 예외: src > main > java > hello > jdbc > repository 패키지 내부에 ex 패키지를 생성하자. 그리고 그 내부에 MyDbException 클래스를 생성하자.
    • RuntimeException 을 상속받았다. 따라서 MyDbException 은 런타임(언체크) 예외가 된다.
  • MemberRepositoryV4_1 생성: src > main > java > hello > jdbc > repository 패키지 내부에 MemberRepositoryV4_1 클래스를 생성하자. (기존 MemberRepositoryV3 를 복사해서 만들자.)
    • MemberRepository 인터페이스를 구현한다.
    • 이 코드에서 핵심은 SQLException 이라는 체크 예외를 MyDbException 이라는 런타임 예외로 변환해서 던지는 부분이다.
    • (참고) findById, update, delete 메서드도 같은 방식으로 수정하자.
    • (참고) 인터페이스를 구현하도록 수정하였기에 @Override를 적용해주었다.

 

✔️ 한번 더 참고

  • 예외 변환
    • 잘 보면 기존 예외를 생성자를 통해서 포함하고 있는 것을 확인할 수 있다. 예외는 원인이 되는 예외를 내부에 포함할 수 있는데, 꼭 이렇게 작성해야 한다. 그래야 예외를 출력했을 때 원인이 되는 기존 예외도 함께 확인할 수 있다.
    • MyDbException 이 내부에 SQLException 을 포함하고 있다고 이해하면 된다. 예외를 출력했을 때 스택 트레이스를 통해 둘다 확인할 수 있다.
  • 예외 변환 - 기존 예외 무시 ( 다음과 같이 기존 예외를 무시하고 작성하면 절대 안된다! )
    • 잘 보면 new MyDbException() 으로 해당 예외만 생성하고 기존에 있는 SQLException 은 포함하지 않고 무시한다.
    • 따라서 MyDbException 은 내부에 원인이 되는 다른 예외를 포함하지 않는다.
    • 이렇게 원인이 되는 예외를 내부에 포함하지 않으면, 예외를 스택 트레이스를 통해 출력했을 때 기존에 원인이 되는 부분을 확인할 수 없다.
      • 만약 SQLException 에서 문법 오류가 발생했다면 그 부분을 확인할 방법이 없게 된다.
    • 예외를 변환할 때는 기존 예외를 꼭! 포함하자. 장애가 발생하고 로그에서 진짜 원인이 남지 않는 심각한 문제가 발생할 수 있다.

 

👉 이번에는 서비스가 인터페이스를 사용하도록 하자.

  • MemberServiceV4 생성: src > main > java > hello > jdbc > service 패키지 내부에 MemberServiceV4 클래스를 생성하자. ( 기존 코드를 유지하기 위해 MemberServiceV3_3 를 복사해서 수정하자. )
    • MemberRepository 인터페이스에 의존하도록 코드를 변경했다.
    • MemberServiceV3_3 와 비교해서 보면 드디어 메서드에서 throws SQLException 부분이 제거된 것을 확인할 수 있다.
    • 드디어 순수한 서비스를 완성했다.

👉 테스트로 확인해보자.

  • MemberServiceV4Test 생성: test > java > hello > jdbc > service 패키지 내부에 MemberServiceV4Test 클래스를 생성하자. ( MemberServiceV3_4Test를 복사해서 만들자. )
    • MemberRepository 인터페이스를 사용하도록 했다.
    • SQLException 체크 예외를 던지는 구문을 제거하였다.
  • 실행해보자.
    • 테스트가 모두 정상 동작하는 것을 확인할 수 있다.

 

✔️ 정리

  • 체크 예외를 런타임 예외로 변환하면서 인터페이스와 서비스 계층의 순수성을 유지할 수 있게 되었다.
  • 덕분에 향후 JDBC에서 다른 구현 기술로 변경하더라도 서비스 계층의 코드를 변경하지 않고 유지할 수 있다.

✔️ 남은 문제

  • 리포지토리에서 넘어오는 특정한 예외의 경우, 복구를 시도할 수도 있다. 그런데 지금 방식은 항상 MyDbException 이라는 예외만 넘어오기 때문에 예외를 구분할 수 없는 단점이 있다. 만약 특정 상황에는 예외를 잡아서 복구하고 싶으면 예외를 어떻게 구분해서 처리할 수 있을까?

3) 데이터 접근 예외 직접 만들기

데이터베이스 오류에 따라서 특정 예외는 복구하고 싶을 수 있다.
예를 들어서 회원 가입시 DB에 이미 같은 ID가 있으면 ID 뒤에 숫자를 붙여서 새로운 ID를 만들어야 한다고 가정해보자.
ID를 hello 라고 가입 시도 했는데, 이미 같은 아이디가 있으면 hello12345 와 같이 뒤에 임의의 숫자를 붙여서 가입하는 것이다.

데이터를 DB에 저장할 때, 같은 ID가 이미 데이터베이스에 저장되어 있다면, 데이터베이스는 오류 코드를 반환하고, 이 오류 코드를 받은 JDBC 드라이버는 SQLException 을 던진다. 그리고 SQLException 에는 데이터베이스가 제공하는 errorCode 라는 것이 들어있다.

데이터베이스 오류 코드 그림

  • 참고)
    • (참고) H2 데이터베이스의 키 중복 오류 코드: e.getErrorCode() == 23505
    • SQLException 내부에 들어있는 errorCode 를 활용하면 데이터베이스에서 어떤 문제가 발생했는지 확인할 수 있다.

H2 데이터베이스 예

  • 23505 : 키 중복 오류
  • 42000 : SQL 문법 오류
  • H2 데이터베이스 오류 코드 참고: https://www.h2database.com/javadoc/org/h2/api/ErrorCode.html
  • 참고로 같은 오류여도 각각의 데이터베이스마다 정의된 오류 코드가 다르다. 따라서 오류 코드를 사용할 때는 데이터베이스 메뉴얼을 확인해야 한다.
    • 예) 키 중복 오류 코드
      • H2 DB: 23505
      • MySQL: 1062

 

서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인할 수 있어야 한다. 그래야 새로운 ID를 만들어서 다시 저장을 시도할 수 있기 때문이다. 이러한 과정이 바로 예외를 확인해서 복구하는 과정이다. 리포지토리는 SQLException 을 서비스 계층에 던지고 서비스 계층은 이 예외의 오류 코드를 확인해서 키 중복 오류 ( 23505 )인 경우 새로운 ID를 만들어서 다시 저장하면 된다.

그런데 SQLException 에 들어있는 오류 코드를 활용하기 위해 SQLException 을 서비스 계층으로 던지게 되면, 서비스 계층이 SQLException 이라는 JDBC 기술에 의존하게 되면서, 지금까지 우리가 고민했던 서비스 계층의 순수성이 무너진다.

이 문제를 해결하려면 앞서 배운 것 처럼 리포지토리에서 예외를 변환해서 던지면 된다. ( SQLException -> MyDuplicateKeyException )

👉 먼저 필요한 예외를 만들어보자.

  • MyDuplicateKeyException 생성: src > main > java > hello > jdbc > repository > ex 패키지 내부에 MyDuplicateKeyException 클래스를 생성하자.
    • 기존에 사용했던 MyDbException 을 상속받아서 의미있는 계층을 형성한다. 이렇게하면 데이터베이스 관련 예외라는 계층을 만들 수 있다.
    • 그리고 이름도 MyDuplicateKeyException 이라는 이름을 지었다. 이 예외는 데이터 중복의 경우에만 던져야 한다.
    • 예외는 우리가 직접 만든 것이기 때문에, JDBC나 JPA 같은 특정 기술에 종속적이지 않다. 따라서 이 예외를 사용하더라도 서비스 계층의 순수성을 유지할 수 있다. (향후 JDBC에서 다른 기술로 바꾸어도 이 예외는 그대로 유지할 수 있다.)

👉 실제 예제 코드를 만들어서 확인해보자.

  • ExTranslatorV1Test 생성: test > java > hello > jdbc > exception 패키지 내부에 translator 패키지를 생성하자. 그리고 그 내부에 ExTranslatorV1Test 클래스를 생성하자.
  • 실행해보자.
    • 두 번째 아이디 저장시 첫 번째와 같은 ID를 저장했지만, 중간에 예외를 잡아서 복구한 것을 확인할 수 있다.

 

✔️ 코드 분석

  • 리포지토리 부터 중요한 부분을 살펴보자.
    • e.getErrorCode() == 23505 : 오류 코드가 키 중복 오류( 23505 )인 경우 MyDuplicateKeyException 을 새로 만들어서 서비스 계층에 던진다.
    • 나머지 경우 기존에 만들었던 MyDbException 을 던진다.
  • 서비스의 중요한 부분을 살펴보자.
    • 처음에 저장을 시도한다. 만약 리포지토리에서 MyDuplicateKeyException 예외가 올라오면 이 예외를 잡는다.
    • 예외를 잡아서 generateNewId(memberId) 로 새로운 ID 생성을 시도한다. 그리고 다시 저장한다. 여기가 예외를 복구하는 부분이다.
    • 만약 복구할 수 없는 예외( MyDbException )면 로그만 남기고 다시 예외를 던진다.
      • 참고로 이 경우 여기서 예외 로그를 남기지 않아도 된다. 어차피 복구할 수 없는 예외는 예외를 공통으로 처리하는 부분까지 전달되기 때문이다. 따라서 이렇게 복구할 수 없는 예외는 공통으로 예외를 처리하는 곳에서 예외 로그를 남기는 것이 좋다. 여기서는 다양하게 예외를 잡아서 처리할 수 있는 점을 보여주기 위해 이곳에 코드를 만들어두었다.

 

✔️ 정리

  • SQL ErrorCode로 데이터베이스에 어떤 오류가 있는지 확인할 수 있었다.
  • 예외 변환을 통해 SQLException 을 특정 기술에 의존하지 않는 직접 만든 예외인 MyDuplicateKeyException 로 변환할 수 있었다.
  • 리포지토리 계층이 예외를 변환해준 덕분에 서비스 계층은 특정 기술에 의존하지 않는 MyDuplicateKeyException 을 사용해서 문제를 복구하고, 서비스 계층의 순수성도 유지할 수 있었다.

✔️ 남은 문제

  • SQL ErrorCode는 각각의 데이터베이스마다 다르다. 결과적으로 데이터베이스가 변경될 때 마다 ErrorCode도 모두 변경해야 한다.
    • 예) 키 중복 오류 코드
      • H2: 23505
      • MySQL: 1062
  • 데이터베이스가 전달하는 오류는 키 중복 뿐만 아니라 락이 걸린 경우, SQL 문법에 오류 있는 경우 등등 수십 수백가지 오류 코드가 있다. 이 모든 상황에 맞는 예외를 지금처럼 다 만들어야 할까? 추가로 앞서 이야기한 것 처럼 데이터베이스마다 이 오류 코드는 모두 다르다.

다음 내용에서는 스프링이 이런 남은 문제를 어떻게 해결하는지 스프링 예외 추상화에 대해서 알아보자.


강의를 듣고 정리한 글입니다. 코드와 그림 등의 출처는 김영한 강사님께 있습니다.

profile
현실에서 한 발자국

0개의 댓글