[기술 면접] Spring @Transactional 동작 원리(feat. AOP)

송진영·2023년 7월 31일
1

기술면접

목록 보기
6/24

@Transactional 이란?

@Transactional 어노테이션은 스프링에서 많이 사용되는 선언적 트랜잭션 방식으로, Spring AOP를 이용
해당 어노테이션은 getConnection(), setAutoCommit(false), 예외 발생 시 롤백, 정상 종료 시 커밋 등의 필요한 코드를 삽입해준다.

AOP의 주요 개념

  • Aspect : Advice + PointCut로 AOP의 기본 모듈
  • Advice : Target에 제공할 부가 기능을 담고 있는 모듈
  • Target : Advice가 부가 기능을 제공할 대상(Advice가 적용될 비즈니스 로직)
  • JoinPoint : Advice가 적용될 위치
    • 메서드 진입 지점, 생성자 호출 시점, 필드에서 값을 꺼내올 때 등 다양한 시점에 적용 가능
    • PointCut : Target을 지정하는 정규 표현식

Spring AOP

Spring AOP는 기본적으로 프록시 방식으로 동작한다. 프록시 패턴이란 어떤 객체를 사용하고자 할 때, 객체를 직접적으로 참조하는 것이 아니라, 해당 객체를 대행(대리, proxy)하는 객체를 통해 대상 객체에 접근하는 방식을 말한다.

Spring AOP가 왜 프록시 방식을 사용하는 이유

1. 런타임 시점의 삽입 가능:
Spring AOP는 컴파일 시점이 아니라 런타임 시점에 프록시 객체를 생성하여 부가적인 기능을 주업무 코드에 삽입한다. 이는 AOP를 적용하는 대상 클래스의 바이트코드를 수정하지 않고, 부가적인 기능을 주입할 수 있게 한다. 따라서 애플리케이션을 실행 중에 동적으로 관심사를 적용할 수 있다.

2. 코드 재사용과 모듈화:
여러 클래스나 메서드에서 공통적으로 필요한 기능(예: 로깅, 보안 체크, 트랜잭션 관리 등)을 하나의 공통 모듈로 분리하여 관리할 수 있다. 이를 AOP로 구현한 프록시 객체를 여러 클래스에서 재사용할 수 있으며, 이로 인해 코드 중복을 줄이고, 유지보수성을 향상시킨다.

3. 관심사의 분리:
AOP는 관심사를 주업무 코드와 분리하여 코드의 가독성을 높이고 유지보수를 용이하게 한다. 주업무 코드는 핵심 비즈니스 로직에만 집중할 수 있고, 부가적인 기능은 프록시 객체가 담당하게 된다. 이로 인해 코드의 응집도가 증가하고 결합도가 감소하여 코드의 이해와 관리가 쉬워진다.

  1. 런타임 선택 가능:
    AOP를 프록시 방식으로 구현하면 필요한 관심사를 선택적으로 적용할 수 있다. 어떤 메서드에만 특정 기능을 적용하거나, 특정 조건을 만족하는 경우에만 부가적인 기능을 실행하도록 설정할 수 있다.

요약하면, Spring AOP는 프록시 방식을 사용하여 런타임 시점에서 부가적인 기능을 주업무 코드에 삽입하여 코드의 재사용성과 모듈화를 증가시키고, 관심사의 분리를 통해 코드의 가독성과 유지보수성을 향상시킨다.

@Transactional 동작 원리

스프링에서 사용하는 프록시 구현체는 JDK Proxy(Dynamic Proxy), CGLib 두 가지가 있다.

JDK Proxy와 CGLib Proxy

Spring에서는 몇 가지 설정을 하면 자동으로 Target의 프록시 객체를 생성해주는데, JDK Proxy(Dynamic Proxy)와 CGLib Proxy를 만들 수 있다.

두 방식의 가장 큰 차이점은 Target의 어떤 부분을 상속 받아서 프록시를 구현하느냐에 있다.

JDK Proxy는 Target의 상위 인터페이스를 상속 받아 프록시를 만든다. 따라서 인터페이스를 구현한 클래스가 아니면 의존할 수 없다. Target에서 다른 구체 클래스에 의존하고 있다면, JDK 방식에서는 그 클래스(빈)를 찾을 수 없어 런타임 에러가 발생한다.

CGLib Proxy는 Target 클래스를 상속 받아 프록시를 만든다. JDK 방식과는 달리 인터페이스를 구현하지 않아도 되고, 구체 클래스에 의존하기 때문에 런타임 에러가 발생할 확률도 상대적으로 적다. 또한 JDK Proxy는 내부적으로 Reflection을 사용해서 추가적인 비용이 들지만, CGLib는 그렇지 않다고 한다. 여러 성능상 이점으로 인해 Spring Boot에서는 CGLib를 사용한 방식을 기본으로 채택하고 있다.

Proxy 형태로 동작하는 @Transactional

