본문 바로가기

database

Transaction에 대해서

이번 포스팅에선 트랜잭션 대해 다루도록하겠다.

트랜잭션 직역하면 거래라는 뜻인데 데이터베이스에서 트랜잭션 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻한다.

만약 내가 A라는 책이 10권 있는데 1권을 사면 A라는 책은 9권으로 수량이 줄어들어야 한다. 만약 내가 구매에는 성공했지만 남아있는 책이 9으로 줄어들지 않는다면 문제가 발생하게 된다. 이 문제를 트랜잭션을 사용하면 구매와 수량이 줄어드는 것이 둘 다 성공해야 저장하고 중간에 하나라도 실패하면 직전의 상태로 돌아갈 수 있다. 이것을 보장해주는 것이 트랜잭션이다.

트랜잭션 모든 작업이 성공해 데이터베이스에 정상 반영하는 것을 Commit, 커밋

작업 중 하나라도 실패하면 이전으로 되돌리는 것을 Rollback, 롤백이라 한다.

 

트랜잭션은 ACID를 보장해야한다. 여기서 ACID란

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

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

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

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

 

트랜잭션은 원자성, 일관성, 지속성을 보장한다. 격리성에서 문제가 생기는데 트랜잭션 단에 격리성을 완벽히 보장하려면 트랜잭션을 순서대로 실행하게 되어야 한다. 이렇게 처리하게 되면 처리 성능이 매우 나빠진다. 이 문제로 ANSI 표준은 트랜잭션 격리 수준을 4단계로 나누어 정의했다.

 

트랜잭션 격리 수준 -Isolation level

 

READ UNCOMMITED - 커밋되지 않은 읽기

다른 트랜잭션이 변경 중인 데이터를 읽을 수 있다.
데이터베이스의 일관성을 보장하지 않는다.
가장 낮은 격리수준이며, 잘 사용되지 않는다.

 

READ COMMITED - 커밋된 읽기

다른 트랜잭션이 커밋한 데이터만 읽을 수 있다.
읽은 데이터는 다른 트랜잭션에 의해 변경될 수 있다.
일관성 있는 읽기를 제공하지만, 두 번의 같은 쿼리 실행 사이에 데이터가 변경될 수 있다.

 

REPEATABLE READ - 반복 가능한 읽기

트랜잭션이 시작될 때 읽은 데이터는 트랜잭션이 종료될 때까지 변경되지 않는다.
같은 쿼리를 실행하더라도 동일한 결과를 보장한다.
트랜잭션이 진행 중일 때 다른 트랜잭션에 의해 데이터가 변경되더라도 이를 감지하지 않는다.

 

SERIALIZABLE - 직렬화 가능

가장 높은 격리수준으로, 트랜잭션을 직렬화하여 순차적으로 실행한다.
동시성을 희생하여 완벽한 일관성을 제공한다.
모든 트랜잭션을 순차적으로 실행하므로 성능에 영향을 줄 수 있다.

 

일반적으로 많이 사용되는 커밋된 읽기 격리수준을 기준으로 이번 포스팅을 작성하겠다.

 

트랜잭션을 자세히 이해하기 위해 데이터베이스 서버 연결 구조와 DB 세션에 대해 알아보자.

사용자는 WAS나 DB 접근 툴 같은 클라이언트를 사용해서 데이터베이스 서버에 접근할 수 있다. 클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 된다. 이 때 데이터베이스 서버는 내부에 세션을 만들고 앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해 실행하게 된다.

클라이언트를 통해 SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행.

세션은 트랜잭션을 시작하고 커밋 또는 롤백을 통해 트랜잭션을 종료. 이후에 새로운 트랜잭션을 시작할 수 있다.

커넥션 풀이 10개라면 세션도 10개가 만들어진다.

 

데이터 변경 쿼리를 실행하고 데이터베이스에 그 결과를 반영하려면 커밋을 호출하고 결과를 반영하고 싶지 않다면 롤백을 호출하면 된다.

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

등록, 수정, 삭제 모두 같은 원리로 동작한다.

 

만약 커밋하지 않은 데이터를 다른 곳에서 조회 할 수 있게 된다면

예시로 데이터베이스 안에는 A의 데이터가 있는데 세션1의 사용자가 커밋되지 않은 신규 데이터 B, C를 추가했다. 그 때 세션2가 조회를 해서 커밋되지 않은 신규 데이터 B, C가 보이게 되고 세션2가 신규 데이터로 로직을 수행하는데 세션1이 롤백을 수행해서 신규 데이터가 사라지게 되면 세션2의 로직은 문제가 발생하고 데이터 정합성에 큰 문제가 발생한다.

