개발자로 후회없는 삶 살기

spring PART.스프링 데이터 접근 예외 처리 본문

[백엔드]/[spring | 학습기록]

spring PART.스프링 데이터 접근 예외 처리

몽이장쥰 2023. 5. 7. 15:50

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com:443

 

본론

- 예외 처리

기존에 배운 예외를 어플리케이션에서 스프링이 어떻게 깔끔하게 해결해주는지, 레포의 코드 반복을 해결해주는지 알아보겠습니다.

 

- 체크 예외와 인터페이스

서비스 계층은 기술에 의존하지 않고 순수하게 유지하는게 좋다고 했습니다. 이렇게 하려면 예외에 대한 의존도 함께 해결해야합니다.

 

지금 계좌 이체 코드를 보면 순수함을 유지하기 위해 트랜잭션 코드를 프록시에 넣고 파리미터도 매니저로 없었습니다. 근데 throws SQLException이 남아있습니다. 서비스가 처리할 수 없는 시스템 예외인 SQLException를 없애려면 레포의 SQLException 체크 예외를 런타임으로 전환해서 서비스 계층에 던지면 됩니다. 이렇게 하면 서비스가 해당 예외를 무시할 수 있어서 특정 기술에 의존하지 않고 서비스 계층을 순수하게 유지할 수 있습니다.

 

static class Repository {

    void call() {
        try {
            runSQL();
        } catch (SQLException e) {
            throw new RuntimeSQLException(e);
//          throw new IllegalStateException(e);
        }
    }
    void runSQL() throws SQLException {
        throw new SQLException("ex");
    }
}

레포에서 체크 에러가 발생하면 앞에서 배운 것처럼 "런타임 예외로 바꾸면 되겠구나"라고 생각하면 됩니다.

 

-> 인터페이스 도입

먼저 멤버 레포의 인터를 도입해서 구현 기술을 쉽게 변경할 수 있게 할 것입니다. 인터를 도입하면 서비스는 멤버 인터에만 의존하면 되고 이제 구현기술을 변경하고 싶으면 DI를 사용해서 멤버 서비스 코드 변경없이 구현 기술을 변경할 수 있습니다. 저장, 조회, 수정, 삭제 메서드를 역할로 만들면 됩니다.

 

- 체크 예외 인터페이스

근데 이전에도 역할은 충분히 만들 수 있었을 것이라고 생각됩니다. 하지만 다 기존에 이런 인터를 만든지 않은 이유가 있습니다. 왜냐하면 Sql ex가 체크 예외라서 레포를 인터로 만들 수 없었습니다. 여기서도 체크 예외가 발목을 잡는데 체크 예외를 사용하려면 인터도 해당 체크 예외가 선언이 되어있어야 합니다.

 

> 정의할 메서드들이 순수하게 반환형, 이름, 매개변수만 있는 것이 아니라 throws를 선언해야 합니다. 이제는 체크 예외를 런타임으로 전환할 것이니 throws를 역할 메서드에 붙이지 않아도 됩니다.

 

-> 주의

멤버 레포 인터를 일단은 throws의 의존하는 역할을 만들어 봅니다. 그러면 이전에 만들어 놓은 구현체들에 implements를 하면 구현체 메서드들에 인터페이스에도 throws가 있어야한다는 컴파일 오류가 뜹니다.

 

> 이전에는 레포 메서드들에 sql ex라는 체크 예외를 꼭 명시를 해줘야해서 역할의 메서드들도 throws를 했어야했는데 이제 구현체들의 메서드에 sql ex을 런타임으로 전환할 것이라서 구현체의 메서드에 throws sql ex를 붙일 필요가 없고 따라서 역할의 메서드에도 throws sql ex 없이 순수하게 메서드를 정의할 수 있습니다.

 

+ 결과적으로 이전에 체크 예외를 레포에서 사용하면 특정 기술에 종속되는 인터가 나오고 jdbc 기술에 종속적인 인터가 되는 것입니다. 구현체를 jpa 기술을 사용하게 되면 인터에는 throws SQL ex가 아닌 JPA ex를 써야해서 안 됩니다.

 

-> 런타임 예외와 인터페이스

