서비스 계층은 가급적 특정 구현 기술에 의존하지 않고 순수하게 유지하는 것이 좋다.
MemberRepository 인터페이스를 도입해서 구현 기술을 쉽게 변경할 수 있게 해본다.
- 인터페이스를 도입하면 MemberService는 MemberRepository 인터페이스에만 의존하면 된다.
- 구현 기술을 변경하고 싶으면 DI를 사용해서 MemberService 코드의 변경 없이 구현 기술을 변경할
수 있다.
MemberRepository 인터페이스
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);
}
=> 특정 기술에 종속되지 않는 순수한 인터페이스 / 인터페이스를 기반으로 특정 기술을 사용하는 구현체를 만들면 됨.
체크 예외 코드에 인터페이스 도입시 문제점 - 인터페이스
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import java.sql.SQLException;
public interface MemberRepositoryEx {
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;
}
=> 인터페이스의 메서드의 throws SQLException이 있는 것을 확인할 수 있다.
체크 예외 코드에 인터페이스 도입시 문제점 - 구현 클래스
@Slf4j
public class MemberRepositoryV3 implements MemberRepositoryEx {
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values(?, ?)";
}
}
- 인터페이스의 구현체가 체크 예외를 던지려면, 인터페이스 메서드에 먼저 체크 예외를 던지는 부분이 선언
되어 있어야 한다. 그래야 구현 클래스의 메서드도 체크 예외를 던질 수 있다.
- 쉽게 이야기 해서 MemberRepositoryV3 가 throws SQLException 를 하려면
MemberRepositoryEx 인터페이스에도 throws SQLException 이 필요하다.
- 참고로 구현 클래스의 메서드에 선언할 수 있는 예외는 부모 타입에서 던진 예외와 같거나 하위 타입이어야
한다.
- 예를 들어서 인터페이스 메서드에 throws Exception 를 선언하면, 구현 클래스 메서드에 throws
SQLException 는 가능하다. SQLException 은 Exception 의 하위 타입이기 때문이다
런타임 예외 적용
코드에 런타임 예외를 사용하도록 적용해본다.
MemberRepository 인터페이스
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);
}
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);
}
}
=> RuntimeException을 상속받음. 따라서 MyDbException은 런타임(언체크) 예외가 된다.
MemberRepositoryV4_1
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.ex.MyDbException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
/**
* 예외 누수 문제 해결
* 체크 예외를 런타임 예외로 변경
* 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 {
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 {
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);
pstmt.executeUpdate();
} 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);
pstmt.executeUpdate();
} catch (SQLException e) {
throw new MyDbException(e);
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
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;
}
}
- MemberRepository 인터페이스를 구현한다.
- 핵심은 SQLException 이라는 체크 예외를 MyDbException 이라는 런타임 예외로 변환해서
던지는 부분.
예외 변환
catch (SQLException e) {
throw new MyDbException(e);
}
- 기존 예외를 생성자를 통해서 포함하고 있는 것을 확인할 수 있다. 예외는 원인이 되는 예외를
내부에 포함할 수 있는데, 꼭 이렇게 작성해야 한다. 그래야 예외를 출력했을 때 원인이 되는 기존 예외도
함께 확인할 수 있다.
- MyDbException 이 내부에 SQLException 을 포함하고 있다고 이해하면 된다. 예외를 출력했을 때 스택
트레이스를 통해 둘다 확인할 수 있다.
예외 변환 - 기존 예외 무시
catch (SQLException e) {
throw new MyDbException();
}
- new MyDbException() 으로 해당 예외만 생성하고 기존에 있는 SQLException 은 포함하지
않고 무시한다.
- 따라서 MyDbException 은 내부에 원인이 되는 다른 예외를 포함하지 않는다.
- 원인이 되는 예외를 내부에 포함하지 않으면, 예외를 스택 트레이스를 통해 출력했을 때 기존에
원인이 되는 부분을 확인할 수 없다.
- 만약 SQLException 에서 문법 오류가 발생했다면 그 부분을 확인할 방법이 없게 된다.
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;
/**
* 예외 누수 문제 해결
* SQLException 제거
*
* MemberRepository 인터페이스 의존존
*
* */
@Slf4j
public class MemberServiceV4 {
private final MemberRepository memberRepository;
public MemberServiceV4(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);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- MemberRepository 인터페이스에 의존
- MemberServiceV3_3 와 비교해서 보면 메서드에서 throws SQLException 부분이 제거된 것이 확인된다.
MemberServiceV4Test
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import hello.jdbc.repository.MemberRepositoryV4_1;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import javax.sql.DataSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 예외 누수 문제 해결
* 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
memberService.accountTransfer(memberA.getMemberId(),
memberB.getMemberId(), 2000);
//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 findMemberEx =
memberRepository.findById(memberEx.getMemberId());
//memberA의 돈이 롤백 되어야함
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
- MemberRepository 인터페이스를 사용하도록 했다.
- 테스트가 아래와 같이 정상 동작
- 체크 예외를 런타임 예외로 변환하면서 인터페이스와 서비스 계층의 순수성을 유지할 수 있게 됨.
- 덕분에 향후 JDBC에서 다른 구현 기술로 변경하더라도 서비스 계층의 코드를 변경하지 않고 유지할 수
있음. - 이 방식은 MyDbException 이라는 예외만 넘어오기 때문에 예외를 구분할 수 없는 단점이 있다.
<출처 김영한:스프링 DB 1편 - 데이터 접근 핵심 원리>
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard
스프링 DB 1편 - 데이터 접근 핵심 원리 - 인프런 | 강의
백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., - 강의
www.inflearn.com
'Spring > SpringDB' 카테고리의 다른 글
스프링 예외 추상화 이해 (0) | 2022.07.04 |
---|---|
데이터 접근 예외 직접 만들기 (0) | 2022.06.28 |
언체크 예외 활용 (0) | 2022.06.22 |
체크 예외 활용 (0) | 2022.06.22 |
언체크 예외 기본 이해 (0) | 2022.06.22 |