지금까지 만든 애플리케이션의 문제점을 보자.

애플리케이션 구조는 정해진게 없지만 주로 프레젠테이션, 서비스, 데이터 접근 계층 3가지로 나뉜다.
중요한건 서비스 계층에선 다른 라이브러리등에 의존하지 않고 순수 자바코드로 작성하는 것이다. 그래서 로직을 위한 매개변수를 받기위한 http 통신 사전작업, db에 저장 및 조회를 위한 사전작업 및 더러운 작업들은 모두 프레젠테이션 계층이나 데이터 접근 계층에 맡기고 서비스 계층은 순수한 상태를 유지해야 핵심 서비스를 파악하기 쉽고 유지보수에 용이하다.
근데 현재 우리의 서비스를 봐라

커넥션 때문에 서비스 클래스 안에 db의 라이브러리들이 잔뜩 들어가 있다. 물론 커넥션을 여기서 만들어서 트랜잭션을 관리하는건 맞다. 하지만 순수한 자바의 형태를 유지하지 못하게 되었다.
자세히 짚어보면 커넥션 외에도 서비스가 SQLException같은 DB 관련 에러를 던지는 형태로 다루고 있다. 이런 데이터베이스 관련 에러는 데이터베이스 계층 안에서 처리해야지 서비스까지 알면 안된다. 이런 예외 누수도 있고 try catch해서 어차피 con.setAutoCommit(false)하고 성공하면 commit하고 실패하면 rollback하고 뻔하게 같은 내용인데 계속 반복하는 것도 있고 커넥션을 하나로 억지로 유지하려고 매개변수로써 connection을 받는 함수를 만들다보니 트랜잭션이 필요한 함수 필요없는 함수 둘 다 정의해서 중복도 생긴다.
이런 문제들을 스프링을 통해 해결해본다.


각 데이터베이스마다 방법이 조금씩 다르다. 그래서 데이터베이스를 바꿨는데 서비스까지 바꿔야 한다. 이건 예전 자바 5대 원칙중 하나인 OCP(개방-폐쇄 원칙)을 위반하는 거다. 즉 하나에 대해 의존하는 다른 애들이 너무 많다는 것이다.


그래서 이런 문제는 추상화해서 해결할 수 있다. 안의 구체적인 코드도 모른 채 아무든 함수 기능 주세요 하면 주고 구체적인건 각자에 맞춰서 만들어지는거.


이렇게 서비스는 이제 인터페이스에 의존하는 DI덕분에 OCP 원칙을 지킬 수 있게 되었다. 이제 트랜잭션의 시작점과 끝 점을 서비스가 관리하지만 인터페이스에 의존하기 때문에 코드를 변경하거나 할 필요가 없어졌다는 얘기다.
예상했듯이 이런 문제는 오래전부터 있어왔기 때문에 스프링에서 지원한다.


이제 스프링의 이 트랜잭션 메니저를 사용할건데 얘가 해주는건 크게 트랜잭션 추상화, 리소스 동기화 2가지가 있다. 일단 트랜잭션 동기화부터 할거다.

일단 트랜잭션 동기화가 뭐냐면 전에 커넥션을 트랜잭션 전에 만든 그 하나가지고 트랜잭션을 끝까지 수행해야 제대로 되어서 매개변수로 커넥션을 주고 받는 등의 작업을 했을 거다. 이걸 트랜잭션 메니저가 만든 커넥션을 안전하게 보관하고 있다가 필요할 때 꺼내주고 한다는 것.

서비스 계층은 커넥션 신경 쓸 필요 없이 매니저한테 만들어주세요 하면 매니저가 알아서 하나 만들어주고 레퍼지토리에서 바로 그 커넥션을 달라고 하면 주고 트랜잭션 끝나면 알아서 닫아주고 한다는 것이다. 만약 서비스에서 커넥션 만들 필요가 없어서 그냥 해라라고 함수만 넘겨주면 레포지토리에서 매니저에서 온 커넥션이 아니기 때문에 서비스에서 오지 않은 거라고 판단. 자기가 내부에서 만들고 수행하고 없앤다. 매니저에서 온건 없애지 않고 매니저에게 다시 반환만 한다.
트랜잭션 동기화 매니저가 커넥션을 쓰레드로컬 이라는곳에 안전하게 보관한다는데, 이걸 만든 해당 쓰레드만 접근이 가능하게 안전한 곳에 보관한다고 생각하면 된다고 한다. 자세한건 다른 강의에서 설명해준다고 함.
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
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;
/**
* 트랜잭션 - 트랜잭션 메니저
* DataSourceUtils.getConnection()
* DataSourceUtils.releaseConnection()
*/
@Slf4j
public class MemberRepositoryV3 {
private final DataSource dataSource;
public MemberRepositoryV3(DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) throws SQLException {
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) {
log.error("db error", e);
throw e;
} finally {
// pstmt.close();
// con.close();
close(con, pstmt, null);
}
}
public Member findById(String memberId) throws SQLException {
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) {
log.error("db error", e);
throw e;
} finally {
// rs.close();
// pstmt.close();
// con.close();
close(con, pstmt, rs);
}
}
public void update(String memberId, int money) throws SQLException {
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) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public void delete(String memberId) throws SQLException {
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) {
log.error("db error", e);
throw 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);
}
}
밑에 getConnection()과 close만 보면 된다. 매니저를 사용하기 때문에 Utils를 사용해야 하는데 저 DataSourceUtils 안에 결국 트랜잭션 메니저의 getConnection함수와 closeConnection 함수를 실행하는게 들어 있다.