따라서 커밋 전의 데이터는 다른 세션에서 보이지 않는다.

 

자동 커밋과 수동 커밋

자동 커밋은 각각의 쿼리 실행 직후 자동으로 커밋을 호출한다. 커밋이나 롤백을 직접 호출하지 않아도 되는 편리함이 있다.

하지만 각 쿼리에 실행할 때 마다 자동으로 커밋 되어버리기 때문에 원하는 트랜잭션의 기능을 제대로 사용할 수 없다.

set autocommit 모드를 true, false 로 켜고 끌 수 있다.

 

수동 커밋 설정을 하면 꼭 commit과 rollback을 호출해야 한다.

수동 커밋 모드나 자동 커밋 모드는 한번 설정하면 세션에서는 계속 유지가 되고 중간에 변경하는 것도 가능하다.

 

자동 커밋 모드에서의 생길 수 있는 문제점을 하나 보겠다.

만약 a가 b의 물건을 구매해서 a의 계좌에서 1000원을 빼고 b의 계좌에 1000원을 더하는 쿼리가 있다고 가정하면

set autocommit true;
update member set money = money - 1000 where member_name = "a";
update member set money = money + 1000 where member_namee = "b";

이렇게 될 것이다. 각각 쿼리는 구분되어 있어서 쿼리가 2방이 나갈 것이고 자동커밋으로 인해 

update member set money = money - 1000 where member_name = "a";

쿼리는 커밋될 것이다. 하지만 

update member set money = money + 1000 where member_namee = "b";

이 쿼리는 name이 namee으로 not found: SQL statement 오류가 발생한다. 그러므로 롤백되고

a의 money만 줄어들고 b의 money는 더해지지 않는 문제가 발생한다.

이런 종류의 작업은 꼭 수동 커밋 모드를 사용해서 수동으로 커밋 롤백 할 수 있어야 한다.

 

다음으로

DB 락에 대해 알아보자

세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 아직 커밋을 수행하지 않았는데 세션2에서 동시에 같은 데이터를 수정하게 되면 여러가지 문제가 발생한다. 원자성이 깨지는 문제인데 세션1이 중간에 롤백을 하게되면 세션2는 잘못된 데이터를 수정하는 문제가 발생한다.

 

문제를 방지하려면 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.

 

만약 세션1이 a의  money를 100으로 변경하고싶고 세션2는 같은 a를 200으로 변경하고 싶다면 이 때, 문제를 해결하기 위해 데이터 락(lock)의 개념을 제공한다.

 

세션1이 트랜잭션을 시작하고 변경을 시도한다. 이 때 로우의 락을 먼저 획득해야한다. 세션1이 세션2보다 조금 더 빨리 요청했다면 

락이 남아있기 때문에 세션1은 락을 획득한다.

락을 획득하고 update를 수행한다.

 

세션2가 트랜잭션을 시작한다. 세션2도 1과 같이 a의 money를 변경을 시도할 때 해당 로우의 락을 먼저 획득해야하지만 락이 없기 때문에 락이 돌아올 때 까지 대기한다.

무한정 대기하는 것이 아니라 락 대기 시간을 넘어가면 락 타임아웃 오류가 발생한다. 락 대기 시간은 설정할 수 있다.

 

세션1이 커밋을 수행하고 커밋으로 트랜잭션이 종료되고 락을 반납한다.

 

세션2가 락을획득한다.

 

세션2가 update를 수행한다. 수행 후 트랜잭션이 종료되고 락을 반납한다.

 

트랜젝션은 비즈니스 로직이 있는 서비스 계층에서 시작해야한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 떄문이다.

트랜잭션을 시작하려면 커넥션이 필요한데 서비스 계층에서 커넥션을 만들고 트랜잭션 커밋 이후 커넥션을 종료해야한다.

애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야한다. 그래야 같은 세션을 사용할 수 있다.

애플리케이션에서 같은 커넥션을 유지하려면 가장 단순한 방법은 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지하는 것이다.

 

애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 생각보다 매우복잡한 코드를 요구한다. 스프링을 사용해서 이 부분을 해결할 수 있다.

 

