Profile image
Jinyoung
Dev

TIL-01: 스프링 프레임워크에서 트랜잭션을 처리하는 방법 - 1

Written by Human
TIL-01: 스프링 프레임워크에서 트랜잭션을 처리하는 방법 - 1
0 views
17 min read

얼마 전부터 TIL(Today I Learned)를 시작하게 되었습니다. 매일 매일 특정 주제를 선정하여 공부하고 이것을 따로 기록하는 것입니다.

이 TIL은 Claude의 Projects 라는 기능을 활용하여 해보고 있습니다. TIL 전용 Project를 만들어서 채팅창에 학습 주제와 키워드 몇개를 입력하면 Claude가 알아서 학습 계획을 세우게 하는 것입니다. 학습의 가장 마지막 부분은 제가 직접 그날 학습한 것에 대해 다른 사람을 가르치는 것처럼 설명을 하고 Claude가 이를 평가하도록 했습니다. 이는 파인만 테크닉(기법) 이라고 볼 수 있습니다.

따로 TIL repository를 만들어서 여기에 계속 학습한 내용을 올리고 있습니다.


Part 1: Database Transaction 개념

트랜잭션이란?

트랜잭션은 데이터베이스의 여러 작업을 하나의 논리적 단위로 묶어서 "전부 성공" 또는 "전부 실패"를 보장하는 메커니즘입니다.

트랜잭션이 필요한 이유

트랜잭션이 필요한 이유에 대해 실생활에서 맞닥뜨릴 수 있는 예시를 통해 설명하겠습니다.

실생활 예시: 은행 계좌 이체

내 계좌: 10,000원
친구 계좌: 5,000원

내가 친구에게 3,000원 이체하는 과정:
  1. 내 계좌에서 3,000원 차감 -> 7,000원
  2. 친구 계좌에 3,000원 증감 -> 8,000원

만약 트랜잭션이 없다면 😱

1. 내 계좌에서 -3,000원 성공 ✅
2. [갑자기 서버 다운! 🔥]
3. 친구 계좌 +3,000원 실패 ❌

결과:
  - 내 계좌: 7,000원 (3,000원 사라짐!)
  - 친구 계좌: 5,000원 (받지 못함!)
  - 결론: 3,000원이 증발됨. 데이터 정합성이 깨짐

트랜잭션이 있다면 🤗

트랜잭션 시작
├─ 1. 내 계좌 -3,000원
├─ 2. [서버 다운 발생!]
└─ 3. 친구 계좌 +3,000원 (실행 안됨)
   → 트랜잭션 롤백
   → 모든 작업 취소
   → 원래 상태로 복구

결과:
  - 내 계좌: 10,000원 (원상 복구)
  - 친구 계좌: 5,000원 (원래 상태 유지)
  - 결론: 돈은 안전! 정합성 지켜짐

트랜잭션의 4가지 특성 (ACID)

트랜잭션은 4가지 특성을 갖고 있습니다. 이 각각의 특성의 앞머리 글자를 따서 ACID라고 부르기도 합니다. 트랜잭션을 설명할 때 가장 먼저 배우는 것 중에 하나이기도 합니다. 이 특성들을 억지로 외우기 보다는 그 필요성에 대해 이해하고 왜 이러한 특성이 생겨날 수밖에 없는지 생각해보는 것이 중요합니다.

특성영어의미예시
원자성Atomicity전부 성공 or 전부 실패이체 중 실패 시 모든 작업 취소
일관성Consistency규칙을 항상 지킴총 금액은 항상 일정 (돈이 생기거나 사라지지 않음)
격리성Isolation동시 실행되어도 서로 영향 없음A와 B가 동시에 이체해도 충돌 없음
지속성Durability완료된 결과는 영구 저장커밋 후 서버 다운되어도 결과 유지

트랜잭션의 생명 주기 🔄

1. BEGIN (시작)
     ⬇️
2. 여러 SQL 실행
   ├─ INSERT
   ├─ UPDATE
   ├─ DELETE
     ⬇️
3. 성공 여부 판단
   ├─ 성공 → COMMIT (확정) ✅
   └─ 실패 → ROLLBACK (취소) ❌

Java 코드로 보는 트랜잭션 예시(JDBC):

Connection conn = dataSource.getConnection();

