[Spring + DB] 트랜잭션과 스프링

2022. 5. 13. 10:49JAVA

1. 트랜잭션


[ 트랜잭션이란? ]

트랜잭션은 작업의 완전성을 보장해준다. 논리적인 작업 셋을 모두 완벽하게 처리하거나, 모두 처리하지 못할 경우에는 본래 상태로 복구하는 기능이다. 즉, 작업의 일부만 적용되는 현상(Partial update)이 존재하지 않게 해준다.

 

트랜잭션에 쿼리가 여러개 조합되었을 때만 의미있는 것은 아니다. 단일 쿼리도 트랜잭션으로 묶었을 때에도 전부 적용되거나 아무것도 적용되지 않는 결과를 보장해주는 것일 뿐이다.

 

MySQL의 기본 스토리지 엔진은 InnoDB인데, 해당 엔진은 쿼리 중 일부라도 오류가 발생하면 전체를 원 상태로 만든다는 트랜잭션의 원칙대로 아래 insert 문을 실행할 때 롤백된다. (PK 중복) 결과적으로 테이블은 여전히 비어있게 된다.

create table temp_table ( id INT NOT NULL PRIMARY KEY );
insert into temp_table values (1,2,3,3);

 

[ 트랜잭션의 격리 수준]

트랜잭션의 격리 수준(isolation level)이란 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것이다. 총 4가지로 나뉜다.

 

 

  • READ_UNCOMMITTED
  • READ_COMMITTED
  • REPEATABLE_READ
  • SERIALIZABLE

'READ_UNCOMMITTED'는 일반적인 데이터베이스에서는 거의 사용하지 않고, 'SERIALIZABLE' 또한 동시성이 중요한 데이터베이스에서는 거의 사용되지 않는다. 위에서 아래로 내려올수록 데이터 격리(고립 및 잠금) 정도가 높아지며, 동시 처리 성능도 떨어진다. 그러나 'SERIALIZABLE' 격리 수준이 아니라면 성능의 저하가 그리 크지는 않다.

 

 

각 트랜잭션의 격리 수준은 세 가지 데이터 이상현상이 발생한다.

출처 : https://www.geeksforgeeks.org/transaction-isolation-levels-dbms/

자세한 내용은 https://velog.io/@guswns3371/%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%A9%EB%A6%AC%EC%88%98%EC%A4%80

이 글에서 확인하면 된다. 이 내용들을 외울 필요는 없다.

 

요약해서 기억할 것은, 대부분의 서비스에서는 'READ_COMMITTED' 혹은 'REPEATABLE_READ' 격리 수준을 채택한다는 점이다. 그리고 MySQL의 DEAFULT 스토리지 엔진 InnoDB에서는 'REPEATABLE_READ'을 채택했으며, 특이하게 'Phantom' 이상현상이 발생하지 않는다는 점이다. 따라서 'Serializable' 격리수준을 고려할 이유가 하나도 없다는 점이다.

 

 

[ 트랜잭션 사용의 주의점 ]

InnoDB 스토리지 엔진은 테이블이 아닌 레코드 기반 잠금을 수행한다. 테이블 전체를 잠그는 것 보다 레코드 단위로 데이터의 잠금이 수행되니 매우 효율적인 방식으로 요청을 처리할 수 있다.(엄밀히는 레코드 자체가 아닌 인덱스의 레코드를 잠근다)

 

그런데, 주의해야 할 점이 있다. 요청을 수행할 레코드를 찾기 위해 검색한 인덱스의 레코드에 모두 락을 건다는 점이다.

예를 들어

-- // member 테이블에는 10만 명의 데이터가 담겨있다.
-- // member_id를 PK로 가지고 있으며 다른 인덱스는 없다고 가정한다.
-- // 99999명의 member_name은 'A'이고 나머지 1명의 member_name은 'B' 이다.

UPDATE member SET member_name='A' where member_name='B';

위의 update문을 수행하면, InnoDB는 결과적으로 1건의 레코드가 업데이트 될 것이다.

그런데 이 요청을 수행하는 과정에서 잠기는 레코드의 갯수는 몇 건일까?

=> 정답은 10만 건이다.

 

