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

이번 글은 실무에서 JavaMailSender 클래스를 사용하여 메일 내용을 text/html로 보내고자 하였으나, text/plain으로 전송되던 문제를 해결하였던 경험을 공유하고자 하는 목적에서 작성되었습니다.

정확히는 메일 전송시 Content-Type이 text/html로 바뀌지 않는 문제가 있었고, 여러가지 방법을 사용 해본 결과  text/html로 잘 변형되었던 예제 코드를 찾을수 있게 되어 공유하게 되었습니다.

import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import javax.mail.internet.MimeMessage;

@Slf4j
@Service
@AllArgsConstructor
public class EmailUtil {
	private final JavaMailSender javaMailSender;
    
    /**
     * html 내용 메일
     * @param mailDto
     */
    public void sendMailHtml(EmailDto mailDto) throws Exception {
        MimeMessage mimeMessage = javaMailSender.createMimeMessage();

        MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage,true,"UTF-8");

        mimeMessageHelper.setTo(mailDto.getToAddress()); //받는사람
        mimeMessageHelper.setFrom(mailDto.getFromAddress()); //보내는사람
        mimeMessageHelper.setSubject(mailDto.getTitle()); // 제목
        mimeMessageHelper.setText(mailDto.getContent(),true); // 내용

        javaMailSender.send(mimeMessage);
    }
}

위 소스처럼 MimeMessage 를 생성하여 MimeMessageHelper의 생성자로 생성해줍니다.

mimMessageHelper에 mimMessage에 세팅할 값들을 설정해준 후, mimeMessage를 변수로 메일을 전송해줍니다.

위 방식을 적용하였을때  text/plain으로 전송되던 이메일이 text/html로 전송되던 것을 확인할 수 있었습니다.

같은 문제를 고민하는 분들이 이 글을 보고 원활히 해결했으면 하는 바람에서 간단하게 글을 작성해보았습니다.

궁금한점이 있으시다면 댓글로 남겨주시면 감사하겠습니다.

메시지 국제화를 적용하기 전, 스프링에서 제공하는 메시지소스(MessageSource)를 이해하여야 합니다.

스프링 메시지 소스(MessageSource)란?

메시지 소스를 사용하는 이유는 기존에 문자열 하드코딩으로 사용자 및 프론트 개발자에게 제공되는 결과,오류 메세지를 일종의 템플릿 형태로 제공하여 유지보수의 효율성을 높여주고 일관성을 보장해주는 효과가 있습니다. 이러한 기능을 스프링에서 제공합니다.

Controller

    @GetMapping("/literal")
    public String literal(Model model){
        model.addAttribute("data","Spring!");
        return "basic/literal";
    }

    @GetMapping("/operation")
    public String operation(Model model){
        model.addAttribute("nullData",null);
        model.addAttribute("data","Spring!");
        return "basic/operation";
    }

예를 들어 위의 Model에 데이터 전송시 data 오브젝트의 값으로  Spring! 이라는 메세지를 리턴해주기로 하였다면, 현재 코드는 하드코딩으로 문자열 "Spring!" 을 리턴해줍니다. 이 문자열 메시지를 수정하려면 /literal과 /operation 2개의 소스를 수정해줘야 합니다. 단순히 2개라면 상관이 없지만, 실무에서 100개 이상의 "Spring!" 문자열을 수정해달라고 하면 유지보수에 상당히 큰 자원을 소모하게 됩니다. 그렇기 때문에 하드코딩이 아닌 메시지소스라는 공통화 된 모듈을 사용한다면 해당 모듈만 수정하면 100개의 "Spring!" 문자열이 아닌 하나의 모듈 안의 메시지를 수정하면 모든 참조되는 소스들도 자연스레 수정이 됩니다. 위의 소스를 스프링 메시지 소스를 적용하여 수정해보겠습니다.

 

스프링 메시지 소스 설정

스프링의 메시지 설정은 application.properties에 기본적으로 default 설정 되어 있습니다.

메시지 설정 파일의 이름을 messages로 정의한다는 의미로  messages.properties를 읽겠다는 의미입니다. 그렇다면 messages.properties는 어디에 생성해야 할까요?

기본적으로 resources 루트 디렉터리에 messages.properties 파일을 생성해주시면 됩니다.

위 사진에서 messages 프로퍼티가 2개로 나눠진 이유는 기본 설정의 메시지 설정과 언어가 영어로 설정되었을때 읽어오는 설정 파일을 나누기 위해 작성되었습니다. 스프링은 HTTP Request의 accept-language 헤더 값을 읽어 위 설정 중 하나를 읽어와 언어별로 메시지를 다르게 설정해줍니다. 이러한 메시지 소스의 설정을  메시지 국제화라고 합니다.

 

Encoding 설정

경로 : File-Settings-Editor-File Encodings에서 Encoding 설정을 UTF-8로 맞춰주지 않을 경우 메시지 소스가 적용되지 않을수 있습니다.

위와 같이 Global Encoding, Project Encoding, Properties Files를 모두 UTF-8로 맞춰줘야 합니다.

스프링 메시지 기본 설정 인코딩이 UTF-8로 설정되어 있기 때문입니다.

 

스프링 메시지 소스 적용

messages-properties

위와 같이 messages.properties에서 메시지를  key-value 형식으로 메시지를 지정합니다.

 

Controller

MessageSource를 선언해줍니다.

 messages.properties에 있는 key 값인 spring-default를 넣어줍니다.

위와 같이 코드를 수정하였을때 요건이 변경되어 메시지를 수정해야 한다고 하였을때 Controller의 소스를 건들일 필요가 없으며, messages.properties의 메시지만 수정해줌으로써 유지보수를 훨씬 쉽게 할수 있으며 의존성을 나누어 외부에서 메시지를 관리하도록 소스가 설계됩니다.

 

 

그렇다면 메시지 국제화는 어떻게 쓰일까요? 아래의 테스트 코드를 통해 간략하게 설명할수 있습니다.

messges.properties

messges_en.properties

MessageSourceTest (JUnit 테스트 클래스)

MessageSource 인터페이스 getMessage 함수의 첫번째 인자는 불러올 메시지 key 값, 두번째 인자는 해당 key 메시지의 value에 포함될 파라미터, 세번째 인자는 메시지 설정 언어를 설정하는 부분입니다. Locale이 null이거나 KOREA일때는 messages.properties를 참조하는것을 알 수 있고, Locale.ENGLISH일때는 messages_en.properties를 읽어 영문 메시지 설정을 성공적으로 읽어오는 결과를 볼 수 있었습니다.

 

CustomException 메시지 설정

실무에서 스프링 메시지 소스 설정을 가장 많이 사용되는 경우는 오류 처리시 message를 반환해주는 경우입니다.

해당 내용은 추후에 상세하게 다뤄보기로 하겠습니다.

+ Recent posts