try {
  // 1. 트랜잭션 시작
  conn.setAutoCommit(false);

  // 2. 비즈니스 로직 실행
  Statement stmt = conn.createStatement();
  stmt.executeUpdate("UPDATE account SET balance = balance - 3000 WHERE id = 1");
  stmt.executeUpdate("UPDATE account set balance = balance + 3000 WHERE id = 2");

  // 3. 성공 시 커밋
  conn.commit();
} catch (Exception e) {
  // 4. 실패 시 롤백
  conn.rollback();
} finally {
  // 5. 나머지 처리
  conn.setAutoCommit(true);
  conn.close();
}
  1. 트랜잭션 시작: Connection을 생성(획득)하고 일단 auto_commit=false를 설정하는 것으로부터 트랜잭션이 시작됩니다. auto_commit이 true면, 각 쿼리가 실행될 때마다 데이터베이스에 즉각적으로 반영되기 때문입니다.
  2. 비즈니스 로직 실행: 내 계좌에서 3,000원을 차감하고 친구 계좌에서 3,000원을 증감 처리합니다.
  3. 성공 시 커밋: commit 명령어를 통해 트랜잭션 내에서 수행된 모든 변경 사항을 영구적으로 저장(Durability) 합니다. 이를 통해 "전부 성공" 하도록 하는 것입니다.
  4. 실패 시 롤백: rollback 작업을 통해 트랜잭션을 취소하거나 데이터를 이전 상태로 되돌립니다. 이를 통해 "전부 실패" 하도록 하는 것입니다.
  5. 나머지 처리
    • auto_commit 값을 이전 값으로 되돌림
    • Connection을 다시 Pool에 반환

트랜잭션 없이 코드를 작성하면? ⚠️

// ❌ 나쁜 예: 트랜잭션 없음
public void transfer(Long fromId, Long toId, int amount) {
  // 각 작업이 독립적으로 자동 커밋
  accountRepository.decrease(fromId, amount); // Auto-commit ✅

  // 여기서 예외가 발생하면?
  throw new RuntimeException("오류!");

  accountRepository.increase(toId, amount); // 실행 안됨! ❌
  // 첫번째 작업은 이미 커밋되어 롤백 불가능!
}

현재까지 살펴본 트랜잭션 코드의 문제점

  1. 코드 중복: 모듬 메서드에 try-catch-finally
  2. 비즈니스 로직이 흐려짐: 트랜잭션 코드 때문에 핵심 로직 파악이 어려움
  3. 실수 가능성: commit/rollback 빼먹기 쉬움
  4. 테스트 어려움: 트랜잭션 코드까지 테스트 해야 함
// 😢 트랜잭션 코드가 비즈니스 로직을 압도
public void businessLogic() {
  Connection conn = null;
  try {
    conn = dataSource.getConnection();
    conn.setAutoCommit(false);

    // 실제 비즈니스 로직은 단 2줄
    orderService.createOrder();
    paymentService.processPayment();

    conn.commit();
  } catch (Exception e) {
    if (conn != null) {
      try {
        conn.rollback();
      } catch (SQLException ex) {
        // 롤백도 실패할 수 있음!
      }
    }
  } finally {
    if (conn != null) {
      try {
        conn.close();
      } catch (SQLException e) {
        // close도 예외 처리!
      }
    }
  }
}

지금까지 트랜잭션이 필요한 이유와 이를 Java(JDBC) 코드에서 어떻게 구현하는지 그리고 어떤 장/단점이 있는지도 살펴봤습니다. 이 글의 남은 부분에서는 Java 코드/Spring Framework에서 이러한 트랜잭션 처리 패턴을 어떻게 구현하는지를 살펴보겠습니다.


Part 2: 프록시 패턴 이해

일단 곧바로 Spring Framework로 넘어가기 전에 프록시 패턴AOP 라는 개념에 대해 알아보겠습니다. Spring Framework에서 트랜잭션을 어떻게 처리하는지 이해하기 위한 필수 과정입니다.

프록시 패턴이란?

프록시는 실제 객체를 대신하는 대리인으로, 실제 객체 앞에서 추가 작업을 수행 할 수 있는 디자인 패턴입니다.

왜 프록시가 필요한가?

실생활 예시: 비서(Proxy) vs 사장님(Real Object)

고객이 사장님께 미팅 요청
        ⬇️
비서가 먼저 받음 (Proxy)
        ⬇️
   ├─ 일정 확인
   ├─ 회의실 예약
   ├─ 자료 준비
   └─ 사장님께 전달 (Real Object)
        ⬇️
    미팅 진행
        ⬇️
    비서가 마무리 (Proxy)
    ├─ 회의록 작성
    └─ 후속 조치
  • 핵심: 사장님(Real Object)은 본업에만 집중하고, 부가적인 작업은 비서(Proxy)가 처리!

프록시 패턴 구조

// 1. 인터페이스 (공통 계약)
interface Service {
  void execute();
}

// 2. 실제 객체 (Real Object)
class RealService implements Service {
  @Override
  public void execute() {
    System.out.println("핵심 비즈니스 로직 실행!");
  }
}

// 3. 프록시 객체 (Proxy)
class ServiceProxy implements Service {
  private RealService realService;

  public ServiceProxy(RealService realService) {
    this.realService = realService;
  }

