반응형
1. @Async란?
@Async는 메서드를 별도의 스레드에서 비동기로 실행하게 해주는 Spring AOP 기능입니다.
동기 vs 비동기
java
// 동기 처리
public void sendEmail(String email) {
emailService.send(email); // 완료될 때까지 대기 (5초)
System.out.println("다음 작업"); // 5초 후 실행
}
// 비동기 처리
@Async
public void sendEmail(String email) {
emailService.send(email); // 별도 스레드에서 실행
}
public void process() {
sendEmail("test@example.com");
System.out.println("다음 작업"); // 바로 실행!
}
```
**실행 흐름 비교:**
```
[동기]
sendEmail() 시작 → (5초 대기) → sendEmail() 완료 → 다음 작업
[비동기]
sendEmail() 시작 → 다음 작업 (바로 실행)
↓
(별도 스레드에서 5초 걸려서 처리)
2. 동작 원리: AOP와 프록시 패턴
2.1 핵심 개념
@Async는 **AOP(Aspect-Oriented Programming)**를 기반으로 동작합니다.
- AOP: 횡단 관심사(로깅, 트랜잭션, 비동기 등)를 분리하는 기술
- 프록시: 실제 객체를 감싸서 추가 기능을 제공하는 대리 객체
- Pointcut: AOP를 적용할 대상을 지정하는 표현식
2.2 프록시 생성 과정
java
@Service
public class UserService {
@Async
public void sendEmail() {
System.out.println("이메일 전송");
}
}
```
**Spring이 하는 일:**
1. 애플리케이션 시작 시 `@Async`가 있는 클래스 발견
2. **UserService 프록시 객체** 생성 (딱 1개, 싱글톤)
3. Spring Container에 프록시 등록
```
┌─────────────────────────────────────┐
│ Spring Container │
│ │
│ UserService 프록시 (1개) │
│ ┌──────────────────────────────┐ │
│ │ public void sendEmail() { │ │
│ │ executor.execute(() -> { │ │
│ │ realService.sendEmail(); │ │
│ │ }); │ │
│ │ } │ │
│ └──────────────────────────────┘ │
│ ↓ 참조 │
│ 실제 UserService 객체 │
│ │
└─────────────────────────────────────┘
2.3 호출 과정
java
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService; // ← 실제로는 프록시 주입됨
@PostMapping("/users")
public void create() {
userService.sendEmail(); // ← 프록시.sendEmail() 호출
}
}
```
**실행 흐름:**
```
1. Controller에서 userService.sendEmail() 호출
↓
2. 실제로는 UserService 프록시 호출
↓
3. 프록시가 Executor(스레드 관리자)에게 작업 전달
↓
4. Executor가 스레드 풀에서 스레드 선택
↓
5. 선택된 스레드에서 실제 UserService.sendEmail() 실행
2.4 프록시 vs 스레드
중요: 프록시는 1개, 스레드는 여러 개
java
@Service
public class EmailService {
@Async
public void sendEmail(String to) {
System.out.println("스레드: " + Thread.currentThread().getName());
}
}
// 100명이 동시에 요청하면?
for (int i = 0; i < 100; i++) {
emailService.sendEmail("user" + i + "@example.com");
}
```
**구조:**
```
EmailService 프록시 (딱 1개, 모든 요청이 공유)
↓
ThreadPoolTaskExecutor (스레드 관리자)
↓
[Thread-1] sendEmail("user0@...")
[Thread-2] sendEmail("user1@...")
[Thread-3] sendEmail("user2@...")
...
[Thread-10] sendEmail("user9@...")
(나머지는 큐에서 대기)
2.5 프록시가 생성되는 경우
java
// ✅ 프록시 생성 O
@Service
public class UserService {
@Async
public void sendEmail() { }
}
// ✅ 프록시 생성 O
@Service
@Transactional
public class OrderService {
public void createOrder() { }
}
// ❌ 프록시 생성 X
@Service
public class ProductService {
public void getProduct() { } // AOP 어노테이션 없음
}
2.6 Aspect와 프록시
java
// Aspect는 프록시가 아님! 프록시가 사용할 로직일 뿐
@Aspect
@Component
public class LoggingAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("[로그] 시작");
Object result = joinPoint.proceed();
System.out.println("[로그] 종료");
return result;
}
}
```
**실제 구조:**
```
┌─────────────────────────────────────┐
│ Spring Container │
│ │
│ LoggingAspect (1개) ◄───────┐ │
│ │ │
│ UserService 프록시 ─────────┤ │
│ OrderService 프록시 ────────┤ │
│ (각 프록시가 LoggingAspect 사용) │
└─────────────────────────────────────┘
2.7 Pointcut 표현식
java
@Around("execution(* com.example.service.*.*(..))")
```
**분해:**
```
execution(* com.example.service.*.*(..))
│ │ │ │ │ │
│ │ │ │ │ └─ 파라미터: 개수 상관없음
│ │ │ │ └─── 메서드명: 모든 메서드
│ │ │ └───── 클래스명: 모든 클래스
│ │ └─────────────────────── 패키지: com.example.service
│ └───────────────────────── 리턴타입: 모든 타입
└─────────────────────────────────── execution: 메서드 실행 시점
매칭 예시:
java
// ✅ 매칭 O - 프록시 생성됨
package com.example.service;
@Service
public class UserService {
public void createUser() { }
}
// ❌ 매칭 X - 프록시 안 생성됨
package com.example.controller;
@RestController
public class UserController {
public void create() { }
}
3. 기본 설정
3.1 @EnableAsync 필수
java
@Configuration
@EnableAsync // ← 이거 없으면 @Async 동작 안 함!
public class AsyncConfig {
}
3.2 Executor 설정 (매우 중요!)
❌ 기본 설정의 문제점:
java
@Configuration
@EnableAsync // 기본 Executor 사용 (SimpleAsyncTaskExecutor)
public class AsyncConfig {
}
문제:
- 요청마다 새 스레드 생성 → 리소스 낭비
- 스레드 수 제한 없음 → 서버 다운 위험!
✅ 올바른 설정:
java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 기본 스레드 수
executor.setCorePoolSize(5);
// 최대 스레드 수
executor.setMaxPoolSize(10);
// 큐 크기 (대기 가능한 작업 수)
executor.setQueueCapacity(25);
// 스레드 이름 접두사
executor.setThreadNamePrefix("async-");
// 큐가 가득 찼을 때 정책
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.initialize();
return executor;
}
}
```
**스레드 풀 동작 방식:**
```
1. 요청이 들어옴
2. CorePoolSize(5) 미만이면 새 스레드 생성
3. CorePoolSize 도달 → Queue에 추가 (최대 25개)
4. Queue 가득 참 → MaxPoolSize(10)까지 스레드 증가
5. MaxPoolSize도 가득 참 → RejectedExecutionHandler 실행
4. 사용 시 주의사항 (필독!)
4.1 Self-Invocation 불가 ⚠️ (가장 흔한 실수!)
java
@Service
public class EmailService {
public void sendWelcomeEmail(String email) {
// ❌ 같은 클래스 내부 호출 → 비동기 안 됨!
this.sendEmail(email);
}
@Async
public void sendEmail(String email) {
System.out.println("스레드: " + Thread.currentThread().getName());
}
}
```
**왜 안 될까?**
```
sendWelcomeEmail() 내부에서 this.sendEmail() 호출
↓
this = 실제 객체 (프록시 아님)
↓
프록시를 건너뛰고 실제 메서드 직접 호출
↓
비동기 동작 안 됨!
✅ 해결 방법 1: 별도 클래스로 분리
java
@Service
@RequiredArgsConstructor
public class EmailService {
private final AsyncEmailService asyncEmailService;
public void sendWelcomeEmail(String email) {
asyncEmailService.sendEmail(email); // ✅ 외부 호출
}
}
@Service
public class AsyncEmailService {
@Async
public void sendEmail(String email) {
System.out.println("스레드: " + Thread.currentThread().getName());
}
}
✅ 해결 방법 2: Self-Injection
java
@Service
public class EmailService {
@Lazy
@Autowired
private EmailService self; // 자기 자신(프록시) 주입
public void sendWelcomeEmail(String email) {
self.sendEmail(email); // ✅ 프록시 통해 호출
}
@Async
public void sendEmail(String email) {
System.out.println("스레드: " + Thread.currentThread().getName());
}
}
4.2 public 메서드만 가능
java
@Service
public class UserService {
@Async
private void sendEmail() { } // ❌ private 안 됨
@Async
protected void sendEmail() { } // ❌ protected 안 됨
@Async
public void sendEmail() { } // ✅ public만 가능
}
이유: 프록시는 외부에서 접근 가능한 메서드만 가로챌 수 있습니다.
4.3 프록시 확인하기
java
@SpringBootTest
class ProxyTest {
@Autowired
private UserService userService;
@Test
void checkProxy() {
System.out.println(userService.getClass().getName());
}
}
```
**출력:**
```
// 프록시 O
com.example.UserService$$SpringCGLIB$$0
// 프록시 X
com.example.UserService
5. 리턴 타입 처리
5.1 void - Fire and Forget
java
@Async
public void sendEmail(String email) {
// 이메일 전송
}
// 호출
emailService.sendEmail("test@example.com");
System.out.println("다음 작업"); // 바로 실행
특징:
- 결과를 받을 수 없음
- 성공/실패 여부를 모름
- 예외 발생해도 호출한 쪽은 모름
일반 void와의 차이:
java
// 일반 void
public void sendEmail() {
// 작업
}
sendEmail(); // 여기 도달 = 작업 완료 ✅
// @Async void
@Async
public void sendEmail() {
// 작업
}
sendEmail(); // 여기 도달 = 작업 시작만 함 ❌
// 완료 여부, 성공 여부 전부 모름
5.2 CompletableFuture - 결과 받기
java
@Async
public CompletableFuture<String> processOrder(Order order) {
try {
// 주문 처리
Thread.sleep(5000);
String result = "주문 완료: " + order.getId();
return CompletableFuture.completedFuture(result);
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
사용 방법 1: 블로킹 (결과 기다림)
java
CompletableFuture<String> future = orderService.processOrder(order);
String result = future.get(); // 블로킹 - 완료될 때까지 대기
System.out.println(result);
사용 방법 2: 비블로킹 (콜백)
java
orderService.processOrder(order)
.thenAccept(result -> {
System.out.println("성공: " + result);
})
.exceptionally(ex -> {
System.err.println("실패: " + ex.getMessage());
return null;
});
System.out.println("다음 작업"); // 바로 실행
여러 비동기 작업 병렬 실행:
java
@Async
public CompletableFuture<User> getUser(Long id) {
User user = userRepository.findById(id);
return CompletableFuture.completedFuture(user);
}
@Async
public CompletableFuture<List<Order>> getOrders(Long userId) {
List<Order> orders = orderRepository.findByUserId(userId);
return CompletableFuture.completedFuture(orders);
}
// 병렬 실행 후 결과 조합
CompletableFuture<User> userFuture = getUser(1L);
CompletableFuture<List<Order>> ordersFuture = getOrders(1L);
CompletableFuture.allOf(userFuture, ordersFuture)
.thenRun(() -> {
User user = userFuture.join();
List<Order> orders = ordersFuture.join();
System.out.println(user.getName() + "님의 주문: " + orders.size());
});
6. 예외 처리
6.1 void 메서드의 예외 - 사라짐!
java
@Async
public void sendEmail(String email) {
throw new RuntimeException("SMTP 서버 다운!");
// ❌ 예외가 날아가버림, 호출한 쪽은 모름
}
// 호출
emailService.sendEmail("test@example.com");
System.out.println("이메일 전송 완료!"); // ❌ 실패했는데?
6.2 AsyncUncaughtExceptionHandler 설정
java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
// Executor 설정
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
System.err.println("=== 비동기 예외 발생 ===");
System.err.println("메서드: " + method.getName());
System.err.println("파라미터: " + Arrays.toString(params));
System.err.println("예외: " + ex.getMessage());
ex.printStackTrace();
// Slack 알림, 모니터링 등
};
}
}
6.3 CompletableFuture로 예외 처리
java
@Async
public CompletableFuture<String> sendEmail(String email) {
try {
// 이메일 전송
if (email.contains("invalid")) {
throw new RuntimeException("잘못된 이메일");
}
return CompletableFuture.completedFuture("성공");
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
// 호출
emailService.sendEmail("invalid@example.com")
.thenAccept(result -> System.out.println("성공: " + result))
.exceptionally(ex -> {
System.err.println("실패: " + ex.getMessage());
return null;
});
7. 트랜잭션과 함께 사용
7.1 문제 상황
java
@Service
public class OrderService {
@Transactional
@Async
public void processOrder(Order order) {
// ❌ 트랜잭션이 다른 스레드에서 실행됨
// 트랜잭션 컨텍스트가 전파 안 됨!
orderRepository.save(order);
}
}
문제:
- @Transactional은 ThreadLocal 기반
- 새 스레드에서는 트랜잭션 컨텍스트가 없음
7.2 해결 방법
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderProcessor orderProcessor;
@Async
public void processOrderAsync(Order order) {
// 비동기 스레드에서 실행
orderProcessor.processWithTransaction(order);
}
}
@Service
public class OrderProcessor {
@Transactional // ✅ 새 스레드에서 새 트랜잭션 시작
public void processWithTransaction(Order order) {
orderRepository.save(order);
// 트랜잭션 처리
}
}
8. 실무 사례: FTP 파일 다운로드
8.1 문제 상황
업무에서 FTP 서버에 접속해서 PDF 파일을 다운로드하는 기능을 개발했습니다. 메인 로직 중간에서 FTP 다운로드 때문에 전체 응답 시간이 길어져서 @Async로 분리했습니다.
8.2 구현 코드
java
@Service
@Slf4j
public class FtpService {
@Value("${ftp.host}")
private String ftpHost;
@Value("${ftp.port}")
private int ftpPort;
@Value("${ftp.username}")
private String ftpUser;
@Value("${ftp.password}")
private String ftpPassword;
@Async
public void downloadPdf(String filePath) {
FTPClient ftpClient = null;
try {
// FTP 연결 (타임아웃 설정됨)
ftpClient = new FTPClient();
ftpClient.setConnectTimeout(10000); // 10초
ftpClient.setDataTimeout(30000); // 30초
ftpClient.connect(ftpHost, ftpPort);
ftpClient.login(ftpUser, ftpPassword);
ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
ftpClient.enterLocalPassiveMode();
// PDF 다운로드
String fileName = extractFileName(filePath);
File localFile = new File("/download/path/" + fileName);
try (FileOutputStream fos = new FileOutputStream(localFile)) {
boolean success = ftpClient.retrieveFile(filePath, fos);
if (!success) {
throw new RuntimeException("FTP 다운로드 실패: " + filePath);
}
}
log.info("PDF 다운로드 완료: {}", fileName);
// 다운로드한 파일 처리 (파싱, DB 저장 등)
processPdfFile(localFile);
} catch (IOException e) {
log.error("FTP 오류: {}", filePath, e);
} catch (Exception e) {
log.error("예상치 못한 오류: {}", filePath, e);
} finally {
// FTP 연결 해제
if (ftpClient != null && ftpClient.isConnected()) {
try {
ftpClient.logout();
ftpClient.disconnect();
} catch (IOException e) {
log.warn("FTP 연결 해제 실패", e);
}
}
}
}
private void processPdfFile(File pdfFile) {
// PDF 파싱, DB 저장 등
log.info("PDF 처리 완료: {}", pdfFile.getName());
}
private String extractFileName(String filePath) {
return filePath.substring(filePath.lastIndexOf("/") + 1);
}
}
8.3 핵심 포인트
1. Fire and Forget 패턴
- 다운로드 결과를 기다릴 필요 없음
- void 리턴 타입 사용
- 다운로드와 후속 처리가 모두 비동기 메서드 내에서 완료
2. 예외 처리
- try-catch로 모든 예외 처리
- AsyncUncaughtExceptionHandler로 안전망 구축
3. 리소스 관리
- finally 블록에서 FTP 연결 확실히 해제
- 타임아웃 설정으로 무한 대기 방지
4. 선택적 개선사항
java
// 재시도 로직 추가
@Service
public class FtpService {
@Async
@Retryable(
value = {IOException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 5000)
)
public void downloadPdf(String filePath) {
// FTP 다운로드
}
@Recover
public void recoverDownload(IOException e, String filePath) {
log.error("3번 재시도 후에도 실패: {}", filePath, e);
// Slack 알림, 관리자 이메일 등
}
}
9. 체크리스트
✅ 기본 설정
java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("비동기 예외 발생: {}", method.getName(), ex);
};
}
}
✅ 사용 규칙
- @EnableAsync 설정했는가?
- Executor 커스텀 설정했는가?
- public 메서드인가?
- Self-invocation 피했는가?
- 결과가 필요하면 CompletableFuture 사용했는가?
- 예외 처리 설정했는가?
- 트랜잭션과 함께 사용 시 별도 클래스로 분리했는가?
✅ 운영 고려사항
- 스레드 풀 크기를 서버 사양에 맞게 조정했는가?
- 로깅으로 비동기 실행 추적 가능한가?
- 예외 발생 시 알림 체계가 있는가?
- 모니터링 도구로 스레드 풀 상태를 확인하는가?
마치며
@Async는 강력한 도구지만, 올바르게 이해하고 사용해야 합니다. 특히 프록시 패턴과 Self-invocation 문제를 이해하는 것이 핵심입니다.
실무에서는 FTP 다운로드처럼 시간이 오래 걸리는 작업을 비동기로 처리하여 사용자 경험을 개선할 수 있습니다. 다만 예외 처리와 모니터링을 철저히 해야 운영 중 문제를 조기에 발견할 수 있습니다.
반응형
'IT' 카테고리의 다른 글
| System.out.println()의 성능 이슈와 Logback 비교 (0) | 2026.01.15 |
|---|---|
| 커넥션 타임아웃과 리드 타임아웃 (0) | 2026.01.13 |
| 실행계획과 인덱스 (0) | 2025.12.16 |
| 실서비스에서 커넥션 풀(hikari Cp) 제대로 이해하기 (0) | 2025.12.16 |
| Business Exception vs System Exception (0) | 2025.12.15 |
댓글