Search for a command to run...
이제부터는 DB의 트랜잭션을 써야한다. 스프링에서는 @Transactional으로 사용할 수 있다. DB에는 All-or-Nothing이라는 개념이 있다. 프로그램이 실행중 오류가 발생하거나, DB가 작동하면서 오류가 발생할 경우가 생길 수 있다. 이때, 트랜잭션 단위로 DB에 적용될 쿼리가 취소(rollback) 된다.
이것은 스프링 단계 보다 DBMS 단계에서 보면 조금 더 이해가 잘 된다.
UPDATE account SET balance = balance + 1000 WHERE name = 'esukmean';
START TRANSACTION;
UPDATE account SET balance = balance + 1000 WHERE name = 'esukmean';
UPDATE account SET balance = balance - 1000 WHERE name = 'seokminlee';
INSERT INTO log VALUES('esukmean', 1234, NOW());
SELECT name, balance FROM acount FOR UPDATE;
UPDATE account SET balance = ? WHERE name = ?
COMMIT;
일련의 SQL에서 START TRANSACTION 와 COMMIT 사이의 요청에 실패하면 모든 UPDATE 문이 취소된다. 맨 마지막 UPDATE 문에 문제가 있어도 위의 UPDATE, INSERT 문 까지 모두 취소된다.
스프링에서는 @Transactional 이라는 어노테이션이 있다. 이것을 메서드에 붙이면 "메서드 내에서 동작한 SQL 활동"이 START TRANSACTION 과 ~ COMMIT 사이에 들어가게 된다. 예를 들어서 아래 메서드를 보자
public class A {
public void txEntry() {
repo.save(Entity.of("asdf", 1111));
// @Transactional을 AOP(Proxy)로 작동하기 때문에, 다른 클래스의 메서드를 호출해야 한다.
anotherClass.txTest();
}
}
public class anotherClass {
@Transactional
public void txTest() {
var entity = Entity.of("esukmean", 1234);
repo.save(entity);
var anotherEntity = repo.findByName('seokminlee');
anotherEntity.setBalance(5555);
repo.save(anotherEntity);
// 편의를 위해 @Modify 설명을 생략함
}
}
이것은 DB입장에서 아래와 같이 실행된다:
INSERT INTO account VALUES ('asdf', 1111); --실제로는 prepared statement이겠지만... 편의상 직접 파라메터로 넣음
START TRANSACTION;
INSERT INTO account VALUES ('esukmean', 1234);
SELECT * FROM account where name = 'seokminlee'; -- pk가 숫자 1이라고 가정
UPDATE account SET balance = 5555 WHERE id = 1;
COMMIT;
메서드 안에 있는 메서드에서(nested method) 발생한 SQL도 같은 Transaction에서 동작한다.
단, @Transactional은 Spring AOP에 의존한다. 즉, 객체가 Proxy로 접근돼야 효과가 있다. new AnotherMethod() 로 직접 클래스 인스턴스를 만들거나, 같은 클래스 내의 메서드를 호출 할 경우에는 Proxy를 타지 않기 때문에 @Transactional이 효과가 없다.
같은 메서드 내에서 트랜잭션 분리가 필요할 수도 있다. 아래는 트랜잭션에 실패하더라도 "어디까지는 실행 됐는지"를 표기하고자 했다. 만약 start에서 진행이 안됐다면 첫번째 repo.save에서, phase-A에서 멈췄다면 다음 findByName과 setBalance의 저장에서, end에서 끝났다면 정상 종료를 의미하려 했다.
public class anotherClass {
@Transactional
public void txTest() {
var status = statusRepo.findById(10000);
status.setTxStatus('start');
statusRepo.save(status);
var entity = Entity.of("esukmean", 1234);
repo.save(entity);
status.setTxStatus('phase-A');
statusRepo.save(status);
var anotherEntity = repo.findByName('seokminlee');
anotherEntity.setBalance(5555);
repo.save(anotherEntity);
status.setTxStatus('end');
statusRepo.save(status);
// 편의를 위해 @Modify 설명을 생략함
}
}
SQL로 보면 이럴 것이다.
START TRANSACTION;
SELECT * from status WHERE id=10000;
UPDATE status SET status = 'start' WHERE id=10000;
INSERT INTO account VALUES ('esukmean', 1234);
UPDATE status SET status = 'phase-A' WHERE id=10000;
SELECT * FROM account where name = 'seokminlee'; -- pk가 숫자 1이라고 가정
UPDATE account SET balance = 5555 WHERE id = 1;
UPDATE status SET status = 'end' WHERE id=10000;
COMMIT;
하지만, 지금에선 하나의 @Transactional으로 묶여있기 때문에 하나의 쿼리에서만 뻑나도 모든 기록이 rollback 된다. 원치않는 결과가 발생한다는 말이다.
이 경우, 트랜잭션 분리가 필요하다. @Transactional은 @Transactional(propagation = Propagation.REQUIRED) 가 기본값이다. Propagation.REQUIRED 일때는 아무리 많은 메서드를 만나도 하나의 트랜잭션 내에서만 작동된다.
DB 입장에서는 메서드가 flatten 돼 보인다.
Propagation.REQUIRES_NEW 를 사용하면 독립된 트랜잭션을 아예 새로 만든다. 그러므로, 아래와 같이 REQUIRES_NEW를 사용하는 메서드를 만들면 의도한 대로 작동한다. 단, 마찬가지로 AOP로 작동되므로 Proxy 객체로 연결되어 있어야 한다. 쉽게 다른 클래스로 분리가 필요하다.
@Transactional(propagation = Propagation.REQUIRED)
public void log_status(StatusEntity status) {
status.setTxStatus('start');
statusRepo.save(status);
}
이 경우, DB에서는 다음과 같이 보인다.
INSERT INTO account VALUES ('asdf', 1111); --실제로는 prepared statement이겠지만... 편의상 직접 파라메터로 넣음
START TRANSACTION;
SELECT * from status WHERE id=10000;
START TRANSACTION;
-- 별개의 transaction에서 실행됨 (실제로는 새로운 연결이 생김)
UPDATE status SET status = 'start' WHERE id=10000;
END TRANSACTION;
INSERT INTO account VALUES ('esukmean', 1234);
START TRANSACTION;
-- 별개의 transaction에서 실행됨 (실제로는 새로운 연결이 생김)
UPDATE status SET status = 'phase-A' WHERE id=10000;
END TRANSACTION;
SELECT * FROM account where name = 'seokminlee'; -- pk가 숫자 1이라고 가정
UPDATE account SET balance = 5555 WHERE id = 1;
START TRANSACTION;
-- 별개의 transaction에서 실행됨 (실제로는 새로운 연결이 생김)
UPDATE status SET status = 'end' WHERE id=10000;
END TRANSACTION;
COMMIT;
DB 입장에서는 Transaction 내에 Transaction이 있을 수 없다. 그래서 실제로는 새로운 DB연결을 열고, 거기서 쿼리를 실행한다.
하지만 이것도 조금 아쉽다. 이걸 언제 다 별개의 클래스로 분리하는가! 물론 목적(책임)이 다르므로 클래스를 분리하는게 맞긴하다. 그러나, 이정도는 충분히 하나의 클래스 내에서 돌릴만 하다. 여기까진 메서드만 분리해도 괜찮은 수준이다.
이 경우, 트랜잭션을 직접 만들어야 한다. Spring에서는 TransactionTemplate을 제공한다. 이것을 사용하면 아래와 같이 메서드 내에서 새로운 트랜잭션을 만들어 낼 수 있다. 조금 더 다듬어서 updateStatus 따위의 메서드로만 분리해도 훨씬 깔끔하고 편리해질 것이다. 그러면 같은 클래스의 메서드로도 requires_new를 흉내낼 수 있다.
@Transactional
public void txTest() {
var status = statusRepo.findById(10000);
requiresNewTx.execute(status -> {
status.setTxStatus('start');
statusRepo.save(status);
return null;
});
var entity = Entity.of("esukmean", 1234);
repo.save(entity);
requiresNewTx.execute(status -> {
status.setTxStatus('phase-A');
statusRepo.save(status);
return null;
});
var anotherEntity = repo.findByName('seokminlee');
anotherEntity.setBalance(5555);
repo.save(anotherEntity);
requiresNewTx.execute(status -> {
status.setTxStatus('end');
statusRepo.save(status);
return null;
});
}
Data JPA에서 repo.findBy(~) 등으로 Entity를 조회하면 EntityManager에 붙게 된다. 해당 엔터티의 값을 수정하면 repo.save(~)로 직접 저장하지 않아도 트랜잭션이 끝났을때 반영이 된다.
즉, 다음과 같은 코드에서 account.setBalance()를 호출하면 accountRepository.save(account)를 직접 호출하지 않아도 DB에 반영이 된다. 어떻게 보면 ORM의 장점이라 할 수 있다.
이렇게 반영되는 코드를 짤 경우에는 DB에서는
SELECT ~ FOR UPDATE로 값을 부르는게 안전하다.
절대값(immeidate value, UPDATE ~ SET A = 100) 으로 저장할 경우에는 값 손실이 발생할 수 있기 때문이다.
단, 중요한 것은 트랜잭션일 때만 작동한다는 것이다. 좀 쎄게 말해자면, @Transactional 등으로 트랜잭션이 끝났음에도 repo.save(~)로 저장하지 않은 것들을 자동으로 저장하는 기능이다.
더 엄밀하게 말하자면, EntityManager가 자동 저장 및 데이터 저장의 주체이다. 그리고 이것은 트랜잭션 단위마다 생긴다. 조회한 엔터티는 각 EntityManager에 붙는다. 트랜잭션이 끝났을때 (EntityManger가 끝났을때) 데이터에 수정점이 있다면 데이터가 DB에 저장된다.
단순히 findBy~~() 로 조회한 Entity는 그 순간 EntitnyManger에 붙는다.(propagation = required 라고 생각하면 편하다) 그러나 트랜잭션속이 아니라면 그대로 detach되기 때문에 관리가 되지 않는다.

repo.save()를 했을때 되는것도 실제로는 repo.save(entity)는 @Transactional이기 때문이다. 즉, 순간적으로 트랜잭션 환경으로 변화하고, EntityManger 속에 들어가서 저장되는 것이다.
EntityManger가 하나의 트랜잭션 내에서 사용되는 엔티티들을 관리하는것을 안다면 다음의 코드또한 repo.findByAccount() 등으로 직접 DB에 호출하는 대신 EntityManager에서 엔티티를 조회하고, 없으면 실제 DB단 까지 찾아들어가게 할 수도 있다.

기술적 개념은 파트는 이정도 봤으면 대충 알겠다. 근데 진짜로 트랜잭션이 필요한가? 단순히 1개의 row만을 저장하고 끝이라면 트랜잭션이 필요없을 수도 있다. 하지만, 처음에도 언급했던 것 처럼 여러개의 쿼리를 하나처럼 (원자적으로) 실행 하려면 트랜잭션이 필요하다.