JAVA로 트랜잭션 구현하기

송선권·2024년 11월 19일
14

개발 지식

목록 보기
4/4
post-thumbnail

배경

우아한테크코스 프리코스 4주차를 진행하다보니 일괄 롤백이 필요한 상황이 생겼다. 당시에는 일괄 사전 검증을 통해 롤백이 필요한 상황 자체를 회피하여 해결했지만, 이 문제를 근본적으로 해결하기 위해 트랜잭션을 직접 구현해보고자 한다.

들어가기 앞서

이 글에서는 JAVA로 트랜잭션을 직접 구현하지만 간단하게 만들어보는 것을 목표로 하기에 완성도가 그리 높지 않다. 혹시라도 잘못된 부분이 있을 수 있으니 트랜잭션을 이런 방식으로 만들 수 있구나~ 정도로만 봐주면 좋겠다. 😅

트랜잭션이란?

개념

데이터베이스 트랜잭션(Database Transaction)은 데이터베이스 관리 시스템 또는 유사 시스템에서의 상호작용 단위이다. 일반적으로 트랜잭션은 더이상 분할이 불가능한 업무 단위이며, 한번에 수행되야 하는 연산의 모음을 의미한다.

특징

데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 가리켜 ACID 원칙이라고 한다. 각 알파벳은 다음의 의미를 지닌다.

특징설명
원자성(Atomicity) 트랜잭션 내에서 변경된 데이터는 모두 반영되거나, 전혀 반영되지 않아야 한다.
일관성 (Consistency)트랜잭션의 작업 처리 결과는 항상 일관적이어야 한다.
독립성 (Isolation)트랜잭션 수행 시 다른 트랜잭션의 연산 작업이 끼어들지 못해야 한다.
영구성 (Durability) 성공적으로 완료된 트랜잭션의 결과는 영구적으로 반영되어야 한다.

연산

Commit

커밋(Commit) 연산은 트랜잭션 내에서 수행한 모든 작업이 성공적으로 완료되었을 경우 수행한다. 커밋 시 트랜잭션 내에서 변경된 데이터는 데이터베이스에 영구적으로 저장되며 트랜잭션이 종료된다.

Rollback

롤백(Rollback) 연산은 트랜잭션이 비정상적으로 종료되어 데이터베이스의 일관성이 깨진 경우 수행한다. 롤백 시 트랜잭션의 일부가 정상적으로 처리되었더라도 원자성을 위해 트랜잭션의 모든 연산을 취소(Undo)하며, 해당 트랜잭션은 재시작되거나 폐기된다.

Spring 트랜잭션 구조 살펴보기

트랜잭션을 직접 구현하기 위해 스프링 프레임워크에서 제공하고 있는 트랜잭션 코드를 열어봤다. 그 구조를 간단하게 살펴보자.

TransactionTemplate

스프링에서는 TransactionTemplate에서 트랜잭션을 관리하고 있다. 해당 클래스의 구현부를 보면 다음과 같이 작성되어 있다.

package org.springframework.transaction.support;  
  
public class TransactionTemplate extends DefaultTransactionDefinition implements TransactionOperations, InitializingBean {  
    @Nullable  
    private PlatformTransactionManager transactionManager;  
  
    @Nullable  
    public <T> T execute(TransactionCallback<T> action) throws TransactionException {  
        TransactionStatus status = this.transactionManager.getTransaction(this);  

        Object result;  
        try {  
            result = action.doInTransaction(status);  
        } catch (...) {  
            this.rollbackOnException(status, ex);
            // ... 
        }  
        this.transactionManager.commit(status);  
        return result;  
    }  
  
    private void rollbackOnException(TransactionStatus status, Throwable ex) throws TransactionException {  
        try {  
            this.transactionManager.rollback(status);  
        } catch (...) {  
            // ...
        }  
    }  
}

TransactionTemplateexecute() 내부에서 트랜잭션 대상 메서드를 실행(action.doInTransaction(status))한 후 커밋한다. 만약 그 과정에서 예외가 발생할 경우 rollbackOnException() 메서드를 호출하여 롤백을 진행한다.

