Spring @Async는 실제로 어떻게 동작할까? Thread Pool부터 Self-Invocation까지.

|

예전에 스프링에서 @Async로 비동기처리하기 글에서 기본 사용법을 정리했었는데, 그때는 @EnableAsync 붙이고 ThreadPoolTaskExecutor 설정하는 선에서 끝났다. 이번엔 그 안에서 실제로 무슨 일이 일어나는지 — Spring Proxy가 어떻게 비동기로 만들어주는지, Thread Pool은 왜 필요한지, self-invocation 문제와 트랜잭션/예외 처리까지 깊이 들어가 본다.

핵심부터 말하면: Spring의 @Async는 오래 걸리지만 요청 결과에 즉시 필요하지 않은 작업을 현재 요청 Thread가 아닌 별도의 Thread에서 실행하게 해주는 기능이다. 이메일·푸시·알림처럼 본 작업과 분리할 수 있는 작업에 유용하지만, 단순히 @Async만 붙이는 것으로 끝나는 것은 아니다. Thread Pool, Spring Proxy, 예외 처리, 트랜잭션 경계까지 함께 이해해야 안전하게 사용할 수 있다.

1. @Async를 왜 사용할까?

일반적인 Java 메서드 호출은 동기(Synchronous) 방식이다.

예를 들어 주문 처리 과정에서 다음 작업을 수행한다고 해보자.

public void order() {
    saveOrder();      // 1초
    sendEmail();      // 3초
    sendPush();       // 2초
}

실행 순서는 다음과 같다.

요청 Thread
   │
   ├─ saveOrder()   1초
   │
   ├─ sendEmail()   3초
   │
   ├─ sendPush()    2초
   │
   └─ 응답
       총 6초

sendEmail()과 sendPush()가 완료되어야만 주문이 성공하는 것이 아니라면 사용자가 이 작업까지 기다릴 이유는 없다.

이런 작업을 별도의 Thread에 맡기면 다음처럼 처리할 수 있다.

HTTP 요청 Thread
   │
   ├─ saveOrder()
   │
   ├─ sendEmail() ──────────────┐
   │                            │
   └─ 응답                      │
                                ▼
                         Async Thread
                                │
                                └─ 이메일 발송

즉, @Async의 핵심은 다음 한 문장으로 정리할 수 있다.

호출한 Thread는 기다리지 않고 다음 작업을 계속하고, 실제 작업은 다른 Thread가 처리한다.

언제 사용하면 좋을까?

대표적으로 다음과 같은 작업이 있다.

  • 이메일 발송
  • SMS 발송
  • Push 알림
  • 중요도가 낮은 로그/이력 저장
  • 통계성 데이터 처리
  • 응답과 분리해도 되는 외부 API 호출
  • 시간이 오래 걸리지만 사용자가 결과를 기다릴 필요가 없는 작업

반대로 결제, 재고 차감 등 반드시 성공 여부를 확인해야 하는 핵심 비즈니스 로직을 아무 생각 없이 @Async로 넘기는 것은 위험하다.

2. 가장 간단한 사용 방법

Spring에서 비동기 처리를 활성화하려면 설정 클래스에 @EnableAsync를 선언한다.

@Configuration
@EnableAsync
public class AsyncConfig {
}

그리고 비동기로 실행하고 싶은 Spring Bean의 메서드에 @Async를 붙인다.

@Service
public class MessageSender {

    @Async
    public void sendMessage(String message) {
        System.out.println(message);
    }
}

다른 Bean에서 호출한다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final MessageSender messageSender;

    public void order() {
        saveOrder();

        messageSender.sendMessage("주문 완료");
    }

    private void saveOrder() {
        // 주문 저장
    }
}

호출하는 코드만 보면 평범한 메서드 호출과 거의 동일하다.

하지만 내부에서는 Spring이 sendMessage()를 별도의 실행기에 넘겨 비동기로 처리한다.

3. @Async는 어떻게 비동기가 되는 걸까?

@Async를 이해할 때 가장 중요한 것은 Spring Proxy다.

Spring Bean에 @Async가 있다고 해서 Java 자체가 해당 메서드를 특별하게 실행하는 것은 아니다.

Spring이 Bean 앞에 Proxy를 두고 메서드 호출을 가로챈다.

OrderService
     │
     │ messageSender.sendMessage()
     ▼
┌────────────────────┐
│ Spring Proxy       │
│                    │
│ @Async 확인        │
└─────────┬──────────┘
          │
          │ 작업 제출
          ▼
┌────────────────────┐
│ TaskExecutor       │
│                    │
│ async-thread-1     │
│ async-thread-2     │
└─────────┬──────────┘
          │
          ▼
 MessageSender
 .sendMessage()

개념적으로 보면 Spring이 다음과 비슷한 일을 대신 해준다고 생각할 수 있다.

executor.submit(() -> {
    messageSender.sendMessage();
});

실제 구현은 더 복잡하지만 학습 단계에서는 이렇게 이해하면 충분하다.

@Transactional과 비슷하다

@Transactional 역시 Proxy 기반으로 동작한다. (Spring @Transactional은 어떻게 동작할까? 글에서 자세히 다뤘다.)

@Transactional
public void save() {
}

개념적으로는 다음과 같다.

호출
 ↓
Spring Proxy
 ↓
Transaction 시작
 ↓
save()
 ↓
commit / rollback

@Async도 같은 관점으로 보면 된다.

호출
 ↓
Spring Proxy
 ↓
TaskExecutor에 작업 전달
 ↓
별도 Thread
 ↓
실제 메서드 실행

따라서 @Async를 이해하면 Spring AOP와 Proxy에 대한 이해도 자연스럽게 연결된다.

4. Thread Pool은 왜 필요한가?

비동기 요청이 들어올 때마다 Thread를 무한정 새로 만든다고 생각해보자.

요청 1     → Thread 생성
요청 2     → Thread 생성
요청 3     → Thread 생성
...
요청 10,000 → Thread 생성

Thread 생성에는 비용이 든다.

Thread마다 메모리가 필요하고 Thread 수가 지나치게 많아지면 Context Switching 비용도 증가한다.

그래서 일반적으로 일정 개수의 Thread를 만들어 놓고 재사용한다.

이것이 Thread Pool이다.

             작업 Queue
                 │
        ┌────────┴────────┐
        │                 │
        ▼                 ▼
     Thread-1          Thread-2
        │                 │
      작업 A             작업 B
        │                 │
      작업 C             작업 D

Spring에서는 대표적으로 ThreadPoolTaskExecutor를 사용할 수 있다.

5. ThreadPoolTaskExecutor 설정하기

명시적으로 비동기 전용 Executor를 구성하면 어떤 Thread Pool을 사용하는지 코드에서 확인하기 쉽다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("async-");

        executor.initialize();

        return executor;
    }
}

그리고 다음처럼 사용할 Executor를 지정할 수 있다.

@Async("asyncExecutor")
public void sendMessage() {
    // 비동기 작업
}

corePoolSize

executor.setCorePoolSize(2);

기본적으로 작업을 처리하는 Thread 수라고 이해하면 된다.

Thread Pool

Thread-1
Thread-2

maxPoolSize

executor.setMaxPoolSize(10);

작업량이 증가했을 때 Thread Pool이 확장할 수 있는 최대 Thread 수다.

평상시

Thread-1
Thread-2

부하 증가

Thread-1
Thread-2
Thread-3
...
Thread-10

queueCapacity

executor.setQueueCapacity(500);

모든 Thread가 작업 중일 때 새로운 작업을 대기시키는 Queue의 크기다.

여기서 중요한 점이 있다.

작업이 많아졌다고 바로 maxPoolSize까지 Thread가 증가하는 것은 아니다.

