[DB] 트랜잭션

hi·2022년 12월 22일
0

트랜잭션은 ACID라 하는 원자성, 일관성, 격리성, 지속성을 보장해야 한다

원자성 Atomicity
트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하거나 모두 실패해야 한다

일관성 Consistency
모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서
정한 무결성 제약 조건을 항상 만족해야 한다

격리성 Isolation
동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어 동시에 같은
데이터를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준
(Isolation level)을 선택할 수 있다.

지속성 Durability
트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도
데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다


트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야 하는데 이 경우 동시 처리 성능이 매우 나빠진다. ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의했다

트랜잭션 격리 수준 Isolation level

READ UNCOMMITED(커밋되지 않은 읽기)
READ COMMITTED(커밋된 읽기) : 일반적으로 많이 사용
REPEATABLE READ(반복 가능한 읽기)
SERIALIZABLE(직렬화 가능)


데이터베이스 연결 구조

  • 사용자는 WAS나 DB 접근 툴 같은 클라이언트를 사용해 DB 서버에 접근 가능.
    클라이언트는 DB 서버에 연결을 요청하고 커넥션을 맺고, 이때 DB 서버는 내부에 세션이라는 것을 생성. 이후 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행

  • 즉 개발자가 클라이언트를 통해 SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을
    실행

  • 세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료

  • 사용자가 커넥션을 닫거나, DBA(DB 관리자)가 세션을 강제로 종료하면 세션은 종료됨

  • 커넥션 풀이 10개의 커넥션을 생성하면, 세션도 10개 만들어진다


트랜잭션 사용

commit : 데이터 변경 쿼리를 실행하고 데이터베이스에 그 결과를 반영
rollback : 트랜잭션을 시작하기 직전의 상태로 복구

  • 커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것
  • 따라서 해당 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션에는 변경 데이터가 보이지 않음

커밋 설정

set autocommit true : 자동 커밋 모드 설정
set autocommit false : 수동 커밋 모드 설정

  • 보통 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에, 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 표현
  • 수동 커밋 설정을 하면 이후에 꼭 commit , rollback 을 호출

DB 락

여러 세션에서 동시에 같은 데이터를 수정하게 되면 여러 문제가 발생
락(Lock)은 이를 방지한다

  • 락 대기 시간을 넘어가면 락 타임아웃 오류가 발생
    SET LOCK_TIMEOUT <milliseconds> 시간 설정 가능
    ex) SET LOCK_TIMEOUT 10000
    세션2에 설정시 세션2가 10초 동안 대기해도 락을 얻지 못하면 오류 발생

DB 락 조회

select for update

set autocommit false;
select * from member where member_id='memberA' for update;
  • 일반적인 조회는 락을 사용하지 않지만, 데이터 조회시에도 락을 획득하고 싶을 경우 사용
  • 조회를 하면서 동시에 선택한 로우의 락도 획득
    락이 없다면 획득할 때까지 대기

트랜잭션 적용

애플리케이션에서 트랜잭션을 어떤 계층에 걸어야 하는가?

  • 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다.
    비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문
  • 그런데 트랜잭션을 시작하려면 커넥션이 필요
  • 따라서 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료
  • 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야한다. 그래야 같은 세션을 사용할 수 있다

커넥션을 유지하려면?
가장 간단한 방법은 파라미터로 전달하는 것


public void accountTransfer(String fromId, String toId, int money) throws SQLException {
 
 	Connection con = dataSource.getConnection();
 	
    try {
 		con.setAutoCommit(false); //트랜잭션 시작
 		
        //비즈니스 로직

 		con.commit(); //성공시 커밋
        
	} catch (Exception e) {
 		con.rollback(); //실패시 롤백
 		throw new IllegalStateException(e);
 	} finally {
 		release(con); //커넥션 종료
 	}
 }
  • 커넥션 풀을 사용하면 con.close()를 호출 했을 때 커넥션이 종료되지 않고 풀에 반납
  • 수동 커밋 모드로 동작 시에는 반납 전에 기본 값인 자동 커밋 모드로 변경하는 것이
    안전

애플리케이션 구조

역할에 따른 3가지 계층

프레젠테이션 계층

  • UI와 관련된 처리 담당
  • 웹 요청과 응답
  • 사용자 요청을 검증
  • 주 사용 기술: 서블릿과 HTTP 같은 웹 기술, 스프링 MVC

서비스 계층

  • 비즈니스 로직을 담당
  • 주 사용 기술: 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성

데이터 접근 계층

  • 실제 데이터베이스에 접근하는 코드
  • 주 사용 기술: JDBC, JPA, File, Redis, Mongo ...

여기서 가장 중요한 곳은 서비스 계층
시간이 흘러 UI(웹)와 관련된 부분이 변하고, 데이터 저장 기술을 다른 기술로 변경해도, 비즈니스 로직은 최대한 변경없이 유지되어야 한다

그러려면 서비스 계층을 특정 기술에 종속적이지 않게 개발

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작하는 것이 좋다

JDBC 기술을 사용한 트랜잭션의 문제

