AOP란 무엇인가?

AOP란 Aspect Oriented Programming의 약자로 관점 지향 프로그래밍이라는 의미를 가집니다. 스프링에서는 이러한 AOP 기능을  제공합니다. AOP를 간단하게 설명하자면 인터셉터와 필터와는 다르게 내가 원하는 코드에 접근할때 공통으로 처리하는 전후 처리 기능을 구현하는 것이라고 생각하면 편리합니다. @Transactional 또한 AOP를 활용하여 구현된 스프링의 어노테이션입니다. 이번 글에서는 AOP를 활용하여 어노테이션 없이 트랜잭션을 보장하는 방법을 알아보고자 합니다. AOP에 대한 개념은 쉽게 이해하기 어렵기 때문에 자세한 내용은 추후에 다루도록 하겠습니다.

 

@Transactional을 제외하고 만드는 이유가 무엇인가?

여기서 만들고자 하는 것은 @Transactional 어노테이션 없이 Service 단에서만 트랜잭션을 보장받는 방법으로 굳이 어노테이션을 제거하고 만드는 이유는 프레임워크와 연관이 있습니다. 기존 업무에서 사용하던 상용 프레임워크에서는 비즈니스 로직이 동작하는 서비스 단에서만 트랜잭션이 보장되는 것을 확인할 수 있었는데, 이는 프레임워크에서 트랜잭션이 서비스단에서만 적용되도록 커스텀하였기 때문에 어노테이션 없이 동작하는 것이었습니다. 어노테이션을 굳이 제거하려는 이유는 모든 개발자가 @Transactional 어노테이션을 빼놓지 않고 쓰면 상관이 없지만, 대형 프로젝트의 경우 모든 개발자가 그것을 인식해서 사용하지 않기 때문에 애초에 프레임워크에서 보장을 해주자는 취지였습니다. 그렇기 때문에 이러한 업무 경험을 토대로 이번 프로젝트에서도 또한 서비스 단에서는 @Transactional 없이 트랜잭션을 보장해줘야 한다는 요구사항이 나왔고, 제가 담당하는 업무는 아니였지만 이러한 문제를 AOP로 해결할 수 있을 것 같아 AOP 클래스 파일을 작성하여 적용 할 수 있게 되었습니다. 이러한 내용을 공유하고자 글을 작성하게 되었으며, 아래와 같은 예제를 기반으로 설명을 드리고자 합니다.

 

 

샘플 프로젝트 동작 예제 - 트랜잭션이 보장되지 않는 경우

SampleService.java 함수 코드

	public void updateSampleServiceImpl(SampleInVo sampleInVo) throws Exception {	
               /**
		 * 수정 쿼리 호출
		 */		
		int result = dbMapper.sampleUpdate(sampleInVo);
		
		/**
		 * DB 접근 후 예외 발생! - 트랜잭션 보장시 롤백
		 */
		if(true) {
			throw new CustomException("CUSTOM00001", "임의 오류");
		}
   	}

위 코드는 Mybatis의 Mapper를 사용한 DB UPDATE 코드와 DB 접근 이후 임의의 오류를 발생시키는 코드입니다. 위처럼 동작시 트랜잭션이 보장되지 않는 경우 트랜잭션이 롤백되지 않고, 커밋을 수행합니다. 그 경우 예시는 아래 화면과 같습니다.

 

Sample.html

위와 같이 오류가 발생하였음에도 15 -> 20으로 수정하는 업데이트 쿼리가 적용이 된 것을 볼 수 있습니다. 서비스 코드 내에서 오류가 발생했음에도 DB에 적용이 되는 것은 실무에 있어서는 치명적인 문제로 이어지게 됩니다. 이렇듯 트랜잭션을 보장해주는 역할이 중요하기 때문에 @Transactional을 넣지 않은 것을 운영까지 가서 알게 된다면, 실무에서는 돈과 연결되는 리스크로 이어지기 때문에 이런 리스크를 제거하고자 하는 것입니다. 그렇기 때문에 다음으로 트랜잭션을 적용하기 위한 코드들을 만들어볼 것입니다.

 

 

