기술스택
- Spring Boot 3.3.5
- Java 17
- H2 Database
1. 트랜잭션 개념 이해
트랜잭션이란?
데이터베이스 트랜잭션(Database Transaction)은 데이터베이스 관리 시스템 또는 유사한 시스템에서 상호작용의 단위이다. 여기서 유사한 시스템이란 트랜잭션이 성공과 실패가 분명하고 상호 독립적이며, 일관되고 믿을 수 있는 시스템을 의미한다. - 위키백과
데이터를 저장할 때 단순히 파일에 저장해도 되는데, 데이터베이스에 저장하는 이유는 무엇일까?
가장 대표적인 이유는 데이터베이스는 트랜잭션이라는 개념을 지원하기 때문이다.
트랜잭션을 이름 그대로 번역하면 거래라는 뜻이다. 데이터 베이스에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻한다. 즉, 데이터베이스에서 수행되는 일련의 작업 단위를 의미하며, 이 작업들은 모두 성공적으로 완료되거나 모두 실패하여 롤백되는 특징을 갖는다.
모든 작업이 성공해서 데이터베이서에 정상 반영하는 것을 커밋(Commit)이라 하고, 작업중 하나라도 실패해서 거래 이전으로 되돌리는 것을 롤백(Rollback)이라 한다.
트랜잭션 ACID
트랜잭션은 ACID라 하는 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)을 보장해야 한다.
- 원자성(Atomicity): 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하거나 모두 실패해야 한다.
- 일관성(Consistency): 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무 결성 제약 조건을 항상 만족해야 한다.
- 격리성(Isolation): 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어 동시에 같은 데이터 를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(Isolation level)을 선택할 수 있다.
- 지속성(Durability): 트랜잭션이 성공적으로 완료되면 그 결과는 영구적으로 데이터베이스에 반영된다.
트랜잭션은 원자성, 일관성, 지속성을 보장한다. 문제는 격리성인데 트랜잭션 간에 격리성을 완벽히 보장하면 트랜잭션을 거의 순서대로 실행해야 한다. 이렇게 하면 동시처리 성능이 매우 나빠진다. 이런 문제로 인해 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의 했다.
트랜잭션 격리 수준 - Isolation level
- READ UNCOMMITTED (읽기 미완료 허용)
- READ COMMITTED (읽기 완료된 것만 허용)
- REPEATABLE READ (반복 가능한 읽기)
- SERIALIZABLE (직렬화 가능)
2. 데이터베이스 연결 구조와 DB 세션
클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 된다. 이때 데이터베이스 서버는 내부에 세션이라는 것을 만든다. 그리고 앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 된다.
세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그리고 이후에 새로운 트랜잭션을 다시 시작할 수 있다.
커넥션 풀이 10개의 커넥션을 생성하면, 세션도 10개 만들어진다.
3. DB락 - 개념 이해
세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데, 세션2에서 동시에 같은 데이터를 수정하게 되면 여러가지 문제가 발생한다. 바로 트랜잭션의 원자성이 깨진다는 것이다. 추가로 세션1이 중간에 롤백하게 된다면 세션2는 잘못된 데이터를 수정하는 문제가 발생한다.
이런 문제를 방지하려면, 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에 서 해당 데이터를 수정할 수 없게 막아야 한다.
4. 트랜잭션 적용 1
MemberServiceV1.java
@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, toMember.getMoney() + money);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- memberRepository.findById(String id): ID로 Meber 객체를 찾는 메서드
- memberRepository.update(String id, int money): 사용자의 돈을 업데이트하는 메서드
- 예외 상황을 테스트 해보기 위해 toId가 "ex"인 경우 예외를 발생
이제 테스트를 진행해 보겠다.
class MemberServiceV1Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
private MemberServiceV1 memberService;
private MemberRepositoryV1 memberRepository;
@BeforeEach
void before(){
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV1(dataSource);
memberService = new MemberServiceV1(memberRepository);
}
@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(8000);
assertThat(findMemberB.getMoney()).isEqualTo(10000);
}
}
실행결과 성공이 나올 것이다.
memberA는 -2000을 해서 8000원을 memberB는 +2000을 해서 12000원이 되어야 하나 중간에 발생한 예외로 인해 memberB의 돈은 그대로 10000원이 되었다.
5. 트랜잭션 적용2 (해결책)
이번에는 DB 트랜잭션을 사용해서 앞서 발생한 문제점을 해결해보자.
애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 할까? 쉽게 이야기해서 트랜잭션을 어디에서 시작하고, 어디에서 커밋해야 할까?
트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부문을 전체 롤백해야 하기 때문이다. 그런데 트랜잭션을 시작하려면 커넥션이 필요하다. 결국 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.
애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 그래야 같은 세션을 사용할 수 있다.
MemberRepositoryV2.java
@Slf4j
public class MemberRepositoryV2 {
private final DataSource dataSource;
public MemberRepositoryV2(DataSource dataSource) {
this.dataSource = dataSource;
}
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 (Exception e) {
log.error("db error", e);
throw e;
}finally {
JdbcUtils.closeStatement(pstmt);
}
}
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 noy found memberId" + memberId);
}
}catch (Exception e) {
log.info("db error", e);
throw e;
}finally {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
// connection은 여기서 닫지 않는다.
// JdbcUtils.closeConnection(con);
}
}
// 이하 생략...
}
MeberServiceV2.java
@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(); // 실패시 롤백
e.printStackTrace();
throw new IllegalStateException(e);
}finally {
release(con);
}
}
private static void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true); // 커넥션 풀 고려
con.close();
}catch (Exception e) {
log.info("error", e);
}
}
}
// 이하 생략...
}
이제 MemberServiceV2에서 Connection을 생성 후 MemberRepositoryV2에 파라미터로 넘겨주었다. 이제 하나의 커넥션 안에서 비즈니스 로직을 실행하게 됐다.
다시 테스트를 진행해보자.
MemberServiceV2Test.java
class MemberServiceV2Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
private MemberServiceV2 memberService;
private MemberRepositoryV2 memberRepository;
@BeforeEach
void before(){
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV2(dataSource);
memberService = new MemberServiceV2(dataSource, memberRepository);
}
@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);
}
}
테스트를 진행해보면 잘 성공했다고 뜬다. 이제 중간에 실패하여도 하나의 트랜잭션이 전부 롤백이 되므로 모든 데이터를 정상적으로 초기화할 수 있게 되었다.
남은 문제
애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 생각보다 매우 복잡한 코드를 요구한다. 다음 글에선 스프링을 사용해 이런문제들을 해결해보겠다.
'JDBC' 카테고리의 다른 글
[JDBC] 스프링과 문제 해결 - 예외 처리, 반복 (0) | 2024.11.12 |
---|---|
[JDBC] 자바 예외 이해 (1) | 2024.11.09 |
[JDBC] 트랜잭션 - 스프링과 문제 해결 (3) | 2024.11.08 |
[JDBC] 커넥션풀과 데이터소스 이해 (0) | 2024.11.07 |
[JDBC] JDBC 이해 (0) | 2024.11.07 |