이제 만들었던 프로젝트에 적용할건데, 컨트롤러에 인터페이스를 사용할거다. 왜 갑자기 사용하냐면 원래 사용할 수 있었지만 인터페이스로 정의한 함수 뒤에 extends throws 해서 덕지덕지 붙이는게 싫어서 그냥 안만들고 쌩 클래스로 한거임. 그래서 이번에 예외처리에서 throws 없애는 김에 인터페이스로 구현하는 거다.
원래는 체크 예외인 SQLException을 런타임 예외로 바꾸기 위한 런타임 예외 db 클래스를 따로 만들자.
/**
* 예외 누수 문제 해결
* 체크 예외를 런타임 예외로 변경
* MemberRepository 인터페이스 사용
* throws SQLException 제거
*/
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository {
private final DataSource dataSource;
public MemberRepositoryV4_1(DataSource dataSource) {
this.dataSource = 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 new MyDbException(e);
} finally {
// pstmt.close();
// con.close();
close(con, pstmt, null);
}
}
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
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 {
// rs.close();
// pstmt.close();
// con.close();
close(con, pstmt, rs);
}
}
@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 (SQLException e) {
throw new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
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);
}
}
private Connection getConnection() throws SQLException {
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
// Connection con = dataSource.getConnection();
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
// JdbcUtils.closeConnection(con);
DataSourceUtils.releaseConnection(con, dataSource);
}
}
이제 서비스에서 레퍼지토리 함수를 실행할 때 서비스에 체크 예외인 SQLException을 내가 만든 런타임 예외가 넘어가기 때문에 서비스에선 신경 쓸 필요 없이 서비스를 거쳐 더 위로 올라간다.
이제 클래스가 아닌 인터페이스를 가진다. 저렇게만 해놔도 나중에 스프링 빈에 등록할 때 새로운 구체적인 클래스를 해주면 된다.
/**
* 예외 누수 문제 해결
* SQLException 제거
*
* MemberRepository 인터페이스 의존
*/
@Slf4j
@SpringBootTest
class MemberServiceV4Test {
public static final String Member_A = "memberA";
public static final String Member_B = "memberB";
public static final String Member_EX = "ex";
@Autowired
private MemberRepository memberRepository;
@Autowired
private MemberServiceV4 memberService;
@TestConfiguration
static class TestConfig {
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepository memberRepository() {
return new MemberRepositoryV4_1(dataSource);
}
@Bean
MemberServiceV4 memberServiceV4() {
return new MemberServiceV4(memberRepository());
}
}
@AfterEach
void after() {
memberRepository.delete(Member_A);
memberRepository.delete(Member_B);
memberRepository.delete(Member_EX);
}
@Test
void AopCheck() {
log.info("memberService class={}", memberService.getClass());
log.info("memberRepository class={}", memberRepository.getClass());
Assertions.assertThat(AopUtils.isAopProxy(memberService)).isTrue();
Assertions.assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
}
@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 TX");
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
log.info("END TX");
// then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(12000);
}
@Test
@DisplayName("이체중 예외 발생")
void accountTransferEx() {
// given
Member memberA = new Member(Member_A, 10000);
Member memberEx = new Member(Member_EX, 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
// when
assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
// then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberB.getMoney()).isEqualTo(10000);
}
}
이러면 잘 된다.
그럼 순수 서비스 계층을 만드는데 성공했으니, 진짜 예외 처리를 어떻게 하는지 보자.
db에서 원래 내놓는 체크 예외를 받아 런타임 예외로 바꿔서 던지고 처리할 것이다. 각 DB마다 오류 코드도 다 다르기 때문에 문서를 찾아봐야 하지만 좀이따가 스프링에서 해주는걸로 바꿀거다. 일단 코드로 하자. h2의 중복 에러 코드는 23505이다.
ErrorCode
CONNECTION_BROKEN_1 public static final int CONNECTION_BROKEN_1 The error with code 90067 is thrown when the client could not connect to the database, or if the connection was lost. Possible reasons are: the database server is not running at the given por
www.h2database.com
런타임 예외로 변환용 클래스를 만들자.
내가 이미 변환용으로 만든 MyDbException을 더 확장하여 구체화해서 사용하는 키 중복 클래스를 새로 만든다.
이렇게 변환용으로 새로 만들면 좋은점이 내가 만든거기 때문에 라이브러리가 바뀌든 말든 이 클래스는 그대로 유지하는 것이다. 물론 코드도 좀 바꾸고 해야겠지만.
테스트 코드를 만들어서 보자.
@Slf4j
public class ExTranslatorV1Test {
Repository repository;
Service service;
@BeforeEach
void init() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
repository = new Repository(dataSource);
service = new Service(repository);
}
@Test
void duplicateKeySave() {
service.create("myId");
service.create("myId"); // 같은 ID 저장 시도
}
@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);
repository.save(new Member(retryId, 0));
} catch (MyDbException e) {
log.info("데이터 접근 계층 예외", e);
throw e;
}
}
private String generateNewId(String memberId) {
return memberId + new Random().nextInt(10000);
}
}
@RequiredArgsConstructor
static class Repository {
private final DataSource dataSource;
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = dataSource.getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
// h2 db
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
} finally {
JdbcUtils.closeStatement(pstmt);
JdbcUtils.closeConnection(con);
}
}
}
}
봐야할 부분은 repository의 save부분과 service의 create 부분. 먼저 repository에서 변환해서 주는데 DB에서 주는 체크 에러를 코드를 자세히 보면서 만약 중복일 경우 아까 확장해서 만들었던 나의 DB 중복용 런타임 예외를 이 함수를 호출한 서비스에 날리고 아니면 그냥 나의 DB 런타임 예외를 날린다. 서비스가 알아서 처리할것이기 때문에. 그럼 서비스에선 받은 예외가 어떤 예외인지를 판단해서 자기가 처리하거나 자기도 더 위로 보낼 수 있다. 사실 자기가 처리할 수 없어서 위로 넘기는 코드는 굳이 로그를 남기지 않아도 그쪽에서 로그를 남길거기 때문에 처리할 수 없는 곳에서 로그를 남기는건 별로 좋지 않는데 그냥 보여줄려고 한거.
근데 이렇게 DB마다 에러코드도 다른데 일일히 찾아서 넣기 힘들다. 이런것도 역시 스프링이 알아서 해준다. 스프링 내에서 여러 타입의 SQL 관련 에러들을 런타임으로 만들어놔서 각 DB마다 다른걸 컨버터로 변환하면서 통일시키도록 되어있다.
Transient 예외는 다시 시도하면 될 수도 있는 예외들. 잠시 DB 커넥션이 꽉 찼다던가.. NonTransient는 문법 오류같이 몇번을 해도 오류 날것들.
@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);
int errorCode = e.getErrorCode();
log.info("errorCode={}", errorCode);
log.info("error", e);
// throw new BadSqlGrammarException(e, , ); 원래 이래야 함
}
}
@Test
void exceptionTranslator() {
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);
// BadSqlGrammarException
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator();
DataAccessException resultEx = exTranslator.translate("select", sql, e);
log.info("resultEx", resultEx);
assertThat(resultEx).isInstanceOf(BadSqlGrammarException.class);
}
}
}
db에 상관없이 컨버터에 넣고 돌리면 알아서 스프링의 예외가 나온다. 스프링에서 각 DB 종류에 대한 예외 코드들을 이미 다 구분해놨다. org.springframework.jdbc.support.sql-error-codes.xml 파일에 있음.
이런 스프링이 마든 bad SQL grammar예외도 위에서 봣던 DataAccessException이 부모다. 스프링 버전 예외 구조라고 생각하면 편할듯.
왠만한건 스프링에 다 있으니, 직접 만드는 것보다는 가져다 쓰는게 훨씬 합리적이다.
/**
* SQLExceptionTranslator 추가
*/
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository {
private final DataSource dataSource;
private final SQLExceptionTranslator exTranslator;
public MemberRepositoryV4_2(DataSource dataSource) {
this.dataSource = dataSource;
// DataSource를 넣으면 어떤 DB를 쓰는건지 정보들 찾아서 쓸 수 있음.
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);
} finally {
// pstmt.close();
// con.close();
close(con, pstmt, null);
}
}
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
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("save", sql, e);
} finally {
// rs.close();
// pstmt.close();
// con.close();
close(con, pstmt, rs);
}
}
@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 (SQLException e) {
throw exTranslator.translate("save", sql, e);
} finally {
close(con, pstmt, null);
}
}
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
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("save", sql, e);
} finally {
close(con, pstmt, null);
}
}
private Connection getConnection() throws SQLException {
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
// Connection con = dataSource.getConnection();
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
// 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
// JdbcUtils.closeConnection(con);
DataSourceUtils.releaseConnection(con, dataSource);
}
}
인터페이스로 만들었기 때문에 버전만 살짝 바꿔주면 됨. 스프링이 알아서 주입할거다.
이제 할건 try catch 반복적으로 쓰는거 없앨거임. 템플릿을 쓸거고 자세한 내용은 스프링 고급편 강의에서 다룬다고 함.
저 텀플릿 안에 try 해서 뭐 하고 에러도 처리하고 하는게 저 템플릿 안에 이미 다 들어있다. 조회같은 부분만 저렇게 맵퍼해서 만들면 된다. 일단 그냥 이렇게 쓴다고 알아두자.
'CS > 김영한 스프링 강의' 카테고리의 다른 글
스프링 DB 2편 - 데이터 접근 핵심 원리 - 섹션2. 데이터 접근 기술 - 스프링 JdbcTemplate (0) | 2023.08.19 |
---|---|
스프링 DB 2편 - 데이터 접근 핵심 원리 - 섹션1. 데이터 접근 기술 - 시작 (0) | 2023.08.19 |
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션5. 자바 예외 이해 (0) | 2023.08.15 |
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션4. 스프링과 문제 해결 - 트랜잭션 (0) | 2023.08.13 |
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션3. 트랜잭션 이해 (0) | 2023.08.13 |