  @Override
  public void execute() {
    // 실제 메서드 호출 "전"에 추가 작업
    System.out.println("[프록시] 사전 처리: 로깅, 보안 체크...");
    
    // 실제 객체의 메서드 호출
    this.realService.execute();

    // 실제 메소드 호출 "후"에 추가 작업
    System.out.println("[프록시] 사후 처리: 리소스 정리...");
  }
}

// 4. 클라이언트 사용
Service service = new ServiceProxy(new RealService());
service.execute();

프록시의 핵심 포인트

클라이언트
    ⬇️
  Proxy (대리인)
    ├─ 사전 처리
    ├─ Real Object 호출
    └─ 사후 처리

중요한 특징:

  1. 같은 인터페이스: 프록시와 실제 객체가 같은 인터페이스 구현
  2. 투명성: 클라이언트는 프록시인지 실제 객체인지 모름
  3. 제어 가능: 실제 객체 호출 전/후로 로직 추가 가능

프록시 패턴 사용 사례

종류목적예시
보호 프록시접근 제어권한 체크 후 실제 객체 호출
가상 프록시지연 로딩실제 필요할 때만 객체 생성
로깅 프록시로그 기록메서드 호출 전후 로깅
트랜잭션 프록시트랜잭션 관리⭐ 여기서 배울 것!

프록시 패턴의 중요한 제약사항

Spring의 프록시는 외부에서 호출될 때만 작동합니다.

// 문제 코드 예시
@Service
public class OrderService {
  public void processOrder() {
    // ❌ 같은 클래스 내부 메서드 호출 - 트랜잭션 적용 안됨!
    this.createOrder();
  }

  @Transactional
  public void createOrder() {
    // 트랜잭션이 시작되지 않음!
    orderRepository.save(order);
  }
}
  • 이유: this.createOrder()는 프록시를 거치지 않고 실제 객체(Real Object)를 직접 호출
  • 해결책
    1. 별도 서비스로 분리 (권장)
    2. Self-injection 사용

Spring과 트랜잭션과의 연결

스프링이 프록시 트랜잭션을 관리하는 방식:

// 개발자가 작성한 서비스 (Real Object)
@Service
class OrderService {
  @Transactional // 이 어노테이션이 마법
  public void createOrder() {
    // 비즈니스 로직만 집중
    orderRepository.save(order);
    paymentRepository.save(payment);
  }
}

// 스프링이 자동으로 생성하는 프록시
class OrderServiceProxy extends OrderService {
  private TransactionManager txManager;

  @Override
  public void createOrder() {
    // 1. 트랜잭션 시작
    txManager.begin();

    try {
      // 2. 실제 메서드 호출
      super.createOrder();

      // 3. 트랜잭션 커밋
      txManager.commit();
    } catch (Exception e) {
      // 4. 예외 시 롤백
      txManager.rollback();
      throw e;
    }
  }
}

👉 개발자가 @Transactional 어노테이션만 붙이면, 스프링이 자동으로 프록시를 만들어서 트랜잭션 처리를 대신해줍니다!


Part 3: AOP 개념 복습

AOP란?

AOP(Aspect-Oriented Programming)는 공통 관심사를 핵심 비즈니스 로직에서 분리 하여 코드 중복을 제거하는 프로그래밍 패러다임입니다.

왜 AOP가 필요한가?

문제 상황: 모든 메서드에 로깅과 트랜잭션 추가

// ❌ AOP 없이 - 코드 중복 지옥
public void method1() {
  log.info("method1 시작");           // 로깅 (공통)
  transactionManager.begin();         // 트랜잭션 (공통)
  try {
      // 핵심 비즈니스 로직
      businessLogic1();
      transactionManager.commit();    // 트랜잭션 (공통)
      log.info("method1 종료");       // 로깅 (공통)
  } catch (Exception e) {
      transactionManager.rollback();  // 트랜잭션 (공통)
      log.error("method1 실패", e);   // 로깅 (공통)
  }
}

public void method2() {
  log.info("method2 시작");           // 똑같은 코드 반복!
  transactionManager.begin();
  try {
      businessLogic2();
      transactionManager.commit();
      log.info("method2 종료");
  } catch (Exception e) {
      transactionManager.rollback();
      log.error("method2 실패", e);
  }
}

// method3, method4... 계속 반복! 😱

AOP로 해결:

// ✅ AOP 사용 - 깔끔한 비즈니스 로직
@Transactional  // 트랜잭션은 AOP가 처리
@Logging        // 로깅도 AOP가 처리
public void method1() {
  // 핵심 비즈니스 로직만!
  businessLogic1();
}

@Transactional
@Logging
public void method2() {
  businessLogic2();
}

AOP 핵심 용어

