728x90
1. 체크 예외와 인터페이스
- 서비스 계층이 예외에 대한 의존을 해결 하기 위해서 throws SQLException도 제거 필요
- 서비스가 처리할 수 없는 repository에서 던지는 SQLException 체크 예외를 런타임 예외로 전환해서 서비스 계층에 던진다.
- 인터페이스를 도입하면 인터페이스에만 의존하면 된다.
- 구현 기술을 변경하고 싶으면 DI를 사용해서 MemberService 코드의 변경 없이 구현 기술을 변경할 수 있다.
- memberRepository interface
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
- 인터페이스르 미리 만들지 못했던 이유
- SQLException이 체크 예외여서 인터페이스에도 해당 예외를 선언해야한다.
자바의 인터페이스 구현의 조건 중 조상보다 예외가 많으면 안된다. - 그래서 미리 만들었다가는 SQLException에 의존하는 interface가 생성된다.
- SQLException이 체크 예외여서 인터페이스에도 해당 예외를 선언해야한다.
2. 런타임 예외 적용
- MyDbException 런타임 예외
package hello.jdbc.repository.ex;
public class MyDbException extends RuntimeException{
public MyDbException() {
}
public MyDbException(String message) {
super(message);
}
public MyDbException(String message, Throwable cause) {
super(message, cause);
}
// 기존 예외를 포함하는 생성자를 사용할 것이다.
public MyDbException(Throwable cause) {
super(cause);
}
}
- repository4_1
package hello.jdbc.repository;
/**
* 예외 누수 문제 해결
* 체크 예외를 런타임 예외로 변경
* throws SQLException 제거
*/
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository{
// data source 생성
// DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
private final DataSource dataSource;
// 생성자 생성
public MemberRepositoryV4_1(DataSource dataSource) {
this.dataSource = dataSource;
}
// 1. 등록
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(? , ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
// 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
con = getConnection(); // 1. connection 연결
pstmt = con.prepareStatement(sql); // 2. db랑 소통
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate(); // db에서 실행됨
return member;
} catch (SQLException e) {
throw new MyDbException(e); // 기존 예외를 runtime 예외에 담는다.
} finally {
close(con, pstmt, null);
}
}
// 2. 조회
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
ResultSet rs = null; // select query 문의 결과를 담고 있는 통
try {
// 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery(); // 조회시에는 Query() 사용, 등록시 update 사용
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
throw new MyDbException(e);
} finally {
close(con, pstmt, rs);
}
}
// transaction 용 findById 제거
// 3. 수정
@Override
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
try {
// 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
throw new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
// transaction 용 findById 제거
// 4. 삭제
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
throw new MyDbException(e); // 원인 꼭 넣어줘야함
} finally {
close(con, pstmt, null);
}
}
// close() 예외 처리
private void close(Connection con, Statement stmt, ResultSet rs) {
// jdbc에서 제공하는 util
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
// JdbcUtils.closeConnection(con);
// 트랜젝션 동기화 매니저를 사용하려면 DataSourceUtils를 사용해야한다.
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException{
// Connection con = dataSource.getConnection();
// 트랜젝션 동기화 매니저를 사용하려면 DataSourceUtils를 사용해야한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
}
- 첫 MemberRepository 인터페이스 구현
- SQLException 을 MyException 에 담아서 런타임 에러로 변경했다.
- 예외 변환시, 꼭 기존 예외를 담아야한다. - 기존 예외를 무시하면 절대 안된다.
catch(SQLException e) {
throw new MyDbException(e);
}
- MemberServiceV4
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
//@Transactional class와 method 둘다 붙여도 된다.
public class MemberServiceV4 {
private final MemberRepository memberRepository; // 인터페이스에만 의존해서 DI를 지킨다.
public MemberServiceV4(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional // spring AOP - proxy 사용
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, fromMember.getMoney() + money);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- 특징
- 인터페이스 구현한 repository를 이용
- service에 따라 붙던 throws SQLException을 제거
- 남은 문제점
- 리포지토리에서 넘어오는 특정한 예외의 경우 복구를 시도할 수 있다.
- 현재 MyDbException이 받는 예외는 어떤것이 올줄 몰라서 특정 상황을 꼬집어서 처리하기가 힘들다.
- 그래서 특정 조건일 때, 생성되는 runtimeException을 만들어서 반환하도록 한다.
3. 데이터 접근 예외 직접 만들기
- 특정 예외는 복구하고 싶을 수 있다.
- 회원 가입시 DB에 이미 동일 ID 존재시 새로운 아이디를 만드는 상황이 있다고 가정
- 이럴 경우 pk가 중복되어서 db에서는 예외를 던지게 되고 이와 같은 특정 조건일 경우를 business logic에서 처리하는 logic을 수행 할 것이다.
- 서비스 계층 작업
- 예외 복구를 위해 key 중복 오류를 확인할 수 있어야 한다.
- 바로 예외를 확인해서 복구하는 과정이다.
- 오류 코드를 활용하기 위해 SQLException을 서비스 계층으로 던지게 되면, 서비스 계층이 SQLexception이라는 JDBC 기술에 의존하게 된다.
- 그래서 repository에서 runtimeException을 만들어서 던져주는 방식을 택한다.
- MyDuplicateKeyException
package hello.jdbc.repository.ex;
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을 상속받아서 의미있는 계층을 형성한다.
- db 관련 예외라는 계층을 만들 수 있게 된다.
- db 중복인 경우만 던지기 위해서 이름도 MyDuplicateKeyException이라고 지었다.
- test code 핵심 로직 code block
@Slf4j
@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) {
// runtimeException이지만 잡을 필요가 있을 때 사용한다. 의존성이 없다. 하지만 db로 부터 온 error code를 받을 수 있는 형태가 된다.
log.info("key 중복, 복구 시도");
String retryId = generateNewId(memberId);
log.info("retryId={}", retryId);
repository.save(new Member(retryId, 0));
}
}
private String generateNewId(String memberId) {
return memberId + new Random().nextInt(1000);
}
}
// repository
@RequiredArgsConstructor
static class Repository {
private final DataSource dataSource;
public Member save(Member member) {
...
} catch (SQLException e) {
//h2 db
// 핵심 코드, repository 내부에서 error message를 확인하고 해당 error인 조건에 맞는 runtimeException(내가 만든 것) 날림
// 의존성은 없지만 error code를 활용할 수 있는 형태가 된다.
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
} finally {
JdbcUtils.closeStatement(pstmt);
JdbcUtils.closeConnection(con);
}
}
}
- 남은 문제
- sqlErrorCode는 각각 db마다 다르다.
- 결과적으로 db가 변경될 때마다 Repository의 errorCode도 변경해야한다.
4. 스프링 예외 추상화 이해
- 특징
- RuntimeException
- db 접근에 관한 수십가지의 예외를 정리한 계층을 제공 - sql-error-codes.xml 에 정의
- 특정 기술에 종속적이지 않게 설계되었기 때문에 service 계층에서도 스프링이 제공하는 예외를 사용하면 된다.
- 스프링 메뉴얼에 모든 예외가 정리되진 않았기 때문에 코드를 직접 열어서 확인해보는 것이 필요
4.1. NonTransient 예외와 Transient 예외
- NonTransient
- 일시적이지 않다는 뜻
- 반복해서 실행시 실패
- SQL 문법 오류, 데이터베이스 제약조건 위배
- Transient
- 일시적
- 동일 SQL 다시 시도했을 때 성공 가능성이 높다.
- 쿼리 타임아웃, 락과 관련된 오류가 대표적
5. 스프링이 제공하는 예외 변환기
- 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공
- test code
package hello.jdbc.exception.translator;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;
@Slf4j
public class SpringExceptionTranslatorTest {
DataSource dataSource;
@BeforeEach
void init() {
dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
// 현실성 없는 예외 변환
@Test
void sqlExceptionErrorCode(){
String sql = "select bad grammar";
try{
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
}catch (SQLException e){
assertThat(e.getErrorCode()).isEqualTo(42122); // h2 db code
int errorCode = e.getErrorCode();
log.info("errorCode={}", errorCode);
// org.h2.jdbc.JdbcSQLSyntaxErrorException
log.info("error", e);
}
}
// spring이 제공해주는 예외 변환기
@Test
void exceptionTranslator(){
String sql = "select bad grammar";
try{
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e){
// org.springframework.jdbc.support.sql-error-codes.xml -> 각 db마다 해당하는 error 코드가 있다.
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); // xml 파일을 통해서 적절한 error 코드를 찾는다.
// org.springframework.jdbc.BadSqlGrammarException
DataAccessException resultEx = exTranslator.translate("select", sql, e); // 해당 db의 예외 class 반환
log.info("resultEx", resultEx);
assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
}
}
}
- 핵심코드
// org.springframework.jdbc.support.sql-error-codes.xml -> 각 db마다 해당하는 error 코드가 있다.
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); // xml 파일을 통해서 적절한 error 코드를 찾는다.
// org.springframework.jdbc.BadSqlGrammarException
DataAccessException resultEx = exTranslator.translate("select", sql, e); // 해당 db의 예외 class 반환
- new SQLErrorCodeSQLExceptionTranslator(dataSource)
: dataSource를 생성자 초기화 값으로 넣어줘야 어떤 db인지 예측 가능해진다. - translate()
- method의 첫번째 파라미터는 읽을 수 있는 설명, 두번째는 실행한 sql, 마지막은 발생된 SQLException을 전달
- 적절한 스프링 데이터 접근계층의 예외로 변환해서 반환된다. = runtimeException
- db별로 sqlErrorcode를 알 수 있는 이유 : sql-error-codes.xml 에 미리 다 정의 되어있기 때문에 가능
6. 스프링 예외 추상화 적용
6.1. MemberRepositoryV4_2
package hello.jdbc.repository;
/* * spring 제공 예외 변환기 - SQLExceptionTranslator
* SQLExceptionTranslator : interface
* SQLErrorCodeSQLExceptionTranslator : 구현체 - 이거말고 다른 구현체도 존재. ex) status 관련한 구현체 등등
*/
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository{
// data source 생성
// DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
private final DataSource dataSource;
private final SQLExceptionTranslator exTranslator; // 인터페이스로 생성자 주입 받을 code
// 생성자 생성
public MemberRepositoryV4_2(DataSource dataSource) {
this.dataSource = dataSource;
this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); // 구현부 생성자 주입
}
// 1. 등록
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(? , ?)";
Connection con = null;
PreparedStatement pstmt = null; // sql params를 바인딩 받는 class
try {
// 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
con = getConnection(); // 1. connection 연결
pstmt = con.prepareStatement(sql); // 2. db랑 소통
pstmt.setString(1, member.getMemberId()); // sql string values의 첫번째 ?
pstmt.setInt(2, member.getMoney()); // sql string values의 두번째 ?
pstmt.executeUpdate(); // db에서 실행됨
return member;
} catch (SQLException e) {
throw exTranslator.translate("save", sql, e); // 스프링 변환기로 변환된 db 예외 관련 RuntimeException
} finally {
close(con, pstmt, null); // close하는 과정에서 예외발생시 뒤에것도 close안되니깐 method로 분리
}
}
// 2. 조회
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
ResultSet rs = null; // select query 문의 결과를 담고 있는 통
try {
// 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery(); // 조회시에는 Query() 사용, 등록시 update 사용
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
throw exTranslator.translate("findById", sql, e);
} finally {
close(con, pstmt, rs);
}
}
// 3. 수정
@Override
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
try {
// 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
throw exTranslator.translate("update", sql, e);
} finally {
close(con, pstmt, null);
}
}
// 4. 삭제
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id = ?";
Connection con = null; // db와 연결
PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
throw exTranslator.translate("delete", sql, e);
} finally {
close(con, pstmt, null);
}
}
// close() 예외 처리
private void close(Connection con, Statement stmt, ResultSet rs) {
// jdbc에서 제공하는 util
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
// 트랜젝션 동기화 매니저를 사용하려면 DataSourceUtils를 사용해야한다.
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException{
// 트랜젝션 동기화 매니저를 사용하려면 DataSourceUtils를 사용해야한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
}
- 핵심 코드
catch (SQLException e) {
throw exTranslator.translate("save", sql, e); // 해당 method()가 반환하는 게 예외이다.
}
6.2. MemberService
- repository로 부터 오는 예외를 잡아서 복구해야 하는 경우, 예외가 스프링이 제공하는 데이터 접근 예외로 변경되어서 서비스 계층에 넘어오기 때문에 필요한 경우 예외를 잡아서 복구하면 된다.
7. 반복 문제 해결 - jdbc template
- service 계층의 Transaction 문제 해결 완료
- 이제 Repository의 JDBC 반복문제 해결 필요
- repository의 상당 부분이 반복되는데 반복을 효과적으로 처리하는 방법이 템플릿 콜백 패턴이다.
- 스프링은 JDBC의 반복문제를 해결하기 위해 JDBCTemplate 제공
- 중복이 제거된다는 것에 초점
7.1. MemberRepositoryV5
- 결론 : connection, 여러 생성자 주입, statement, resultSet, spring 예외변환기, close() 과정이 내부적으로 수행됨.
package hello.jdbc.repository;
/**
* JDBC tmeplate 사용
*/
@Slf4j
public class MemberRepositoryV5 implements MemberRepository{
private final JdbcTemplate template; // connection, pstmt, close, 예외 변환까지 다 수행
// 생성자 생성
public MemberRepositoryV5(DataSource dataSource) { // dataSource는 기본 설정값만 넣으면 springboot가 자동으로 넣어줌
template = new JdbcTemplate(dataSource);
}
// 1. 등록
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(? , ?)";
template.update(sql, member.getMemberId(), member.getMoney());
return member;
}
/** connection, 여러 생성자 주입, statement, resultSet, spring 예외변환기, close() 과정이 내부적으로 수행됨.
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());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
throw exTranslator.translate("save", sql, e); // 스프링 변환기로 변환된 db 예외 관련 RuntimeException
} finally {
close(con, pstmt, null);
}
}
// close() 예외 처리
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, dataSource);
}
// TxManager로부터 connection 받아오기 or 직접 connection 생성 획득하기
private Connection getConnection() throws SQLException{
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
* */
// 2. 조회
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
// sql, memberId로 가지고 온 단일 결과를 memberRowMapper()를 이용해서 member 반환
return template.queryForObject(sql, memberRowMapper(), memberId);
}
// 3. 수정
@Override
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id = ?";
template.update(sql, money, memberId); // 순서 중요
}
// 4. 삭제
@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;
});
}
}