왜 이런 결과가 나올까?

=> update 문장의 조건에 인덱스를 활용할 수 없는 속성이 있기 때문이다. 그래서 테이블의 레코드를 풀 스캔하게 되며 그 과정에서 검색되는 레코드가 모두 잠기게 된다. 1번부터 10만번 까지의 레코드의 데이터를 하나씩 순차탐색하며 결국엔 모든 레코드가 잠기게 된다. 이처럼 최악의 경우에는 테이블 잠금과 다를 것이 없게 된다.

 

이것이 트랜잭션 기반 엔진에서 인덱스 설계가 중요한 이유 중 하나다. 백엔드 개발자는 인덱스를 정말 잘 잡고 잘 사용해야 한다........

 

 

 

 

2. 스프링에서의 트랜잭션


[ 스프링에서 제공하는 트랜잭션 기능 ]

Spring은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용함으로써 애플리케이션에 각 기술마다(JDBC, JPA, Hibernate 등) 종속적인 코드를 이용하지 않고도 일관되게 트랜잭션을 처리할 수 있도록 해주고 있다.

(출처: https://mangkyu.tistory.com/154)

 

하지만, 이것도 부족하다. 여전히 비즈니스 로직과 트랜잭션을 담당하는 기술 코드가 섞여있다. 이것을 AOP로 더 추상화해서 트랜잭션을 비즈니스 로직과 분리했다. 그렇게 나온 결과가 @Transactional 어노테이션이다.

 

[ 스프링의 트랜잭션 전파속성 ]

트랜잭션을 시작하거나 기존 트랜잭션에 참여하는 방법을 결정하는 속성이다. 트랜잭션 경계의 시작 지점에서 트랜잭션 전파 속성을 참조해서 해당 범위의 트랜잭션을 어떤 식으로 진행시킬지 정할 수 있다. 7가지가 존재한다.

 

  • REQUIRED
  • SUPPORTS
  • MANDATORY
  • REQUIRES_NEW
  • NOT_SUPPORTED
  • NEVER
  • NESTED

 

아래는 실제 spring의 Propagation enum 파일이다.

public enum Propagation {

	/**
	 * Support a current transaction, create a new one if none exists.
	 * Analogous to EJB transaction attribute of the same name.
	 * <p>This is the default setting of a transaction annotation.
	 */
	REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED),

	/**
	 * Support a current transaction, execute non-transactionally if none exists.
	 * Analogous to EJB transaction attribute of the same name.
	 * <p>Note: For transaction managers with transaction synchronization,
	 * {@code SUPPORTS} is slightly different from no transaction at all,
	 * as it defines a transaction scope that synchronization will apply for.
	 * As a consequence, the same resources (JDBC Connection, Hibernate Session, etc)
	 * will be shared for the entire specified scope. Note that this depends on
	 * the actual synchronization configuration of the transaction manager.
	 * @see org.springframework.transaction.support.AbstractPlatformTransactionManager#setTransactionSynchronization
	 */
	SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS),

	/**
	 * Support a current transaction, throw an exception if none exists.
	 * Analogous to EJB transaction attribute of the same name.
	 */
	MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY),

	/**
	 * Create a new transaction, and suspend the current transaction if one exists.
	 * Analogous to the EJB transaction attribute of the same name.
	 * <p><b>NOTE:</b> Actual transaction suspension will not work out-of-the-box
	 * on all transaction managers. This in particular applies to
	 * {@link org.springframework.transaction.jta.JtaTransactionManager},
	 * which requires the {@code javax.transaction.TransactionManager} to be
	 * made available to it (which is server-specific in standard Java EE).
	 * @see org.springframework.transaction.jta.JtaTransactionManager#setTransactionManager
	 */
	REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW),

	/**
	 * Execute non-transactionally, suspend the current transaction if one exists.
	 * Analogous to EJB transaction attribute of the same name.
	 * <p><b>NOTE:</b> Actual transaction suspension will not work out-of-the-box
	 * on all transaction managers. This in particular applies to
	 * {@link org.springframework.transaction.jta.JtaTransactionManager},
	 * which requires the {@code javax.transaction.TransactionManager} to be
	 * made available to it (which is server-specific in standard Java EE).
	 * @see org.springframework.transaction.jta.JtaTransactionManager#setTransactionManager
	 */
	NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED),

	/**
	 * Execute non-transactionally, throw an exception if a transaction exists.
	 * Analogous to EJB transaction attribute of the same name.
	 */
	NEVER(TransactionDefinition.PROPAGATION_NEVER),

	/**
	 * Execute within a nested transaction if a current transaction exists,
	 * behave like {@code REQUIRED} otherwise. There is no analogous feature in EJB.
	 * <p>Note: Actual creation of a nested transaction will only work on specific
	 * transaction managers. Out of the box, this only applies to the JDBC
	 * DataSourceTransactionManager. Some JTA providers might support nested
	 * transactions as well.
	 * @see org.springframework.jdbc.datasource.DataSourceTransactionManager
	 */
	NESTED(TransactionDefinition.PROPAGATION_NESTED);


	private final int value;


	Propagation(int value) {
		this.value = value;
	}

	public int value() {
		return this.value;
	}

}

 

