본문 바로가기
스프링

[스프링] 트랜잭션과 스프링의 @Transactional

by kdohyeon (김대니) 2023. 2. 2.
반응형

스프링에서 데이터베이스와 관련된 처리를 하는 작업을 진행하다 보면 @Transactional 어노테이션을 사용하게 된다. 한번 알아보도록 하자.

트랜잭션(transaction)이란?

"Transaction" 는 사전적 의미로 "거래", "매매", "처리(과정)" 이라는 의미를 가지고 있다 (네이버 사전). 일반적인 "거래"를 생각해보면 내가 무언가를 얻기 위해서는 무언갈 주어야 한다. 예를 들어, 사과 하나를 사기 위해서는 일정 금액의 돈을 지불해야 한다. 돈은 지불하지 않은 상태에서 사과만 획득하고 거래를 마칠 수는 없다. A 가 돈을 지불하고, B 는 그 돈을 받고 A 에게 사과를 건내 준다 까지 성공적으로 완료되어야 "거래"가 성사된다.

데이터베이스에서도 트랜잭션은 데이터베이스와 관련된 하나의 논리적인 작업 단위를 의미한다. 함께 수행되어야 할 작업들의 묶음을 하나의 작업 단위인 트랜잭션으로 볼 수도 있다. 하나의 트랜잭션 내 일련의 과정 중 하나라도 실패가 되면 그 트랜잭션은 전체 롤백 되어야 한다. (부분 성공은 없음) 모든 과정이 성공하면 트랜잭션은 커밋된다 (= 데이터베이스에 반영된다).

트랜잭션의 성질

데이터베이스의 트랜잭션에는 ACID 라는 성질을 가지고 있는데, 이에 대해 알아보도록 하자.

A. Atomicity (원자성)

트랜잭션 내 포함된 일련의 과정들은 모두 성공되거나, 전체 실패가 되어야 한다. 부분적으로 성공하는 경우는 없어야 하며, 중간에 에러가 발생하여 중단되는 경우에는 전체 실패로 트랜잭션이 롤백되어야 한다. 전체 성공이 되는 경우에만 커밋되어야 한다.

C. Consistency (일관성)

데이터베이스의 상태는 일관되어야 한다. 예를 들어, "user_name" 이라는 DB 컬럼은 not null 이라는 제약이 있다. 이 컬럼에 대한 값이 nullable 인 데이터를 insert 하려는 쿼리가 하나의 트랜잭션으로 구성된다고 했을 때, 그 트랜잭션은 실패되어야 한다. 그 전까지 모든 데이터는 "user_name" 에 대한 값이 not null 로 저장되었을텐데, 이 트랜잭션으로 인해 데이터베이스의 상태가 이 전과 다르게 되기 때문이다.

I. Isolation (독립성)

하나의 트랜잭션이 수행될 때, 다른 트랜잭션과는 분리되어 독립적으로 수행되어야 하며 타 연산에 끼어들지 못하도록 보장해야 한다. 만약두 개의 트랜잭션이 동시에 실행되면, 그 결과는 순차적으로 실행된 결과와 동일해야 한다. 예를 들어, 상품 A 에 대해 100개의 재고를 가지고 있는 물류 창고에서 고객 B 에게 80개, 고객 C 에게 80개를 보내는 각각의 트랜잭션이 존재한다면, 동시에 실행된 결과와 각각 실행된 결과는 동일해야 한다. 두 트랜잭션을 동시에 실행했다고 해서 -60개의 재고가 되어서는 안된다.

D. Durability (영속성)

트랜잭션이 완료되거나 실패되어도 그 결과는 영구적으로 반영되어야 한다. 시스템에 문제가 발생하여 시스템이 종료되어도, 시스템이 복구된 이후에는 종료되기 전의 데이터와 복구되서 나서의 데이터가 동일해야 한다.

스프링에서의 트랜잭션 관리

작업의 단위를 메소드로 묶어두고 해당 메소드 위에 @Transactional 어노테이션을 부여하면 해당 메소드가 하나의 작업 단위로 묶이게 된다. 아래 예시에서는 사용자를 userId 로 조회하고, 존재하면 사용자 이름을 수정하는 트랜잭션이다.