AOP 클래스 작성

서비스 단에서 트랜잭션을 적용하기 위해 3개의 클래스를 만들것입니다. 

 

TransactionContextHolder.java

import org.springframework.transaction.TransactionStatus;

public class TransactionContextHolder {

    private static final ThreadLocal<TransactionStatus> transactionStatusHolder = new ThreadLocal<>();

    public static TransactionStatus getTransactionStatus() {
        return transactionStatusHolder.get();
    }

    public static void setTransactionStatus(TransactionStatus transactionStatus) {
        transactionStatusHolder.set(transactionStatus);
    }

    public static void clearTransactionStatus() {
        transactionStatusHolder.remove();
    }
}

해당 클래스는 ThreadLocal 변수를 사용하여 TransactionStatus의 트랜잭션 상태를 유지해주는 코드입니다. 트랜잭션의 상태를 유지한다는 의미는 멀티 쓰레드 환경에서 서비스 동작시 각 쓰레드가 전역변수 등에 의한 문제 없이 A 쓰레드, B쓰레드의 일관 된 처리를 보장해준다는 의미입니다. 쓰레드 로컬을 사용하지 않는 경우, A 트랜잭션의 쓰레드, B 트랜잭션 쓰레드가 동시 발생시 A가 B의 처리 혹은 B가 A의 처리에 영향을 주는 경우가 생깁니다. 이러한 문제를 방지하는 것이 쓰레드 로컬의 역할입니다.

 

 

CustomTransactionAspect.java

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;


@Aspect
@Component
public class CustomTransactionAspect {

    @Autowired
    private PlatformTransactionManager transactionManager;

    // com.sample 하위 패키지의 ServiceImpl 명을 가지는 프로그램 실행시 동작하는 AOP 메소드
    @Around("execution(* com.sample..*ServiceImpl*.*(..))")
    public Object manageTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        // 전 처리 : 트랜잭션 시작
        TransactionStatus transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());
        TransactionContextHolder.setTransactionStatus(transactionStatus);

        try {
            // 호출 할 메서드 호출
            Object result = joinPoint.proceed();

            // 후처리 : 트랜잭션 커밋
            transactionManager.commit(transactionStatus);
            return result;
        } catch (Exception ex) {
            // 서비스에서 예외 발생 시 트랜잭션 롤백
            transactionManager.rollback(transactionStatus);
            throw ex; // 예외를 다시 던져서 상위 레벨에서 처리할 수 있도록 함
        } finally {
            TransactionContextHolder.clearTransactionStatus();
        }
    }
}

위 코드에서 @Aspect는 이 클래스가 AOP 클래스인것을 명시합니다.

@Around 어노테이션은 AOP를 어떤 패키지 경로에 적용할지 지정하는 것으로 위에서는 com.sample 하위 경로의 클래스명이 ServiceImpl가 포함된 클래스에 AOP를 적용시키겠다는 의미입니다.

ProceedingJoinPoint 객체는 실제로 호출할 메소드 정보를 담고 있으며, manageTransaction 안에서 동작하는 함수는 CGLIB 혹은 JDK 다이나믹 프록시로 동작하여 기존의 호출을 인터셉터 하여 동작하는 방식으로 함수가 동작합니다. 그렇기 때문에 이 함수 내에서 전처리,후처리를 할 수 있으며 구현한 함수 내에서 전처리로 트랜잭션을 시작하고 joinPoint.proceed()를 통해 실제 호출 함수를 동작 시킵니다. 동작 후 다시 후처리로 넘어와 트랜잭션을 커밋 혹은 롤백 시키는 전후처리를 구현하였습니다.

이렇게 내가 원하는 클래스 혹은 패키지 경로에 전후 처리를 구현하는 것을 AOP(관점 지향 프로그래밍) 이라고 합니다.

