안녕하세요 오늘은 Transaction에 대해서 알아보려고 합니다. 흔히 저희는 RollBack을 하기위하여 단순히 @Transaction을 사용은 하고 있는데 어떻게 동작 하는지 정확 하게 알지 못 하고 사용 하는 경우가 많은데 한번 알아보려고 합니다.
Transaction
Transaction은 작업의 논리적인 단위 라고 정의 할 수 있습니다. 흔히들 예시로 드는게 입금과 출금을 많이 이야기 합니다. 아마 이보다 이해하기 쉬운 예시가 없어서 그러지 않을까 생각합니다.
Pooney라는 사람이 A라는 사람에게 10000원을 보내는 것을 생각해 보겠습니다.
위처럼 Pooney의 통장에는 10000원이 차감 되고 A에게는 10000원 증감 되어야 합니다. 매번 이렇게 아름답게 끝나면 좋겠지만 그렇지 않은 경우도 있을 것입니다.
만약 Ponney가 아래와 같이 입금하는 과정에서 정전이 나서 장애가 났다면 어떻게 될까요?
Pooney의 계좌에서는 A에게 입금하기 위하여 이미 DB에서 10000원이 빠진 상태입니다. 이후 A에게 10000원이 입금 되려면 DB에 Write가 되어야하는데 DB에 Write 하려는 순간에 정전이 일어났기 때문에 DB에는 A라는 사람의 계좌에는 아무런 변화가 없습니다. Pooney 입장에서는 돈만 없어지고 A에게는 입금이 안된 황당한 상황입니다.
서비스를 운영하다 보면 자연재해, 트래픽 증가, 클라우드 장애와 같은 예상하지 못한 일이 발생하곤 합니다. 그럴때 마다 위와 같은 문제가 발생하면 많은 사람들이 피해를 보게 될 것입니다. 이러한 피해를 줄이기 위해서는 장애가 일어났을때 다시 처음으로 원상복구를 해 줄 수 있어야 합니다. 이러한 장애를 원상복구 하는 것이 Transaction 입니다. 그렇다면 Transaction이 어떻게 원상복구를 할 수 있을까요?
어떠한 작업을 수행하기 위하여 발생하는 명령어들을 모두 모아두었다고 Commit이라는것을 만났을때 실제 Disk에 Write하여 데이터의 영속성을 보장 해주는 것이 가능합니다.
이해를 돕기 위하여 Pooney가 A에게 돈을 입금하는 과정을 표현하면 아래와 같습니다.
이것을 SQL로 변환하면 아래와 같습니다
여기서 BEGIN , COMMIT이 시작과 끝에 갑지기 생겨 이상 할 수 있는데요. BEGIN은 트랜잭션의 시작을 의미하고 COMMIT은 트랜잭션의 종료라고 보시면 됩니다.
위의 내용을 해석하면 BEGIN으로 트랜잭션의 시작을 알린 후 2,3,4번의 명령을 수행 최종적으로 COMMIT을 만나면서 실제 DISK에 Write를 한다라고 보시면 됩니다.
여기서 중요한 것은 COMMIT을 만나기 전까지는 DB에는 반영되지 않는 다는 것입니다. COMMIT을 만나지 않고 장애가 발생 했을 때는 ROLLBACK을 하게 됩니다. ROLLBACK은 말그대로 "되돌린다" 입니다. 때문에 지금까지 행한 작업을 전부취소하여 처음으로 돌아가는 것입니다.
때문에 2,3,4을 트랜잭션으로 묶으면 POONEY에게 10000원이 차감되고 A에게 10000원 입금이 되지 않으면 절대 COMMIT을 만나지 않기 때문에 위와 같은 장애가 일어날경우 ROLLBACK을 하여 데이터의 영속성과 무결성을 보장 할 수 있습니다.
그러면 Spring boot에서는 어떻게 트랜잭션을 사용할까요? Spring에서는 간단하게 @Transaction 어노테이션을 사용하면 됩니다. 간단하게 한번 테스트해보겠습니다.
우선 dependency를 추가하겠습니다. mysql과 jpa를 사용할 것이기 때문에 추가했지만 사용하시는 환경에 따라 변경 하시면 됩니다.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
implementation 'org.slf4j:slf4j-api:1.7.31'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
application.yml
spring:
jpa:
hibernate:
ddl-auto: create
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
use-new-id-generator-mappings: false
show-sql: true
properties:
hibernate.format_sql: true
datasource:
url: jdbc:mysql://localhost:3306/batch?serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
logging:
level:
ROOT: INFO
org:
springframework:
orm:
jpa: DEBUG
transaction: DEBUG
mybatis:
config-location: classpath:mybatis-config.xml
mapper-locations: mappers/*.xml
server:
port: 8888
Game
@Entity
@Getter
@NoArgsConstructor
public class Game {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long idx;
@Column(name = "name")
private String name;
@Builder
public Game(String name) {
this.name = name;
}
}
간단하게 이름만 가지는 Game Entity를 만들었습니다.
GameRepository
public interface GameRepository extends JpaRepository<Game, Long> {}
ReqGaveSave
@Setter
@Getter
public class ReqGaveSave {
private String name;
}
TransController
@Slf4j
@RequestMapping("trans")
@RequiredArgsConstructor
@RestController
public class TransController {
private final TransService transService;
@GetMapping("/game/list")
public ResponseEntity<?> getTrans() {
List<Game> gameList = transService.getGameAllList();
return new ResponseEntity(gameList, HttpStatus.OK);
}
@PostMapping("/game/save")
public String saveGame(@RequestBody ReqGaveSave reqGaveSave) {
transService.saveGame(reqGaveSave);
return "success";
}
}
TransService
@Service
@RequiredArgsConstructor
public class TransService {
private final GameRepository gameRepository;
//게임의 전체 목록 조회
public List<Game> getGameAllList(){
return gameRepository.findAll();
}
//게임 저장
@Transactional
public void saveGame(ReqGaveSave reqGaveSave){
Game game = Game.builder().name(reqGaveSave.getName()).build();
gameRepository.save(game);
//Game을 Save 한후 예외 발생
if(true) throw new RuntimeException();
}
}
TransService의 saveGame 메소드를 보시면 @Transactional이 붙어 있는데요. Spring에서는 간단하게 @Transactional 어노테이션으로 rollback을 가능하게 도와줍니다. 그러면 진짜로 예외가 발생했을때 rollback을 진행하는지 확인하기 위하여 save 이후 RuntimeException을 발생하게 하겠습니다.
그러면 save를 요청하기 위하여 저는 PostMan을 사용하도록 하겠습니다.
Request를 하시면 아래와 같은 로그를 확인 하실 수 있는데요
위 화면을 보시면 Game이 save 된 후 RuntimeException이 발생된 것과 함께 "Initiating transaction rollback" 이라는 문구를 보실 수 있는데요. 이내용은 commit을 하지 않고 rollback을 진행한다는 뜻입니다. 때문에 Game을 insert하는 query를 날렸지만 결과는 아래와 같이 아무것도 등록이 되지 않는 것을 확인 할 수 있습니다.
단 여기서 주의해야 할 점이 존재하는데요. 그러면 exception이 발생하면 무조건 Rollback을 진행 할까요?
그렇지 않습니다.
스프링은 디폴트로 UnCheckedException을 rollback한다는 정책으로 가지고 있다는 것입니다.
그렇다면 CheckedException은 왜 기본적으로 rollback을 하지 않을까요? CheckedException은 예외처리를 예외가 발생한 metohd 또는 예외가 발생한 metohd를 호출한 method에서 진행해야 하기때문입니다. 그럼으로 Spirng에서는CheckedException이 났을때는 rollback을 진행하지 않는 것입니다.
그러면 진짜로 CheckedException은 rollback을 진행하지 않는지 확인 하기 위해 IOException을 발생시켜 보겠습니다.
@Transactional
public void saveGame(ReqGaveSave reqGaveSave) throws IOException {
Game game = Game.builder().name(reqGaveSave.getName()).build();
gameRepository.save(game);
if(true) throw new IOException();
}
결과는 Game이 save 된 후 IOException이 발생된 것과 함께 "Initiating transaction commit" 라는 문구를 확인 하 실 수 있습니다. RuntimeException과는 다르게 IOException은 rollback이 아닌 commit을 하여 정상적으로 pooney라는 게임이 Game 테이블에 저장되는 것을 확인 할 수 있습니다.
그렇다면 CheckedException은 rollback을 할 수 없을까요? 아닙니다. 간단한 속성인 rollbackFor을 지정하여 rollback을 지정할 수 있습니다.
rollbackFor의 의미는 지정한 Exception이 발생 했을때 rollback을 지정한다는 의미를 가지고 있습니다. 위에서는 IOException이 발생했을때 rollback을 지정한다고 했으니 해당 Exception이 발생했을때 rollback을 수행 할 것입니다.
그러면 확인해 보겠습니다.
rollbackFor을 지정하지 않았을 때 와는 다르게 "Initiating transaction rollback" 로그를 확인 할 수 있습니다.
rollbackFor은 상황에 따라 사용하시면 됩니다. 하지만 저는 CheckedException을 rollbackFor을 통하여 rollback을 하는 것보다 좀더 구체적인 UnCheckedException으로 변환하여 발생시키고 Spring에서 Default로 rollback을 진행하는 방법이 좀더 좋은 방법이라 생각합니다. 이유는 해당 Exception이 왜 발생 했는지에 대하여 구체적으로 파악가능하고 별도로 속성을 주지 않아도 되기 때문에 선호하고 있습니다.
DuplicateGameException
@Setter
@Getter
public class DuplicateGameException extends RuntimeException{
public DuplicateGameException() {
}
public DuplicateGameException(String message) {
super(message);
}
public DuplicateGameException(String message, Throwable cause) {
super(message, cause);
}
public DuplicateGameException(Throwable cause) {
super(cause);
}
public DuplicateGameException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
위처럼 RuntimeException을 상속받는 DuplicateGameException을 만들고 CheckedException을 발생시키는 블록을 try~catch로 감싼 후 좀더 구체적인 DuplicateGameException을 throw을 해주면 아래와 같이 구체적인 Exception의 내용과 함께 Default로 rollback이 진행되는 것을 확인 할 수 있습니다.
이번에는 Trasnsaction이 무엇이고 어떤상황에서 동작하는지 알아보았는데요. 다음에는 트랜잭션 Propagation에 대해서 알아 보도록 하겠습니다. 감사합니다.
'Spring boot' 카테고리의 다른 글
Spring-boot Enum으로 Request,Response 받기 (0) | 2022.12.19 |
---|---|
Spring boot - Transaction Propagation (1) | 2022.07.15 |
Spring boot - Docker를 이용한 JENKINS 설치 (0) | 2022.06.28 |
Spring boot @PropertySource로 yml 로드 방법 (3) | 2021.11.07 |
Spring boot - jenkins(젠킨스) webhook 연동 (0) | 2021.10.04 |