1. 체크 예외와 인터페이스
이전 서비스 코드를 보면 아직 서비스 계층이 SQLException이라는 서비스에서 처리할 수 없는 예외에 의존하고 있는 것을 알 수 있다.
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3 {
private final MemberRepositoryV3 memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
}
서비스 로직에서 SQLException을 던져야 하는 이유는 데이터 접근 로직에서 던지는 SQLException이 체크 예외이기 때문이다. 따라서 해당 예외를 런타임 예외로 전환해 서비스 계층으로 던져지면 문제를 해결할 수 있다.
1.1. 인터페이스 도입
위 코드를 보면 서비스 코드에서 MemberRepositoryV3라는 구체 클래스에 의존하고 있다. 이후 수정에 용이하도록 해당 부분을 인터페이스로 바꿔주면 좋을 것이다. 하지만, 기존 MemberRepositoryV3의 각 메서드는 JDBC 기술을 사용하기 때문에 SQLException을 throws한다. 이 때문에 인터페이스를 만드는 경우에 아래와 같이 특정 기술에 종속적 코드를 작성해야 한다.
public interface MemberRepository {
Member save(Member member) throws SQLException;
Member findById(String memberId) throws SQLException;
void update(String memberId, int money) throws SQLException;
void delete(String memberId) throws SQLException;
}
1.2. 런타임 예외 적용
먼저 SQLException을 런타임 에러로 바꾸어 throw해줄 Exception을 선언하자.
public class DBException extends RuntimeException {
public DBException() {
}
public DBException(String message) {
super(message);
}
public DBException(Throwable cause) {
super(cause);
}
public DBException(String message, Throwable cause) {
super(message, cause);
}
}
이전의 Repository의 코드에서 각 메서드는 아래와 같이 try 문에서 update, delete 등의 로직을 수행하였다. 이때 try문에서 SQLException이 발생하면, 그대로 SQLException을 throw했다. 코드는 아래와 같다.
try {
...
//Data Access Logic
...
} catch (SQLException e) {
log.info("db error", e);
throw e;
} finally {
close(con, ps, null);
}
위 코드는 방금 만든 DBException 런타임 에러를 대신 throw하는 방식으로 아래와 같이 코드를 작성할 수 있다.
try {
...
//Data Access Logic
...
} catch (SQLException e) {
throw new DBException(e);
} finally {
close(con, ps, null);
}
그럼 1.1에서 작성한 MemberRepository 인터페이스는 아래와 같이 변경될 수 있다.
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
서비스 코드는 아래와 같이 특정 구현체가 아닌 인터페이스에 의존하고, SQLException과 같은 특정 기술에 의존하지 않도록 변경될 수 있다.
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV4 {
private final MemberRepository memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money) {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney()-money);
memberRepository.update(toId, toMember.getMoney() + money);
}
}
하지만 위와 같은 코드는 DBException이라는 예외만 구분하기 때문에 SQL 문법 오류인지, 키의 중복 때문인지, 락을 얻지 못해서인지 알 수 없다는 단점이 있다.
2. 예외 직접 만들기
만약 특정 예외를 직접 만들고 싶다면, SQLException에서 제공하는 errorCode를 사용하면 된다. 예를 들어, H2 DB의 경우 키 중보 오류는 23505, SQL 문법 오류는 42000이라는 코드 값을 가진다. 단, 데이터베이스마다 정의된 errorCode는 다르므로 본인이 사용하는 데이터베이스의 메뉴얼을 확인해야 한다. 특정 에러를 처리하기 위해 먼저 아래와 같은 Exception 하나를 만들자. 해당 Exception은 이전에 만든 DBException을 상속받는다.
public class DuplicateKeyException extends DBException{
public DuplicateKeyException() {
}
public DuplicateKeyException(String message) {
super(message);
}
public DuplicateKeyException(Throwable cause) {
super(cause);
}
public DuplicateKeyException(String message, Throwable cause) {
super(message, cause);
}
}
그럼 특정 Data Access Logic에서 키 중복으로 인한 SQLException이 발생하는 경우, 아래와 같이 현재 만든 Exception을 throw해주면 된다.
try {
...
//Data Access Logic
...
} catch (SQLException e) {
if (e.getErrorCode() == 23505) {
throw new DuplicateKeyException(e);
}
} finally {
close(con, ps, null);
}
하지만 errorCode는 데이터베이스마다 달라지기 때문에 만약 H2 DB를 사용하다가 MySQL로 넘어가는 경우 해당 코드들을 모두 변경해야 한다.
3. 스프링 예외 추상화
스프링은 데이터 접근 계층에 대한 여러 예외를 정리해 일관된 예외 계층을 제공한다. 각 예외는 특정 기술에 종속적이지 않기 때문에 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다.
- 최상위 예외는 DataAccessException이며 RuntimeException을 상속받는다.
- DataAccessException은 NonTransient와 Transient 두 가지로 구분한다.
- Transient는 일시적이라는 의미로 이후 동일한 SQL을 다시 시도했을 때 성공할 수도 있다. 타임아웃, 락 관련 오류들이 해당한다.
- NonTransient는 일시적이지 않다는 의미로 이후 동일한 SQL을 다시 시도해도 실패한다. SQL 문법 오류, 제약조건 위배 등이 해당한다.
위와 같은 Exception 외에도 여러 예외들이 있다. 스프링은 이러한 예외들을 제공하면서, 데이터베이스에서 발생하는 오류 코드를 통해 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공한다.
@Test
void sqlExceptionErrorCode() {
String sql = "SELECT BAD GRAMMER";
try {
Connection con = dataSource.getConnection();
PreparedStatement pr = con.prepareStatement(sql);
pr.executeQuery();
} catch (SQLException e) {
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("SELECT", sql, e);
Assertions.assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
}
}
- 위와 같이 SQLException의 에러 코드에 따라 알맞게 Exception을 변환해주는 것을 알 수 있다.
- DB마다 에러 코드가 다른데 위와 같이 단 두 줄의 코드로 해결할 수 있는 이유는 스프링 내부적으로 "sql-error-codes.xml"이라는 파일을 정의했기 때문이다. 해당 파일에는 각 DB마다 어떤 에러 코드를 가지는지 모두 정의되어 있다.
그럼 스프링에서 제공하는 Exception을 통해 Repository 코드를 아래와 같이 변경할 수 있다.
@Slf4j
public class MemberRepositoryV4 implements MemberRepository {
private final DataSource dataSource;
private final SQLExceptionTranslator exTranslator;
public MemberRepositoryV4(DataSource dataSource) {
this.dataSource = dataSource;
this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
}
@Override
public Member save(Member member) {
...
try {
...
}
catch (SQLException e) {
throw exTranslator.translate("save", sql, e);
} finally {
close(con, ps, null);
}
}
...
}
4. 반복 문제 제거
Repository 코드를 보면 Connection을 얻고, PrepareStatement 객체를 얻는 등 반복되는 작업이 굉장히 많다. 이런 방법을 효과적으로 처리하는 방법이 템플릿 콜백 패턴이다. 스프링은 JDBC 반복 문제를 해결하기 위해 JdbcTemplate을 제공한다. 아래와 같이 기존 코드에 비해 굉장히 짧아진 것을 확인할 수 있다.
@Slf4j
public class MemberRepositoryV5 implements MemberRepository {
private final JdbcTemplate template;
public MemberRepositoryV5(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
@Override
public Member save(Member member) {
String sql = "INSERT INTO member(member_id, money) VALUES (?, ?)";
int update = template.update(sql, member.getMemberId(), member.getMoney());
return member;
}
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Member member = template.queryForObject(sql, memberRowMapper(), memberId);
return member;
}
@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;
};
}
}
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard
'BackEnd > Spring' 카테고리의 다른 글
[Spring MVC-2] Bean Validation (0) | 2024.05.24 |
---|---|
[Spring MVC-2] Validation (0) | 2024.05.23 |
[Spring DB-1] 문제 해결 - 트랜잭션 (0) | 2024.05.07 |
[Spring DB-1] 트랜잭션 이해 (0) | 2024.05.05 |
[Spring DB-1] Connection Pool과 DataSource (0) | 2023.11.24 |