PlatformTransactionManager

TransactioinTemplate의 코드를 보면 실질적인 트랜잭션 연산(commit, rollback)은 PlatformTransactionManager에게 위임하고 있는데, 스프링에서는 트랜잭션 처리 로직을 추상화하여 이 인터페이스를 통해 제공하고 있다. 따라서 JPA나 Mongo 등의 실제 트랜잭션 처리 로직은 모두 이 인터페이스의 구현체에 의해 이루어진다.

PlatformTransactionManager에 대해 자세한 내용이 궁금하다면 다음 문서를 읽어보자.

Interface PlatformTransactionManager - Spring Docs

직접 만들어보기

지금까지 트랜잭션의 개념과 스프링에서 트랜잭션을 어떻게 구현하고 있는지 알아보았다. 이제 그 내용을 기반으로 java에서 트랜잭션을 직접 구현해보자!

클래스 분리

스프링에서는 TransactionTemplate이 언제 커밋이나 롤백을 수행할지 정의하고 TransactionManager가 실질적인 트랜잭션 연산을 수행한다. 하지만 여기서는 간단한 구현을 목표로 하니 하나의 클래스에서 전부 구현하겠다.

클래스 내부 구조

public class TransactionTemplate {  
    public void execute(Runnable action) {  
        try {  
            begin();  
            action.run();  
            commit();  
        } catch (Exception e) {  
            rollback();  
            throw e;  
        }  
    }  
  
    private void begin() {  

    }  
  
    private void commit() {  

    }  
  
    private void rollback() {  

    }  
}

가장 먼저 기본적인 TransactionTemplate을 작성했다. 이 클래스는 execute 메서드에 인자로 넘어온 로직을 수행하는데, 여기에 트랜잭션을 적용한다. begin() 실행 후 로직을 수행하며, 정상적으로 완료된 경우 commit()이 실행된다. 이 과정에서 예외가 발생한 경우 rollback()을 실행하고 다시 예외를 던진다.

컬렉션 기반 트랜잭션 연산

트랜잭션의 대상이 되는 데이터베이스 영역을 어디까지로 정의해야할지 고민하다가 컬렉션에 한정짓기로 했다. 기존 코드에서는 Repository 단에서 내부적으로 객체나 컬렉션 상태를 유지하며 데이터베이스 역할을 수행하고 있었다.

다음은 기존 Repository 중 컬렉션을 유지하는 클래스이다.

public class NoticeRepository {  
    private static final int DEFAULT_AUTO_INCREMENT = 1;  
  
    private final Map<Integer, Notice> db = new HashMap<>();  
    private int autoIncrement = DEFAULT_AUTO_INCREMENT;  
  
    public void save(Notice notice) {  
        if (db.containsKey(notice.getId())) {  
            db.replace(notice.getId(), notice);  
            return;  
        }  
        db.put(autoIncrement, notice);  
        notice.save(autoIncrement);  
        autoIncrement++;  
    }  
  
    public Notice findById(int id) {  
        return db.get(id);  
    }  
  
    public void remove(Notice notice) {  
        db.remove(notice.getId());  
    }  
  
    public void saveAllByCustomer(Customer customer) {  
        customer.getNotices().forEach(this::save);  
    }  
}

실제 데이터베이스가 없다보니 데이터를 가져오려면 Repository에 접근하는 수밖에 없는데, TransactionTemplate이 각 Repository에 일괄적으로 접근할 수 있도록 Repository 인터페이스로 추상화시켰다.

public interface Repository<T> {   
    // 내부 컬렉션의 복사본 반환(원본 참조 x)
    Collection<T> getCopiedCollection();  

    // 내부 컬렉션 초기화
    void clear();  

    // 매개변수 컬렉션 전체 추가
    void addAll(Collection<T> collection);  
}

그리고 이 메서드들을 통해 TransactionTemplate에서 트랜잭션 연산을 구현했다.