결국 레포에서 런타임 예외를 던지면 됩니다. 런타임 예외는 이런 부분에서 자유롭고 인터에 런타임 예외를 따로 선언하지 않아도 됩니다. 그래서 지금까지 역할을 만들지 않은 것 입니다.

 

- 런타임 적용 

public interface MemberRepository {
    Member save(Member member);
    Member findById(String memberId);
    void update(String memberId, int money);
    void delete(String memberId);
}

실제 코드에 런타임 예외를 적용해서 이런 문제를 다 해결해보겠습니다. 먼저 제대로 된 멤버 레포 인터를 만듭니다. throws SQL ex가 없는 순수 인터입니다.

 

그 다음 각 구현체의 메서드에 만든 SQL ex를 던지는 것을 런타임으로 전환해야 합니다. 그래야 서비스에서 해결 할 수 없는 복구 불가 예외를 무시할 수 있고 이것을 컨트도 무시하여 was가 받아서 공통 처리 할 수 있게 하면 됩니다.

 

public class MyDBRuntimeException extends RuntimeException{
    public MyDBRuntimeException() {
    }

    public MyDBRuntimeException(String message) {
        super(message);
    }

    public MyDBRuntimeException(String message, Throwable cause) {
        super(message, cause);
    }

    public MyDBRuntimeException(Throwable cause) {
        super(cause);
    }
}

레포에 ex 패키지를 만들고 MyDB 사용자 예외를 만들고 런타임 예외를 상속 받아서 사용자 지정 런타임 예외를 만들고 생성자를 기본, 메세지, 메세지+cause, cause로 4개 만듭니다. 전환할 때는 무조건 cause를 가져야하고 얘는 런타임 예외를 상속받아서 언체크입니다.

 

보면 이전에도 SQL 예외를 다른 것을 전환할 때 런타임 예외를 사용자 지정으로 만들었습니다.

 

-> 레포

레포 구현체를 새로 만듭니다. 예외 누수를 해결한 레포입니다. 체크 예외를 런타임 예외로 변경할 것이고 멤버 레포 인터를 사용할 것이고 throws SQL ex인 체크 예외를 제거할 것입니다.

 

// 기존
try {
    con = getConnection();
    pstmt = con.prepareStatement(sql);
    pstmt.setString(1, member.getMemberId());
    pstmt.setInt(2, member.getMoney());
    pstmt.executeUpdate();
    return member;
} catch (SQLException e) {
    log.error("db error", e);
    throw e;
} finally {
    close(con, pstmt, null);
}

// 이후
@Override
public Member save(Member member) {
    String sql = "insert into member (member_id, money) values(?, ?)";

    Connection con = null;
    PreparedStatement pstmt = null;

    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, member.getMemberId());
        pstmt.setInt(2, member.getMoney());
        pstmt.executeUpdate();
        return member;
    } catch (SQLException e) {
        throw new MyDBRuntimeException(e);
    } finally {
        close(con, pstmt, null);
    }
}

catch 부분만 바꾸면 됩니다. 기존에는 그냥 SQL 예외를 e로 던졌는데 사용자 지정 런타임 예외를 바꿔서 던지면 됩니다. 꼭 원인을 넣어줘야합니다. 이렇게 하면 save 메서드의 throws를 할 필요가 없습니다. 코드를 보면 더 이상 throws가 없어지고 런타임 사용자 예외가 서비스로 던져집니다.

 

-> 서비스

서비스도 멤버 레포 인터에 의존하도록 합니다. 서비스도 지금 모든 메서드가 throws가 있는데 이것이 레포를 서비스가 호출하기에 레포에서 던진 예외를 서비스도 던져야해서 그랬던 것인데 이제 레포가 런타임 언체크 예외를 던지니 예외 누수 문제인 throws를 해결할 것입니다.

또한 이제 서비스는 멤버 레포 인터에만 의존합니다. 지금까지 선언을 레포V4로 했는데 레포로만 해도 됩니다. 부모.으로 구현 기능을 사용하는 것입니다.

 

// 이전
@Transactional
public void accountTransfer(String fromMemberId, String toMemberId, int money) throws SQLException{
    bizLogic(toMemberId, money, fromMemberId);
}