대략 다음 순서로 동작한다.

1. corePoolSize까지 Thread 사용
            ↓
2. 추가 작업을 Queue에 저장
            ↓
3. Queue가 가득 참
            ↓
4. maxPoolSize까지 Thread 증가
            ↓
5. Thread와 Queue가 모두 한계에 도달
            ↓
6. 새로운 작업 거절

예를 들어 다음 설정을 사용한다고 하자.

corePoolSize  = 2
maxPoolSize   = 10
queueCapacity = 500

작업이 한꺼번에 들어오고 앞선 작업들이 아직 끝나지 않았다고 단순화하면:

1번 작업   → Thread-1
2번 작업   → Thread-2

3 ~ 502번  → Queue 대기

503번 작업
      ↓
Queue가 가득 참
      ↓
추가 Thread 생성

따라서 maxPoolSize만 크게 설정한다고 처리량이 바로 증가하는 것은 아니다.

Thread Pool 크기는 CPU 작업인지, I/O 작업인지, 작업 시간이 얼마나 긴지, 초당 요청량이 어느 정도인지 등을 고려해 정해야 한다.

6. 실제 예제

이메일 발송을 비동기로 처리한다고 해보자.

@Service
@Slf4j
public class MessageSender {

    @Async("asyncExecutor")
    public void sendSimpleMessage(
            String to,
            String subject,
            String text) {

        log.info(
            "send mail. thread={}",
            Thread.currentThread().getName()
        );

        sendSimpleMessageSync(to, subject, text);
    }

    private void sendSimpleMessageSync(
            String to,
            String subject,
            String text) {

        // 실제 이메일 발송
    }
}

호출하는 서비스는 다음과 같다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final MessageSender messageSender;

    public void order() {

        saveOrder();

        messageSender.sendSimpleMessage(
                "test@test.com",
                "주문 완료",
                "주문이 완료되었습니다."
        );
    }

    private void saveOrder() {
        // 주문 저장
    }
}

실행 Thread는 다음과 같이 분리될 수 있다.

http-nio-8080-exec-1
        │
        │ order()
        │
        ├─ saveOrder()
        │
        └─ sendSimpleMessage()
                 │
                 │ @Async
                 ▼
              async-1
                 │
                 └─ 이메일 발송

order()를 실행하는 요청 Thread가 이메일 발송 완료를 기다리지 않는 것이 핵심이다.

7. 가장 많이 하는 실수: 같은 클래스 내부 호출

다음 코드를 보자.

@Service
public class MessageSender {

    public void send() {
        sendAsync();
    }

    @Async
    public void sendAsync() {
        // 비동기 작업
    }
}

겉으로 보면 sendAsync()에 @Async가 있으므로 비동기로 실행될 것 같다.

하지만 기본 Proxy 방식에서는 기대한 대로 동작하지 않는다.

내부 호출은 사실상 다음과 같기 때문이다.

this.sendAsync();

호출 흐름은:

MessageSender
     │
     │ send()
     ▼
 sendAsync()

중간에 Spring Proxy가 없다.

따라서 @Async를 처리할 기회도 없다.

이를 self-invocation 문제라고 한다.

다른 Bean에서 호출하면 Proxy를 통과한다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final MessageSender messageSender;

    public void order() {
        messageSender.sendAsync();
    }
}
OrderService
     │
     ▼
Spring Proxy
     │
     │ @Async 확인
     ▼
TaskExecutor
     │
     ▼
별도 Thread
     │
     ▼
MessageSender.sendAsync()

@Transactional에서 같은 클래스 내부 호출이 문제가 되는 이유와 같은 맥락이다.

8. 반환값이 필요하면 CompletableFuture

비동기 작업의 결과가 필요하다면 CompletableFuture를 사용할 수 있다.

@Async("asyncExecutor")
public CompletableFuture<String> sendMessage() {

    // 작업 수행

    return CompletableFuture.completedFuture("SUCCESS");
}

호출하는 쪽에서는:

CompletableFuture<String> future =
        messageSender.sendMessage();

결과가 완료된 뒤 후속 작업을 연결할 수도 있다.

future.thenAccept(result -> {
    log.info("result={}", result);
});

단순한 이메일, Push, 알림처럼 호출자가 결과를 받을 필요가 없다면 void도 충분하다.

9. 비동기 예외 처리는 다르다

다음 메서드에서 예외가 발생한다고 생각해보자.

@Async
public void sendMessage() {
    throw new RuntimeException("메일 발송 실패");
}

호출 Thread와 실행 Thread는 다르다.

요청 Thread

messageSender.sendMessage()
        │
        │ 작업 전달
        ▼
      return


Async Thread

sendMessage()
      │
      └─ Exception 발생

따라서 호출부에서 다음처럼 작성해도 비동기 Thread에서 발생한 예외를 일반적인 방식으로 잡을 수 없다.

try {
    messageSender.sendMessage();
} catch (Exception e) {
    // @Async void 메서드 내부 예외를
    // 이런 방식으로 처리할 수 있다고 생각하면 안 된다.
}

void 반환 @Async 메서드의 예외는 호출자에게 직접 전달할 수 없으므로 별도의 예외 처리 전략이 필요하다.

예를 들어:

  • 비동기 메서드 내부에서 로깅
  • AsyncUncaughtExceptionHandler 구성
  • 재시도 정책 적용
  • 실패 데이터를 별도 저장
  • 중요한 작업이라면 메시지 큐 도입

등을 고려할 수 있다.

10. @Transactional과 같이 사용할 때 주의

다음 코드를 보자.

@Transactional
public void order() {

    Order order = saveOrder();

    messageSender.sendAsync(order.getId());
}

개발자는 자연스럽게 다음 순서를 기대할 수 있다.

주문 저장
   ↓
COMMIT
   ↓
비동기 작업

하지만 반드시 그런 것은 아니다.

실제로는 다음과 같은 상황이 가능하다.

Thread A                     Thread B

Transaction 시작

INSERT Order
     │
sendAsync() ────────────────→ 실행
     │                         │
     │                         └─ Order 조회
     │
COMMIT

비동기 Thread가 먼저 실행되면 아직 Transaction이 Commit되지 않은 데이터를 조회하려 할 수도 있다.

따라서 DB Commit 이후에 반드시 실행되어야 하는 작업이라면 단순히 @Transactional 메서드 안에서 @Async를 호출하는 것만으로는 부족할 수 있다.

이럴 때는 예를 들어:

@TransactionalEventListener(
    phase = TransactionPhase.AFTER_COMMIT
)

등을 이용해 Commit 이후 이벤트를 처리하는 구조를 고려할 수 있다.

11. @Async와 메시지 큐는 다르다

@Async는 편리하지만 작업의 영속성을 보장하는 메시징 시스템은 아니다.

예를 들어:

요청
 ↓
@Async 작업 등록
 ↓
아직 작업 실행 전
 ↓
서버 프로세스 종료

같은 상황에서는 작업이 유실될 가능성을 고려해야 한다.

따라서 다음과 같이 구분해서 생각하는 것이 좋다.

@Async가 잘 맞는 경우

실패해도 재시도가 절대적으로 필요하지 않음
작업 유실 가능성을 어느 정도 허용할 수 있음
같은 애플리케이션 안에서 간단하게 비동기로 처리하고 싶음

Kafka / RabbitMQ 등의 메시징을 고려할 경우

작업 유실이 허용되지 않음
재처리가 중요함
대량의 비동기 작업을 처리함
서비스 간 비동기 통신이 필요함

즉,

@Async는 간단한 애플리케이션 내부 비동기 처리에 매우 편리하지만, 신뢰성 있는 메시징 시스템을 대체하는 기능은 아니다.

