프로젝트 서비스 중 문서를 발급해주는 서비스가 있었다.
다사다난했던 개발 건이였지만, 어차저차 개발을 끝내 고객들이 잘 써주는 서비스 중 하나로 자리잡게 되었다.
허나 이 서비스는 큰 문제가 하나가 있는데, 그것은 문서를 발급하는 시간이 굉장히 오래 걸린다.
업무적인 내용이기에 구체적으로 얘기를 할 수 없지만, 문서 발급 API 을 실패할 경우 재출력하는 프로세스가 자동화가 되어있어 최대 발급 시 4~5분이 소요되는 문제점을 가지고 있었다.
그러다보니 고객 클레임은 다소 들어오게 되는 경향이 있었지만 일단 서비스 자체의 문제는 없어 나중에 재개발을 해보자는 생각으로 잠시 관심을 피하고(?) 있었다.
그러다 이번 달이 되어 팀 내 회의 끝에 비동기로 고객이 서비스 사용을 다른 곳으로 하고 추후 발급내역으로 출력을 하실 수 있도록 변경시키자고 하시고, 처음으로 비즈니스 내에 비동기를 녹여볼 수 있는 기회가 있었다.
그래서 이번 주제는 비동기에 대한 개념과 스프링이 제공해주는 @Async에 대해 소개를 하겠다.
물론 자바 비동기를 사용하기 위해 Future, CompletableFuture 와 같은 기술들도 제공을 해주고 있지만 이건 나중에 소개해보도록 하겠다. (필자가 아직 잘 모른다.)
MDN Web Docs에서 비동기는 아래와 같이 설명한다.
비동기라는 용어는 둘 이상의 객체 또는 이벤트가 동시에 존재하지 않거나 발생하지 않는 경우(또는 이전 객체 또는 이벤트가 완료될 때까지 기다리지 않고 발생하는 여러 관련 작업)를 말합니다.
예전에 비동기와 관련해 처음 공부를 할 때, 이해하기 좋은 예제가 있었다.
우리가 카톡을 할 때, 친구들에게 문자를 보내기도 하고, 사진이나 파일을 첨부하여 보내기도 한다. 그런데 만약 파일을 업로드할 동안 문자를 보낼 수가 없거나 파일이 업로드가 다 되어야만 문제를 보낸다면 얼마나 불편할까?
문자를 보내면서 파일이 업로드가 되어 보내지게 되는 것이 당연한 것이다. 이럴 때 쓰는 것이 비동기 개념이다.
즉 비동기란, A와 B라는 작업이 있을 경우 A→B와 같이 작업의 순차적 실행이 일어나는 것이 아닌 (A, B)와 같이 작업이 단번에 일어나는 경우를 말한다.
이 개념을 개발에 응용하게 된다면 위 예제와 같이 파일이 업로드될 때 문자를 보낼 수 없는 불상사는 일어나지 않을 것이다.
대부분 비동기를 구축할 때, 스레드풀(Thread Pool)도 함께 구축하는 경우가 많다.
그러면 비동기를 구축하기 위해 스레드풀은 필수적인 것인가하는 의문이 들었다. 이 의문에 대해 답을 위해 스레드풀을 알고 넘어가고자 한다.
스레드풀은 스레드 여러 개를 미리 생성해두어, 스레드가 처리할 작업이 생기면 해당 스레드에 처리를 요청하는 것이다. 스레드풀 내에 있는 스레드를 일정히 관리해주기 때문에 불필요한 메모리를 줄이는 작업 형태이다.
스레드 생명주기를 생각해보면,
작업이 일어나기 전에 스레드가 생성이 되고 작업이 끝난 후에는 자신의 임무를 다했기 때문에 프로그램 측은 스레드 자원의 낭비를 막기 위해 소멸을 시키는 구조를 가진다.
비즈니스로 사용되는 서버들은 입출력 직약접 작업(I/O intensive task)이 많아 단일 요청인 경우가 대다수이기에 스레드 생명주기로 인해 서버의 메모리 과부하가 심하지 않을 것이다.
허나 배치성 업무라면? 말이 다를 것이다. 배치성 업무처럼 TPS가 큰 작업들의 경우는 스레드 생명주기가 서버에 영향을 끼칠 경우가 크다.
그렇기 때문에 이런 배치성 업무들은 스레드풀을 이용하여 스레드를 사용자가 지정한 갯수를 임의로 활성화시켜놓아 업무를 지속적으로 할 수 있도록 만들어두는 것이다.