// 이후
@Transactional
public void accountTransfer(String fromMemberId, String toMemberId, int money) {
    bizLogic(toMemberId, money, fromMemberId);
}

멤버 레포 인터를 주입받으면 지금 이 역할을 구현한 레포는 모든 메서드가 런타임이라서 서비스에서 레포를 호출해도 런타임이 던져지니 서비스의 메서드도 throws를 다 지워도 됩니다. 이렇게 해서 서비스는 이제 거의 순수한 자바 코드만 남아있습니다. 예외 누수, 트랜잭션 관리 코드를 다 없앴습니다.

 

- 테스트

// 이전
@Test
@DisplayName("정상 이체")
void accountTransfer() throws SQLException {

// 이후
@Test
@DisplayName("정상 이체")
void accountTransfer() {
    //given
    Member memberA = new Member(MEMBER_A, 10000);
    Member memberB = new Member(MEMBER_B, 10000);
    memberRepository.save(memberA);
    memberRepository.save(memberB);
    //when
    log.info("START");
    memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
    log.info("END");

서비스에 멤버 인터의 구현체 레포를 주입합니다. 레포와 서비스를 빈 등록했었는데 선언은 멤버 레포 인터로 하여 주입받고 등록은 인터를 구현한 sql ex를 다 언체크로 전환해서 던져서 throws를 다 없앤 레포로 합니다. 이제 테스트할 때도 throws가 다 없어집니다. 실행해보면 성공합니다.

 

- 정리

체크 예외를 런타임으로 변환하면서 멤버 레포 인터페이스를 만들 수 있었고 서비스 계층의 순수성을 예외 누수를 없애서 유지할 수 있었습니다. 덕분에 향후 데이터 계층 기술을 jdbc에서 다른 것으로 변경해도 서비스 계층 코드를 변경하지 않아도 됩니다.

- 남은 문제

그런데 레포에서 넘어오는 특정한 예외의 경우 복구를 시도할 수도 있습니다. 예를 들어서 db에서 같은 id가 중복이 된다던가 등의 예외가 올라왔을 때 서비스 계층에서 서비스에 "이것은 우리가 한 번 잡아서 복구 해보자" 라는 매커니즘이 발생할 수도 있습니다. 그런데 지금 방식은 MyDb 예외만 올라와서 예외를 구분할 방법이 없습니다.

지금 db 예외가 sql 문법 오류인지, 기본 키 문제인지 구분할 방법이 없습니다. 만약 특정 상황에는 서비스가 예외를 잡아서 복구하고 싶을 때가 있는데 예외를 상황 별로 구분해서 처리하는 방법이 필요합니다.

 

- 데이터 접근 예외 직접 만들기

db 오류에 따라서 특정 예외는 복구를 하고 싶을 때가 있습니다. 예를 들어서 "회원가입할 때 DB에 이미 같은 id가 있으면 id 뒤에 숫자를 붙여서 가입을 시킨다"라는 룰이 있다고 해보겠습니다.

 

hello를 시도했는데 같은 아이디가 있으면 hello12345와 같이 임의의 숫자를 붙여서 가입하는 것입니다. 같은 id가 이미 저장되어 있다면 db는 오류 코드를 반환할 것이고 unique 제약 조건으로 반환할 것입니다. 이 오류 코드를 받은 jdbc 드라이버는 sql 예외를 던지고 sql 예외에는  db가 제공하는 오류 코드가 errorcode(unique 제약 조건) 라는 것으로 들어있습니다.

 

-> 그림

insert 코드를 날리면 db가 제약 조건 오류가 났다고 하고 오류 코드를 db 내부에서 가지고 있다가 반환합니다. pk가 중복이라고 하면 23505를 db 내부에서 가지고 있다가 반환합니다. 이걸 jdbc 드라이버가 SQL 예외를 만드는데 그 안에 오류 코드를 넣어놓습니다.(23505) 이 예외가 레포에게 넘어오고 그것을 get 에러코드로 꺼내서 확인해볼 수 있습니다.

 

꺼내서 23505면 "이것은 키가 중복되는 오류구나"할 수 있습니다. 에러코드를 활용하면 db에서 어떤 문제가 발생했는지 확인할 수 있습니다. 

 

h2는 23505는 키 중복, 42000은 sql 문법 오류라고 하고 db마다 다 오류코드가 다릅니다.

 

서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인할 수 있어야합니다. 지금 룰이 id가 중복되면 숫자를 만들어 넣겠다는 것인데 오류를 알아야 룰을 적용할 수 있습니다. 이러한 과정이 바로 예외를 확인해서 복구하는 과정입니다. 레포는 SQL 예외를 서비스 계층에 던지고 서비스가 이 예외의 오류 코드를 get으로 확인해서 새로운 id인 경우 다시 저장하면 됩니다.

> 그런데 오류 코드를 알기 위해서 또 서비스 계층에 sql ex를 던져야하고 그러면 서비스 계층이 또 jdbc 기술에 의존하게 됩니다. 이 문제를 해결하기 위해 레포에서 예외를 변환해서 던지면 되고 SQL 예외를 중복 키 예외로 변환해서 런타임으로 던지는 것입니다.

 

-> 예외 만들기

public class MyDbDuplicateKeyException extends MyDBRuntimeException{
    public MyDbDuplicateKeyException() {
    }

    public MyDbDuplicateKeyException(String message) {
        super(message);
    }

    public MyDbDuplicateKeyException(String message, Throwable cause) {
        super(message, cause);
    }

    public MyDbDuplicateKeyException(Throwable cause) {
        super(cause);
    }
}

런타임을 상속받아도 되지만 전에 만든 MyDB 예외를 상속받아서 이건 DB 에러라고 카테고리를 묶을 수 있습니다. 이름을 MyDuplicateKeyException이라는 이름을 지었는데 이 예외는 데이터 중복의 경우에만 던질 것입니다.(약속) 이 예외는 우리가 직접 만든 예외라서 jdbc나 jpa같은 특정 기술에 종속적이지 않고 서비스 계층의 순수성을 유지할 수 있고 향후 다른 기술로 바꿔도 이 예외를 그대로 유지할 수 있습니다. 

 

> 대신 jdbc 예외면 jdbc 레포에서 이 MyDuplicateKeyException 예외로 전환해서 서비스에 넘겨야하고 jpa 레포에서 MyDuplicateKeyException로 전환해서 서비스로 넘겨야합니다.(약속)

 

- 구현

중복 예외가 터졌을 때 실행되는 코드를 구현해봅니다. 예외를 전환하는 테스트를 만들 것입니다.

 

1. 레포

@RequiredArgsConstructor
static class Repository {
    private final DataSource dataSource;
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";

        try {

        } catch (SQLException e) {
            //h2 db
            if (e.getErrorCode() == 23505) {
                throw new MyDuplicateKeyException(e);
            }
            throw new MyDBRuntimeException(e);
        } finally {
            closeStatement(pstmt);
            closeConnection(con);
        }
    }
}

레포로 insert하는 것을 만듭니다. catch에서 sql 예외를 던지지 않고 만약 에러 코드가 23505면 아까 만든 DB 중복 예외를 던질 것입니다. 이제 이것을 서비스에서 잡아서 복구를 할 것입니다. catch에서 중복 예외는 중복 예외를 던지자고 약속하고 그 외는 런타임 예외를 던지도록 합니다.

 

 

2. 서비스

@RequiredArgsConstructor
static class Service {
    private final Repository repository;
    public void create(String memberId) {
        try {
            repository.save(new Member(memberId, 0));
            log.info("saveId={}", memberId);
        } catch (MyDuplicateKeyException e) {
            log.info("키 중복, 복구 시도");
            String retryId = generateNewId(memberId);
            log.info("retryId={}", retryId);
            repository.save(new Member(retryId, 0));
        } catch (MyDBRuntimeException e) {
            log.info("데이터 접근 계층 예외", e);
            throw e;
        }
    }
    private String generateNewId(String memberId) {
        return memberId + new Random().nextInt(10000);
    }
}

이제 이 db 중복 예외를 받아서 복구하는 서비스 코드를 만듭니다. 회원 가입하는 로직을 짭니다. 레포.save를 했는데 hello라는 id가 이전에도 있으면 레포에서 db 중복 예외가 터지고 이것을 catch로 잡습니다. 로그로 키 중복이 났고 복구를 시작한다고 남기고 id를 하나 생성을 할 것입니다. id를 받아서 숫자를 붙여서 램덤값을 만들 것입니다. 그리고 다시 save를 호출합니다.

 

> 이것이 언제 될 것이냐면 현재 회원 저장은 레포의 save 메서드로 직접 했었습니다. 계좌 이체만 멤버 서비스에 만든 것인데 그것과 별개로 멤버 서비스에 create라는 회원 가입 메서드를 만들고 거기서 레포의 save를 호출할 때 키 중복이면 중복 예외를 터뜨리게 해서 한 것입니다.

 

+ MyDb 런타임 오류도 안 만들면 그냥 자동으로 던져지는데 여러개 catch 할 수 있다는 것을 보이기 위해 만듭니다.

 

- 테스트

@BeforeEach
void beforeEach() {
    dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    repository = new Repository(dataSource);
    service = new Service(repository);
}

beforeEach를 합니다. 현재 스프링 부트 테스트가 아니라서 등록한 빈을 가져올 수 없어서 소스도 생성합니다.

 

서비스에서 create의 hello를 만들고 한 번 더 하면 같은 id로 저장을 시도할 것입니다. 로그를 보면 처음에는 저장을 하는데 또 저장하면 키 중복, 복구 시도를 하며 다른 id로 저장합니다.

 

- 정리

db 예외 코드로 db에 어떤 오류가 있는지 확인할 수 있었습니다. 예외 변환을 통해 sql ex에 의존하지 않은 직접 만든 예외로 변환하였고 레포가 변환을 해준 덕분에 서비스 계층은 순수성을 유지할 수 있었습니다. 사용자 예외를 만든 것이 런타임을 상속받아서 예외 누수를 해결하는 것도 있지만 특정 기술에 의존하지 않는 MyDuplicateKey처럼 사용자 지정으로 특정 기술에 의존하지 않게 하는 것도 있는 것입니다.

 

- 남은 문제

sql 에러코드가 db마다 다 다릅니다. 그래서 db가 변경되면 에러 코드도 다 레포에서 변경해야합니다. 또한 db가 전달하는 오류는 수십 수백까지 코드가 있습니다. 이 모든 상황에 맞는 예외를 다 사용자 지정으로 만드는 것이 아니고 스프링이 추상화 해주는 것입니다.

 

-> 스프링이 제공하는 예외 변환기

catch (SQLException e) {
    //h2 db
    if (e.getErrorCode() == 23505) {
        throw new MyDuplicateKeyException(e);
    }
    throw new MyDBRuntimeException(e);
}

스프링은 db에서 발생한 오류 코드를 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공합니다. 우리가 직접 한다면 badGrammer의 경우 레포에서 문법이 잘못된 것을 if로 찾아서 badGrammer를 던져야하는데 그 코드를 짤 수 없습니다. 그래서 스프링이 sql ex를 분석을 해서 이게 badGrammer 예외면 자동으로 변환해주는 기능을 제공합니다.

 

- 구현

@Test
void sqlExceptionErrorCode() {
    String sql = "select bad grammer";

    try {
        Connection con = dataSource.getConnection();
        PreparedStatement stmt = con.prepareStatement(sql);
        stmt.executeQuery();
    } catch (SQLException e) {
        assertThat(e.getErrorCode()).isEqualTo(42122);
        throw new BadSqlGrammarException(e);

        int errorCode = e.getErrorCode();
        log.info("errorCode={}", errorCode);
        log.info("error", e);
    }
}

이 기능을 알아보기 전에 앞에 한 것을 간단히 복습해보겠습니다. sql에 문법이 잘못된 sql을 만듭니다. 이것을 try에서 레포에서 한 것처럼 pstmt로 db에 전달합니다. 그러면 문법이 잘 못 되어서 catch에서 에러 코드가 H2의 경우 42122로 뜹니다.

 

로그와 에러코드를 찍어보면 관련 로그가 나옵니다. 이전에 살펴봤던 sql 에러코드를 직접 확인하여 하나하나 스프링이 만들어준 예외로 변환하는 것은 현실성이 없습니다. 이런 경우에 if로 42122면 throw new BadSql로 서비스에 직접 던지고 서비스에는 try catch에서 find를 했는데 Bad이면 catch에서 어떤 처리를 하도록 약속을 일일히 다 해야하는 것입니다.

 

-> 스프링 예외 변환기

@Test
void sqlExceptionErrorCode() {
    String sql = "select bad grammer";

    try {
        Connection con = dataSource.getConnection();
        PreparedStatement stmt = con.prepareStatement(sql);
        stmt.executeQuery();
    } catch (SQLException e) {
        assertThat(e.getErrorCode()).isEqualTo(42122);
        SQLErrorCodeSQLExceptionTranslator exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
        DataAccessException resultEx = exceptionTranslator.translate("select", sql, e);

        log.info("errorCode={}", resultEx);
        assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
    }
}

예외 변환기를 추가합니다. try까지는 똑같습니다. catch를 만드는 것도 똑같은데 변환기가 나옵니다. SQLErrorCodeSQLExceptionTranslator를 제공하고 여기에 데이터 소스를 넣고 translate하고 task에 작업명(원하는 것) , 실행된 sql, SQL 예외 넣으면 SQL 예외 분석해서 DataAccessException을 반환해 줍니다. 

 

> 물론 DataAccessException이 최상위이니 얘의 자식 중에 하나로 반환이 되는 것이고 추상화해서 부모.으로 처리하는 것 입니다.

 

결과는 데이터 접근 예외 계층에 있던 클래스 중 하나이고 여기서는 BadGrammer입니다. db 예외가 터지면 jdbc가 내부에 오류를 가지고 SQL 예외를 만들어서 준다고 했는데 스프링이 발생한 SQL 예외를 다 분석해줘서 어떤 예외인지 반환해주는 것입니다. 레포에서 지금까지 예외 변환을 직접 if문하고 사용자 지정 예외 만들고 throw해서 했는데 스프링이 해주는 것입니다.

 

-> 원리

각 db마다 sql 에러 코드가 다르다고 했는데 어떻게 변환이 되는 것일까요? xml이 있는데 h2의 경우에 이 에러 코드면 badsql이라고 표를 가지고 있습니다. 변환기가 에러 코드를 e.getErrorCode로 구하고 이 파일에 대입하여 badsql로 반환하는 것입니다.

 

- 정리

결과적으로 스프링 translator가 반환한 예외를 레포에서 throw로 던지고 서비스에서 받아서 쓰면 됩니다.

 

- 스프링 예외 추상화 적용

스프링 예외 추상화를 우리 어플에 적용해 보겠습니다. 이전에 체크 에러를 런타임으로 바꾸기 위해서 MyDBRuntime 사용자 지정 예외를 만들고 레포의 모든 메서드에 throws를 땠었습니다. 

 

private final DataSource dataSource;
private final SQLExceptionTranslator sqlExceptionTranslator;

public MemberRepositoryV4_3(DataSource dataSource) {
    this.dataSource = dataSource;
    sqlExceptionTranslator =new SQLErrorCodeSQLExceptionTranslator(dataSource);
}

먼저 SQLExceptionTranslator 인터를 주입합니다. 생성자에서 SQLErrorCodeSQLExceptionTranslator를 생성하고 데이터 소스를 넣습니다. 데이터 소스를 쓰는 이유는 어떤 DB인지 알기 위해서 필요합니다. 

 

// 이전
try {
    con = getConnection();
    pstmt = con.prepareStatement(sql);
    pstmt.setString(1, member.getMemberId());
    pstmt.setInt(2, member.getMoney());
    pstmt.executeUpdate();
    return member;
} catch (SQLException e) {
    throw new MyDBRuntimeException(e);
} finally {
    close(con, pstmt, null);
}

// 이후
try {
    con = getConnection();
    pstmt = con.prepareStatement(sql);
    pstmt.setString(1, member.getMemberId());
    pstmt.setInt(2, member.getMoney());
    pstmt.executeUpdate();
    return member;
} catch (SQLException e) {
    throw sqlExceptionTranslator.translate("insert", sql, e);
} finally {
    close(con, pstmt, null);
}

이제 변환기로 예외를 반환해서 throw하면 됩니다. 그러면 이거 한 줄로 스프링이 제공하는 예외 계층을 다 처리할 수 있고  서비스에서 원하면 쓰고 아니면 던지면 되는 것입니다. 레포의 모든 메서드에 다 이 구문을 넣습니다. 이것으로 레포에서 if와 사용자 지정 예외를 만드는 것을 해결했습니다.

 

> 이전에 있던 MyDBRuntimeException이 레포가 SQL 예외를 던지는게 체크 예외라서 런타임 예외로 전환해서 하는 것이었는데 그것도 되어서 throws Exception 예외 누수도 해결됩니다. 이거 한 줄로 모든 고민이 해결이 됩니다. 최상위 DataAccessException의 부모가 RuntimeException이라서 체크 예외 누수도 해결됩니다.

 

-> 테스트

기존 레포를 인터페이스로 만들고 체크 예외를 런타임으로 바꿔 서비스의 예외 누수를 없앤 레포에서 스프링 예외 추상화까지 적용한 레포로 바꾸고 실행하면 끝납니다.

 

- 정리

private void bizLogic(String toMemberId, int money, String fromMemberId) {
    Member findMember = memberRepository.findById(fromMemberId);
    Member toMember = memberRepository.findById(toMemberId);

    try {
        memberRepository.update(fromMemberId, findMember.getMoney() - money);
    } catch (BadSqlGrammarException e) {

    }

    validation(toMember);
    memberRepository.update(toMemberId, findMember.getMoney() + money);
}

드디어 예외에 대한 부분을 깔끔하기 정리했습니다. 스프링이 예외를 추상화해준 덕분에, 서비스 계층은 특정 레포의 구현 기술과 예외에 종속적이지 않게 되었습니다. 서비스 계층은 이제 스프링이 제공하는 데이터 접근 예외로 변경되어서 서비스 계층에 넘어오기 때문에 필요한 경우 예외를 잡아서 복구하면 됩니다.

 

} catch (MyDuplicateKeyException e) {
    log.info("키 중복, 복구 시도");
    String retryId = generateNewId(memberId);
    log.info("retryId={}", retryId);
    repository.save(new Member(retryId, 0));
} catch (MyDBRuntimeException e) {
    log.info("데이터 접근 계층 예외", e);
    throw e;
}