12. 2018년 코드에서 바뀐 부분

예전 글에서는 다음처럼 AsyncConfigurerSupport를 상속하는 예제를 썼었다.

@Configuration
@EnableAsync
public class SpringAsyncConfig
        extends AsyncConfigurerSupport {

    @Override
    public Executor getAsyncExecutor() {
        // ...
    }
}

하지만 Spring Framework 6.0부터 AsyncConfigurerSupport는 deprecated 되었고, 필요한 경우 AsyncConfigurer를 직접 구현하는 방식이 권장된다. (Javadoc에도 “as of 6.0 in favor of implementing AsyncConfigurer directly”라고 명시되어 있다.)

또한 단순히 특정 비동기 작업용 Executor를 만들고 싶다면 다음처럼 Bean을 명시적으로 등록하는 방식도 이해하기 쉽다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {

        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("async-");
        executor.initialize();

        return executor;
    }
}

그리고:

@Async("asyncExecutor")
public void sendMessage() {
}

처럼 어떤 Executor를 사용하는지 명확하게 표현할 수 있다.

참고로 최신 Spring Boot에서는 별도의 Executor가 없을 때 비동기 작업에 사용할 실행기를 자동 구성해주는 기능도 있으므로, “Spring에서는 무조건 SimpleAsyncTaskExecutor가 기본이다”라고 단정하기보다는 Spring Framework 자체의 기본 동작과 Spring Boot의 Auto Configuration을 구분해서 이해하는 것이 좋다.

13. 전체 동작 구조

지금까지의 내용을 한 장으로 정리하면 다음과 같다.

                 HTTP Request
                      │
                      ▼
                OrderService
                      │
                      │
           messageSender.send()
                      │
                      ▼
               Spring Proxy
                      │
                 @Async 확인
                      │
                      ▼
             TaskExecutor
                      │
                      ▼
          ThreadPoolTaskExecutor
                      │
            ┌─────────┼─────────┐
            ▼         ▼         ▼
        Thread-1  Thread-2    Queue
            │         │
            ▼         ▼
        이메일 발송   Push 발송

가장 중요한 흐름은 이것이다.

@Async
   ↓
Spring Proxy
   ↓
TaskExecutor
   ↓
Thread Pool
   ↓
별도의 Thread에서 실제 메서드 실행

14. 핵심 정리

@Async를 공부할 때 단순히

@Async
public void send() {
}

만 기억하면 실제 운영 환경에서 문제가 생겼을 때 원인을 찾기 어렵다.

다음 다섯 가지를 함께 기억하는 것이 중요하다.

1. @Async는 다른 Thread에서 실행한다

호출자는 비동기 작업의 완료를 기다리지 않고 다음 작업을 진행할 수 있다.

2. 실제 비동기 처리는 Spring Proxy가 담당한다

그래서 같은 객체 내부에서 메서드를 호출하는 self-invocation에서는 주의가 필요하다.

3. Thread는 무한하지 않다

ThreadPoolTaskExecutor의 corePoolSize, maxPoolSize, queueCapacity 관계를 이해해야 한다.

4. Thread가 다르면 예외와 트랜잭션도 분리해서 생각해야 한다

특히 @Transactional과 함께 사용할 때 Commit 시점에 주의해야 한다.

5. 중요한 비동기 작업은 @Async만으로 부족할 수 있다

작업 유실 방지와 재처리가 중요하다면 Kafka, RabbitMQ 등의 메시징 시스템도 고려해야 한다.

마무리

Spring의 @Async는 사용법 자체는 매우 간단하다.

@Async
public void sendMessage() {
}

하지만 이 한 줄 뒤에는 다음 개념들이 숨어 있다.

Spring AOP / Proxy
        ↓
TaskExecutor
        ↓
Thread Pool
        ↓
Multi Thread
        ↓
Transaction / Exception

따라서 @Async를 제대로 이해한다는 것은 단순히 비동기 어노테이션 하나를 배우는 것이 아니라 Spring이 Proxy를 통해 부가기능을 적용하는 방식과 Java의 Thread Pool이 실제로 어떻게 동작하는지를 함께 이해하는 것이라고 볼 수 있다.

참고자료


Spring Boot @Transactional은 어떻게 동작할까? AOP Proxy부터 CGLIB까지.

|

예전에 Spring 에서의 트랜잭션 처리 글에서 트랜잭션의 개념과 propagation 옵션을 정리했었는데, 그때는 “@Transactional을 붙이면 된다”는 사용법 위주였다. 이번엔 그 안에서 실제로 무슨 일이 일어나는지 — Spring AOP Proxy의 동작 원리 — 를 파고들어 본다.

Spring을 사용하다 보면 자연스럽게 이런 코드를 작성하게 된다.

@Service
public class OrderService {

    @Transactional
    public void order() {
        // DB 작업
    }
}

@Transactional을 붙이면 메서드 실행 중 예외가 발생했을 때 롤백되고, 정상적으로 끝나면 커밋된다.

그런데 여기서 한 단계 더 들어가 보면 몇 가지 궁금증이 생긴다.

  • @Transactional은 어떻게 메서드 실행 전후에 트랜잭션을 처리할까?
  • Spring AOP와 Proxy는 무슨 관계일까?
  • JDK Dynamic Proxy와 CGLIB는 뭐가 다를까?
  • 왜 자기 자신의 @Transactional 메서드를 호출하면 동작하지 않을까?
  • 예전에는 public이어야 한다고 했는데 왜 Spring Boot 3.x에서는 package-private(default)도 동작할까?
  • private은 왜 여전히 안 될까?

이 글에서는 이 질문들을 Spring AOP Proxy의 동작 원리를 중심으로 정리해본다.

1. @Transactional 자체가 트랜잭션을 시작하는 것은 아니다

먼저 가장 중요한 부분이다.

@Transactional
public void order() {
    orderRepository.save(...);
}

@Transactional 어노테이션 자체가 트랜잭션을 시작하고 커밋하는 것은 아니다.

Spring이 해당 객체 앞에 Proxy 객체를 만들어 놓고, Proxy가 메서드 호출을 가로채 트랜잭션을 처리한다.

개념적으로 보면 다음과 같다.

Controller
    │
    ▼
┌──────────────────┐
│   Spring Proxy   │
├──────────────────┤
│ Transaction 시작 │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ 실제 OrderService │
│     order()       │
└────────┬─────────┘
         │
    ┌────┴─────┐
    ▼          ▼
 정상 종료    예외 발생
    │          │
 COMMIT     ROLLBACK

실제 Spring 내부 구현은 훨씬 복잡하지만 개념적으로는 다음과 비슷하다고 생각하면 된다.

public void order() {

    transactionManager.begin();

    try {
        target.order();

        transactionManager.commit();
    } catch (Exception e) {
        transactionManager.rollback();
        throw e;
    }
}

개발자가 직접 begin, commit, rollback을 작성하는 대신 @Transactional을 선언하면 Spring이 AOP를 통해 대신 처리해주는 것이다.

2. 그렇다면 AOP란 무엇일까?

AOP는 Aspect-Oriented Programming, 즉 관점 지향 프로그래밍이다.

이름만 보면 어렵지만 Spring을 사용하는 입장에서는 간단하게 생각할 수 있다.

여러 비즈니스 로직에서 반복되는 공통 기능을 실제 비즈니스 코드와 분리하는 방법

예를 들어 다음 메서드가 있다고 해보자.

order();
cancel();
refund();

모든 메서드에서 트랜잭션 처리가 필요하다면 직접 구현할 경우 다음과 같은 코드가 반복된다.

public void order() {
    begin();

    // 주문 처리

    commit();
}

public void cancel() {
    begin();

    // 취소 처리

    commit();
}