public class TransactionTemplate {  
    private Map<Repository, Collection<Object>> beganData = new HashMap<>();  
    private List<Repository> targetRepositories = new ArrayList<>();  
  
    public void addTarget(Repository<?> targetRepository) {  
        if (!targetRepositories.contains(targetRepository)) {  
            targetRepositories.add(targetRepository);  
        }  
    }
    
    private void begin() {  
        targetRepositories.forEach(targetRepository ->  
            beganData.put(targetRepository, (Collection<Object>)targetRepository.getCopiedCollection()));  
    }  
  
    private void commit() {  
        beganData = new HashMap<>();  
    }  
  
    private void rollback() {  
        targetRepositories.forEach(targetRepository -> {  
            Collection<Object> beganCollection = beganData.get(targetRepository);  
  
            targetRepository.clear();  
            targetRepository.addAll(beganCollection);  
        });  
    }  
}

Repository들을 List로 가지며 이와 대응하는 컬렉션을 beganData에 유지한다. 트랜잭션이 시작할 때 begin()에서 Repository를 통해 복사본을 가져오고, commit() 시 해당 데이터를 초기화한다. 만약 예외가 발생하면 rollback()에서 현재 데이터를 전부 초기화하고 내부에 유지중이던 복사본을 주입시킨다.

변경사항 감지는?

사실 복사 기반보다는 변경감지 기반 트랜잭션을 구현하고 싶었다. 변경감지가 훨씬 효율도 좋고 실제 트랜잭션에 더 근접한 방향이라고 생각하기 때문이다.

하지만 변경감지는 구현이 쉽지 않았다. TransactionTemplate에서 컬렉션의 변경을 매번 감지하려면 지금과 같은 Repository 기반으로는 불가능해보인다. 컬렉션에 대한 프록시를 만들어야 할 것 같은데 쉽지 않았다. 프록시없이 만들면 Repository에서 TransactionTemplate를 호출하는 등 의존관계가 이상하게 꼬여버릴 것 같고, 프록시 객체를 사용하자니 너무 스케일이 커져버린다.

여기서는 기존 코드의 변경을 최소화하면서 간단하게 트랜잭션을 만들어 보고자 했기에 변경사항 감지를 포기하고 간단하게 컬렉션 복사로 구현했다.

컬렉션 복사 구현

public class NoticeRepository implements Repository<Notice> {  
    private final Map<Integer, Notice> db = new HashMap<>();  
  
    @Override  
    public Collection<Notice> getCopiedCollection() {  
        return db.values().stream()  
            .map(Notice::copyOf)  
            .toList();  
    }  
  
    @Override  
    public void clear() {  
        db.clear();  
    }  
  
    @Override  
    public void addAll(Collection<Notice> collection) {  
        if (collection != null && !collection.isEmpty()) {  
            collection.forEach(content -> db.put(content.getId(), content));  
        }  
    }  
}

기존 Repository가 인터페이스를 구현하도록 하고 추상 메서드들을 재정의하여 트랜잭션 제공을 위한 기능들을 제공하도록 했다.

clone()을 재정의하지 않은 이유

위 코드를 잘 보면 복사본 반환 시 copyOf 정적 팩토리 메서드를 사용하고 있다. 처음에는 복사본 반환이니 당연히 clone() 써야지~ 하고 Clonable을 implement하여 구현했다. 하지만 clone() 메서드를 통해 복사를 구현하니 뭔가 계속 예상치 못한 방향으로 튀며 로직도 이상하게 돌아가고 코드도 점점 더러워졌다. 어떻게 해결할 수 있을지 찾다보니 clone() 재정의가 좋은 방향이 아니라는 글을 접했는데, 그 내용이 인상깊었다.

clone 메서드 재정의

복사생성자나 정적 팩토리 메서드를 활용하는 방향을 권장하고 있었는데, 복사생성자는 그 사용법이 뭔가 어색해서 이름을 부여할 수 있고 평소 자주 사용하던 정적 팩토리 메서드 방법으로 구현했다.