중요한 곳은 서비스 계층이다. 왜냐하면 핵심 비즈니스 로직이 들어있는 계층이기 때문이다. 웹의 부분을 변경해도 데이터 저장 기술을 다른 기술로 변경해도 비즈니스 로직은 최대한 변경 없이 유지해야한다.

즉, 서비스는 특정 기술이 종속적이게 개발하지 않아야한다. 

 

구현 기술에 따라 트랜잭션의 사용법이 다르다.

트랜잭션은 원자적 단위의 비즈니스 로직을 처리하기 위해 사용한다.

JDBC: con.setAutoCommit(false)

JPA: transaction.begin()

특정 기술에 종속을 해결하려면 begin과 commit, rollbakc을 인터페이스로 만들어서 각 기술에 맞는 구현체를 만들면 된다.

 

 

트랜잭션 추상화

스프링은 트랜잭션 추상화 기술을 제공한다. 인터페이스와 구현체를 데이터베이스 접근 기술에 따라 대부분 만들어 두어서 가져다 사용하면 된다. 

public interface PlatformTransactionManager extends TransactionManager {
     TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
             throws TransactionException;
     void commit(TransactionStatus status) throws TransactionException;
     void rollback(TransactionStatus status) throws TransactionException;
}

PlatformTransactionManager 의 구현체로 JDBC, JPA, 하이버네이트 등 여러가지 구현체가 있다.

getTransaction(): 트랜잭션을 시작한다.

commit(): 트랜잭션을 커밋한다.

rollback(): 트랜잭션을 롤백한다.

 

스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다.

트랜잭션 추상화와 리소스 동기화

트랜잭션 추상화는 이미 설명했고 리소스 동기화는 트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야한다. 이전에는 같은 커넥션을 맞추기 위해 파라미터로 커넥션을 담아서 전달하는 방법을 사용했다. 이 부분은 코드도 지저분하고 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야하는 등 단점이 많다.

 

스프링은 트랜잭션 동기화 매니저를 제공한다. 이는 쓰레드 로컬을 사용해 커넥션을 동기화해준다. 트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저를 사용한다.

트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 상황에서 안전하게 커넥션을 동기화할 수 있다.

커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다. 따라서 이전처럼 파라미터로 커넥션을 전달하지 않아도 된다.

 

동작 방식

트랜잭션을 시작하려면 커넥션이 필요하므로 트랜잭션 매니저를 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.

트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.

리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 따라서 파라미터로 커넥션을 전달하지 않아도 된다.

트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다.

쓰레드 로컬에 대해서는 스프링부트 포스팅에서 이미 다룬 주제이다.

 

트랜잭션 매니저의 전체 흐름을 알아보자.

1. 서비스 계층에서 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다.
2. 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부에서 데이터소스를 사용해
서 커넥션을 생성한다.
3. 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
4. 커넥션을 트랜잭션 동기화 매니저에 보관한다.
5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 멀티 쓰레드 환경에 안전하게 커넥션을 보관할 수 있다.

6. 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다. 이때 커넥션을 파라미터로 전달하지 않는다.
7. 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 리포지토리는 DataSourceUtils.getConnection()을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고, 트랜잭션도 유지된다.
8. 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.

9. 비즈니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.
10. 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
11. 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
12. 전체 리소스를 정리한다.

 

지금까지는 로직에 트랜잭션을 시작하고 비즈니스 로직을 실행하고 커밋하고 롤백하는 코드에 try catch finally 코드가 꼭 들어가게 된다.

템플릿 콜백 패턴을 사용하면 깔끔하게 코드를 유지할 수 있을것이다. 템플릿 콜백 패턴도 스프링부트 포스팅에서 다뤘다.

 

스프링은 TransactionTemplate을 지원한다.

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

 

응답 값이 있으면 execute, 없다면 executeWithoutResult를 사용하면 된다.

 

여기까지 적용하게 되어도 서비스 로직에 트랜잭션을 처리하는 기술 로직이 포함되어 있다. 순수하게 유지하기 위해서는 트랜잭션 AOP를 알아보자.

AOP와 프록시도 스프링부트 포스팅에서 다뤘던 내용이다.

 

스프링 AOP를 직접 사용해서 트랜잭션 처리를 해도 되지만 스프링이 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다.

스프링 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.

간편하게 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션을 붙여주면 된다. 스프링 트랜잭션 AOP는 이 애노테이션을 인식해 트랜잭션 프록시를 적용해준다.

 

어드바이저, 포인트컷, 어드바이스는 스프링 부트가 각 기능을 처리하는 빈을 스프링 컨테이너에 자동으로 등록하고 사용된다.