@Transactional
public void modifyUserName(Long userId, String userName) {
    Optional<User> userOptional = userRepository.findByUserId(userId);

    userOptional.ifPresent(user -> {
        user.modifyUserName(userName);
    });
}

스프링에서 사용하는 @Transactional 어노테이션은 org.springframework.transaction.annotation 패키지 아래에 있는 어노테이션을 활용하고 있다.

@Transactional 어노테이션을 부여하게 되면 실제로는 어떻게 작동할까?

스프링에서는 @Transactional 어노테이션이 부여된 클래스와 메소드에 대한 프록시를 생성하게 된다. 스프링 AOP 기능을 활용하는 것인데 트랜잭션 어노테이션이 부여된 메소드 전/후로 메소드 시작, 커밋, 롤백 등 트랜잭션을 관리하기 위한 기능이 주입된다.

위의 예시를 통해 프록시로 생성되는 코드를 살펴보자.

public void modifyUserName(Long userId, String userName) {
	Connection connection = dataSource.getConnection();
	try (connection) {
		Optional<User> userOptional = userRepository.findByUserId(userId);
        
		userOptional.ifPresent(user -> {
			user.modifyUserName(userName);
		});
		connection.commit();
	} catch(Exception e) {
		connection.rollback();
	}
}

선언적 (Declarative) 트랜잭션 vs. 프로그래밍적 (Programmatic) 트랜잭션

스프링 환경에서 트랜잭션을 사용할 땐 크게 2가지 방식으로 트랜잭션을 사용할 수 있다. 하나는 @Transactional 어노테이션을 부여해 사용하는 선언적 트랜잭션이 있고, 또 하나는 TransactionTemplate 등을 활용한 트랜잭션 사용 방식이 있다. 개발자 성향에 따라, 그리고 팀에서 사용하는 컨벤션에 따라 다른데 둘 다 사용해본 결과, 나는 프로그래밍적 트랜잭션 사용 방식이 좀 더 효율적인 것 같다.

비즈니스 요구사항에 맞춰 트랜잭션 범위를 자유롭게 설정하는 것이 개발하기 편한데, 선언적으로 @Transactional 어노테이션만 활용하면 그 자율성이 많이 떨어진다고 느꼈다. 프로그래밍적 트랜잭션 사용 방식은 개발자가 트랜잭션 범위를 메소드 내에서 직접 설정해줘야 하기 때문에 개발을 하면서 트랜잭션에 대해서 한번 더 고민해볼 수 있고, 추후 리팩토링하기도 좀 더 편리하다고 느꼈다. 사실 정답은 없을테니 본인 상황에 맞게 잘 활용하면 되지 않을까 생각한다.

사용 시 주의해야 할 점이 있다면?

DB 커넥션 고갈 이슈

어플리케이션에서 데이터베이스에 접근하기 위해서는 DB 커넥션을 맺어야 한다. 하지만 커넥션을 맺는 행위 자체가 비용이 크기 때문에 스프링 환경에서는 보통 서버가 시작될 때 커넥션 풀에 커넥션을 미리 만들어두고 사용한다. 하나의 트랜잭션이 실행되면 담당 쓰레드는 커넥션 풀에서 하나의 커넥션을 빌려오고, 트랜잭션이 완료되면 (커밋 또는 롤백) 다시 반납하게 된다.

쓰레드가 커넥션을 빌려간 뒤에 작업 수행 시간이 오래 걸려 반납을 하지 못하게 되면 어떻게 될까? 요청은 계속 들어오는데, 커넥션 풀에 존재하는 커넥션 수는 반납이 없으니 당연히 줄어들게 된다. 지금까지 겪어본 서비스들은 보통 10개에서 40개 정도의 DB 커넥션을 미리 만들어두고 사용하고 있었고, 요청은 그 보다 훨씬 더 많은 수가 동시다발적으로 들어오기 때문에 잠깐이라도 커넥션 반납이 지연되면 커넥션 고갈 이슈가 발생했다.

많은 이유가 있겠지만 겪어본 케이스에서는 DB I/O 와 외부 I/O 가 하나의 트랜잭션 내 함께 묶여 있었고, 외부 I/O 가 지연을 발생시키는 원인이었다. 이를 해결하기 위해 트랜잭션을 좀 더 세분화하여 분리했고, 문제를 해결할 수 있었다.

반응형

댓글