-
@Async, 생각보다 까다롭다.Java & Kotlin 2021. 11. 1. 00:53반응형
1) 서론
요즘 @Async를 어느 때보다 많이 사용하고 있습니다.
사실 그동안 간단하게 애노테이션과 thread pool 설정만 하면 된다고 생각했습니다. 하지만 실제로 사용할 때 예상하는 것과 다른 흐름으로 동작하고, 은근히 까다롭다는 것을 느꼈습니다.
제가 사용하며 만난 문제점과 해결 과정을 기록합니다.
2) 문제의 코드
전체적인 콘셉트는 간단합니다. 파싱 해온 데이터를 원하는 방향으로 가공합니다.
이때 이름, 가격을 나눠서 가공하고 비동기 처리합니다.
public class MainClas { public void trimAndAddWines(List<WebElement> names, List<WebElement> prices) { log.info("\n === trimAndAddWines() 시작 ==="); // @Async wineNameList(names); winePriceList(prices); } // 파라미터, body 생략 // 파싱 데이터 가공 @Async private void wineNameList(){}; @Async private void winePriceList(){}; }
... 생략 <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern> %d{HH:mm} [%t] %-5level %logger{36} - %msg%n </pattern> </encoder> </appender> ... 생략
- [%t] - 현재 실행 중인 Thread ID 출력
3) 문제 발생
==== trimAndAddWines() 시작 ==== // Thread ID: exec-1 [http-nio-8080-exec-1] ==== wineNameList() ==== ... wineNameList() 종료 기다림 ... // Thread ID: exec-1 [http-nio-8080-exec-1] ==== winePriceList() ====
- exec-1 하나의 스레드 사용
- 하나의 작업이 끝나기를 기다리는 동기 실행
각각의 스레드로 등록된 후 비동기 동작을 할 것이라는 예측과 다릅니다.
왜 그럴까요?
4) @Async의 조건
@Async를 사용하기 위해서는 아래의 조건이 만족되어야 합니다.
- public 메서드에만 적용된다
- self-invocation은 안된다 (같은 클래스의 메서드 호출 불가)
위의 두 가지 조건을 이해하기 위해 AOP와 프록시 패턴에 대한 이해가 필요합니다.
4. 1) AOP
@Async는 스프링 AOP에 의해 프록시 패턴 기반으로 동작합니다.
사실 AOP는 스프링 프레임워크에만 존재하는 개념은 아닙니다. OOP와 같이, 하나의 프로그래밍 방법인데요. 위키피디아에서는 AOP를 "cross-cutting concerns를 통해 모듈화를 향상하는 것"이라고 설명합니다.먼저 아래의 두 용어를 정리합니다.
- concern: 관심사
- aspect: 각각의 관심사들을 묶어 모듈화 한 것
보통 cross-cutting concerns를 "공통 관심사"라고 표현하는데요. 이는 영어단어와 그림을 통해 이해할 수 있습니다.
crosscut은 "가로로 자르는" 의미를 가지고 있습니다. 아래의 그림에서 보듯이 여러 부분에서 공통으로 발생하는 concern들이 있고, 이들을 가로로 잘라 aspect로 모듈화 합니다.
그리고 이 aspect를 "어디에", "언제", "어떻게" 적용할 것인지 개발자가 정해야 하는데요.
스프링 AOP는 @Async 메서드를 특정 시점에 가로채고, 프록시 객체를 만들어 새 스레드에 추가합니다. 이때 해당 메서드를 가로채고, 스레드에 등록해주는 것들이 aspect를 모듈화 한 것입니다.
@Async 메서드는 TaskExcuter 빈을 통해서 thread pool에 등록됩니다. Task는 독립적으로 실행 가능한 하나의 작업을 말하는데요. 이때 TaskExuter가 실행시켜주는 대상은 Runnable 인터페이스 타입의 객체입니다. 즉 하나의 작업을 별도의 thread로서 동작시켜주는 것을 알 수 있습니다.
스프링 AOP는 객체가 IoC 컨테이너에 의해 빈으로 등록되는 시점에 가로채서, 프록시 객체로 만들어줘야 합니다.하지만 실제(타깃) 메서드가 private 타입이면, 프록시 객체에서 사용할 수 없습니다. 왜냐하면 프록시 객체도 결국 다른 형태의 클래스일 뿐입니다. 즉 외부 클래스에서 참조하려면, 당연히 public이어야 합니다.
추가적으로 프록시는 self-invocation 경우 접근하지 못합니다. 여기서 self-invocation은 같은 객체의 다른 메서드를 참조하는 것을 말하는데요. 외부(external) 혹은 다른 객체의 메서드만 참조해야 합니다. 같은 클래스 안에서 다른 메서드에 @Async를 추가하더라도, 실제로는 프록시가 접근하지 못하고 적용되지 않습니다.
그렇다면 프록시 패턴이 뭘까요?4. 2) 프록시 패턴
프록시 패턴이란 프록시 객체, 실제(target) 객체가 공통의 인터페이스를 구현합니다.
여기서 프록시 객체는 실제 객체(target)로의 "위임(delegation)" 역할만 담당하게 되는데요. 여기서 "위임"이라는 것은 실제 객체를 호출하여, 사용한다는 의미입니다. 이때 실제 객체의 값을 조작하거나 변경하면 안 됩니다.
"인터페이스 --> 프록시 객체 --> 실제 객체" 방식으로 호출합니다.
즉 인터페이스 타입으로 프록시 객체를 호출합니다. 그리고 프록시 객체는 앞서 언급한 대로 "위임"의 역할만 하고, 실제 객체를 호출합니다. 이때 중요한것은 단순히 호출만 하는것이 아니라 위임 전, 후 추가적인 동작을 할 수 있습니다.
그렇다면 프록시 패턴의 장점은 뭘까요?
앞서, 프록시 패턴은 위임 전, 후 추가적인 동작을 한다고 언급했는데요. 만약 실제로 수행되어야 할 동작의 크기가 아주 크다면, 메모리에 할당되고 실제 수행되는데 시간이 걸릴 수밖에 없습니다. 이를 사용자가 기다려야 한다면, 매우 지루한 시간이 될 텐데요.
이때 프록시 패턴을 사용하여 실제 수행되어야 하는 동작 전 캐싱된 데이터를 미리 보내줄 수 있습니다. 즉 사용자가 전체 데이터가 로드될 때까지 기다리는 것이 아닌, 캐싱된 데이터를 바로 응답받고 사용하여 응답성 측면에서 큰 이득을 가질 수 있습니다.
추가적으로 인터페이스를 참조하고 노출함으로써, 실제 수행되는 객체의 내용을 숨길 수 있습니다. 그리고 실제 객체가 수행되기 전 유효성 검증을 통해 보안적 측면에서의 장점도 있습니다.4. 2. 1) 프록시 패턴 예시
public interface TestInterface { method1(); } public class TargetClass implements TestInterface { @Override public void method1() { System.out.println("Target"); } } public class ProxyClass implements TestInterface { @Override public void method1() { System.out.println("hello"); TargetClass target = new TargetClass(); } } public class MainClass() { public static void main() { // 인터페이스 타입의 프록시 객체 초기화 TestInterface testInterface = new ProxyClass(); testInterface.method1(); // 프록시 객체의 method1() 사용 } }
4. 3) 스프링 프레임워크의 프록시 빈 생성
스프링 프레임워크에서는 개발자가 직접 프록시 패턴을 작성하지 않습니다. 앞서 언급했듯이, 스프링 AOP가 런타임 시점에 프록시 빈을 대신 생성해주는데요.
만약 프로그래머가 직접 프록시 객체를 작성한다면, 아래와 같을 것입니다.
하지만 인터페이스를 빈으로 주입받을 때 어떤 메서드를 참조하는지 알 수 없습니다. 그래서 스프링 AOP에서는 프레임워크가 프록시 객체를 대신 생성하고 처리합니다.
public interface TestInterface { method1(); } public class TargetClass implements TestInterface { @Override public void method1() { System.out.println("Target"); } } public class ProxyClass implements TestInterface { @Override public void method1() { System.out.println("hello"); TargetClass target = new TargetClass(); // 실제 객체 target.method1(); } } public class MainClass() { public static void main() { @Autowired private final TestInterface testInterface; // 인터페이스 testInterface.method1(); // 프록시, 타겟 객체 중 어떤 method1()? } }
이때 프록시 빈을 생성하게 해주는 JDK Dynamic Proxy와 CGLIB 두 가지 방법이 있습니다.
JDK Dynamic Proxy는 반드시 인터페이스를 구현하여 프록시 객체를 생성해줍니다. 그리고 CGLIB는 클래스 타입으로 프록시를 생성하고, 스프링 AOP의 기본 설정입니다. 기본 설정은 CGLIB임에도 불구하고 인터페이스 타입으로 객체를 참조한다면, JDK Dynamic Proxy를 사용하게 됩니다.
하지만 이때의 문제가 있습니다. 만약 클래스 타입으로 프록시 객체를 생성할 때 실제 객체가 final 타입이거나, private 생성자 타입이라면 프록시 객체 생성이 안됩니다. 이때는 반드시 인터페이스 타입으로 프록시 객체를 생성해야 합니다. 이는 CGLIB가 AOP의 기본 설정이고, 클래스 타입으로만 프록시 객체를 생성하기 때문입니다.
스프링 관련 문서에서 강조하는 것 중 하나가 인터페이스를 빈으로 주입받고, 객체를 구현하는 것입니다. 이는 각 계층의 결합도를 낮추는 것에도 목적이 있지만, 스프링 AOP의 특성에도 이유가 있을 것으로 추측합니다.
왜냐하면 항상 인터페이스를 빈으로 주입받으면, 클래스의 형태를 굳이 신경 쓸 필요가 없습니다. 즉 프록시 객체를 생성하고, AOP를 사용하는 것에 예기치 못 한 에러를 방지할 수 있습니다.
5) 개선
public interface LotteWineModify { wineNameList(List<WebElement> names); winePriceList(List<WebElement> prices); } public class LotteWineModifyImpl implements LotteWineModify { @Async @Override public void wineNameList(List<WebElement> names) { 생략 } @Async @Override public void winePriceList(List<WebElement> prices) { 생략 } }
public void trimAndAddWines(List<WebElement> names, List<WebElement> prices) { @Autowired private final LotteWineModify lotteWineModify; // 인터페이스 lotteWineModify.wineNameList(names); // 외부 호출 lotteWineModify.winePriceList(prices); // 외부 호출 }
- 기존의 내부 호출 --> 외부 호출
- wineNameList(), winePriceList() private --> public 변경
... 생략 ... [task-2] INFO c.e.w.s.c.lotte.LotteWineModify ==== winePriceList() ==== [task-1] INFO c.e.w.s.c.lotte.LotteWineModify ==== wineNameList() ==== [task-2] INFO c.e.w.s.c.lotte.LotteWineModify [task-1] INFO c.e.w.s.c.lotte.LotteWineModify ... 생략 ...
- task-1, task-2 각각의 스레드 등록 및 실행
- 비동기 수행
6) 정리
그동안 @Async, @Transactional을 무수히 사용하면서 어떻게 동작하는지 깊게 이해하지 못했습니다. 그래서 업무에서도 같은 클래스의 메서드를 내부 호출로 사용하고는 했는데요.
사실 사용을 하고 이것이 정말로 비동기로 동작하는지 확인하는 과정이 없었고, 당연히 된다고 생각했던 것이 더 큰 문제라고 생각합니다. 항상 작성된 코드가 의도한 대로 동작하는지 검증해야 하며, 안된다면 왜 안되는지 명확히 분석하는 것이 필요하다는 것을 느낍니다.
7) 참고 문헌
https://dzone.com/articles/effective-advice-on-spring-async-part-1
https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html반응형'Java & Kotlin' 카테고리의 다른 글
Stream은 일회용품이다. (0) 2021.12.12 그래서 예외처리는요? (0) 2021.12.05 인터페이스의 default, static 메소드 (0) 2021.10.06 과거의 나에게...(와인 검색 서비스 회고) (0) 2021.10.02 (Java)primitive, reference 타입 (0) 2021.08.26