마지막으로 작성할 클래스는 AOP를 사용할 것이라는 Config 클래스로 @Aspect 코드를 찾아주는 클래스입니다.

 

AopConfig.java

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@Configuration
@EnableAspectJAutoProxy
@ComponentScan("com.ibks.cwcore.common.aop") // CustomTransactionAspect가 속한 패키지 지정
public class AopConfig {
    // 추가적인 설정이 필요한 경우 작성
}

 

위 코드는 설명하면 @ComponentScan으로 실제 동작 시킬 AOP 클래스 경로를 지정하고, @Configuration을 통해 스프링의 환경설정 파일로 등록하겠다는 의미입니다. 마지막으로 @EnableAspectJAutoProxy는 AOP 사용을 위한 프록시를 사용하겠다는 의미로 위와 같은 설정에서는 기본 값 설정으로 JDK 다이나믹 프록시를 사용하며, @EnableAspectJAutoProxy(proxyTargetClass = true)로 옵션을 줄 시 CGLIB를 사용하게 됩니다. JDK 다이나믹 프록시와 CGLIB의 개념 또한 프록시의 어려운 개념이기에 다음에 따로 정리하도록 하겠습니다. 현재로써의 가장 큰 차이점은 JDK 다이나믹 프록시는 인터페이스를 구현한 클래스에 대해서만 프록시 객체를 생성하는 의미라고 하며, CGLIB는 모든 클래스에 대해서 프록시 객체를 생성하겠다는 뜻입니다.

이렇듯 3개의 클래스를 작성하게 되면, ServiceImpl이 포함된 클래스 안의 메소드에서는 @Transactional 없이 트랜잭션이 보장 되는 것을 알 수 있습니다. 아래는 적용된 코드의 예시입니다.

 

샘플 프로젝트 동작 예제 - AOP 클래스 적용 후

Sample.html

AOP 적용 후 똑같은 케이스로 UPDATE 후 예외 발생시, 트랜잭션이 롤백 처리 되어 트랜잭션의 일관성이 보장되는 것을 확인할 수 있습니다.

 

정리

이번 글에서는 스프링에서 트랜잭션을 보장해주는 @Transactional 어노테이션 없이 함수의 트랜잭션을 보장받는 방법을 알아보았습니다. 트랜잭션의 전파 속성 등을 고려하면 아직 초안이라고 생각합니다. 하지만 이러한 방법을 잘 완성하였을때에는 코드의 반복성을 줄일 수 있고, 인적 리스크를 줄일 수 있습니다. 그렇기 때문에 다른 프레임워크에서 지원하는 기능을 비슷하게나마 만들어 현재 프로젝트에 적용하게 되었습니다. 트랜잭션 처리를 위한 전후 처리 이외에도 이러한 AOP는 클래스별 로깅, 공통 처리 등이 가능합니다. 저 또한 아직 기본적인 개념만 알기 때문에 꾸준히 공부하여 효율적인 아키텍처를 만들 수 있는 고급 개발자가 되고 싶습니다. 글에 대해서 부족한 점이나 수정해야 할 점이 있을때에는 말해주시면 수정 보완하도록 하겠습니다. 읽어주신 분들께 감사드립니다.

 

 

스프링 프레임워크는 @Transactional 어노테이션을 제공하여 트랜잭션을 지원합니다. @Transactional의 옵션 중  isolation level 옵션은 무엇이고, 어떠한 경우에 필요한 개념인지 뱅킹 프로세스에 자주 사용되는 입출금 예제를 기반으로 설명하고자 합니다.

 

Isolation Level

일반적으로 어플리케이션에서 서비스가 실행 되면 프로세스가 생성되고 각각 쓰레드가 할당 됩니다. 이러한 쓰레드들이 동시에 같은 서비스에 접근하여 SELECT , UPDATE 등의 작업을 공유하게 되었을 경우 예외적인 경우가 발생합니다. 이러한 문제를 해결하기 위하여 격리 수준(Isolation Level)을 지정하며 쓰레드의 작업, 즉 트랜잭션을 어느 정도까지 공유할 것인지 정의 해주는 것이 해당 옵션입니다. Isolation Level은 크게 4가지로 분류됩니다.

 

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