Spring에서는 대신 다음과 같이 작성한다.

@Transactional
public void order() {
    // 주문 처리
}

@Transactional
public void cancel() {
    // 취소 처리
}

트랜잭션이라는 공통 관심사는 Proxy에게 맡기고 Service에는 비즈니스 로직을 남기는 것이다.

Spring에서는 이러한 AOP를 구현하는 주요 방법 중 하나로 Proxy 패턴을 사용한다.

3. Proxy란 무엇일까?

Proxy는 말 그대로 대리 객체다.

실제 Service를 바로 호출하지 않고 중간에 대리인을 하나 둔다고 생각하면 쉽다.

호출자
  │
  ▼
Proxy
  │
  ▼
실제 Service

Proxy는 실제 메서드를 호출하기 전후로 추가적인 작업을 수행할 수 있다.

order() 호출
     │
     ▼
@Transactional 확인
     │
     ▼
Transaction 시작
     │
     ▼
실제 order() 호출
     │
 ┌───┴────┐
 ▼        ▼
정상      예외
 │        │
commit  rollback

이 구조 덕분에 @Transactional뿐 아니라 여러 Spring 기능이 AOP Proxy를 활용할 수 있다.

대표적으로 다음과 같은 기능들이 있다.

@Transactional
@Cacheable
@Async

4. Spring Proxy에는 두 가지 대표적인 방식이 있다

Spring AOP Proxy를 공부하면 반드시 등장하는 것이 있다.

  • JDK Dynamic Proxy
  • CGLIB Proxy

둘의 목적은 동일하다.

실제 객체 앞에 Proxy를 만들어 메서드 호출을 가로챈다.

하지만 Proxy 객체를 만드는 방법이 다르다.

가장 간단하게 정리하면 다음과 같다.

  JDK Dynamic Proxy CGLIB Proxy
방식 인터페이스 구현 클래스 상속
핵심 키워드 implements extends
인터페이스 필요 없어도 가능
final class 영향 없음 Proxy 생성 불가
final method 클래스 override 방식 아님 AOP 적용 불가
private method 인터페이스 메서드가 될 수 없음 override 불가능

핵심은 이것이다.

JDK Dynamic Proxy = Interface 기반

CGLIB Proxy = Class 상속 기반

5. JDK Dynamic Proxy

다음과 같은 Service가 있다고 해보자.

public interface OrderService {

    void order();
}

구현체가 존재한다.

@Service
public class OrderServiceImpl implements OrderService {

    @Transactional
    @Override
    public void order() {
        // 주문 처리
    }
}

JDK Dynamic Proxy는 동일한 인터페이스를 구현하는 Proxy를 만든다.

개념적으로는 다음과 같다.

class OrderServiceProxy implements OrderService {

    private OrderService target;

    @Override
    public void order() {

        // Transaction 시작

        target.order();

        // Transaction commit
    }
}

구조를 보면 다음과 같다.

              OrderService
              (interface)
               ▲       ▲
               │       │
      implements       implements
               │       │
       ┌───────┘       └─────────┐
       │                         │
  JDK Proxy              OrderServiceImpl
       │                         ▲
       └─────────────────────────┘
                 호출

즉 JDK Dynamic Proxy는 실제 클래스를 상속하는 것이 아니라 같은 인터페이스를 구현하는 별도의 객체를 만드는 방식이다.

6. CGLIB Proxy

CGLIB는 접근 방법이 다르다.

@Service
public class OrderService {

    @Transactional
    public void order() {
        // 주문 처리
    }
}

CGLIB는 실제 클래스를 상속하여 Proxy를 만든다.

개념적으로 보면 다음과 비슷하다.

class OrderService$$SpringCGLIB$$0 extends OrderService {

    @Override
    public void order() {

        // Transaction 시작

        super.order();

        // Transaction commit
    }
}

구조는 훨씬 단순하다.

        OrderService
             ▲
             │
           extends
             │
OrderService$$SpringCGLIB$$0

실제 런타임에서 Bean의 클래스를 출력해 보면 환경에 따라 다음과 비슷한 이름을 볼 수도 있다.

OrderService$$SpringCGLIB$$0

이것이 Spring이 만들어 놓은 Proxy 객체다.

7. CGLIB에서 final이 문제가 되는 이유

CGLIB의 원리를 알고 나면 final이 문제가 되는 이유도 자연스럽게 이해된다.

CGLIB의 핵심은

class Proxy extends OrderService

이기 때문이다.

따라서 다음 클래스는 상속할 수 없다.

public final class OrderService {
}

즉 개념적으로 다음 코드가 불가능하다.

class Proxy extends OrderService {
    // compile error
}

메서드 역시 마찬가지다.

public final void order() {
}

자식 클래스에서 final 메서드를 override할 수 없다.

따라서 CGLIB가 해당 메서드를 가로채 AOP 기능을 적용할 수 없다.

결국 CGLIB의 제약사항 상당수는 Spring의 특별한 규칙이라기보다 Java 상속의 제약사항에서 나온다.

8. 가장 유명한 함정: Self Invocation

Proxy의 동작 원리를 이해하면 @Transactional의 유명한 문제도 이해할 수 있다.

다음 코드를 보자.

@Service
public class OrderService {

    public void order() {
        saveOrder();
    }

    @Transactional
    public void saveOrder() {
        // DB 작업
    }
}

겉으로 보면 saveOrder()에 @Transactional이 있으니 트랜잭션이 시작될 것 같다.

하지만 일반적인 Spring Proxy 기반 AOP에서는 그렇지 않다.

외부에서 order()를 호출할 때는 Proxy를 거친다.

Controller
    │
    ▼
Proxy
    │
    ▼
OrderService.order()

하지만 order() 안에서

saveOrder();

를 호출하는 것은 사실상

this.saveOrder();

와 같다.

따라서 호출 구조가 다음과 같이 된다.

Controller
    │
    ▼
Proxy
    │
    ▼
OrderService.order()
    │
    │ this.saveOrder()
    ▼
OrderService.saveOrder()

saveOrder() 호출 과정에서 Proxy를 다시 거치지 않는다.

따라서 Proxy가 @Transactional을 확인하고 새로운 트랜잭션 처리를 수행할 기회도 없다.

이것이 Self Invocation 문제다.

9. CGLIB면 Self Invocation이 되지 않을까?

처음 CGLIB의 상속 구조를 알게 되면 이런 생각이 들 수 있다.

CGLIB는 실제 클래스를 상속해서 Proxy를 만드는데 내부 호출도 override된 메서드를 타지 않을까?

하지만 Spring의 일반적인 proxy 기반 AOP를 사용할 때는 self-invocation을 트랜잭션 경계로 기대하면 안 된다.

실무에서는 다음 원칙으로 이해하는 것이 가장 안전하다.

@Transactional은 외부에서 Spring Proxy를 통해 들어오는 호출을 트랜잭션 경계로 잡는다.

그래서 트랜잭션을 별도의 Service로 분리하는 패턴을 자주 사용한다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderSaveService orderSaveService;

    public void order() {
        orderSaveService.saveOrder();
    }
}
@Service
public class OrderSaveService {

    @Transactional
    public void saveOrder() {
        // DB 작업
    }
}

그러면 호출 구조가 명확해진다.

OrderService
     │
     ▼
OrderSaveService Proxy
     │
     ▼
Transaction 시작
     │
     ▼
OrderSaveService.saveOrder()

10. 그런데 왜 최신 Spring에서는 public이 아니어도 될까?

여기서 처음에 가졌던 의문으로 돌아온다.

다음 코드가 있다고 해보자.

@Service
public class OrderService {

    @Transactional
    void save() {
        // package-private
    }
}

