기술스택
- Spring Boot 3.3.5
- Java 17
- H2 Database
1. 체크 예외와 인터페이스
서비스 계층은 가급적 특정 구현 기술에 의존하지 않고 순수하게 유지하는 것이 좋다. 이렇게 하려면 예외에 대한 의존도 함께 해결해야 한다. 서비스가 처리할 수 없는 SQLException에 대한 의존을 제거하려면 어떻게 해야할까?
바로 SQLException 체크 예외를 RuntimeException 언체크 예외로 전환해서 서비스 계층에 던지면 서비스 계층이 해당 예외를 무시할 수 있기 때문에 특정 구현 기술에 의존하는 부분을 제거하고 서비스 계층을 순수하게 유지할 수 있다.
인터페이스 도입
이렇게 인터페이스를 도입하면 MemberService는 MemberRepository 인터페이스에만 의존하면 된다.
MemberRepository 인터페이스
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
특정 기술에 종속되지 않는 순수한 인터페이스다. 이 인터페이스를 기반으로 특정 기술을 사용하는 구현체를 만들면 된다.
인터페이스에 체크 예외 도입시 문제점
public interface MemberRepository {
Member save(Member member) throws SQLException;
// 이하 생략...
}
그럼 왜 인터페이스에 체크 예외를 선언하지 않을까?
체크예외를 사용하려면 인터페이스에도 해당 체크 예외가 선언 되어 있어야 한다. 그러면 이 인터페이스의 구현체 모두 해당 체크 예외가 선언되어 있어야 한다. 구현 기술을 쉽게 변경하기 위해 인터페이스를 도입하였으나 SQLException과 같은 특정 구현 기술에 종속적이게 된다.
2. 런타입 예외 적용
그럼 이제 런타임 예외(언체크 예외)를 사용하도록 적용해보겠다.
MyDbException
public class MyDbException extends RuntimeException{
public MyDbException() {
super();
}
public MyDbException(String message) {
super(message);
}
public MyDbException(String message, Throwable cause) {
super(message, cause);
}
public MyDbException(Throwable cause) {
super(cause);
}
}
MemberRepository 인터페이스 구현체인 MemberRepositoryV4_1
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository{
private final DataSource dataSource;
public MemberRepositoryV4_1(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void update(String memberId, int money){
String sql = "UPDATE MEMBER SET money = ? WHERE member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
int resultSize = pstmt.executeUpdate();
log.info("resultSize = {}", resultSize);
}catch (Exception e) {
throw new MyDbException(e);
}finally {
//Connection, PreparedStatement, ResultSet 종료 함수
close(con, pstmt, null);
}
}
// 이하 생략...
}
MyDbException을 보면 기존 예외를 생성자를 통해서 포함하고 있는 것을 확인할 수 있다. 예외는 원인이 되는 예외를 내부에 포함할 수 있는데, 꼭 이렇게 작성해야 한다.
서비스 코드인 MemberServiceV4
@Slf4j
public class MemberServiceV4 {
private final MemberRepository memberRepository;
public MemberServiceV4(final MemberRepository memberRepository) {
this.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);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
// 이하 생략
}
그럼 이제 서비스의 메서드 선언부에 throws SQLException 부분을 선언 하지 않아도 된다.
3. 데이터 접근 예외 직접 만들기
데이터베이스 오류에 따라서 특정 예외는 복구하고 싶을 수 있다. 예를 들자면 회원 가입시 데이터베이스에 이미 같은 ID가 있으면 ID뒤에 랜덤 숫자를 붙여서 새로운 ID를 만들어야 한다고 가정해보자.
그럼 같은 ID가 이미 데이터베이스에 저장되어 있다면 데이터 베이스는 오류 코드를 반환하고, 이 오류 코드를 받은 JDBC 드라이버는 SQLException을 던진다. 그리고 SQLException에는 데이터베이스가 제공하는 errorCode라는 것이 들어있다.
SQLException 내부에 들어있는 errorCode를 활용하면 데이터베이스에서 어떤 문제가 발생했는지 알 수 있다.
예) 키 중복 오류 코드
- H2 DB: 23505
- MySQL: 1062
서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인할 수 있어야 한다. 그래야 새로운 ID를 만들어서 다시 저장을 시도할 수 있기 때문이다. 이러한 과정을 예외를 확인해서 복구하는 과정이라 불린다.
리포지토리는 SQLException을 서비스 계층에 던지고 서비스 계층은 이 예외 오류 코드를 확인해서 키 중복 오류(23505)인 경우 새로운 ID를 만들어서 다시 저장하면 된다.
하지만 문제가 존재한다. SQLException에 들어있는 오류 코드를 활용하기 위해 SQLException을 서비스 계층으로 던지게 되면 서비스 계층이 SQLException이라는 JDBC기술에 의존하게 된다.
이 문제를 해결할려면 앞서 배운 것 처럼 리포지토리에서 예외를 변환해서 던지면된다.
앞서 만든 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);
}
}
기존에 사용했던 MyDbException을 상속받아서 의미있는 계층을 형성한다. 이렇게 하면 데이터베이스 관련 예외라는 계층을 만들 수 있다. 이름을 MyDuplicateKeyException라고 지은 이유는 이 예외는 데이터 중복의 경우에만 던질 것이기 때문이다.
그럼 앞으로 리포지토리 계층에서
catch (SQLException e) {
//h2 db
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
}
SQLException에서 에러코드가 23505(H2 DB의 키 오류 에러코드) 인 경우 MyDuplicateKeyException을 던지면 된다.
이제 리포지토리를 호출하는 서비스 계층에서는
try {
repository.save(new Member(memberId, 0));
}catch (MyDuplicateKeyException e){
log.info("키 복구 시도");
// generateNewId() == memberId 뒤에 랜덤한 숫자를 붙이는 메서드
String retryId = generateNewId(memberId);
repository.save(new Member(retryId, 0));
} catch (MyDbException e){
log.info("데이터 접근 계층 예외", e);
throw e;
}
catch절에 MyDuplicateKeyException을 선언 후 복구를 시도하면 된다.
하지만 여기서도 남은 문제가 존재한다. SQL ErrorCode는 각각의 데이터베이스 마다 다르다.
결과적으로 데이터베이스가 변경될 때 마다 ErrorCode도 모두 변경해야 한다. 데이터베이스가 전달하는 오류는 키 중복 뿐만 아니라 락이 걸린 경우, SQL 문법 오류 등등 수백가지 오류 코드가 있다. 그럼 이모든 상황에 맞는 예뢰를 다 만들어야 할까?
이 문제도 역시 스프링이 문제를 해결해준다.
4. 스프링 예외 추상화 이해
스프링은 앞서 설명한 문제들을 해결하기 위해 데이터 접근과 관련된 예외를 추상화해서 제공한다.
스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리해서 일관된 예외 계층을 제공한다. 각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있다. 따라서 서비스 계층에서도 JDBC를 사용하든 JPA를 사용하든 스프링이 제공하는 예외를 사용하면 된다.
이 예외들의 최고 상위는 org.springframework.dao.DataAccessException이다. 그림에서 보는 것처럼 RuntimeException을 상속받았기 때문에 언체크 예외이다.
DataAccessException는 크게 2가지로 구분하는데 NonTransient, Transient 예외이다.
- NonTransient: 일시적이지 않다는 뜻이다. 같은 SQL을 반복해서 실행하면 실패한다. 예로는 SQL문법 오류, 데이터베이스 제약조건 위배등이 있다.
- Transient: 일시적이라는 뜻이다. 동일한 SQL를 실행했을 때 성공할 가능성이 있다. 예로는 쿼리 타임 아웃, 락 관련된 오류들이다.
스프링이 제공하는 예외 변환기
스프링은 데이터베이스에서 발생하는 오류 코드를 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공한다.
다음과 같이 사용하면 된다.
SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);
translate( ) 메서드의 첫번째 파라미터는 읽을 수 있는 설명이고, 두번째는 실행한 sql, 마지막은 발생된 SQLException을 전달하면 된다. 이렇게 하면 스프링이 적절한 예외를 변환해서 반환해준다.
각각의 DB마다 SQL ErrorCode는 다르다. 그런게 스프링은 각각의 DB가 제공하는 SQL ErrorCode까지 고려해서 예외를 변환할 수 있을 까?
비밀은 바로 다음 파일에 있다.
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>
스프링 SQL 예외 변환기는 SQL ErrorCde를 이 파일에 대입해서 어떤 스프링 데이터 접근 예외로 전환해야 할지 찾아낸다. 예를 들어서 H2 데이터베이스에서 42000이 발생하면 badSqlGrammerCodes 이기 때문에 BadSqlGrammerException을 반환한다.
그럼 이제 리포지토리 catch 절에서
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository{
private final DataSource dataSource;
private final SQLExceptionTranslator exTranslator;
public MemberRepositoryV4_2(DataSource dataSource) {
this.dataSource = dataSource;
this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
}
@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());
int count = pstmt.executeUpdate();
return member;
}catch (SQLException e) {
throw exTranslator.translate("save", sql, e);
} finally {
close(con, pstmt, null);
}
}
// 이하 생략...
}
throw exTranslator.translate("save", sql, e); 이런식으로 예외를 변환해서 던지면 된다.
5. JDBC 반복 문제 해결 - JdbcTemplate
이번에는 리포지토리에서 Jdbc를 사용하기 때문에 발생하는 반복 문제를 해결해보자.
JDBC 반복 문제
- 커넥션 조회, 커넥션 동기화
- PreparedStatement 생성 및 파라미터 바인딩
- 쿼리 실행
- 결과 바인딩
- 예외 발생시 스프링 예외 변환기 실행
- 리소스 종료
리포지토리의 각각의 메서드를 살펴보면 상당히 많은 부분이 반복된다. 스프링은 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(?,?)";
template.update(sql, member.getMemberId(), member.getMoney());
return member;
}
}
JdbcTemplate를 사용하면 위에있던 코드들과 비교했을 때 상당히 깔끔해진것을 볼 수 있다.
6. 정리
완성된 코드를 확인해보면
- 서비스 계층 순수성
- 트랜잭션 추상화 + 트랜잭션 AOP 덕분에 서비스 계층의 순수성을 최대한 유지하면서 서비스 계층에서 트랜잭션을 사용할 수 있다.
- 스프링이 제공하는 예외 추상화 예외 변환기 덕분에, 데이터 접근 기술이 변경되어도 서비스 계층의 순수성을 유지하면서 예외도 사용할 수 있다.
- 서비스 계층이 리포지토리 인터페이스에 의존한 덕분에 향후 리포지토리가 다른 구현 기술로 변경되어도 서비스 계층을 순수하게 유지할 수 있다.
- 리포지토리에서 JDBC를 사용하는 반복코드가 JdbcTemplate으로 대부분 제거 되었다.
'JDBC' 카테고리의 다른 글
[JDBC] 자바 예외 이해 (1) | 2024.11.09 |
---|---|
[JDBC] 트랜잭션 - 스프링과 문제 해결 (3) | 2024.11.08 |
[JDBC] 트랜잭션 이해 (0) | 2024.11.08 |
[JDBC] 커넥션풀과 데이터소스 이해 (0) | 2024.11.07 |
[JDBC] JDBC 이해 (0) | 2024.11.07 |