메서드의 트랜잭션 전파속성을 정할 수 있다.

 

각각의 상세 내용은

https://dev-jj.tistory.com/entry/Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%84%A4%EC%A0%95-Isolation-%EA%B2%A9%EB%A6%AC%EC%88%98%EC%A4%80

이 글을 참고하면 된다.

 

중요한 속성 두 가지만 뽑자면, 'NESTED'와 기본 설정인 'REQUIRED'다.

 

 

  • REQUIRED (기본값)

이미 시작된 트랜잭션이 있으면 참여하고 없으면 새로 시작한다. 일반적인 상황에서는 이 속성으로만 사용해도 괜찮다.

 

 

  • NESTED

이미 진행중인 트랜잭션이 있으면 중첩 트랜잭션을 시작한다.

중첩의 의미가 무엇이냐면, 트랜잭션 안에 트랜잭션을 하나 더 만드는 것이다. 트랜잭션을 감싸는 트랜잭션의 형태가 된다.

결과적으로는 상위 트랜잭션의 롤백에는 영향을 받지만, 하위 트랜잭션의 롤백에 부모가 롤백되는 일은 없다.

 

로깅과 관련된 일을 수행할 때 유용하다. 상위 트랜잭션이 메인 비즈니스 로직을 수행한 후, 하위 트랜잭션이 이에 관련된 로그를 남길 때, 하위 트랜잭션에서 오류가 발생하여 롤백이 된다고 하더라도 메인 비즈니스 로직은 영향을 받지 않아야 할 때 이 속성을 사용하면 아주 유용하다.

 

 

[ 스프링에서 제공하는 기타 트랜잭션 옵션 ]

 

  • noRollbackFor

어노테이션에 예외를 명시해서 해당 예외가 발생했을 때의 롤백을 방지할 수 있다. 명시적으로 예외를 적어두고 해당 예외가 발생했을 때 다른 로직은 정상적으로 흘러가도록 의도한다. 주로 커스텀 익셉션으로 핸들링 하는 것이 좋다.

이와 반대되는 역할을 하는 rollbackFor 옵션도 있다.

 @Transactional(noRollbackFor = { Exception.class })

 

 

 

  • timeout

지정한 시간 내에 메서드가 끝마치지 않으면 rollback이 실행된다. 기본 값은 -1인데, timeout을 사용하지 않는다는 의미다.

@Transactional(timeout = 1)

 

 

  • readonly
@Transactional(readonly = true)

가장 친숙한 옵션이다. 트랜잭션을 읽기 전용으로 설정한다. insert update delete 명령을 수행하면 예외가 발생한다.

JPA의 dirty check를 하지 않는 등 성능상의 이점과 개발자가 명시적으로 읽기 작업만 수행한다는 가독성의 측면에서도 이점을 가진다.

 

 

 

 

[ 어플리케이션 코드에서 트랜잭션 사용의 주의점 ]

https://leezzangmin.tistory.com/25 에 자세히 기록해 두었다.

요약하자면, 프로그램 코드에서 트랜잭션의 범위를 최소화해야한다는 것이다.