접근제어자를 작성하지 않았으므로 save()는 Java에서 package-private(default) 메서드다.

과거 Spring 관련 자료를 보면 흔히

@Transactional은 public 메서드에 사용해야 한다.

라고 설명한다.

하지만 Spring Framework 6부터는 클래스 기반 Proxy에서 protected와 package-private 메서드도 기본적으로 트랜잭션 메서드로 사용할 수 있다.

Spring Boot 3.x가 Spring Framework 6 계열을 사용하기 때문에 최근 프로젝트에서는 package-private @Transactional이 정상적으로 동작하는 모습을 볼 수 있다.

11. package-private이 가능한 이유

여기서 CGLIB의 동작 원리를 다시 생각해보면 재미있다.

다음 클래스가 있다고 하자.

package com.example.order;

@Service
public class OrderService {

    @Transactional
    void save() {
    }
}

CGLIB Proxy를 아주 단순화해서 표현하면 다음과 비슷하다.

package com.example.order;

class OrderService$$SpringCGLIB$$0 extends OrderService {

    @Override
    void save() {

        // Transaction 처리

        super.save();
    }
}

Java에서 package-private 메서드는 같은 패키지에서 접근 가능하다.

따라서 같은 패키지에 정의된 CGLIB 기반 Proxy가 해당 메서드를 override하고 가로챌 수 있는 것이다.

com.example.order

 ├── OrderService
 │       │
 │       └── void save()
 │
 └── OrderService$$SpringCGLIB$$0
         │
         └── override save()

이 부분을 이해하면 Spring 6에서 package-private 트랜잭션 메서드가 가능한 이유가 훨씬 자연스럽게 이해된다.

12. 그런데 private은 왜 여전히 안 될까?

다음과 같이 작성했다고 해보자.

@Service
public class OrderService {

    @Transactional
    private void save() {
    }
}

CGLIB가 같은 패키지에 Proxy를 만든다고 해도 이 메서드는 가로챌 수 없다.

왜냐하면 Java에서 private 메서드는 자식 클래스에서 override할 수 없기 때문이다.

즉 다음과 같은 구조 자체가 성립하지 않는다.

class OrderServiceProxy extends OrderService {

    @Override
    private void save() {
        // 불가능
    }
}

따라서 클래스 기반 Proxy를 기준으로 보면 접근제어자에 따른 차이는 대략 다음과 같이 이해할 수 있다.

접근제어자 Proxy 가능 여부 이유
public O override 가능
protected O override 가능
package-private O 조건을 만족하는 클래스 기반 Proxy에서 가능
private X override 불가능
final method X override 불가능

13. 접근제어자와 Self Invocation은 별개의 문제다

여기서 특히 주의해야 한다.

다음 코드가 있다고 하자.

@Service
public class OrderService {

    public void order() {
        save();
    }

    @Transactional
    void save() {
        // DB 작업
    }
}

Spring 6에서 package-private 메서드가 트랜잭션 대상이 될 수 있다는 것과 이 코드에서 트랜잭션이 적용되는지는 별개의 문제다.

두 가지를 분리해서 생각해야 한다.

첫 번째: 이 메서드를 Proxy가 가로챌 수 있는가?

@Transactional
void save()

Spring 6의 클래스 기반 Proxy에서는 가능하다.

두 번째: 실제 호출이 Proxy를 거치는가?

public void order() {
    save();
}

이 호출은 self-invocation이다.

OrderService.order()
       │
       │ this.save()
       ▼
OrderService.save()

Proxy를 통한 새로운 호출이 아니므로 save()의 @Transactional을 새로운 트랜잭션 경계로 기대해서는 안 된다.

즉 다음 두 질문을 항상 따로 해야 한다.

1. 이 메서드는 Proxy가 가로챌 수 있는 메서드인가?

                +

2. 실제 호출이 Proxy를 통해 들어오는가?

둘 다 만족해야 Proxy 기반 AOP를 제대로 이해할 수 있다.

14. Spring Boot에서는 JDK Proxy와 CGLIB 중 무엇을 사용할까?

전통적인 Spring AOP 설명에서는 흔히 다음과 같이 설명한다.

Interface 있음
      ↓
JDK Dynamic Proxy

Interface 없음
      ↓
CGLIB

Spring Framework의 기본적인 Proxy 선택 원리를 이해하는 데는 좋은 설명이다.

다만 Spring Boot에서는 설정에 따라 클래스 기반 Proxy를 기본으로 사용하는 환경이 일반적이므로,

“인터페이스가 존재하면 무조건 JDK Dynamic Proxy다.”

라고 단순하게 생각하면 실제 프로젝트에서 혼란이 생길 수 있다.

중요한 것은 현재 애플리케이션이 어떤 Proxy 전략을 사용하고 있는지 확인하는 것이다.

필요하다면 런타임에서 실제 Bean 타입을 확인해볼 수도 있다.

System.out.println(orderService.getClass());

CGLIB Proxy라면 환경에 따라 다음과 비슷한 형태를 볼 수 있다.

class com.example.order.OrderService$$SpringCGLIB$$0

15. 전체 흐름을 하나의 그림으로 정리해보자

결국 Spring의 @Transactional 동작 원리는 다음 그림으로 정리할 수 있다.

                     Spring Container
                           │
                           ▼
                    Bean 생성 과정
                           │
                           ▼
                 @Transactional 발견
                           │
                           ▼
                     Proxy 생성
                           │
              ┌────────────┴────────────┐
              │                         │
              ▼                         ▼
      JDK Dynamic Proxy             CGLIB Proxy
              │                         │
       implements Interface          extends Class
              │                         │
              └────────────┬────────────┘
                           │
                           ▼
                     외부 메서드 호출
                           │
                           ▼
                    Proxy가 가로챔
                           │
                           ▼
                   Transaction 시작
                           │
                           ▼
                    실제 Service 호출
                           │
                 ┌─────────┴─────────┐
                 ▼                   ▼
               정상                  예외
                 │                   │
              COMMIT              ROLLBACK

반면 self-invocation은

Proxy
  │
  ▼
Service.methodA()
  │
  │ this.methodB()
  ▼
Service.methodB()

처럼 Proxy를 다시 거치지 않는다는 것이 핵심이다.

16. 실무에서 기억할 것

모든 내부 구현을 외울 필요는 없다.

Spring에서 @Transactional을 사용할 때는 다음 정도를 기억하면 대부분의 문제를 이해할 수 있다.

  1. @Transactional 자체가 트랜잭션을 실행하는 것이 아니다.

    Spring AOP Proxy가 메서드 호출을 가로채 트랜잭션을 시작하고 종료한다.

  2. JDK Dynamic Proxy는 인터페이스 기반이다.

    핵심은 implements다.

  3. CGLIB Proxy는 클래스 상속 기반이다.

    핵심은 extends와 method overriding이다.

  4. CGLIB에서는 final을 주의해야 한다.

    상속하거나 override할 수 없기 때문이다.

  5. Spring 6의 클래스 기반 Proxy에서는 package-private @Transactional도 사용할 수 있다.

    따라서 Spring Boot 3.x에서는 public이 아니어도 트랜잭션이 정상적으로 동작하는 경우가 있다.

  6. 하지만 private 메서드는 Proxy가 override할 수 없다.

  7. 접근제어자보다 더 자주 문제가 되는 것은 Self Invocation이다.

    같은 객체 내부에서 this.xxx() 형태로 호출하면 일반적인 Spring Proxy 기반 AOP를 다시 거치지 않는다.

마무리

처음 @Transactional을 배울 때는 보통 이렇게 외운다.

@Transactional
public void save() {
}

“이렇게 붙이면 트랜잭션이 된다.”

