728x90
1. 문제점
- application의 구조
- 프레젠테이션 계층
- UI와 관련된 처리 담당
- 웹 요청과 응답
- 사용자 요청을 검증
- spring MVC, web Servlet 기술
- 서비스 계층
- 비즈니스 로직 담당
- 주 사용 기술 : 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
- 데이터 접근 계층
- 실제 db에 접근하는 code
- 주 사용 기술 : JDBC, JPA, File, Redis, Mongo ...
1.1. 서비스 계층
- 가장 중요한 계층
- UI 와 db 저장 기술들은 다른 기술로 변경 가능성이 높지만 비즈니스 로직은 최대한 변경없이 유지 되어야한다.
- 서비스 계층은 특정 기술에 종속적이지 않게 개발되어야한다.
- 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 크다
- service 계층은 가급적business logic만 구현하고 특정 구현 기술에 직접 의존해서는 안된다.
- 이렇게 해야 향후 구현 기술이 변경될 때 변경의 영향 범위를 최소화 할 수 있다.
1.2. 실제 서비스 코드 비교
- serviceV1 code
@RequiredArgsConstructor
public class MemberServiceV1 {
private final MemberRepositoryV1 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember); // 중간에 예외 발생
memberRepository.update(toId, fromMember.getMoney() + money);
// 이 부분에서 commit or rollback이 수행
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- 특징
- 특정 기술에 종속적이지 않고, 순수한 비즈니스 로직만 존재
- 향후 비즈니스 로직의 변경이 필요하면 이 부분을 변경하면 된다.
- 코드가 깔끔하고 유지보수하기 쉽다
- 참고
SQLException 이라는 JDBC 기술에 의존하지만 memberRepository에서 올라오는 예외이기 때문에 memberRepository에서 해결을 할 것이다.
- serviceV2 code
package hello.jdbc.service;
/* *
* 트랜잭션 - params 연동, pool을 고려한 close()
* transaction == connection 시작 종료를 service에서 관리
* service 핵심 로직에서 예외가 발생했을 경우 connection을 롤백을 해줘야 하기 때문
* connection의 역할이 수동 commit 설정, commit, rollback
*
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
private final DataSource dataSource;
private final MemberRepositoryV2 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try{
con.setAutoCommit(false); // transaction 시작
bizLogic(con, fromId, toId, money); // 비즈니스 로직 최대한 분리
con.commit(); // 성공시, commit
}catch (Exception e){
con.rollback(); // 실패시, rollback
throw new IllegalStateException(e);
}finally {
release(con); // connection 끊기
}
// 이 부분에서 commit or rollback이 수행
}
/* *connection 종료 method
* setAutoCommit(true) 원복
* 1. 원래 autoCommit은 true가 default값
* 2. DriverMangerDatasource와 같은 connection Pool을 사용하지 않는 connection은 close()시, connection이 완전 제거되어
* 자동으로 autoCommit으로 돌아가서 신경 쓸 필요 X
* 3. HikariCP같이 connectionPool을 사용하는 dataSource는 connection이 해제되지 않으므로 수동으로 autoCommit true를 설정 필요
* */
private static void release(Connection con) {
try{
if (con != null){
con.setAutoCommit(true); // 다음에 쓰는 사람을 위해서 true로 원복
con.close();
}
} catch(Exception e){
log.info("error", e);
}
}
private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(con, fromId);
Member toMember = memberRepository.findById(con, toId);
memberRepository.update(con, fromId, fromMember.getMoney() - money);
validation(toMember); // 중간에 예외 발생
memberRepository.update(con, toId, fromMember.getMoney() + money);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- 특징
- 트랜잭션이 비즈니스 로직이 있는 서비스 계층에서 시작하는 것이 좋다.
- 하지만 트랜잭션을 사용하기 위해서 DataSource, Connection, SQLException 같은 jdbc 기술에 의존한다.
- 향후 JDBC에서 JPA 혹은 MongoDB 같은 다른 기술로 변경시 서비스 코드도 모두 함께 변경되는 문제 발생한다.
- 유지보수가 힘들게 된다.
1.3. 문제정리
- 트랜잭션 문제
- 예외누수 문제
- JDBC 반복 문제
- 트랜잭션을 적용하면 생긴 문제들
- JDBC 구현 기술이 서비스 계층에 누수되는 문제
- 트랜잭션 적용을 위해 JDBC 기술이 서비스 계층에 누수되었다.
- 서비스 계층에 순수 서비스 코드만 넣으려고 repository 등 분리를 해서 최대한 노력했지만 transaction 적용을 하면서 서비스 계층에 JDBC 구현 기술의 누수가 발생했다.
- 트랜잭션 동기화 문제
- 동일 트랜잭션 유지하기 위해 connection(con)을 params로 넘겼다.
- params로 넘기는 바람에 동일 트랜잭션 유지 코드 아닌 코드 거의 중복 코드가 발생
- 트랜잭션 적용 반복 문제
- try, catch, finally를 작성해야한다.
- 서비스 계층이 늘어나면 늘어날 수록 중복코드가 늘어날 것이다.
- JDBC 구현 기술이 서비스 계층에 누수되는 문제
- 예외 누수
- SQLException은 체크 예외이기 때문에 데이터 접근 계층을 호출한 서비스 계층에서 해당 예외를 처리해야한다.
- 그런데 위의 예외는 JDBC 전용기술로 향후 JPA나 다른 data 접근 기술을 사용하면, 그에 맞는 다른 예외를 변경해야 하고, 결국 서비스 코드를 건들여야한다.
- JDBC 반복문제
- 반복이 너무 많다. try-catch-finally
- 내가 느낀점
- 의존성에 관한 문제들은 계속 발생한다.
- 그 동안 해결 방안을 생각해보면 controller - service - repository 로 큰 맥락으로 구분하면 각각에 맞는 코드 위치로 최대한 몰아넣고 서로 연결을 시킬 경우 DI를 이용해서 연결을 한다.
- 그리고 DI 까지 써서 각각의 코드로 옮겼는데 어쩔수 없이 다른 계층에 꼭 존재하는 경우 filter나 aop 같이 제일 앞단 혹은 제일 뒷단의 숨겨진 곳에서 처리하도록 한다.
- 의존하지 않는다의 기준
- 다른 곳의 코드를 받아오는 것자체가 문제 되는 것이 아니라 A class 변경이 이루어질 때 A를 쓰고 있던 B class 와 C class가 같이 코드를 바꾸는 것이 아니라 A class만 변경을 하면 되도록 작성된것이 의존되지 않은 것이다.
- 이를 지키기 위해서 DI, interface를 이용한 추상화를 활용
- transaction(==connection 생성 || 획득) 이 service 계층에 있어야 하는 이유
- 논리적으로 service계층에서 connection을 생성 || 획득 하는 이유를 알지만 의존성 문제 및 코드가 복잡해지니깐 repository에 넣고 transaction을 처리하는 것도 방법이지 않나?
- 맞는 말이지만, 그렇게 되면 transaction을 해야하는 sql 관련 코드를 계속 중복적으로 만들어내야 하고 repository 자체에서도 해당 목적에 맞지 않는 transaction 관심사가 들어가게 된다.
- 그래서 service층에서 목적에 맞게 일단은 code를 구성했고 앞으로 해결해 나갈 것이다.
- transaction을 하기 위해서는 connection이 service 층에서 시작되고 종료되야함이 핵심이다.
2. 트랜잭션의 추상화
- 트랜잭션을 사용하는 방식은 JDBC, JPA마다 접근 기술(method())이 다르다.
2.1. 트랜잭션 추상화
- 트랜잭션 : connection 시작(=트랜잭션 시작)하고, commit || rollback 하고, 종료하는 단순한 기능
- 추상화 인터페이스를 만든다면 아래와 같을 것이다.
public interface TxManager {
begin();
commit();
rollback();
}
- 해당 interface에 맞는 구현체를 만들면 된다.
- JdbcTxManager: JDBC 트랜잭션 기능을 제공하는 구현체
- JpaManager: JPA 트랜잭션 기능을 제공하는 구현체
2.2. 스프링의 트랜잭션 추상화
- 트랜잭션매니저라는 interface를 이용해서 트랜잭션을 추상화한다.
- 해당 interface 명이 PlatformTransactionManger인 이유는 이미 ejb에서 TransactionManger class를 사용하고 있기 때문
- PlatformTransactionManager 인터페이스
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
- method()
- getTransaction() : 트랜잭션을 시작
기존에 이미 진행중인 트랜잭션이 있는 경우 해당 transaction에 참여 가능 - commit() : 트랜잭션 커밋
- rollback() : 트랜잭션 롤백
- close()는 따로 작성하지 않고 알아서 해준다.
- try-catch-finally에서 finally가 필요없어짐
- getTransaction() : 트랜잭션을 시작
3. 트랜잭션 동기화
- 트랜잭션 매니저 역할 2가지
- 트랜잭션 추상화
- 리소스 동기화 : 트랜잭션의 시작과 끝까지 동일한 connection을 유지해야하는 것
3.1. 트랜잭션 동기화 매니저
- 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용해서 connection을 동기화 한다.
- 트랜잭션 매니저는 내부적으로 트랜잭션 동기화 매니저 사용한다.
- 동작방식
- 트랜잭션을 시작하려면 커넥션이 필요.
- 트랜잭션 매니저는 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작
- 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관
- 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용
따라서 파라미터로 커넥션을 전달하지 않아도 된다. - 트랜잭션이 종료되면 트랜잭션 매니저는
- 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고,
- 커넥션도 닫는다
4. 트랜잭션 문제 해결 - 트랜잭션 매니저1
4.1. MemberRepositoryV3
- params로 con을 받는 method 모두 제거
package hello.jdbc.repository;
/* * 트랜잭션 - 트랜잭션 매너저
* service에서 tx매니저에 저장한 connection을 사용하던가, 없으면 repository에서 connection을 만들어서 사용
* DataSourceUtils.getConnection() // transaction 용 connection 연결
* DataSourceUtils.releaseConnection(con, dataSource); // transaction 용 connection 끊기
* * cf) JdbcUtils
* JdbcUtils.closeResultSet(rs);
* JdbcUtils.closeStatement(stmt);
*/
@Slf4j
public class MemberRepositoryV3 {
// data source 생성
// DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
private final DataSource dataSource;
// 생성자 생성
public MemberRepositoryV3(DataSource dataSource) {
this.dataSource = dataSource;
}
// 1. 등록
public Member save(Member member) throws SQLException {
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) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null); // close하는 과정에서 예외발생시 뒤에것도 close안되니깐 method로 분리
}
}
// 2. 조회
public Member findById(String memberId) throws SQLException {
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) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, rs);
}
}
// transaction 용 findById 제거
// 3. 수정
public void update(String memberId, int money) throws SQLException {
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) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
// transaction 용 findById 제거
// 4. 삭제
public void delete(String memberId) throws SQLException {
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) {
log.error("db error", e);
throw 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;
}
}
- DataSourceUtils.getConnection()
- 트랜잭션 동기화 매너지의 connection을 사용하기 위한 접근 class
- 트랜잭션 동기화 매니저가 관리하는 connection이 있을 시, 해당 connection 반환
- 트랜잭션 동기화 매니저가 관리하는 connection이 없을 시, new connection 반환
- transaction을 사용하지 않는 method의 경우에도 repository 자체적으로 connection 연결이 가능해졌다.
- DataSourceUtils.releaseConnection()
- con.close()로 인한 connection 유지(리소스 유지) 문제를 해결
- 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다
4.2. MemberServiceV3_1
- close()는 transaction manger가 알아서 해주기 때문에 finally{} 필요 없다
- con을 params로 안넘겨도 된다.
- transactionManger interface가 주입 받는 구현체
- JDBC : DataSourceTransactionManger
- JPA : JpaTransactionManger
- TransactionManger method()
- private final PlatformTransactionManager transactionManager; // 인터페이스 변수 사용
- TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); // 트랜잭션 시작
- transactionManager.commit(status); // 트랜잭션 commit
- transactionManager.rollback(status); // 트랜잭션 rollback
package hello.jdbc.service;
/**
* 트랜잭션 - 트랜젝션 매니저
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
// private final DataSource dataSource;
// dataSource에 의존하지 않고 transactionManager라는 interface를 이용해서 다형성 유지, spring Tx 추상화 인터페이스
private final PlatformTransactionManager transactionManager; // datasourceTransactionManager 구현체를 config file로 부터 DI
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// Connection con = dataSource.getConnection();
// transaction 시작 - transctionManger 내부에서 connection 생성
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try{
// transaction 시작
// con.setAutoCommit(false);
bizLogic(fromId, toId, money); // 비즈니스 로직 최대한 분리
// con.commit();
transactionManager.commit(status);
}catch (Exception e){
// con.rollback();
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
finally {
// transaction manger가 알아서 close() 시킴
}
}
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);
validation(toMember); // 중간에 예외 발생
memberRepository.update( toId, fromMember.getMoney() + money);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
4.3. test code
package hello.jdbc.service;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MemberServiceV3_1Test {
private MemberRepositoryV3 memberRepository;
private MemberServiceV3_1 memberService;
@BeforeEach
void before(){
// 설정으로 관리한 것
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
// transaction manager은 내부에 dataSource를 params로 가져와야지 해당 dataSource를 통해서 connection을 만들던가 획득(connectionPool)할 수 있다.
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberRepository = new MemberRepositoryV3(dataSource);
memberService = new MemberServiceV3_1(transactionManager, memberRepository); // transaction 사용 위해서 dataSource 넘김
}
@AfterEach
void after() throws SQLException {
memberRepository.delete("memberA");
memberRepository.delete("memberB");
memberRepository.delete("ex");
}
@Test
@DisplayName("정상 거래")
void accountTransfer() throws SQLException {
//given
Member memberA = new Member("memberA", 10000);
Member memberB = new Member("memberB", 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() throws SQLException {
//given
Member memberA = new Member("memberA", 10000);
Member memberEx = new Member("ex", 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when
// accountTransfer으 실제 내부적의 business logic인 bizLogic()에서 params를 4개를 받지만
// accountTransfer은 3개의 params만 받으면 된다.
assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
5. 트랜잭션 문제 해결 - 트랜잭션 매니저2
- transactionManger 와 Transaction 동기화 Manger 작동 원리
6. 트랜잭션 문제 해결 - 트랜잭션 템플릿
6.1. transactionTemplate
- 템플릿 콜백 패턴 활용
- connection - business logic - commit || rollback 을 하는 try-catch-finally 반복의 문제를 해결
- transactionTemplate 내부에 transactionManger를 넣어서 내부적으로 반복 구문 수행
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
}
- execute() : 응답 값이 있을 때 사용
- executeWithoutResult() : 응답 값이 없을 때 사용
6.2. MemberServiceV3_2
- 반복부분 제거
- 템플릿이 transaction 실행, comit, rollback 코드 모두 제거
package hello.jdbc.service;
/**
* 트랜잭션 - 트랜젝션 템플릿
*/
@Slf4j
public class MemberServiceV3_2 {
// private final DataSource dataSource; // dataSource에 의존하지 않고 transactionManager라는 interface를 이용해서 다형성 유지
// private final PlatformTransactionManager transactionManager; // datasource를 config file에서 주입 받아야한다.
private final TransactionTemplate txTemplate;
private final MemberRepositoryV3 memberRepository;
// 생성자
public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
// transaction template를 사용하려면 transactionManger를 주입 받아야한다.
// transactionManger를 bean으로 등록 가능하지만 class여서 유연성이 없고 관례상 이렇게 많이 한다.
this.txTemplate = new TransactionTemplate(transactionManager);
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
/* *TxTemplate
* 동일 람다식의 status랑 위의 status는 동일한 것
* txTemplate 내부에 TxManager가 들어가 있다.
* txTemplate에서 jdbc 관련 commit, rollback, connection 반복 code를 내부적으로 수행
* bizlogic 성공시 commit, 실패시 rollback 수행
* */
txTemplate.executeWithoutResult((status) -> { // 반환 type이 void여서 WithoutResult 사용
try {
bizLogic(fromId, toId, money);
} catch (SQLException e){
throw new IllegalStateException(e);
}
});
/* transaction 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try{
// transaction 시작
bizLogic(fromId, toId, money);
transactionManager.commit(status);
}catch (Exception e){
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
* */
}
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);
validation(toMember); // 중간에 예외 발생
memberRepository.update( toId, fromMember.getMoney() + money);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
6.3. test code
package hello.jdbc.service;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MemberServiceV3_2Test {
private MemberRepositoryV3 memberRepository;
private MemberServiceV3_2 memberService;
@BeforeEach
void before(){
// 설정으로 관리한 것
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
// transaction manager은 내부에 dataSource를 가지고 있다. 그래서 service에서 transaction을 시작할 수 있다.
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberRepository = new MemberRepositoryV3(dataSource);
memberService = new MemberServiceV3_2(transactionManager, memberRepository); // transaction 사용 위해서 dataSource 넘김
}
@AfterEach
void after() throws SQLException {
memberRepository.delete("memberA");
memberRepository.delete("memberB");
memberRepository.delete("ex");
}
@Test
void accountTransfer() throws SQLException {
//given
Member memberA = new Member("memberA", 10000);
Member memberB = new Member("memberB", 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
void accountTransferEx() throws SQLException {
//given
Member memberA = new Member("memberA", 10000);
Member memberEx = new Member("ex", 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when
// accountTransfer으 실제 내부적의 business logic인 bizLogic()에서 params를 4개를 받지만
// accountTransfer은 3개의 params만 받으면 된다.
assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
- 정리
- 트랜잭션 템플릿 덕분에, 트랜잭션을 사용할 때 반복하는 코드를 제거할 수 있었다.
하지만 아직도 서비스 로직에 비즈니스 로직 뿐만 아니라 트랜잭션을 처리하는 기술 로직이 함께 포함되어 있다. - 애플리케이션을 구성하는 로직을 핵심 기능과 부가 기능으로 구분하자면 서비스 입장에서 비즈니스 로직은 핵심 기능이고, 트랜잭션은 부가 기능이다.
- 이렇게 비즈니스 로직과 트랜잭션을 처리하는 기술 로직이 한 곳에 있으면 두 관심사를 하나의 클래스에서 처리하게 되어 유지보수하기 어려움
- 트랜잭션 템플릿 덕분에, 트랜잭션을 사용할 때 반복하는 코드를 제거할 수 있었다.
7. 트랜잭션 문제 해결 - 트랜잭션 AOP 이해
- spring AOP를 통해 proxy를 도입하면 문제를 해결
- spring이 @Transactional을 통해서 Proxy를 만들어서, 트랜잭션 처리 기술과 비즈니스 처리 기술을 분리했다는 것이 핵심
- 프록시를 사용해서 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리
- spring이 @Transactional annotation을 보고 내부적으로 transacation 코드와 비즈니스 로직이 합쳐지 프록시 객체를 생성
7.1. MemberServiceV3_3
package hello.jdbc.service;
/**
* 트랜잭션 - @Transactional
*/
@Slf4j
//@Transactional class와 method 둘다 붙여도 된다.
public class MemberServiceV3_3 {
/* transaction 관련 code 제거
* private final TransactionTemplate txTemplate;
*
* public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
* this.txTemplate = new TransactionTemplate(transactionManager);
* this.memberRepository = memberRepository;
* }
*
* txTemplate.executeWithoutResult((status) -> { // 반환 type이 void여서 WithoutResult 사용
* try {
* bizLogic(fromId, toId, money);
* } catch (SQLException e){
* throw new IllegalStateException(e);
* }
* }
* */
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional // spring AOP - proxy 사용 - 해당 어노태이션으로 transactionTemplate,manger 역할 다 수행
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);
validation(toMember); // 중간에 예외 발생
memberRepository.update(toId, fromMember.getMoney() + money);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
7.2. 스프링이 제공하는 transaction AOP
- 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공
- 스프링부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록
- 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다.
class, method 아무 곳에 어노테이션 작성 가능 - 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용
7.3. @Transactional
- org.springframework.transaction.annotation.Transactional
- 참고
- 스프링 AOP를 적용하려면 어드바이저, 포인트컷, 어드바이스가 필요
- 스프링은 트랜잭션 AOP 처리를 위해 다음 클래스를 제공한다.
- 스프링 부트를 사용하면 해당 빈들은 스프링 컨테이너에 자동으로 등록
- 어드바이저: BeanFactoryTransactionAttributeSourceAdvisor
- 포인트컷: TransactionAttributeSourcePointcut
- 어드바이스: TransactionInterceptor
7.4. 사용자 bean 등록 test
package hello.jdbc.service;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Slf4j
@SpringBootTest // spring이 springContainer를 띄운다. - @Transactional 사용시, proxy에서 @bean을 사용하기 때문
class MemberServiceV3_3Test {
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
// 이전 의존관계 주입을 하려는 memberRepository, memberService를 bean으로 등록해야 DI 가 가능해짐
@TestConfiguration // testCode 내부에서 bean 등록하는 방법
static class TestConfig{
@Bean
DataSource dataSource() { return new DriverManagerDataSource(URL,USERNAME,PASSWORD); }
// transactionManger는 proxy(@Transactional)로부터 내부적으로 사용해야하기 때문에 bean 등록
@Bean
PlatformTransactionManager transactionManager(){
return new DataSourceTransactionManager(dataSource());
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource());
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
/* *
@BeforeEach
void before(){
// 설정으로 관리한 것
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberRepository = new MemberRepositoryV3(dataSource);
memberService = new MemberServiceV3_3(memberRepository); // transaction 사용 위해서 dataSource 넘김
}
*/
@AfterEach
void after() throws SQLException {
memberRepository.delete("memberA");
memberRepository.delete("memberB");
memberRepository.delete("ex");
}
@Test
void accountTransfer() throws SQLException {
//given
Member memberA = new Member("memberA", 10000);
Member memberB = new Member("memberB", 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
void accountTransferEx() throws SQLException {
//given
Member memberA = new Member("memberA", 10000);
Member memberEx = new Member("ex", 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when
// accountTransfer으 실제 내부적의 business logic인 bizLogic()에서 params를 4개를 받지만
// accountTransfer은 3개의 params만 받으면 된다.
assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
// proxy 적용 확인 test
@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();
}
}
- @SpringBootTest
- 스프링 AOP를 적용하려면 스프링 컨테이너가 필요
- 테스트시 스프링 부트를 통해 스프링 컨테이너를 생성
- 테스트에서 @Autowired등을 통해 스프링 컨테이너가 관리하는 빈들을 사용 가능
- @TestConfiguration
- 테스트 안에서 내부 설정 클래스
- 스프링 부트가 자동으로 만들어주는 빈 추가
- 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 가능
- TestConfig
- DataSource 스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록
- 추가로 트랜잭션 매니저에서도 사용
- DataSourceTransactionManager 트랜잭션 매니저를 스프링 빈으로 등록
- 스프링이 제공하는 트랜잭션 AOP는 Proxy 객체 생성 시, 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다.
- 참고로 springboot에서 자동 설정기능을 통해서 트랜잭션 매너저가 등록된다. properties에서 수정하면 된다.
- proxy 적용 확인 test
- memberService class=class hello.jdbc.service.MemberServiceV3_3$ $EnhancerBySpringCGLIB$$.
- AOP 프록시가 적용 되었다.
7.5. 트랜잭션 AOP 정리
8. 스프링 부트의 자동 리소스 등록
8.1. 데이터소스 - 자동 등록
- 스프링 부트는 데이터소스( DataSource )를 스프링 빈에 자동으로 등록
- 자동으로 등록되는 스프링 빈 이름: dataSource , transactionManger
- 참고로 개발자가 직접 데이터소스를 빈으로 등록하면 스프링 부트는 데이터소스를 자동으로 등록하지 않는다.
- 스프링 부트는 application.properties에 있는 속성을 사용해서 DataSource를 생성하고 스프링 빈에 등록
- application.properties
# data source springboot bean 등록
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
- 스프링 부트가 기본으로 생성하는 데이터소스 : HikariDataSource(connection pool 제공)
- 커넥션풀과 관련된 설정도 application.properties를 통해서 지정가능
- spring.datasource.url 속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도
8.2. 트랜잭션 매니저 - 자동 등록
- 스프링 부트는 적절한 트랜잭션 매니저( PlatformTransactionManager )를 자동으로 스프링 빈에 등록
- 자동으로 등록되는 스프링 빈 이름: transactionManager
- 참고로 개발자가 직접 트랜잭션 매니저를 빈으로 등록하면 스프링 부트는 트랜잭션 매니저를 자동으로 등록하지 않는다.
- 어떤 트랜잭션 매니저를 선택할지는 현재 등록된 라이브러리를 보고 판단
- JDBC를 기술을 사용하면 DataSourceTransactionManager 를 빈으로 등록
- JPA를 사용하면 JpaTransactionManager 를 빈으로 등록
- 둘다 사용하는 경우 JpaTransactionManager를 등록
8.3. 자동 bean 등록 test
package hello.jdbc.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 예외 누수 문제 해결
* SQLException 제거
* MemberRepository 인터페이스 의존
*/
@Slf4j
@SpringBootTest // spring이 springContainer를 띄운다.
class MemberServiceV4Test {
@Autowired
private MemberRepository memberRepository;
@Autowired
private MemberServiceV4 memberService;
// 이전 @Before의 설정 들도 bean으로 등록 필요
@TestConfiguration
static class TestConfig {
// spring이 자동으로 만들어줄 dataSource - dataSource 생성자 주입
private final DataSource dataSource;
public TestConfig(DataSource dataSource) { this.dataSource = dataSource; }
@Bean
MemberRepository memberRepository() { return new MemberRepositoryV5(dataSource); }
@Bean
MemberServiceV4 memberServiceV3_3() { return new MemberServiceV4(memberRepository()); }
}
@AfterEach
void after() {
memberRepository.delete("memberA");
memberRepository.delete("memberB");
memberRepository.delete("ex");
}
@Test
void accountTransfer() {
//given
Member memberA = new Member("memberA", 10000);
Member memberB = new Member("memberB", 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
void accountTransferEx() {
//given
Member memberA = new Member("memberA", 10000);
Member memberEx = new Member("ex", 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when
// accountTransfer으 실제 내부적의 business logic인 bizLogic()에서 params를 4개를 받지만
// accountTransfer은 3개의 params만 받으면 된다.
assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
// proxy 적용 확인 test
@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 code 참고 발행글 : 2023.09.23 - [test/JUnit5] - SpringBoot test - @SpringBootTest, @TestConfiguration