READ UNCOMMITTED

READ UNCOMMITTED는 COMMIT 여부에 상관없이 모든 트랜잭션이 자원을 공유하는 레벨로 트랜잭션의 가장 낮은 격리 수준입니다. 해당 레벨로 설정하게 될 경우 위와 같은 ROLLBACK 상황에서 잘못 된 데이터를 읽게 되는데 이를 Dirty Read(더티 리드)라고 합니다. 스프링에서는 아래와 같이 작성하여 적용합니다.

@Transactional(isolation = Isolation.READ_UNCOMMITTED)

 

READ COMMITTED

READ COMMITTEDCOMMIT된 데이터만 읽어오겠다는 설정으로 트랜잭션A 내에서 UPDATE가 이루어지더라도 COMMIT 되지 않았다면, 트랜잭션B는 트랜잭션A의 COMMIT 이전 시점의 데이터를 읽어옵니다. 해당 옵션을 사용하게 되면 DIRTY READ 문제를 해결할 수 있지만 동시성 문제를 해결하지 못합니다. 위와 같이 COMMIT 되기 전 트랜잭션 B가 접근하게 된다면, 트랜잭션A의 작업이 끝나고 트랜잭션B의 작업이 종료되는 경우가 발생하여 이전 작업이 덮어씌워지게 될 수 있습니다. 그 외에도 READ COMMITTED에 생길 수 있는 문제로는 Phantom Read와 Non-Repeatable Read 문제가 발생하는데 이 부분은 자세히 다루지 않도록 하겠습니다.

@Transactional(isolation = Isolation.READ_COMMITTED)

REPEATABLE READ

REPEATEABLE READ는 트랜잭션 내에서 항상 동일한 조회를 보장하는 설정으로 Non-Repeatable Read 문제가 발생하지 않는 격리 수준입니다. 이는 동일한 트랜잭션에서 재조회시 조회 내용을 보장해주는 것이지만, 위와 같은 동시성 문제를 해결하지는 못합니다.

@Transactional(isolation = Isolation.REPEATABLE_READ)

 

SERIALIZABLE

SERIALIZABLE은 SELECT가 사용되는 모든 데이터에 트랜잭션이 끝날때까지 Shared Lock(공유 락)을 걸어 동기 처리를 지원하는 레벨로 트랜잭션 A와 트랜잭션 B가 동시에 실행된다면 완전한 격리 수준으로써 트랜잭션A가 종료되고 트랜잭션B가 실행될 수 있습니다. 이러한 레벨을 사용하면 동시적인 처리에 대해서 해결할 수 있지만, 동기 처리를 지원하기 때문에 트래픽이 많은 서비스에서는 속도를 보장해주지 못할 수 있습니다.

@Transactional(isolation = Isolation.SERIALIZABLE)

 

정리

지금까지 계좌입출금 예제를 통해 동시성 처리를 위주로 트랜잭션 격리 수준에 대해 정리해보았는데요. 실무에서 isolation 옵션을 사용하면서 개발자로써 중요한 내용이라는 생각이 들어 글을 작성하게 되었습니다. 입출금을 예제로 든 이유는 이 업무를 해보고 싶어 예제로 정리하면 괜찮지 않을까 하여 주제로  정하게 되었습니다. 현업에서는 이러한 입출금 트랜잭션 처리를 어떻게 하는지 궁금하며, 혹시 실무를 경험한 분들이 비슷한 처리 경험을 공유해주신다면 감사하겠습니다. 부정확한 내용이 포함되어 있는것 또한 댓글로 남겨주시면 수정하도록 하겠습니다. 앞으로도 공유하고 싶은 지식이 있을시 글로 정리하고자 하며 많은 분들이 봐주셨으면 좋겠습니다.

+ Recent posts