#JPA
Transaction은 DB에서 매우 중요한 핵심 개념이다.
보통 DB에서 수행되는 '작업 단위', '일련의 연산'이라고 표현하는데,
Transaction을 처음 접한다면 이런 짤막한 설명으로 이해하기는 어렵기 때문에 예시를 들어서 설명하겠다.

Database에 2개의 Account(은행 계좌)가 저장되어 있다.
각각 user1, user2의 계좌이며, 잔액은 100만원, 1만원이다.
여기서 user1이 user2에게 10만원을 송금한다고 생각해보자.
-- ✅ user1의 계좌에서 10만원 감액
update account
set amount = amount - 100000
where user_id = 1;
-- ✅ user2의 계좌에서 10만원 증액
update account
set amount = amount + 100000
where user_id = 2;
'송금'이라는 작업은 user1의 계좌에서 10만원을 차감하고, user2의 계좌에서 10만원을 증액하는 2개의 연산이 연속된 형태이다.
이 두 연산이 정상적으로 수행된다면 문제가 되지 않겠지만, 만약 다음과 같은 상황이 발생한다면 어떨까.
-- ✅ user1의 계좌에서 10만원 감액
update account
set amount = amount - 100000
where user_id = 1;
-- ❌ user2의 계좌에서 10만원 증액 도중 오류 발생
update account
set amount = amount + 100000
where user_id = 2;
user1의 계좌에서 10만원을 차감하는 작업은 정상적으로 수행 되었으나, user2의 계좌에 10만원을 증액하는 작업 도중 오류가 발생하여 처리가 완료되지 않았다.
결과적으로 user1의 계좌에서 차감된 10만원은 증발해버렸다.
실제 은행 서비스를 사용하던 도중에 이런 일이 발생한다면, 고객들이 이탈할 것이고 서비스는 망할 것이다.
결코 일어나서는 안 되는 상황이다.
그렇다고 오류가 절대 발생하지 않는 프로그램을 만들 수는 없으며, 프로그램의 문제가 아닌 자연 재해나 네트워크 오류 등의 예상치 못한 외부 변수로 인해 처리가 완료되지 않을 가능성도 존재한다.
어떤 상황에서도 잘못된 처리를 방지하기 위해서 Transaction을 사용해야 한다.
송금이라는 작업은 출금, 입금, 2개의 세부 작업으로 나뉘어진다.
이 중 하나라도 실패하면 계좌 내의 금액이 증발하는 심각한 문제가 발생한다.
그래서 출금, 입금을 묶어서 1개의 작업으로 보고, 모든 작업이 성공했을 경우에만 DB에 반영시켜야 한다.
이렇게 여러 작업을 하나의 단위로 묶는 것을 Transaction이라고 한다.
'논리적인 작업 단위'라고 볼 수 있겠다.
작업이 모두 성공한다면 DB에 Commit 명령을 내린다.
지금까지 작업한 내용을 DB에 실제로 반영하라는 명령이다.
만약 하나라도 실패한다면, DB에 Rollback 명령을 내린다.
지금까지 작업한 내용을 DB에 반영하지 않고 없던 일로 만드는 것이다.
아래 코드가 Transaction을 통해 출금, 입금을 하나의 작업으로 묶어서 처리하는 SQL Code다.
begin으로 Transaction을 시작하고, 모든 작업의 끝에 commit을 호출하여 Transaction을 종료한다.
begin과 commit 사이에 해당 Transaction에 포함될 작업들을 모두 넣어주면 된다.
-- 트랜잭션 시작
begin;
-- ✅ user1의 계좌에서 10만원 감액
update account
set amount = amount - 100000
where user_id = 1;
-- ✅ user2의 계좌에서 10만원 증액
update account
set amount = amount + 100000
where user_id = 2;
-- 두 작업이 모두 정상적으로 처리 되었을 경우에만 실제 Record에 반영
commit;
두 작업이 모두 성공할 경우, 마지막에 위치한 commit 명령이 수행되며 DB에 변경 내역이 반영된다.
-- 트랜잭션 시작
begin;
-- ✅ user1의 계좌에서 10만원 감액
update account
set amount = amount - 100000
where user_id = 1;
-- ❌ user2의 계좌에서 10만원 증액 도중 오류 발생 -> 실제 Record에 반영하지 않고 Rollback
update account
set amount = amount + 100000
where user_id = 2;
-- 두 작업이 모두 정상적으로 처리 되었을 경우에만 실제 Record에 반영
commit;
반대로 작업이 하나라도 실패한 경우는 자동으로 rollback 명령이 수행되어 변경 내역이 Record에 반영되지 않고 모두 소거된다.
이렇게 여러 작업을 묶어서 논리적인 단위, Transaction을 형성하면 하위 작업이 모두 처리되거나, 모두 처리되지 않는다.
덕분에 송금 중 금액이 증발하는 큰 사고들을 막을 수 있는 것이다.
지금까지 SQL을 작성할 때, begin, commit 등을 추가한 적이 없을 수도 있다.
insert into "account" (user_id, amount)
values(1, 10000);
대부분이 위와 같은 형태로 작성 했었겠지만 사실 위 SQL은 다음을 축약한 구문이나 다름 없다.
begin;
insert into "account" (user_id, amount)
values(1, 10000);
commit;
DB는 보통 auto-commit mode 상태인데, begin; commit;을 명시하지 않으면, SQL이 하나의 Transaction으로 취급된다.
그래서 위 SQL을 실행 성공하면 commit을 자동으로 수행했던 것이고, 결과가 정상적으로 DB에 반영 되었던 것이다.
DB는 항상 작업 종료 후에 commit을 수행해야 처리 결과가 반영된다는 사실을 기억하자.
JPA도 @Transaction Annotation이 존재하고, 이와 비슷한 짓을 하기 때문이다.
테스트 코드를 작성할 때, UserTest 클래스에 @SpringBootTest, @Transactional Annotation을 붙였던 것이 기억날 것이다.
@Transactional은 내부적으로 Reflection을 통해 Proxy를 생성하여 UserTest의 메소드에 추가적인 로직을 수행하도록 변경한다.
쉽게 말해서 @Transactional Annotation이 적용된 메소드나 클래스에 우리가 작성한 코드 외에 자동으로 commit, rollback 로직을 추가한다는 것이다.
어떤 코드가 자동으로 추가되는지 UserTest 코드를 예시로 살펴보겠다.
원본은 다음과 같다.
// UserTest.java
@SpringBootTest
@Transactional
class UserTest {
@Autowired
private EntityManager em;
@Test
void test() {
User user = new User();
em.persist(user);
}
}
UserTest에 @Transactional이 내부적으로 Transaction을 시작하고 성공 시 commit, 실패 시 rollback하는 로직이 추가된 모습이다.
// UserTest.java
@SpringBootTest
@Transactional
class UserTest {
@Autowired
private EntityManager em;
@Test
void test() {
// 트랜잭션 생성
EntityTransaction tx = em.getTransaction();
try {
tx.begin(); // 트랜잭션 시작
// 원래 로직
User user = new User();
em.persist(user);
// 모든 요청 성공 시, commit 수행
tx.commit();
} catch (Exception e) {
// 예외가 발생하면 rollback 수행
tx.rollback();
throw e;
}
}
}
SQL에 자동으로 begin, commit, rollback이 적용되듯,
Reflection과 Proxy가 어떤 원리로 동작하는지 잘 모르더라도 @Transactional이 붙으면 트랜잭션 begin과 모든 로직 성공 시 commit, 실패 시 rollback하는 코드를 추가 해준다고 생각하면 된다.
실제 코드 상에 보이지 않더라도 말이다.
지금까지 DB에 SQL을 전송하기 위해 em.flush를 직접 호출했었다.
IDENTITY 전략을 사용할 때는 em.persist 만으로 SQL이 전송 되었지만, 나머지는 Persistence Context에 Entity를 모아두었다가 em.flush를 호출해야 DB로 SQL이 전송되었기 때문이다.
그러나 사실, tx.commit을 호출하면 그 안에서 em.flush도 호출된다.
정말 그런지 확인해보자
// UserTest.java
@SpringBootTest
@Transactional
class UserTest {
@Autowired
private EntityManager em;
@Test
void test() {
User user = new User();
em.persist(user);
}
}
위 코드를 실행하고 Console을 살펴보면 다음과 같다.
Hibernate:
select
next value for user_seq
----------
방금 전 설명과 달리, insert 문이 호출되지 않았다.
@Transactional을 클래스에 붙였기 때문에 모든 메소드에 @Transactional을 붙인 것과 다름이 없을 것이고,
모든 로직이 성공했으니 tx.commit이 마지막에 호출 될 것이고,
tx.commit은 내부적으로 em.flush를 호출하니 SQL이 나가야 하는 것이 아닌가.
맞다. Test가 아니라면.
@Test Annotation이 붙어서 Junit에 의해 로직이 수행되는 테스트 환경에서는 동작의 차이가 존재한다.
테스트가 정상적으로 수행 되더라도, 마지막에 자동으로 tx.commit 대신, tx.rollback이 호출되는 것이다.
우리의 생각대로라면 @Transactional에 의해 다음과 같은 로직이 작성된다.
@Test
void test() {
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
User user = new User();
em.persist(user);
// ✅ 모든 요청 성공 시, commit 수행 -> em.flush 호출
tx.commit();
} catch (Exception e) {
tx.rollback();
throw e;
}
}
그러나 테스트 시에는 tx.commit이 아닌 tx.rollback이 수행된다.
코드는 다음과 같다.
@Test
void test() {
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
User user = new User();
em.persist(user);
// ❌ 모든 요청 성공 시, rollback 수행 -> em.flush 호출하지 않음
tx.rollback();
} catch (Exception e) {
tx.rollback();
throw e;
}
}
tx.commit과 달리 tx.rollback은 em.flush를 호출하지 않기 때문에 Console에서 SQL을 확인할 수 없었던 것이다.
그럼 Test 환경이 아니면 tx.commit이 자동으로 호출되어 Console에 SQL이 잘 출력 되는지 확인해보자.
코드는 매우 간단하게 작성할 것이다.
// UserController.java
@RestController
public class UserController {
@Autowired UserRepository userRepository;
@GetMapping
public void getUsers() {
userRepository.saveUsers();
}
}
// UserRepository.java
@Repository
public class UserRepository {
@Autowired EntityManager em;
@Transactional
public void saveUsers() {
User user = new User();
em.persist(user);
System.out.println("----------");
}
}
이제 Spring Application을 실행하고, 터미널을 하나 더 실행시켜서 다음 명령어를 입력해본다.
$ curl localhost:8080
Spring Application의 Console로 돌아가서 출력 결과를 확인한다.
Hibernate:
select
next value for user_seq
Hibernate:
select
next value for user_seq
----------
Hibernate:
insert
into
"user"
(name, id)
values
(?, ?)
Hibernate:
insert
into
"user"
(name, id)
values
(?, ?)
Test가 아닌 환경에서는 @Tranactional 적용 시, tx.commit가 자동으로 호출되어 SQL이 출력되는 것을 확인했다.
Test 환경에서도 모든 로직 성공 시, tx.commit을 자동으로 호출하도록 만들 수 있다.
@Commit Annotation을 붙여주기만 하면 된다.
// UserTest.java
@SpringBootTest
@Transactional
@Commit
class UserTest {
@Autowired
private EntityManager em;
@Test
void test() {
User user = new User();
em.persist(user);
}
}
예상대로 동작 하는지 테스트를 실행해보자.
Hibernate:
select
next value for user_seq
----------
Hibernate:
insert
into
"user"
(name, id)
values
(?, ?)
이제 테스트 환경에서도 SQL이 잘 출력된다.
@Commit Annotation은 일반적으로 테스트 환경에서 사용되므로 실제 Application 실행 환경에서는 찾을 수 없을 것이다.
어차피 실행할 때는 자동으로 tx.commit이 되기 때문에 큰 문제는 없을 것이다.
그리고 @Rollback이라는 Annotation도 존재하는데, @Commit과 반대로 마지막에 tx.rollback을 호출한다고 생각하면 된다.
사실 @Commit을 까보면 내부적으로 @Rollback(false)가 적용되어 있는 것을 볼 수 있다.
@Rollback은 기본 value가 true이고, @Commit은 아래와 같이 @Rollback(false)와 동일하다.
@Rollback(false)
public @interface Commit {
}
정리하겠다.
로직 처리 성공 후 @Rollback(true)면 tx.rollback을 호출할 것이고, @Rollback(false)면 tx.commit을 호출할 것이다.
@Commit은 @Rollback(false)이므로 적용 시 tx.commit을 호출하고, tx.commit은 em.flush를 호출하기에 SQL이 잘 출력된 것이고,
테스트 환경에서는 기본적으로 @Rollback(true)가 적용되어 있기에 성공 후 tx.rollback을 호출하기 때문에 SQL이 출력되지 않은 것이다.