본문 바로가기
IT

@Async 적용하기

by urosie 2026. 1. 14.
반응형

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 다운로드처럼 시간이 오래 걸리는 작업을 비동기로 처리하여 사용자 경험을 개선할 수 있습니다. 다만 예외 처리와 모니터링을 철저히 해야 운영 중 문제를 조기에 발견할 수 있습니다.

반응형

댓글