원래 con해서 만들고 setAutoCommit어쩌구 해서 시작했었지만 매니저를 통해 커넥션을 관리할거다. status로 관리한다.

테스트에서 달라진건 이제 서비스에서 getConnection 같은 것을 위해 받았던 DataSource대신 트랜잭션 매니저를 받기 때문에 매니저를 받도록 변경해준다. 나머진 그대로고. 하면 잘 된다.
이 코드들을 그림으로 다시 짚어준다.

서비스는 트랜잭션 매니저만 DI로 전달받는다. 매니저로만 통해서 비즈니스 로직의 트랜잭션을 실행하기 위한 커넥션을 만들고 해당 status를 받은 뒤 시작한다. 트랜잭션 시작이기 때문에 매니저가 알아서 setAutoCommit(false)를 알아서 해서 매니저에서 이 커넥션을 보관한다.

서비스에서 레퍼지토리한테 보관하거나 변경하라는 명령을 실행하면 레퍼지토리에서 로직을 실행할 때 매니저에서 커넥션을 찾아본다. 있으면 해당 커넥션 가지고 작업을 수행한다.

트랜잭션을 커밋 할건지 안할건지는 서비스가 가지고 있으므로 서비스가 실행하면서 만들었던 status로 커밋이나 롤백을 매니저한테 수행하라고 하면 매니저가 해당 커넥션 가지고 수행하고 없애는 등 작업을 정리한다.
이렇게 한 덕분에 서비스는 매니저만 가지고 있어서 데이터베이스가 바뀌어서 함수가 바뀌든 말든 신경 안써도 된다. 또 Connection을 넘겨주지 않아도 된다.
이제 대충 됐으니 try catch 반복하는걸 트랜잭션 템플릿을 통해 없애보자.


템플릿이 매니저를 받아 대신 실행하는 방식으로 진행된다. 템플릿의 자세한 작동 방식은 후의 심화 강의에서 다룬다. 얼핏 보면 콜백 같은거인듯.
그래서 저 .executeWithoutResult안의 함수가 제대로 실행되면 commit하고 언체크 예외가 발생하면 rollback한다(체크 예외의 경우는 커밋하는데, 이 부분은 뒤에서 설명한다고 함).
저 람다 안에선 체크 예외를 밖으로 던질 수 없어서 안에서 잡아야 한다.
테스트 코드는 달라진게 없음.
다음은 프록시를 알아본다.

어쨌든 서비스 내에서 순수한 서비스 계층으로만 구성이 안되고 커넥션을 관리해줘야 하는건 변하지 않는다. 이것조차 해결하기 위해 AOP라는 프록시를 도입했다.


스프링이 제공하는 트랜잭션 AOP에는 여러가지가 있는데 여기선 그중 @Transactional를 사용할거다. 서비스 내에서 트랜잭션이 필요한 곳에 @Transactional만 달아주면 된다. 자세한건 후의 심화 강의에서 다룬다고 함. 스프링이 만들어주는걸 쓰기 때문에 빈에 등록해서 스프링을 실행해야 한다. 안그럼 없는것 처럼 실행되서 에러 뜸.

/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {
public static final String Member_A = "memberA";
public static final String Member_B = "memberB";
public static final String Member_EX = "ex";
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig {
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@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);
// memberRepository = new MemberRepositoryV3(dataSource);
// memberService = new MemberServiceV3_3(memberRepository);
// }
@AfterEach
void after() throws SQLException {
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() throws SQLException {
// 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() throws SQLException {
// 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);
}
}

스프링 AOP를 사용하려면 실제 스프링을 실행해야 하는데 그럼 테스트에는 어떻게 실행하냐. @SpringBootTest를 해주고 @Testconfiguration으로 원하는 테스트 설정을 해준다.
@Transaction이 들어있는 서비스의 경우 크록시 클래스가 사용되었고 없는 레퍼지토리의 경우 그냥 클래스가 사용됨을 볼 수 있다. 즉 위 그림처럼 실제로 사용되는건 프록시이다.

흐름은 이럼. 실제로는 프록시가 다 한다.
이 프록시 방식도 선언적 트랜잭션 관리랑 프로그래밍 방식의 트랜잭션 관리라는게 있다는데 전자는 @Transactional 애노테이션을 이용하는것 같이 도움을 빌리는거고 프로그래밍 방식은 초기에 했던 것처럼 직접 커넥션 관리하고.. 하는거.

@Transactional에도 기능이 엄청 많은데 자세한 사용법은 뒤에서 다룬다고 함.
사실 스프링부트를 쓰면 데이터소스와 매니저는 자동으로 등록할 수 있다. 소스는 설정파일에서 주소값만 잘 먹으면 되고 매니저는 현재 등록된 라이브러리를 보고 파단하는데, JDBC를 기술로 사용하면 DataSourceTransactionManager를 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager를 등록하는 방식. 만약 아까같이 빈에 직접 등록하면 이 자동 등록은 실행되지 않는다.


DataSource는 자동으로 생성되기 때문에 스프링에서 받아오면 된다.
여기서 트랜잭션 문제는 해결했으니 예외 누수와 JDBC 반복 문제를 다음 섹션들에서 해결해본다.
'CS > 김영한 스프링 강의' 카테고리의 다른 글
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션6. 스프링과 문제 해결 - 예외 처리, 반복 (0) | 2023.08.17 |
---|---|
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션5. 자바 예외 이해 (0) | 2023.08.15 |
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션3. 트랜잭션 이해 (0) | 2023.08.13 |
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션2. 커넥션풀과 데이터소스 이해 (0) | 2023.08.12 |
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션1. JDBC 이해 (0) | 2023.08.12 |