트랜잭션 문제

  • JDBC 구현 기술이 서비스 계층에 누수
  • 트랜잭션 동기화 문제
    같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘겨야 함
  • 코드 반복이 많음 try catch finally

예외 누수 문제

  • 데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층으로 전파
  • 향후 다른 기술에 맞는 예외로 변경해야 하고, 결국 서비스 코드도 수정 필요

JDBC 반복 문제

  • 유사한 코드의 반복
    try , catch , finally ... 등

이 문제들을 해결할 방법

1. 트랜잭션 문제

트랜잭션 추상화

PlatformTransactionManager 인터페이스
org.springframework.transaction.PlatformTransactionManager

💡 참고
스프링 5.3부터는 JDBC 트랜잭션을 관리할 때 DataSourceTransactionManager 를 상속받아서 약간의 기능을 확장한 JdbcTransactionManager 를 제공

package org.springframework.transaction;

public interface PlatformTransactionManager extends TransactionManager {

	TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
			throws TransactionException;

	void commit(TransactionStatus status) throws TransactionException;
	void rollback(TransactionStatus status) throws TransactionException;
}

getTransaction() : 트랜잭션을 시작
기존에 이미 진행중인 트랜잭션이 있는 경우 해당 트랜잭션에 참여 가능


리소스 동기화

스프링은 트랜잭션 동기화 매니저를 제공
트랜잭션 동기화 매니저는 쓰레드 로컬(ThreadLocal)을 사용해 커넥션을 동기화

  • 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득

동작 방식
1. 트랜잭션 매니저는 데이터 소스를 통해 커넥션을 만들고 트랜잭션을 시작
2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관
3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용
4. 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션 종료, 커넥션을 닫음


💡 참고
쓰레드 로컬을 사용하면 각각의 쓰레드마다 별도의 저장소가 부여된다. 따라서 해당 쓰레드만 해당 데이터에 접근 가능


트랜잭션 매니저

DataSourceUtils.getConnection()

  • 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환
  • 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환

DataSourceUtils.releaseConnection()

  • 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지
  • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫음

전체 동작 흐름

  • 트랜잭션 시작

  • 로직 실행

  • 트랜잭션 종료

트랜잭션 템플릿

반복되는 패턴의 문제 해결

TransactionTemplate 클래스

public class TransactionTemplate {
	private PlatformTransactionManager transactionManager;
	
    public <T> T execute(TransactionCallback<T> action){..}
	void executeWithoutResult(Consumer<TransactionStatus> action){..}
}

execute() : 응답 값이 있을 때 사용
executeWithoutResult() : 응답 값이 없을 때 사용

  • TransactionTemplate을 사용하려면 transactionManager 필요
  • 비즈니스 로직이 정상 수행되면 커밋
  • 언체크 예외가 발생하면 롤백, 그 외의 경우 커밋 (체크 예외 커밋)

but
반복하는 코드는 제거할 수 있지만 서비스 로직에 비즈니스 로직과 트랜잭션을 처리하는 기술 로직이 섞이게 된다

이것을 스프링 AOP를 통해 프록시를 도입하여 해결

  • 프록시 도입 전
    서비스의 로직에서 트랜잭션을 직접 시작
  • 프록시 도입 후
    트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 분리

트랜잭션 AOP

@Transactional

org.springframework.transaction.annotation.Transactional

  • 메서드나 클래스에 애노테이션을 추가하여 사용
    클래스에 사용시 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 됨
  • 스프링 AOP를 적용하려면 스프링 컨테이너 필요
    @SpringBootTest
  • 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다
    DataSourceTransactionManager
ex. AOP 프록시 적용 확인

@Test
void AopCheck() {
	log.info("memberService class={}", memberService.getClass());
	log.info("memberRepository class={}", memberRepository.getClass());
	Assertions.assertThat(AopUtils.isAopProxy(memberService)).isTrue();
   	Assertions.assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
}

전체 흐름

  • 트랜잭션 매니저를 통해 트랜잭션이 실행

선언적 트랜잭션 관리(Declarative Transaction Management)

  • @Transactional 애노테이션만 선언하여 트랜잭션을 적용하는 방법
  • 과거 XML에 설정하기도 했다

프로그래밍 방식의 트랜잭션 관리(programmatic transaction management)

  • 트랜잭션 매니저, 트랜잭션 템플릿 등을 사용해 트랜잭션 관련 코드를 직접 작성하는 방식

💡 테스트의 @Transactional

테스트에서 @Transactional 을 사용하면

  • 스프링은 테스트를 트랜잭션 안에서 실행
  • 테스트가 끝나면 트랜잭션을 자동으로 롤백
  • 중간에 테스트가 강제로 종료되어도 문제 X
    (보통 데이터베이스 커넥션이 끊어지면 자동으로 롤백)
  • 테스트 실행이 종료될 때 까지 테스트가 실행하는 모든 코드가 같은 트랜잭션 범위에 들어감
  • 같은 범위 = 같은 트랜잭션 사용 = 같은 커넥션 사용

강제 커밋

  • @Commit 을 함께 붙이거나 @Rollback(value = false) 사용


스프링 부트의 자동 리소스 등록