사용하는 데는 충분하지만, 조금 복잡한 코드를 만나면 금방 의문이 생긴다.

@Transactional
void save() {
}

“어? public이 아닌데 왜 되지?”

또는

public void order() {
    save();
}

@Transactional
public void save() {
}

“어? @Transactional을 붙였는데 왜 안 되지?”

이런 현상을 제대로 이해하려면 어노테이션 자체보다 Proxy를 먼저 생각해야 한다.

결국 핵심은 한 문장으로 정리할 수 있다.

Spring의 @Transactional은 메서드 자체에 트랜잭션 기능을 넣는 것이 아니라, Spring이 만든 Proxy가 메서드 호출을 가로채 트랜잭션을 시작하고 commit/rollback하는 AOP 기능이다.

그리고 그 Proxy를 만드는 대표적인 방법이 JDK Dynamic Proxy와 CGLIB다.

이 원리를 알고 나면 @Transactional뿐 아니라 @Cacheable, @Async 등 Spring의 여러 AOP 기반 기능에서 발생하는 “어노테이션을 붙였는데 왜 동작하지 않지?”라는 문제도 훨씬 쉽게 이해할 수 있다.

참고자료


MSA 분산 트랜잭션과 Saga 패턴 정리.

|

🧩 MSA 아키텍처 시리즈 (전체 3편)

  1. MSA 기본 개념
  2. MSA 회복탄력성(Resilience) 패턴
  3. MSA 분산 트랜잭션과 Saga 패턴 (현재 글)

이 블로그의 Spring 에서의 트랜잭션 처리 글에서 다룬 트랜잭션은 하나의 DB 안에서의 이야기였다. MSA에서는 서비스마다 DB가 분리되므로(MSA 기본 개념 글의 “Database per Service”), 여러 서비스에 걸친 작업을 어떻게 하나의 트랜잭션처럼 묶을지가 새로운 문제가 된다.

문제 상황

전자상거래 주문 처리를 생각해보자. 모놀리스라면 하나의 DB 트랜잭션으로 묶어서 처리했을 것이다.

BEGIN;
  INSERT INTO orders (...);          -- 주문 생성
  UPDATE inventory SET stock = stock - 1 WHERE product_id = ...;  -- 재고 차감
  INSERT INTO payments (...);         -- 결제 처리
COMMIT; -- 셋 중 하나라도 실패하면 전부 ROLLBACK

MSA에서는 주문/재고/결제가 각각 다른 서비스, 다른 DB에 있다. BEGIN...COMMIT으로 묶을 수 있는 대상이 아니다 — 재고 서비스 DB를 주문 서비스가 직접 트랜잭션으로 묶을 방법이 없다(DB 자체가 물리적으로 분리되어 있으므로).

2PC(Two-Phase Commit) — 왜 MSA에서 잘 안 쓰이나

분산 트랜잭션의 고전적 해법이다. 코디네이터가 모든 참여자에게 “준비됐어?”(Prepare) 물어보고, 전부 “그렇다”고 하면 “커밋해”(Commit)를 보내는 2단계 방식이다.

MSA에서 잘 안 쓰이는 이유

  1. 모든 참여자가 준비 완료 응답을 할 때까지 각자 락을 걸어둔 채 대기해야 함 → 참여 서비스가 많아질수록, 그 중 하나만 느려도 전체가 블로킹됨.
  2. 코디네이터가 죽으면 참여자들이 영원히 대기(blocking) 상태에 빠질 수 있음.
  3. 가용성(Availability)을 강하게 희생하는 방식이라, MSA가 애초에 추구하는 “서비스별 독립성/가용성”과 철학적으로 충돌.

그래서 MSA에서는 강한 일관성(Strong Consistency) 대신, 최종적 일관성(Eventual Consistency)을 받아들이는 Saga 패턴을 주로 사용한다.

Saga 패턴 — 보상 트랜잭션으로 되돌리기

아이디어는 이렇다: 하나의 큰 트랜잭션을, 각 서비스가 처리하는 일련의 로컬 트랜잭션으로 쪼갠다. 중간에 실패하면, 이미 실행된 이전 단계들을 되돌리는 보상 트랜잭션(Compensating Transaction)을 순서대로 실행한다.

1. 주문 서비스: 주문 생성 (로컬 트랜잭션, 커밋)
2. 재고 서비스: 재고 차감 (로컬 트랜잭션, 커밋)
3. 결제 서비스: 결제 시도 -> 실패!

-- 실패 이후 보상 트랜잭션 (역순으로)
2'. 재고 서비스: 차감했던 재고 복구 (보상)
1'. 주문 서비스: 주문 상태를 '취소'로 변경 (보상)

각 단계는 “성공하거나, 실패하면 이전 단계를 취소하는 보상 로직이 반드시 존재”해야 한다는 게 전제다. 각 로컬 트랜잭션 자체는 각 서비스 DB 안에서 원자적(ACID)이지만, 그 여러 개를 묶는 “사가 전체”는 원자적이지 않고, 실패 시 명시적으로 되돌리는 코드를 짜야 한다.

방식 1) Choreography (안무 방식) — 이벤트를 통한 자율 조정

주문 서비스 --(OrderCreated 이벤트)--> 재고 서비스 --(StockReserved 이벤트)--> 결제 서비스
                                                                                |
                                                                          결제 실패!
                                                                                v
                                            (PaymentFailed 이벤트) <-----------+
재고 서비스가 구독 -> 재고 복구
주문 서비스가 구독 -> 주문 취소

각 서비스가 이벤트(메시지)를 발행하고, 다른 서비스들이 그 이벤트를 구독해서 각자 다음 행동을 결정한다. 중앙에서 지휘하는 주체가 없다 (RabbitMQ/Kafka 같은 메시지 브로커 위에서 구현).

  • 장점: 서비스 간 결합이 낮음(누가 이 이벤트를 구독하는지 발행자는 몰라도 됨).
  • 단점: 서비스가 많아질수록 “지금 전체 흐름이 어떻게 되고 있는지”를 한눈에 파악하기 어려움 — 이벤트를 따라다니며 로직이 여기저기 흩어짐.

방식 2) Orchestration (오케스트레이션 방식) — 중앙 조정자

                    ┌──────────────────┐
                    │  Saga Orchestrator │
                    └──────────────────┘
                     |        |        |
                     v        v        v
                  주문      재고      결제
                 서비스     서비스     서비스

중앙의 오케스트레이터(Saga Manager)가 “1단계 호출 → 성공하면 2단계 호출 → 실패하면 보상 호출” 순서를 명시적으로 관리한다.

  • 장점: 흐름이 한 곳(오케스트레이터 코드)에 명시적으로 드러나서 파악/디버깅이 쉬움.
  • 단점: 오케스트레이터 자체가 각 서비스를 다 알아야 하므로 결합도가 상대적으로 높아지고, 오케스트레이터가 또 하나의 장애 지점(이전 글의 회복탄력성 패턴을 여기에도 똑같이 적용해야 함)이 될 수 있음.

실무에서는 참여 서비스 수가 많고 흐름이 복잡할수록 Orchestration을, 단순하고 서비스가 적을수록 Choreography를 선호하는 경향이 있다.

Outbox 패턴 — “DB에 저장했는데 이벤트 발행을 깜빡하면?”

Saga에서 실제로 자주 겪는 함정이 있다: “DB 커밋은 성공했는데, 그 직후 이벤트 발행에 실패하면?” (또는 반대로 이벤트는 발행됐는데 DB 커밋이 롤백되면?) → 둘 사이에 정합성이 깨진다.

