[본 포스팅은 스프링 DB 1편 - 데이터 접근 핵심 원리 편을 기반으로 작성하였습니다.]
트랜잭션이란?
트랜잭션이란 더 이상 나눌 수 없는 가장 작은 하나의 단위를 의미한다.
상황 1: 원자성(Atomicity))
만약 A 사용자가 B 사용자에게 5000원을 송금한다면?
- A 사용자 계좌 5000원 감소
- B 사용자 계좌 5000원 증가
위처럼 2가지의 작업이 일어나게 되는데, 만약 2번에서 오류가 발생하였을 경우
- 트랜잭션을 적용하지 않은 상태에서 A 사용자의 계좌는 5000원이 감소하였지만 B 사용자 계좌는 5000원 증가를 하지 않았을 것이다.
- 하지만 트랜잭션을 사용하였다면 작업 중 하나라고 실패해서 거래 이전으로 되돌리는 롤백을 할 것이다.
(정상 반영하는 것은 커밋)
따라서 2가지 이상의 작업이 하나의 작업처럼 동작해야 할 경우 트랜잭션을 사용해야 한다.
상황 2: 격리성(Isolation)
만약 동시에 A 사용자와 B 사용자가 C 사용자에게 동시에 5000원씩 송금한다면?
- C 사용자 계좌의 잔액은 10000원인 상태이다.
- A 사용자와 B 사용자가 동시에 C 사용자에게 5000원을 송금한다.
- 정상적으로는 C 사용자는 20000원이 되어야 한다.
- 트랜잭션을 사용하지 않았을 경우, A 사용자와 B 사용자가 C 사용자의 잔액을 조회했을 때 10000원이기 때문에 모두 송금 완료 시 C 사용자는 15000원이 될 것이다.
- 하지만 트랜잭션을 사용하였다면 격리성(동시에 데이터 수정 불가)을 보장하기 때문에 정상적으로 C 사용자가 20000원으로 되는 것을 볼 수 있다.
트랜잭션 ACID
그렇다면 위와 같은 트랜잭션은 무엇을 보장하는지 알아보자.
- 원자성(Atomicity): 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 한다.
- 일관성(Consistency): 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
- 격리성(Isolation): 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 * 트랜잭션 격리 수준(Isolation level)을 선택할 수 있다.
- 지속성(Durability): 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.
* 트랜잭션 격리 수준 - Isolation level
- READ UNCOMMITTED - 커밋되지 않은 읽기
- READ COMMITTED - 커밋된 읽기
- REPEATABLE READ - 반복 가능한 읽기
- SERIALIZABLE - 직렬화 가능
그렇다면 스프링 트랜잭션이란?
간단하게 어노테이션 @Transactional을 메서드 또는 클래스 위에 붙여서 사용하는 것이다.
기존에 직접 작성한 트랜잭션들은 수동 커밋을 해주고, sql문을 보내고 다시 자동 커밋으로 바꾸고...
코드가 복잡했다.
하지만 스프링 트랜잭션은 @Transactional을 통해 간결하게 만들어준다.
적용하는 방법에는 두 가지가 있고, 차이점은 다음과 같다.
메서드에 적용할 경우)
메서드 시작부터 트랜잭션이 시작되고, 메서드가 성공적으로 마치면 트랜잭션 커밋, 도중에 문제가 발생하면 롤백을 하게 된다.
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
bizLogic(fromId, toId, money);
}
클래스에 적용할 경우)
해당 클래스에 존재하는 모든 메서드에 어노테이션이 적용된다.
@Transactional
public class MemberServiceV4 {
만약 중첩되어 있는 경우 다음과 같은 순으로 실행된다.
- 클래스 메서드
- 클래스
- 인터페이스 메서드
- 인터페이스
하지만 통상적으로 세밀한 설정을 하기 위해 메서드에 붙여 사용된다.
계좌를 예시로 코드로 사용해 보자 - MemberRepository
(feat. 스프링 예외 추상화, JdbcTemplate를 사용해서 코드를 간결하게)
기능에는 유저 정보를 저장, 검색, 수정, 삭제를 할 수 있고,
서비스 로직에는 송금 기능(bizLogic())이 있다.
먼저 MemberRepository를 추상화해주기 위해 인터페이스로 MemberRepository를 다음과 같이 생성한다.
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
그 후 MemberRepository 클래스 코드에 MemberRepository를 상속받은 코드는 다음과 같다.
전체 코드
@Slf4j
public class MemberRepositoryV5 implements MemberRepository {
private final JdbcTemplate template;
public MemberRepositoryV5(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values (?, ?)";
template.update(sql, member.getMemberId(), member.getMoney());
return member;
}
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
return template.queryForObject(sql, memberRowMapper(), memberId);
}
@Override
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id=?";
template.update(sql, money, memberId);
}
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id=?";
template.update(sql, memberId);
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
};
}
}
코드를 보면 Jdbc 템플릿을 사용하기 위해
private final JdbcTemplate template;
public MemberRepositoryV5(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
JdbcTemplate를 선언해준 후 생성자에서 new JdbcTemplate(dataSource);를 주입해주었다.
그 이유 중 하나는 어떠한 DB를 사용하는지에 대한 정보도 들어간다.
그리고 기존에 try, catch, finally로 반복되었던 것들이 Jdbc 템플릿으로 간결하게 되었다.
문법은 template.update(sql문, sql 파라미터, ...)로 들어가고,
RowMapper의 경우 findById를 실행시켰을 때 반환되는 row 값을 Member 객체로 생성하여 리턴해주기 위해 사용한 것이다.
참고
JdbcTemplate는 트랜잭션을 위한 커넥션 동기화는 물론이고, 예외 발생 시 스프링 예외 변환기도 자동으로 실행해준다.
계좌를 예시로 코드로 사용해 보자 - MemberService
(스프링 트랜잭션 @Transactional 사용)
전체 코드
@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에 생성자 주입을 해주고
다음 줄에 accountTransfer 함수는 송금해주는 로직을 실행하기 위한 함수이고
트랜잭션을 적용하기 위해 @Transactional을 함수 위에 두었다.
* 참고로 트랜잭션은 리포지토리에서도 사용할 수 있지만 서비스 계층에서 사용하는 것을 권장한다.
이로써 스프링 트랜잭션 적용은 끝이 난 것이다.
매우 간단하다!
계좌를 예시로 코드로 사용해 보자 - 테스트 코드
(테스트 코드에서 실행해보자!)
전체 코드
@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 MemberRepositoryV5(dataSource);
}
@Bean
MemberServiceV4 memberServiceV4() {
return new MemberServiceV4(memberRepository());
}
}
@AfterEach
void after() {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@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 findMemberB = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberB.getMoney()).isEqualTo(10000);
}
}
테스트 코드 전체이다.
길어서 눈에 잘 안 들어올 수 있지만 위에서 차근차근 설명해보자면
멤버 리포지토리와 서비스를 @Autowired를 통해 객체를 주입해준다.
@Autowired
private MemberRepository memberRepository;
@Autowired
private MemberServiceV4 memberService;
@TestConfiguration을 통한 빈 등록
@TestConfiguration
static class TestConfig {
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepository memberRepository() {
return new MemberRepositoryV5(dataSource);
}
@Bean
MemberServiceV4 memberServiceV4() {
return new MemberServiceV4(memberRepository());
}
}
TestConfig 클래스를 통해 빈 등록을 해주기 위해 @TestConfiguration을 붙여주고
앞서 @Autowired를 통해 객체를 주입해주기 위해 @Bean을 통해 빈 등록을 해준다.
(참고로 DataSource는 프로퍼티 파일에서 설정해둔 값이 자동 매핑된다.)
- application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
@AfterEach 테스트 코드 종료 시 실행
@AfterEach
void after() {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
DB에 저장되었던 정보를 다시 삭제해주는 역할
void accountTransfer()
@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);
}
위 코드는 정상적으로 이체가 되는 로직이다.
- 멤버를 저장하고
- 송금하고
- 잘 됐는지 확인했다.
void accountTransferEx()
@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);
}
이번엔 오류를 발생시켜 트랜잭션을 통해 롤백하도록 하는 로직이다.
그래서 마지막 assertThat을 보면 10000원 그대로인 것을 확인할 수 있다.
'JAVA > DB' 카테고리의 다른 글
[Spring] 데이터베이스 동시성 문제 해결 코드 (STEP 1. LOCK) (0) | 2023.07.06 |
---|---|
[DB] 커넥션 풀(Connection Pool)이란? HikariCP 사용해보기 (0) | 2022.09.26 |