스프링 부트 등장 이전에는 데이터소스와 트랜잭션 매니저를 개발자가 직접 스프링 빈으로 등록하여 사용했었다

@Bean
DataSource dataSource() {
	return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}

@Bean
PlatformTransactionManager transactionManager() {
	return new DataSourceTransactionManager(dataSource());
}

데이터소스 자동등록

  • 스프링 부트는 데이터소스를 dataSource 이름으로 스프링 빈에 자동 등록
  • 직접 데이터소스를 빈으로 등록하면 스프링 부트는 데이터소스 자동 등록 X
  • application.properties 에 있는 속성을 사용해서 DataSource 를 생성하고 스프링 빈에 등록
application.properties

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
  • 스프링 부트가 기본으로 생성하는 데이터소스는 커넥션풀을 제공하는 HikariDataSource 이다
  • 커넥션풀과 관련된 설정도 application.properties를 통해 지정 가능
  • spring.datasource.url 속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도

트랜잭션 매니저 자동등록

  • 스프링 부트는 적절한 트랜잭션 매니저(PlatformTransactionManager)를 transactionManager 이름으로 스프링 빈에 자동 등록
  • 직접 트랜잭션 매니저를 빈으로 등록하면 스프링 부트는 트랜잭션 매니저 자동 등록 X
  • 트랜잭션 매니저 선택은 현재 등록된 라이브러리를 보고 판단
    JDBC 사용 : DataSourceTransactionManager 등록
    JPA 사용 : JpaTransactionManager 등록
    둘다 사용 : JpaTransactionManager 등록

2. 예외 누수 문제

예외 포함과 스택 트레이스

예외를 전환할 때는 꼭 기존 예외를 포함

@Test
void printEx() {
	Controller controller = new Controller();
	try {
		controller.request();
	} catch (Exception e) {
		//e.printStackTrace(); //실무에서는 로그 사용
		log.info("ex", e);
 	}
}
  • 로그 출력시 마지막 파라미터에 예외를 넣으면 로그에 스택 트레이스 출력 가능
    ex) log.info("message={}", "message", ex) , log.info("ex", ex)

public void call() {
	try {
		runSQL();
	} catch (SQLException e) {
		throw new RuntimeSQLException(e); //기존 예외(e)를 감싸서 변환
	}
}


static class RuntimeSQLException extends RuntimeException {
	public RuntimeSQLException(Throwable cause) { //원인 필요
    	super(cause);
    }
}
  • 예외 포함시 기존에 발생한 java.sql.SQLException 과 스택 트레이스를 확인 가능
  • 예외 미포함시 변환한 RuntimeSQLException 부터 예외를 확인 가능

스프링 예외 추상화

  • 데이터 접근 계층에 대한 예외를 정리하여 일관된 예외 계층을 제공
  • 각각의 예외는 특정 기술에 종속적이지 않게 설계
  • 예외의 최고 상위는 org.springframework.dao.DataAccessException
    모든 예외는 런타임 예외

DataAccessException 은 크게 2가지로 구분한다

Transient

  • 일시적
  • 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다
  • ex) 쿼리 타임아웃, 락과 관련된 오류

NonTransient

  • 일시적이지 않음
  • 같은 SQL을 그대로 반복해서 실행하면 실패
  • ex) SQL 문법 오류, 데이터베이스 제약조건 위배 등

예외 변환기

데이터베이스에서 발생하는 오류 코드를 스프링이 정의한 예외로 자동으로 변환

SQLExceptionTranslator exTranslator = new
SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);
  • translate(읽을 수 있는 설명, 실행한 SQL, 발생된 SQLException)

예외 변환 원리

sql-error-codes.xml

  • SQL ErrorCode를 이 파일에 대입하여 어떤 스프링 데이터 접근 예외로
    전환해야 할지 찾아낸다
  • 10개 이상의 관계형 데이터베이스를 지원

3. JDBC 반복 문제

  • 커넥션 조회, 커넥션 동기화
    PreparedStatement 생성 및 파라미터 바인딩
    쿼리 실행
    결과 바인딩
    예외 발생시 스프링 예외 변환기 실행
    리소스 종료

위처럼 Repository에서 반복되는 문제들을 템플릿 콜백 패턴으로 처리할 수 있다

JdbcTemplate

  • 트랜잭션을 위한 커넥션 동기화, 스프링 예외 변환기를 자동으로 실행
public class MemberRepositoryV5 implements MemberRepository {

	private final JdbcTemplate template;

	public MemberRepository(DataSource dataSource) {
		template = new JdbcTemplate(dataSource);
	}

	@Override
	public Member save(Member member) {
		String sql = "insert into member(member_id, money) values(?, ?)";
		template.update(sql, member.getMemberId(), member.getMoney());
		return member;
	}

	@Override
	public void update(String memberId, int money) {
		String sql = "update member set money=? where member_id=?";
		template.update(sql, money, memberId);
	}
 
	private RowMapper<Member> memberRowMapper() {
		return (rs, rowNum) -> {
			Member member = new Member();
			member.setMemberId(rs.getString("member_id"));
			member.setMoney(rs.getInt("money"));
			return member;
		};
	}
}    

0개의 댓글