해결책은 DB 트랜잭션 안에 “발행할 이벤트” 자체를 같은 트랜잭션으로 outbox 테이블에 함께 저장하고, 별도의 프로세스(polling 또는 CDC, Change Data Capture)가 그 outbox 테이블을 읽어서 실제 메시지 브로커로 발행하는 것이다. 이렇게 하면 “비즈니스 데이터 저장”과 “이벤트 저장”이 같은 로컬 트랜잭션으로 묶여서 원자성이 보장되고, 실제 브로커 발행은 그 이후 안정적으로 재시도할 수 있는 별개의 문제로 분리된다.


3편에 걸쳐 MSA의 기본 개념부터 회복탄력성 패턴, 분산 트랜잭션까지 정리해봤다. 이 시리즈에서 다룬 내용 대부분은 “모놀리스에서는 고민할 필요 없던 문제를, MSA로 넘어가면서 새로 떠안게 된다”는 흐름으로 이어진다 — 첫 글에서 강조했듯, MSA는 기술적 우열이 아니라 그 복잡도를 감당할 조직/운영 역량이 있는지의 문제라는 걸 다시 한번 짚고 마무리한다.

참고자료


MSA 회복탄력성(Resilience) 패턴 정리 - API Gateway, 서킷 브레이커, Bulkhead.

|

🧩 MSA 아키텍처 시리즈 (전체 3편)

  1. MSA 기본 개념
  2. MSA 회복탄력성(Resilience) 패턴 (현재 글)
  3. MSA 분산 트랜잭션과 Saga 패턴

MSA 기본 개념 글에서 “메서드 호출이 네트워크 호출로 바뀐다”고 했는데, 네트워크는 항상 느려지거나 실패할 수 있다는 전제를 깔고 설계해야 한다. 이 글에서는 그 실패를 어떻게 격리하고 견뎌낼지에 대한 패턴들을 정리한다.

1. API Gateway — 클라이언트와 서비스들 사이의 단일 진입점

클라이언트(모바일 앱, 웹)가 수십 개의 마이크로서비스 주소를 직접 다 알고 각각 호출하게 하면 관리가 불가능해진다. 게이트웨이가 그 앞을 가로막고 하나의 진입점 역할을 한다.

게이트웨이가 대신 처리해주는 것들

  • 라우팅 (요청 경로에 따라 알맞은 서비스로 전달)
  • 인증/인가 (매 서비스마다 인증 로직을 중복 구현하지 않도록 한 곳에서 처리)
  • Rate Limiting (특정 클라이언트의 과도한 요청 제한)
  • 응답 캐싱, 로깅, 로드밸런싱