위 그림과 같이 생산자가 작업 대기열에 작업을 기록해두면, 소비자가 작업 대기열에서 작업을 꺼내가서 작업을 진행한다.
이 중 스레드풀에 등록된 스레드들은 소비자에 해당된다. 그렇기 때문에 소비자의 갯수는 업무량을 고려하여 설정해두어야 하며, 작업대기열 또한 요청 갯수를 파악해 설정해두는 게 Best Fit이라고 본다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("pool-name")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("test-");
executor.initialize();
return executor;
}
@Bean("pool-name2")
public Executor taskExecutor2() {
// ...
return executor;
}
}
위 자바코드가 기본적으로 스레드풀을 설정하는 코드이다.
위 그림을 통해 적당히 알 수 있지만 뜻은 아래와 같다.
그 이외에도 작업 성격에 따라 설정할 수 있는 옵션들이 많아 보인다.
사실 위 Annotation을 사용하여 비동기를 구축하는 건 굉장히 쉬운 일이다.
@Async(value = "pool-name")
public void testAsync() {
// .. biz logic
}
위 형태와 같이 비즈니스로직을 작성할 포맷을 만들면 된다.
@Async에는 value를 통하여 설정에 등록시킨 스레드풀 네임을 지정할 수 있다. 그리하여 특정 Class에 비동기 작업을 응집시켜놓아도 각각 스레드풀의 설정을 이용할 수 있다.
반환 타입은 기본적으로 void, Future, CompletableFuture 를 사용할 수 있다.

위 로그를 보면 알 수 있듯이 Async Annotation가 데코레이트된 Method는 value명으로 등록된 스레드풀을 이용하여 동작을 한다.
비동기이다보니 아래와 같이 비동기 메서드 호출을 부른다해도 순차적인 접근이 아닌 동시 호출이기에 Thread 번호는 작업이 먼저 끝난 스레드가 먼저 로그를 찍는다.
log.info("동기 스레드. 비동기 호출");
asyncService.testAsync(); // 두 번째로 끝남
asyncService.testAsync(); // 첫 번째로 끝남
log.info("비동기 호출완료");
이와 같이 스프링은 비동기 작업을 위해 간편한 비동기 설정을 제공해주고 있다.
비동기 작업은 깊게 들어갈수록 어려우며, 고심이 많이 필요한 작업이 많다고 들었다.
실제로 순차적으로 코드가 실행된다는 보장이 없어지기 때문에 사용자 편의성 개편 및 서버 성능과 개발자의 고생은 반비례적인 형태를 가지게 될 수 밖에 없다.
또한 비동기를 다루는 것은 이제 서버 개발(=백엔드)에 있어 기본이 된 게 아닐까라는 생각도 한다.
카카오톡과 같은 메신저방 예제처럼 서비스 사용자가 작업을 끝날 때까지 기다리며 서비스를 이용하는 것이 아닌 타 작업을 하면서 다른 서비스는 시간이 지나 확인을 하면 되는 편리한 서비스를 경험하게 해줄 수 있는 게 아닐까?
필자 사내 프로젝트 또한 비동기를 도입했더니 5분동안 대기해야 됬던 업무 프로세스가 1분 30초 정도로 끝나게 되었다. 아직도 길다면 긴 시간이지만.. 프로세스 문제처리때문에 더이상 줄일 수 없었다 ㅠ.
그래도 비동기를 학습함으로써 고객에게 더욱 원활한 서비스를 제공할 수 있는 시간이 되었다.