데이터를 저장할 때 단순히 파일에 저장해도 되는데, 데이터베이스에 저장하는 이유는 뭘까? 가장 대표적인 이유는 데이터베이스는 트랜잭션이라는 개념을 지원하기 때문이다.
set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋
set autocommit false; //수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; //수동 커밋
세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다르 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.
SET LOCK_TIMEOUT <milliseconds>
: 락 타임아웃 시간을 설정한다.SET LOCK_TIMEOUT 10000
: 세션이 10초 동안 대기해도 락을 얻지 못하면 락 타임아웃 오류가 발생한다.set autocommit false;
select * from member where member_id='memberA' for update;
select for update
구문을 사용한다. 물론 이 경우도 트랜잭션을 커밋하면 락을 반납한다.select for update
는 락이 반납될때까지 기다려야 한다.
- 실제 애플리케이션에 DB 트랜잭션을 사용해서 계좌이체 같이 원자성이 중요한 비즈니스 로직을 어떻게 구현하는지 알아보자.
- 애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까?
//기존 findById() 밑에 추가
public Member findById(Connection con, String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
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 {
//connection은 여기서 닫지 않는다.
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
}
}
//기존 update() 밑에 추가
public void update(Connection con, String memberId, int money)
throws SQLException {
String sql = "update member set money=? where member_id=?";
PreparedStatement pstmt = null;
try {
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 {
//connection은 여기서 닫지 않는다.
JdbcUtils.closeStatement(pstmt);
}
}
MemberRepositoryV2
는 기존 코드와 같고 커넥션 유지가 필요한 두 메서드가 추가된다. 위의 두 메서드는 계좌이체 서비스 로직에서 호출되는 메서드이다.con = getConnection()
코드가 있으면 안된다./**
* 트랙잭션 - 파라미터 연동, 풀을 고려한 종료
*/
@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); //트랙잭션 시작
//비즈니스 로직
bizLogic(con, fromId, toId, money);
con.commit(); //성공시 커밋
} catch (Exception e) {
con.rollback(); //실패시 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}
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, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
private void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true); //커넥션 풀 고려 (풀 사용 안하면 안해도 됨)
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
Connection con = dataSource.getConnection();
con.setAutoCommit(false); //트랜잭션 시작
set autocommit false
가 전달되고, 이후부터는 수동 커밋 모드로 동작한다.bizLogic(con, fromId, toId, money);
con.commit(); //성공시 커밋
con.rollback(); //실패시 롤백
release(con);
/**
* 트랜잭션 - 커넥션 파라미터 전달 방식 동기화
*/
@Slf4j
class MemberServiceV2Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
private MemberRepositoryV2 memberRepository;
private MemberServiceV2 memberService;
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV2(dataSource);
memberService = new MemberServiceV2(dataSource, memberRepository);
}
@AfterEach
void after() throws SQLException {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@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);
}
}
delete from member;
@AfterEach
javax.sql.DataSource
, java.sql.Connection
, java.sql.SQLException
같은 JDBC 기술에 의존한다.앞서 개발한 애플리케이션의 문제점은 크게 3가지이다.
- 트랜잭션 문제
- 예외 누수 문제
- JDBC 반복 문제
try
, catch
, finally
...SQLException
은 체크 예외이기 때문에 데이터 접근 계층을 호출한 서비스 계층에서 해당 예외를 잡아서 처리하거나 명시적으로 throws
를 통해서 다시 밖으로 던져야한다.SQLException
은 JDBC 전용 기술이다. 향후 JPA나 다른 데이터 접근 기술을 사용하면, 그에 맞는 다른 예외로 변경해야 하고, 결국 서비스 코드도 수정해야 한다.MemberRepository
코드는 순수한 JDBC를 사용했다.현재 서비스 계층은 트랜잭션을 사용하기 위해서 JDBC 기술에 의존하고 있다. 향후 JPA 같은 다른 데이터 접근 기술로 변경하면, 서비스 계층의 트랜잭션 관련 코드도 모두 함께 수정해야 한다. 이 문제를 해결하려면 트랜잭션 기능을 추상화하면 된다.
public interface TxManager {
begin();
commit();
rollback();
}
TxManager
인터페이스를 기반으로 각각의 기술에 맞는 구현체를 만들면 된다.DataSourceTransactionManager
를 상속받아서 약간의 기능을 확장한 JdbcTransactionManager
를 제공한다. 둘의 기능 차이는 크지 않으므로 같은 것으로 이해하면 된다.//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;
}
getTransaction()
: 트랜잭션을 시작한다.getTransaction()
인 이유는 기존에 이미 진행중인 트랜잭션이 있는 경우 해당 트랜잭션에 참여할 수 있기 때문.commit()
: 트랜잭션을 커밋rollback()
: 트랜잭션을 롤백PlatformTransactionManager
인터페이스와 구현체를 포함해서 트랜잭션 매니저로 줄여서 이야기하겠다.스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다.
- 트랜잭션 추상화
- 리소스 동기화: 트랜잭션 유지를 위한 커넥션 동기화(맞추어 사용)
ThreadLocal
)을 사용해서 커넥션을 동기화 해준다.org.springframework.transaction.support.TransactionSynchronizationManager
//close(), getConnection() 변경
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;
}
/**
* 트랙잭션 - 트랜잭션 매니저
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money)
throws SQLException {
//트랜잭션 시작
TransactionStatus status
= transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
//비즈니스 로직
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, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
private final PlatformTransactionManager transactionManager
DataSourceTransactionManager
구현체를 주입 받아야 한다.transactionManager.getTransaction()
TransactionStatus status
를 반환한다. 현재 트랜잭션의 상태 정보가 포함되어 있다. 이후 트랜잭션을 커밋, 롤백할 때 필요하다.new DefaultTransactionDefinition()
transactionManager.commit(status)
transactionManager.rollback(status)
// V2와 다른 부분
private MemberRepositoryV3 memberRepository;
private MemberServiceV3_1 memberService;
@BeforeEach
void before() {
DriverManagerDataSource dataSource
= new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository
= new MemberRepositoryV3(dataSource);
PlatformTransactionManager transactionManager
= new DataSourceTransactionManager(dataSource);
memberService
= new MemberServiceV3_1(transactionManager, memberRepository);
}
new DataSourceTransactionManager(dataSource)
DataSource
가 필요하다.참고로 여기서는
DataSourceTransactionManager
의 동작 방식을 위주로 설명한다. 다른 트랜잭션 매니저는 해당 기술에 맞도록 변형되어 동작한다.
transactionManager.getTransaction()
을 호출해서 트랜잭션을 시작한다.DataSourceUtils.getConnection()
을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해 자연스럽게 같은 커넥션을 사용하고, 트랜잭션도 유지된다.con.setAutoCommit(true)
로 되돌린다. 커넥션 풀을 고려해야 한다.con.close()
를 호출해서 커넥션을 종료한다. 커넥션 풀을 사용하는 경우 con.close()
를 호출하면 커넥션 풀에 반환된다.트랜잭션을 사용하는 로직에서 템플릿 콜백 패턴을 활용하면 반복 문제를 깔끔하게 해결할 수 있다.
TransactionTemplate
이라는 템플릿 클래스를 제공한다.public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
execute()
: 응답 값이 있을 때 사용executeWithoutResult()
: 응답 값이 없을 때 사용// V3_1과 다른 부분
private final TransactionTemplate txTemplate;
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_2(PlatformTransactionManager transactionManager,
MemberRepositoryV3 memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager);
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money)
throws SQLException {
txTemplate.executeWithoutResult((status) -> {
//비즈니스 로직
try {
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e); //언체크 예외(런타임 예외)
}
});
}
TransactionTemplate
을 사용하려면 transactionManager
가 필요하다. 생성자에서 transactionManager
를 주입 받으면서 TransactionTemplate
을 생성했다.try~catch
가 들어갔는데, bizLogic()
메서드를 호출하면 SQLException
체크 예외를 넘겨준다. 해당 람다에서 체크 예외를 밖으로 던질 수 없기 때문에 언체크 예외로 바꾸어 던지도록 예외를 전환했다.MemberServiceV3_1
만 MemberServiceV3_2
로 바꿔준다.
- 지금까지 트랜잭션을 편리하게 처리하기 위해서 트랜잭션 추상화도 도입하고, 반복적인 트랜잭션 로직을 해결하기 위해 트랜잭션 템플릿도 도입했다.
- 트랜잭션 템플릿 덕분에 트랜잭션을 처리하는 반복 코드는 해결할 수 있었다. 하지만 서비스 계층에 순수한 비즈니스 로직만 남긴다는 목표는 아직 달성하지 못했다.
- 이럴 때 스프링 AOP를 통해 프록시를 도입하면 문제를 깔끔하게 해결할 수 있다.
//트랜잭션 프록시 코드 예시
public class TransactionProxy {
private MemberService target;
public void logic() {
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
//실제 대상 호출
target.logic();
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}
//트랜잭션 프록시 적용 후 서비스 코드 예시
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
- 스프링이 제공하는 AOP 기능을 사용하면 프록시를 매우 편리하게 적용할 수 있다.
- 스프링 AOP를 직접 사용해서 트랜잭션을 처리해도 되지만, 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다. 스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.
- 트랜잭션 처리가 필요한 곳에
@Transactional
애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.
BeanFactoryTransactionAttributeSourceAdvisor
TransactionAttributeSourcePointcut
TransactionInterceptor
/**
* 트랙잭션 - @Transactional AOP
*/
@Slf4j
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional
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, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
@Transactional
애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다. 클래스에 붙이면 외부에서 호출 가능한 public
메서드가 AOP 적용 대상이 된다./**
* 트랜잭션 - @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());
}
}
@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);
}
}
@SpringBootTest
: 스프링 AOP를 적용하려면 스프링 컨테이너가 필요하다. 이 애노테이션이 있으면 테스트시 스프링 부트를 통해 스프링 컨테이너를 생성한다. 그리고 테스트에서 @Autowired
등을 통해 스프링 컨테이너가 관리하는 빈들을 사용할 수 있다.@TestConfiguration
: 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 애노테이션을 붙이면, 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.TestConfig
DataSource
: 스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록한다. 추가로 트랜잭션 매니저에서도 사용한다.DataSourceTransactionManager
: 트랜잭션 매니저를 스프링 빈으로 등록한다.//AopCheck() 실행 결과
memberService class=class hello.jdbc.service.MemberServiceV3_3$
$EnhancerBySpringCGLIB$$...
memberRepository class=class hello.jdbc.repository.MemberRepositoryV3
memberService
에 EnhancerBySpringCGLIB..
라는 부분을 통해 프록시(CGLIB)가 적용된 것을 확인할 수 있다.@Transactional
애노테이션 선언스프링 부트가 등장하기 이전에는 데이터소스와 트랜잭션 매니저를 개발자가 직접 스프링 빈으로 등록해서 사용했다. 스프링 부트가 나오면서 많은 부분이 자동화되었다.
DataSource
)를 스프링 빈에 자동으로 등록한다.dataSource
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
application.properties
에 있는 속성을 사용해서 DataSource
를 생성한 후 스프링 빈에 등록한다.HikariDataSource
이다. 커넥션풀과 관련된 설정도 application.properties
를 통해서 할 수 있다.spring.datasource.url
속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도한다.PlatformTransactionManager
)를 자동으로 스프링 빈에 등록한다.transactionManager
DataSourceTransactionManager
를 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager
를 빈으로 등록한다. 둘다 사용하는 경우 JpaTransactionManager
를 등록한다. JpaTransactionManager
는 DataSourceTransactionManager
가 제공하는 기능도 대부분 지원한다.spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
//V3_3TEST와 다른 부분
@TestConfiguration
static class TestConfig {
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource);
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
application.properties
를 통해 설정도 편리하게 할 수 있다.
정보 감사합니다.