이 주제는 이펙티브자바 책에 나오는 내용인 것 같은데 조만간 먼지털고 읽어봐야겠다...

싱글톤 패턴 적용

TransactionTemplate은 그 특성 상 싱글톤 패턴이 잘어울린다. 트랜잭션 기능은 어떤 로직에 대해 수행하든 항상 일정(begin -> commit or rollback)하기 때문이다. 그래서 바로 적용해봤다.

여기서는 여러 싱글톤 패턴 방법 중 Bill Pugh 방법을 사용했다.

public class TransactionTemplate {  
    private Map<Repository, Collection<Object>> beganData = new HashMap<>();  
    private List<Repository> targetRepositories = new ArrayList<>();  
  
    private TransactionTemplate() {  
    }  
    
    private static class TransactionTemplateHolder {  
  
        private static final TransactionTemplate INSTANCE = new TransactionTemplate();  
    }  
  
    public static TransactionTemplate getInstance() {  
        return TransactionTemplateHolder.INSTANCE;  
    }  
}

적용하고 보니 뭔가 꺼림칙한 기분이 들었다. 다시보니 TransactionTemplate은 싱글톤이면서 상태를 유지하고 있었다. 이는 멀티스레드 환경에서 치명적일 수 있었기에 반드시 조치가 필요했다.

싱글톤 패턴을 포기하면 쉽게 해결되는 문제겠지만, 트랜잭션을 효율적으로 사용하기 위해 싱글톤 패턴을 적용해보고 싶었다.

Transaction ID 적용

TransactionTemplate에서 유지중인 상태는 targetRepositoriesbeganData 두 가지가 있다. 이 중에서 targetRepositories는 여러 스레드에서 함께 사용할 수 있어보이고, beganData를 스레드별로 분리해야겠다고 생각했다.

그래서 각 beganData에 고유한 key값(Transaction Id, txId)을 부여해 Map으로 관리하고자 했고, 이 key값으로는 임의의 고유값을 주입하기 위해 UUID.randomUUID.toString()을 활용하기로 했다. UUID에 대해 궁금하다면 다음 게시글을 읽어보자.

[Java] UUID 이해 및 사용방법

Map<key, beganData>와 같이 관리할 수도 있겠지만, 각 스레드에서 beganData 외에 추가로 유지해야 하는 데이터가 추가될 경우를 감안하여 내부 클래스를 만들어 Map<key, InnerClass>의 형태로 작성했다.

  
public class TransactionTemplate {   
    private List<Repository> targetRepositories = new ArrayList<>();  
    // ConcurrentHashMap: 멀티스레드 환경에 안전한 HashMap 자료구조
    private final Map<String, TransactionState> transactionStates = new ConcurrentHashMap<>();

    public void execute(Runnable action) {  
        String txId = UUID.randomUUID().toString();  
        try {  
            begin(txId);  
            action.run();  
            commit(txId);  
        } catch (Exception e) {  
            rollback(txId);  
            throw e;  
        }  
    }

    private void begin(String txId) {  
        TransactionState state = new TransactionState();  
        transactionStates.put(txId, state);  
      
        targetRepositories.forEach(targetRepository ->  
            state.beganData.put(targetRepository, targetRepository.getCopiedCollection()));  
    }  
      
    private void commit(String txId) {  
        transactionStates.remove(txId);  
    }  
      
    private void rollback(String txId) {  
        TransactionState state = transactionStates.get(txId);  
        if (state != null) {  
            targetRepositories.forEach(targetRepository -> {  
                Collection<?> beganCollection = state.beganData.get(targetRepository);  
      
                targetRepository.clear();  
                targetRepository.addAll(beganCollection);  
            });  
            transactionStates.remove(txId);  
        }  
    }
    
    private static class TransactionState {  
        private Map<Repository<?>, Collection<Object>> beganData = new HashMap<>();  
    }
}

최종 코드