# Spring Cloud Gateway 설정 예시
spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://ORDER-SERVICE      # 서비스 디스커버리와 연동 (아래 참고)
          predicates:
            - Path=/api/orders/**
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/api/users/**

이 구조는 결국 리버스 프록시 기반 로드밸런서(nginx 등)에서 다루는 upstream 개념의 확장판이다 — 리버스 프록시가 “서버 여러 대”에 대한 진입점이었다면, API Gateway는 “서비스 여러 개”에 대한 진입점이라는 차이다.

2. 서비스 디스커버리 (Service Discovery)

MSA 환경에서는 서비스 인스턴스가 오토스케일링으로 계속 늘었다 줄었다 하고, IP도 계속 바뀐다. 설정 파일에 서버 IP를 하드코딩해두던 방식이 더 이상 통하지 않는다는 뜻이다.

해결책은 서비스가 뜰 때 자신의 위치(IP:Port)를 레지스트리(registry)에 등록하고, 호출하는 쪽은 서비스 이름(예: ORDER-SERVICE)만 알면 레지스트리가 현재 살아있는 인스턴스 목록을 알려주는 방식이다.

  • 대표 구현체: Netflix Eureka, HashiCorp Consul, Kubernetes의 내장 서비스 디스커버리(kube-dns + Service 오브젝트).
  • Kubernetes를 쓴다면 사실 Eureka 같은 걸 따로 안 둬도, Kubernetes의 Service/DNS 자체가 이 역할을 대신해준다 — “쿠버네티스 위에서 MSA를 하면 이 문제의 상당 부분이 인프라 레벨에서 이미 해결되어 있다”는 게 실무에서 k8s를 선호하는 이유 중 하나다.

3. 서킷 브레이커 (Circuit Breaker)

서비스 A가 서비스 B를 호출하는데, B가 응답이 느려지거나 죽었다면? A가 계속 B를 호출하며 기다리면, A의 쓰레드/커넥션이 전부 B를 기다리는 데 묶여버려서 A까지 함께 죽는 연쇄 장애(Cascading Failure)가 발생한다.

해결책은 가정용 전기 회로의 차단기(circuit breaker)와 같은 아이디어다 — 특정 호출의 실패율이 임계치를 넘으면, 아예 회로를 “열어서”(Open) 더 이상 그 서비스를 호출하지 않고 즉시 실패(또는 대체 응답, Fallback)를 반환한다.

서킷 브레이커의 3가지 상태

CLOSED(정상) --[실패율 임계치 초과]--> OPEN(차단)
   ^                                        |
   |                                  [일정 시간 경과]
   |                                        v
   +----[성공]---- HALF_OPEN(일부만 시험 호출) <--+
                       |
                 [다시 실패하면 OPEN으로]
  • CLOSED: 평소 상태. 요청이 정상적으로 통과됨.
  • OPEN: 실패율이 임계치를 넘으면 전환. 일정 시간 동안은 호출 자체를 시도하지 않고 즉시 실패/폴백 처리 (죽어가는 서비스에 요청을 계속 던져서 상황을 더 악화시키지 않기 위함).
  • HALF_OPEN: OPEN 상태로 일정 시간이 지나면, 일부 요청만 실제로 흘려보내서 “이제 복구됐는지” 시험. 성공하면 CLOSED로 복귀, 다시 실패하면 OPEN으로 되돌아감.
// Resilience4j 예시
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
public PaymentResult charge(long amount) {
    return paymentClient.charge(amount); // 외부(결제) 서비스 호출
}

private PaymentResult fallback(long amount, Throwable t) {
    return PaymentResult.pending("결제 서비스 응답 지연 - 나중에 재처리"); // 대체 응답
}

4. Retry, Timeout, Bulkhead — 서킷 브레이커와 함께 쓰이는 패턴들

  • Timeout: 응답을 무한정 기다리지 않고, 일정 시간이 지나면 포기하고 실패 처리. 모든 외부 호출에는 예외 없이 타임아웃이 설정되어 있어야 한다 — 타임아웃이 없는 호출 하나가 앞서 말한 연쇄 장애의 시작점이 되기 쉽다.
  • Retry: 일시적인 실패(네트워크 순간 끊김 등)라면 짧게 재시도. 단, 무조건 재시도하면 이미 부하로 힘든 서비스에 요청을 더 퍼붓는 꼴이 되므로 지수 백오프(Exponential Backoff)(재시도 간격을 점점 늘림) + 재시도 횟수 제한을 함께 걸어야 한다.
  • Bulkhead(격벽): 배의 방수격벽처럼, 서비스 A가 B, C, D를 호출한다면 B/C/D 호출에 쓰는 쓰레드풀/커넥션풀을 각각 분리해두는 패턴. 이 블로그의 @Async로 비동기처리하기 글에서 다룬 쓰레드풀 개념이 여기서 실전으로 이어진다 — 만약 A가 B, C, D 호출에 같은 쓰레드풀을 공유해서 쓰고 있었다면, B가 느려지는 순간 그 풀의 쓰레드가 전부 B 응답을 기다리는 데 묶여서 C, D 호출까지 처리 못 하게 된다(자원 고갈로 인한 연쇄 장애). 호출 대상별로 풀을 분리해두면, B가 죽어도 C·D 호출은 영향받지 않는다.

정리 — 이 패턴들이 실제로 막아주는 것

위 패턴들의 공통 목표는 결국 하나다: 부분 실패(하나의 서비스 장애)가 전체 시스템 장애로 번지지 않도록 격리하는 것.

모놀리스에서는 이런 고민이 상대적으로 덜 필요했다(같은 프로세스 안의 메서드 호출은 “네트워크가 끊긴다”는 실패 모드가 없으므로) — MSA로 전환하면서 반드시 새로 떠안게 되는 복잡도이고, 이걸 감당할 준비 없이 MSA를 도입하면 첫 글에서 경고한 대로 오히려 안정성이 떨어질 수 있다.

참고자료


MSA(마이크로서비스 아키텍처) 기본 개념 정리.

|

🧩 MSA 아키텍처 시리즈 (전체 3편)

  1. MSA 기본 개념 (현재 글)
  2. MSA 회복탄력성(Resilience) 패턴
  3. MSA 분산 트랜잭션과 Saga 패턴

2018년에 마소콘(마이크로소프트웨어 컨퍼런스) 후기를 몇 편 썼었는데, 그때 다뤘던 “사일로 해체”, “빠르고 자주 배포” 같은 문화적인 이야기(더 웨더 컴퍼니의 데브옵스 참고)를 실제로 가능하게 하는 아키텍처 형태가 MSA다. 몇 년이 지나 실무에서 이런저런 MSA 전환을 겪어보면서 다시 정리해본다.

이 글에서는 “MSA가 뭔지”와 “언제 써야(말아야) 하는지”를 먼저 정리하고, 이어지는 글에서 회복탄력성 패턴과 분산 트랜잭션을 다룬다.

모놀리스(Monolith) vs 마이크로서비스(Microservices)

  모놀리식 아키텍처 마이크로서비스 아키텍처
배포 단위 애플리케이션 전체가 하나의 배포 단위 서비스마다 독립적으로 배포
코드 구조 하나의 코드베이스, 모듈로 분리 서비스마다 별도 리포지토리(보통)
데이터베이스 보통 하나의 DB를 공유 서비스마다 자신의 DB를 소유 (Database per Service)
통신 방식 같은 프로세스 내 메서드 호출 네트워크를 통한 API 호출(HTTP/gRPC) 또는 메시징
스케일링 애플리케이션 전체를 통째로 스케일 트래픽이 몰리는 서비스만 선택적으로 스케일
장애 범위 한 부분의 버그가 전체에 영향을 줄 수 있음 원칙적으로는 장애가 해당 서비스로 국한(격리)
초기 개발 속도 빠름 (하나의 프로젝트, 트랜잭션 관리 쉬움) 느림 (분산 환경 설정, 서비스 간 계약 정의 필요)

MSA가 해결하려는 문제는 결국 “모놀리스가 커지면서 생기는 문제“들이다.

  • 코드베이스가 거대해져서 빌드/테스트 시간이 길어짐.
  • 한 팀이 배포하려면 다른 팀의 변경사항까지 다 같이 배포해야 해서(결합된 배포) 배포 주기가 느려짐.
  • 트래픽이 몰리는 기능(예: 주문) 하나 때문에 관계없는 기능(예: 리뷰)까지 통째로 스케일링해야 해서 자원 낭비.

Bounded Context — 서비스를 어떻게 나눌 것인가

DDD(Domain-Driven Design)에서 나온 개념. 하나의 “용어”가 문맥(context)에 따라 다른 의미를 가질 수 있다는 것을 인정하고, 그 문맥의 경계를 명확히 나누는 것이다.

예를 들어 전자상거래에서 “상품(Product)”이라는 단어는 카탈로그 서비스에서는 “이름, 설명, 이미지”를 뜻하지만, 재고 서비스에서는 “수량, 창고 위치”를 뜻하고, 결제 서비스에서는 “가격, 세금”만 관심사다. 이 세 가지를 하나의 거대한 Product 테이블/클래스로 억지로 합치면, 한 팀이 필드 하나만 바꿔도 다른 팀 코드가 깨지는 결합이 생긴다.

서비스 경계를 나누는 실전 기준은 비즈니스 능력(business capability) 단위다. “주문”, “결제”, “재고”, “배송”처럼 독립적으로 변경/배포될 수 있는 단위로 나눈다. 기술 계층(예: “모든 컨트롤러”, “모든 배치”)으로 나누는 건 잘못된 분리 기준이다.

Conway’s Law (콘웨이의 법칙)

“시스템을 설계하는 조직은, 자신들의 커뮤니케이션 구조를 그대로 닮은 설계를 만들어낸다.”

팀 구조와 시스템 구조는 서로 강하게 영향을 주고받는다. 앞서 언급한 “사일로 해체”가 여기서 다시 등장한다 — 팀이 사일로(부서 간 단절)로 나뉘어 있으면, 그 팀들이 만드는 시스템도 결국 사일로처럼 서로 강하게 결합되고 소통 비용이 큰 구조가 되기 쉽다.

MSA를 성공적으로 도입한 조직(아마존, 넷플릭스 등)은 대부분 팀 조직 자체를 서비스 단위(Two-Pizza Team)로 먼저 재편했다 — 아키텍처만 바꾼 게 아니라 조직 구조를 먼저/함께 바꾼 것이다. 이 순서를 거꾸로 해서 “조직은 그대로 두고 코드만 서비스로 쪼개면” 결국 팀 간 조율 비용이 그대로 남아 MSA의 이점(독립 배포)을 못 누리게 된다.

MSA 도입의 대가 (Trade-off) — 공짜가 아니다

  • 분산 시스템의 복잡도: 메서드 호출이 네트워크 호출로 바뀌면서, 지연시간·타임아웃·부분 실패(partial failure)를 항상 고려해야 함 → 다음 글(회복탄력성 패턴)의 주제.
  • 데이터 일관성: 서비스마다 DB가 분리되면 하나의 트랜잭션으로 묶을 수 없음 → 분산 트랜잭션 문제 발생 → 세 번째 글(Saga 패턴)의 주제.
  • 운영 복잡도: 서비스가 수십~수백 개가 되면, 로그/모니터링/추적(트레이싱)을 각 서비스마다 따로 볼 수 없어 분산 트레이싱(Zipkin/Jaeger), 중앙 로그 수집(ELK) 같은 인프라가 필수가 됨.
  • 테스트 복잡도: 서비스 하나를 테스트하려 해도 의존하는 다른 서비스들이 필요 — Contract Testing(Pact 등), 서비스 가상화(Mock 서버)로 대응.

“Monolith First” — 언제 MSA를 쓰지 말아야 하는가

Martin Fowler가 제안한 개념: 도메인 경계가 아직 불명확한 초기 단계에서는 모놀리스로 시작하고, 실제로 어디를 나눠야 하는지 경험을 통해 파악한 뒤에 MSA로 전환하는 게 안전하다는 접근이다.

MSA를 섣불리 도입하면 안 되는 신호

  • 팀이 작아서(2~3명) 서비스별로 팀을 나눌 수도 없는 규모인데 무작정 서비스만 쪼갬 → 한 사람이 여러 서비스를 오가며 유지보수 → 오히려 분산 시스템의 복잡도만 떠안음.
  • 도메인 경계가 자주 바뀜(스타트업 초기 피벗 단계) → 서비스 경계를 잘못 그으면, 잘못 그은 경계를 넘나드는 통신이 계속 생겨 오히려 모놀리스보다 못한 결합도가 생김.

즉 “MSA냐 모놀리스냐”는 기술적 우열의 문제가 아니라, 조직 규모·도메인 성숙도·운영 역량에 따라 달라지는 선택의 문제다.

참고자료