이전에 if와 사용자 지정 예외 만드는 것과 레포에서 던져서 서비스에서 받는 것을 약속을 했었는데 이제 if와 사용자 지정을 만드는 것을 없앴고 스프링에 의존하여 레포와 서비스가 예외 추상화 계층에 맞게 약속만 하면 되는 것입니다. 하지만 대부분 잡지 않고 런타임으로 자동으로 throws 없이 던집니다.

 

- jdbc 탬플릿

지금까지 서비스의 순수함을 유지하기 위해 노력을 했고 덕분에 순수함을 유지했습니다. 이번에는 레포의 jdbc 반복을 없애보겠습니다. con, pstmt, rs 반복에 try, catch, finally가 모든 crud 메서드마다 다 똑같습니다. 바뀌는 것은 sql 말고는 거의 없습니다. 이전에 만들 때도 복붙으로 했습니다. 이것을 탬플릿으로 해결합니다. 반복 코드를 제거하는 모습을 살펴보겠습니다.

 

- 구현

private final JdbcTemplate jdbcTemplate;

public MemberRepositoryV5(DataSource dataSource) {
    this.jdbcTemplate = 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;
}

save 부터 바꿔보겠습니다. 지금 코드다 다 반복인데 template.update()라고 하고 여기에 sql 넘기고 파라미터 넘기면 끝납니다. 이것을 여기서 그냥 다 해줍니다.

> 탬플릿 안에서 이전에 있던 반복 코드인 con 받아오는 거, 실행하는 거, 심지어, throw sqlExceptionTranslator.translate("insert", sql, e);으로 예외 변환해주는 것도 다 해줍니다. 커낵션 열고, 동기화 매니저 하는 것도 다 해줍니다. 이것이 jdbc 탬플릿 안에 다 들어가 있습니다.

Comments