이렇게 완성한 TransactionTemplate 클래스 코드는 다음과 같다.

package store.util;  
  
import java.util.ArrayList;  
import java.util.Collection;  
import java.util.HashMap;  
import java.util.List;  
import java.util.Map;  
import java.util.UUID;  
import java.util.concurrent.ConcurrentHashMap;  
import java.util.function.BooleanSupplier;  
import java.util.function.Consumer;  
  
import store.repository.Repository;  
  
public class TransactionTemplate {  
  
    private List<Repository> targetRepositories = new ArrayList<>();  
    private final Map<String, TransactionState> transactionStates = new ConcurrentHashMap<>();  
  
    private TransactionTemplate() {  
    }  
    private static class TransactionTemplateHolder {  
  
        private static final TransactionTemplate INSTANCE = new TransactionTemplate();  
    }  
  
    public static TransactionTemplate getInstance() {  
        return TransactionTemplateHolder.INSTANCE;  
    }  
  
    public void addTarget(Repository<?> targetRepository) {  
        if (!targetRepositories.contains(targetRepository)) {  
            targetRepositories.add(targetRepository);  
        }  
    }  
  
    public void execute(Runnable action) {  
        String txId = UUID.randomUUID().toString();  
        try {  
            begin(txId);  
            action.run();  
            commit(txId);  
        } catch (Exception e) {  
            rollback(txId);  
            throw e;  
        }  
    }  
  
    public <T> void execute(Consumer<T> action, T arg) {  
        String txId = UUID.randomUUID().toString();  
        try {  
            begin(txId);  
            action.accept(arg);  
            commit(txId);  
        } catch (Exception e) {  
            rollback(txId);  
            throw e;  
        }  
    }  
  
    public boolean executeAndGet(BooleanSupplier action) {  
        String txId = UUID.randomUUID().toString();  
        try {  
            begin(txId);  
            boolean result = action.getAsBoolean();  
            commit(txId);  
            return result;  
        } catch (Exception e) {  
            rollback(txId);  
            throw e;  
        }  
    }  
  
    private void begin(String txId) {  
        TransactionState state = new TransactionState();  
        transactionStates.put(txId, state);  
  
        targetRepositories.forEach(targetRepository ->  
            state.beganData.put(targetRepository, targetRepository.getCopiedCollection()));  
    }  
  
    private void commit(String txId) {  
        transactionStates.remove(txId);  
    }  
  
    private void rollback(String txId) {  
        TransactionState state = transactionStates.get(txId);  
        if (state != null) {  
            targetRepositories.forEach(targetRepository -> {  
                Collection<?> beganCollection = state.beganData.get(targetRepository);  
  
                targetRepository.clear();  
                targetRepository.addAll(beganCollection);  
            });  
            transactionStates.remove(txId);  
        }  
    }  
  
    private static class TransactionState {  
        private Map<Repository<?>, Collection<Object>> beganData = new HashMap<>();  
    }  
}

트랜잭션 내부에서 여러 상황을 테스트해본 결과, 예외 발생 시 데이터가 정상적으로 일괄 롤백됨을 확인할 수 있었다.

실제 트랜잭션 구현 및 적용 코드가 궁금하다면 PR 링크를 첨부하니 직접 확인해보자.

트랜잭션 구현 PR

참고자료

[MYSQL] 트랜잭션(Transaction) 개념 & 사용 완벽 정리 - Inpa
데이터베이스 트랜잭션 - 위키백과
ACID - 위키백과
[프로그래밍 애피타이저] 10장 트랜잭션의 정의 - DEVGEAR
Interface PlatformTransactionManager - Spring Docs
clone 메서드 재정의
싱글톤(Singleton) 패턴 - 꼼꼼하게 알아보자
[Java] UUID 이해 및 사용방법

4개의 댓글

comment-user-thumbnail
2024년 11월 25일

잘 보고 갑니다

1개의 답글
comment-user-thumbnail
2024년 11월 28일

멀디 스레드 환경에선 작동 안하는 거죠?

1개의 답글