용어의미스프링 트랜잭션 예시
Aspect공통 관심사 모듈트랜잭션 관리
Join Point적용 가능한 지점메서드 실행 시점
Advice실제 부가 기능 코드begin/commit/rollback
PointcutAdvice를 적용할 위치@Transactional이 붙은 메서드
WeavingAspect를 적용하는 과정프록시 생성

AOP 동작 방식

개발자가 작성한 코드:
┌──────────────────┐
│  @Transactional  │
│  public void     │
│  createOrder() { │
│    비즈니스 로직    │
│  }               │
└──────────────────┘
         ⬇️
   AOP가 자동으로 변환
         ⬇️
실제 실행되는 코드:
┌─────────────────────────┐
│ 트랜잭션 시작               │ ⬅️ Advice (Before)
│   ⬇️                     │
│ 비즈니스 로직 실행           │ ⬅️ Join Point
│   ⬇️                     │
│ 성공 시 커밋 / 실패 시 롤백   │ ⬅️ Advice (After)
└─────────────────────────┘

프록시 패턴 + AOP = 스프링 트랜잭션

스프링 트랜잭션은 프록시 패턴과 AOP가 결합된 결과물입니다. 트랜잭션 관리라는 공통 관심사를 AOP로 정의하고, 이를 프록시 패턴으로 구현합니다. 프록시 패턴이 "비서"라는 구조를 제공한다면, AOP는 "어떤 일을 비서에게 맡길지" 정의하는 역할입니다. 스프링은 이 둘을 결합해 트랜잭션 관리를 자동화합니다.

3단계 연결:

  1. 트랜잭션 관리 = 공통 관심사 (Aspect)
    • 모든 서비스 메서드가 필요함
    • 항상 같은 패턴: begin -> 로직 -> commit/rollback
  2. 프록시로 구현 (Weaving)
    • 실제 객체를 감싸는 프록시 생성
    • 프록시가 트랜잭션 처리
  3. @Transactional = 적용 위치 지정 (Pointcut)
    • 어떤 메서드에 트랜잭션을 적용할지 표시

이 구조를 시각적으로 표현해 보자면:

@Transactional이 붙은 메서드
    ⬇️
AOP가 감지
    ⬇️
Proxy 생성
    ⬇️
Proxy가 트랜잭션 관리
  ├─ begin()
  ├─ 실제 메서드 호출
  └─ commit() / rollback()

Spring AOP의 특징

  1. Runtime Proxy 기반
// 컴파일 타임에는 원본 코드 그대로
@Transactional
public void createOrder() {
  orderRepository.save(order);
}

// Runtime에 프록시가 생성되어 실행
// 개발자는 직접 프록시를 작성할 필요 없음!
  1. 메서드 실행 시점만 지원
// ✅ 지원: 메서드 호출 전후
@Transactional
public void method() { }

// ❌ 미지원: 필드 접근, 생성자 호출 등
  • @Transactional 어노테이션은 class와 method에 선언할 수 있습니다.
  1. Spring Bean에만 적용
// ✅ 적용됨: 스프링이 관리하는 빈
@Service
class OrderService {
    @Transactional
    public void createOrder() { }
}

// ❌ 적용 안됨: new로 생성한 객체
OrderService service = new OrderService();
service.createOrder(); // 트랜잭션 없음!

AOP 없이 vs AOP 사용

코드 비교:

// ❌ AOP 없이 (100줄)
public class OrderService {
    public void createOrder() {
        // 트랜잭션 코드: 15줄
        Connection conn = null;
        try {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);
            
            // 비즈니스 로직: 3줄
            orderRepository.save(order);
            paymentRepository.save(payment);
            emailService.send(email);
            
            conn.commit();
        } catch (Exception e) {
            if (conn != null) conn.rollback();
            throw e;
        } finally {
            if (conn != null) conn.close();
        }
    }
    
    // 다른 메서드 5개도 똑같이 반복...
}

// ✅ AOP 사용 (10줄)
@Service
public class OrderService {
    
    @Transactional
    public void createOrder() {
        // 비즈니스 로직만: 3줄
        orderRepository.save(order);
        paymentRepository.save(payment);
        emailService.send(email);
    }
    
    // 다른 메서드들도 깔끔!
}

효과:

  • 코드 중복 제거: 90% 감소
  • 가독성 향상: 핵심 로직만 보임
  • 유지보수 개선: 트랜잭션 정책 변경 시 한 곳만 수정

지금까지 트랜잭션 개념에 대해 간단히 살펴보고 Spring Framework에서 이 트랜잭션 처리를 어떻게 하는지 간소화 된 예제와 함께 살펴봤습니다. 다음 글에서는 Spring이 트랜잭션 처리를 위해 만들어 놓은 구체 클래스 예시와 함께 좀 더 자세히 알아보도록 하겠습니다.

그럼 끝까지 읽어주셔서 감사합니다!

Comments (0)

Checking login status...

No comments yet. Be the first to comment!