어드바이저: BeanFactoryTransactionAttributeSourceAdvisor 

포인트컷: TransactionAttributeSourcePointcut
어드바이스: TransactionInterceptor

 

public void logic() { //트랜잭션 시작
         TransactionStatus status = transactionManager.getTransaction(..);
         try {
		//실제 대상 호출
			target.logic(); 
            transactionManager.commit(status); //성공시 커밋
			} catch (Exception e) {
            transactionManager.rollback(status); //실패시 롤백 throw new IllegalStateException(e);
		} 
	}

 

프록시 예시이다. 

 

 

선언적 트랜잭션 관리

애노테이션을 선언해서 트랜잭션을 관리하는 것으로 일반적으로 이것을 사용하면 된다.

 

프로그래밍 방식의 트랜잭션 관리

트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그램이 방식의 트랜잭션 관리라 한다.

테스트시에 가끔 사용된다.

 

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class UserService {

    private final UserRepository userRepository;


    @Transactional(readOnly = true)
    public User findByLoginId(String loginId) {
        Optional<User> optionalUser = userRepository.findByLoginId(loginId);
                //호출부에서 null 반환시 처리하는 로직 필요
        return optionalUser.orElse(null);

    }

    //회원 가입시 유저를 생성하고 저장하는 로직
    public User createSaveUser(UserForm userForm) {
        Address address = new Address(userForm.getZipcode(), userForm.getCity(), userForm.getStreet());

        User user = new User(
                userForm.getLoginId(),
                userForm.getName(),
                userForm.getAge(),
                userForm.getEmail(),
                userForm.getPassword(),
                address,
                Tier.NORMAL);
        return userRepository.save(user);
    }

    /**
     * 테스트에 사용되는 서비스 로직
     */
    public void clear() {
        userRepository.deleteAll();
    }


    /**
     * 로그인 로직
     * @return 비밀번호가 틀릴 시 null 반환
     */
    @Transactional(readOnly = true)
    public User login(String id, String password) {
        return userRepository.findByLoginId(id)
                .filter(m -> m.getPassword().equals(password))
                .orElse(null);
    }

    /**
     * 구매시 구매한 item의 총 가격만큼 누적금액을 더하고 티어를 바꿔주는 로직
     */
    @Transactional(readOnly = true)
    public int addAccumulatedAmount(User user, int totalPrice) {
        Optional<User> optionalUser = userRepository.findById(user.getId());
        User findUser = optionalUser.orElseThrow(null);

        int resultPrice = findUser.addAmount(totalPrice);

        if (findUser.getAccumulatedAmount() <= 1000000) {
        findUser.upgradeTier(findUser.getAccumulatedAmount());
        }

        return resultPrice;
    }

    /**
     * 구매에서 할인에 필요한 user의 tier를 가져오기 위한 로직
     */
    @Transactional(readOnly = true)
    public Tier findUserTier(Long userId) {
        Optional<User> user = userRepository.findById(userId);
        User findUser = user.orElseThrow(null);
        return findUser.getTier();
    }



}

 

쭉 설명한 부분들을 거쳐 생성된 로직이다 본 서비스 로직은 필자가 개발중인 코드에서 일부 가져온 것이다.

@Transactional은 클래스와 메서드 둘 모두에서 선언할 수 있으며 더 자세한게 먼저 인식 되기 때문에 @Transactional(readOnly = true)가 클래스의 선언된 @Transactional보다 우선권을 가진다. (readOnly = true) 부분은 다음 포스팅에 다루도록 하겠다.

 

기존에 커넥션을 얻고 파라미터로 넘기고 커밋, 롤백, 예외처리 하는 코드들 전부 트랜잭션 매니저, 트랜잭션 동기화 매니저, 템플릿 패턴, 프로시를 사용하면서 서비스 코드를 깔끔히 유지하게 되었다.

 

본 포스팅은 인프런의 김영한 강사님의 스프링 스프링 DB 1편의 코드, 내용을 일부 발췌하여 사용했습니다.

https://inf.run/AomUA

 

'database' 카테고리의 다른 글

PostgreSQL에 대해서  (0) 2024.06.25
springboot 트랜잭션 AOP와 트랜잭션 전파  (0) 2024.04.21
데이터베이스 데이터 접근 기술  (1) 2024.04.20
데이터 베이스와 JDBC, ORM  (1) 2024.04.18