트랜잭션 처리를 위한 @Transactional 애노테이션은 Spring AOP의 대표적인 예이다.
@Transactional 역시 Proxy 형태로 동작한다. (Spring은 JDK Proxy, Spring boot는 CGLib Proxy를 기본으로 하기 때문에, 사용하는 것에 따라 생성된 프록시 객체 형태는 다를 수 있다.)

  1. target에 대한 호출이 들어오면 AOP Proxy가 이를 가로채서(intercept) 가져온다.
  2. AOP Proxy에서 Transaction Advisor가 commit 또는 rollback 등의 트랜잭션 처리를 한다.
  3. 트랜잭션 처리 외에 다른 부가 기능이 있을 경우 해당 Cusotm Advisor에서 그 처리를 한다.
  4. 각 Advisor에서 부가 기능 처리를 마치면 Target Method를 수행한다.
  5. interceptor chain을 따라 caller에게 결과를 전달한다.

코드 레벨로 보자면 아래와 유사한 작업이 이루어진다.

public class TransactionProxy{
    private final TransactonManager manager = TransactionManager.getInstance();
		...

    public void transactionLogic() {
        try {
            // 트랜잭션 전처리(트랜잭션 시작, autoCommit(false) 등)
			manager.begin();

			// 다음 처리 로직(타겟 비스니스 로직, 다른 부가 기능 처리 등)
			target.logic();
          
			// 트랜잭션 후처리(트랜잭션 커밋 등)
            manager.commit();
        } catch ( Exception e ) {
			// 트랜잭션 오류 발생 시 롤백
            manager.rollback();
        }
    }

@Transactional 주의 사항

1. private은 트랜잭션 처리를 할 수 없다.

앞서 트랜잭션이 코드 레벨에서 어떻게 동작하는지 대략적으로 살펴봤다. 프록시 객체는 타겟 객체/인터페이스를 상속 받아서 구현하는데, private으로 되어 있으면 자식인 프록시 객체에서 호출할 수 없다. 따라서 @Transactional이 붙는 메서드, 클래스는 프록시 객체에서 접근 가능한 레벨로 지정해야 한다.

public void test1() {
  test2();
}

@Transactional
public void test2() {
  // 상위 메서드인 test1이 타깃 오브젝트가 되어 트랜잭션 적용 안됨
}

2. 트랜잭션은 객체 외부에서 처음 진입하는 메서드를 기준으로 동작한다.

다음과 같이 A, B, C의 메서드가 있다고 가정하자.

  • A 메서드는 쿠폰을 생성하는 메서드이고, 트랜잭ㅈ션이 적용되어 있다.
  • B 메서드는 A 메서드를 3번 호출하고, 트랜잭션이 적용되어 있지 않다.
  • C 메서드도 A 메서드를 3번 호출한다. 하지만 C에는 트랜잭션이 적용되어 있다.
@Service
public class TestService {

	@Autowired
	CouponGroupMapper couponGroupMapper;

	@Transactional
	public void A(CouponGroupParam param) {
		param.setStatus(CouponGroupStatus.CREATED);	// 상태 변경
		couponGroupMapper.insertCouponGroup(param);
	}

	public void B() {
		for(int i=0; i<3; i++) {
			CouponGroupParam param = new CouponGroupParam();
    		param.setName("1000포인트 쿠폰");
    		param.setAmount(1000);
    		param.setMaxCount(100);
    		param.setValidFromDt(new Date());
    		param.setValidToDt(new Date());
    		param.setIssuerId("0101");
    		param.setCode("B000" + i);
		
			A(param);
		}
	
		throw new RuntimeException(); // 오류 발생!
	}

	@Transactional
	public void C() {
		for(int i=0; i<3; i++) {
			CouponGroupParam param = new CouponGroupParam();
    		param.setName("1000 포인트 쿠폰");
    		param.setAmount(1000);
    		param.setMaxCount(100);
    		param.setValidFromDt(new Date());
    		param.setValidToDt(new Date());
    		param.setIssuerId("0101");
    		param.setCode("C000" + i);
	
			A(param);
		}

		throw new RuntimeException(); // 오류 발생!
	}
}

B, C 메서드 모두 정상적인 경우라면 쿠폰 3개를 신규 생성한다. 그렇다면 B와 C 메서드는 동일한 기능을 한다고 볼 수 있을까?

클래스에 @Transactional 처리가 되어 잇는 부분(A, C 메서드)이 있다면, Spring은 해당 부분에 트랜잭션 처리를 추가한 프록시를 자동으로 생성한다. 그리고 외부에서 호출하면 원래 클래스가 아닌 프록시가 대신 호출된다.

  1. C 메서드를 호출하면, TestService가 아닌 TestService의 프록시에 구현된 C 메서드가 대신 호출된다. 따라서 C와 C에서 호출하는 A 모두 프록시 객체에서 트랜잭션 처리를 해준다.
  1. 하지만 B 메서드를 호출하는 것은 트랜잭션 처리가 되어 있지 않은 순수 B 메서드를 호출하는 것과 같다. 이때 B에서 호출하는 A 역시 트랜잭션 처리가 되어 있지 않다.

결과적으로 트랜잭션은 객체 외부에서 처음 진입하는 메서드를 기준으로 동작한다는 사실을 알 수 있다.

profile
못하는 건 없다. 단지 그만큼 노력을 안 할 뿐이다.

0개의 댓글