내부 호출과 프록시
외부 호출과 this 내부 호출의 프록시 경로를 비교하고 빈 분리·조정 객체·템플릿·AspectJ 대안을 평가합니다.
Spring AOP의 포인트컷이 메서드와 정확히 맞아도, 대상 안에서 부른 this.method()에는 어드바이스가 실행되지 않습니다. 클라이언트가 가진 프록시로 들어온 외부 호출은 실제 대상 객체로 넘어가고, 그 안에서 일어나는 자바 메서드 호출은 프록시로 되돌아가지 않기 때문입니다.
트랜잭션, 재시도, 보안을 내부 메서드에 붙여 놓고 이 경로를 놓치면 애노테이션이 장식으로만 남습니다. 이 글에서는 외부 호출과 내부 호출, 곧 자기 호출(self-invocation)의 차이를 횟수로 확인하고, 빈 분리, 자기 주입, 프로그래밍 방식 템플릿, AspectJ 위빙이라는 대안을 비교합니다.
포인트컷이 맞아도 프록시를 다시 지나지 않으면 어드바이스는 없다
ProxyBoundary outer 진입 뒤 Target 내부 inner 직접 호출에서 advice 누계가 증가하지 않는 상태를 보여 준다.
SELF CALL · TARGET COUNT AND POLICY BOUNDARY
자기 호출은 대상에 도달해도 새 Advice를 만들지 않는다
외부 outer 호출은 프록시를 통과하지만 this.inner()는 같은 대상 안에서 끝납니다. 독립 정책 경계가 필요하면 Worker의 공개 프록시 참조로 위임합니다.
- 외부 outer → outer 대상 → this.inner
outer는 프록시를 지납니다. inner 대상은 1회 실행되지만 inner의 추가 advice는 0회입니다.
- Coordinator → Worker 프록시 → Worker 대상
독립 경계로 위임하고 일치 관찰 advice가 한 번 proceed하면 Worker advice1·대상1입니다.
진단 · 빈 → 경로 → Pointcut
프록시 여부, 실제 참조와 호출 경로를 먼저 확인합니다. 최종 참조·advice·대상·트랜잭션 수로 증명하며 애노테이션만 검사하지 않습니다.
아웃박스 요구를 먼저 지킨다
business와 outbox는 같은 로컬 트랜잭션입니다. saveOutbox의 REQUIRES_NEW를 살리기 위해 단순 분리하지 않고 발행 relay만 별도 워커로 옮깁니다.
블록 하나의 TransactionTemplate, 임시 자기 주입, AspectJ 위빙은 각각 비용이 다릅니다. 외부 트랜잭션이 있으면 내부 호출도 기존 트랜잭션 안에서 실행될 수 있습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- SelfCallModel의 outer 호출이다. Target 영역은 실제 대상 하나이고 내부 outer에서 inner로 이어지는 직접 호출은 새로운 ProxyBoundary 진입이 아니다. 두 화살표 모두 호출 전달이며 원 Advice 누계는 한 번이다. 이 순수 Java 모델의 객체를 자동 Spring 프록시로 간주하지 않는다.
- RegistrationFlow를 필요한 워커 프록시로 조립한 경로다. Coordinator의 registerAll은 주입된 Worker 노출 참조로 registerOne을 부르고 그 프록시가 실제 워커에 위임한다. 클래스 분리만으로 프록시를 보장하지 않으며 원 인터페이스/조정자 발췌는 자동 컨텍스트나 저장 커밋을 구현하지 않는다.
- 위 경로는 outer 프록시, outer 대상, this.inner 대상이며 추가 inner advice는 0, inner 대상은 1이다. 아래는 Coordinator가 Worker 프록시를 거쳐 Worker 대상에 호출하는 경로이며 일치 관찰 advice 1, 대상 1이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 포인트컷이 맞아도 프록시를 다시 지나지 않으면 어드바이스는 없다 |
| 24 | 위 경로는 outer 프록시, outer 대상, this.inner 대상이며 추가 inner advice는 0, inner 대상은 1이다. 아래는 Coordinator가 Worker 프록시를 거쳐 Worker 대상에 호출하는 경로이며 일치 관찰 advice 1, 대상 1이다. |
| 42 | 독립 경계로 위임하고 일치 관찰 advice가 한 번 proceed하면 Worker advice 1·대상 1입니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| outer proxy 진입 SelfCallModel의 outer 메서드가 ProxyBoundary를 통과합니다. | advice 누계: 1 · target: 한 객체 | 외부 진입에서 advice가 실행됩니다. |
| target 내부 inner outer 메서드가 같은 target의 inner 메서드를 직접 호출합니다. | 호출: target 내부 · proxy 재진입: 없음 | 직접 호출은 새 경계를 열지 않습니다. |
| 한 번의 advice로 반환 inner와 outer가 반환합니다. | advice 누계: 1 · 모델: 순수 Java | 내부 호출에도 누계는 늘지 않습니다. |
포인트컷이 맞아도 프록시를 다시 지나지 않으면 어드바이스는 없다
ProxyBoundary outer 진입 뒤 Target 내부 inner 직접 호출에서 advice 누계가 증가하지 않는 상태를 보여 준다.
SELF CALL · TARGET COUNT AND POLICY BOUNDARY
자기 호출은 대상에 도달해도 새 Advice를 만들지 않는다
외부 outer 호출은 프록시를 통과하지만 this.inner()는 같은 대상 안에서 끝납니다. 독립 정책 경계가 필요하면 Worker의 공개 프록시 참조로 위임합니다.
- 외부 outer → outer 대상 → this.inner
outer는 프록시를 지납니다. inner 대상은 1회 실행되지만 inner의 추가 advice는 0회입니다.
- Coordinator → Worker 프록시 → Worker 대상
독립 경계로 위임하고 일치 관찰 advice가 한 번 proceed하면 Worker advice1·대상1입니다.
진단 · 빈 → 경로 → Pointcut
프록시 여부, 실제 참조와 호출 경로를 먼저 확인합니다. 최종 참조·advice·대상·트랜잭션 수로 증명하며 애노테이션만 검사하지 않습니다.
아웃박스 요구를 먼저 지킨다
business와 outbox는 같은 로컬 트랜잭션입니다. saveOutbox의 REQUIRES_NEW를 살리기 위해 단순 분리하지 않고 발행 relay만 별도 워커로 옮깁니다.
블록 하나의 TransactionTemplate, 임시 자기 주입, AspectJ 위빙은 각각 비용이 다릅니다. 외부 트랜잭션이 있으면 내부 호출도 기존 트랜잭션 안에서 실행될 수 있습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- SelfCallModel의 outer 호출이다. Target 영역은 실제 대상 하나이고 내부 outer에서 inner로 이어지는 직접 호출은 새로운 ProxyBoundary 진입이 아니다. 두 화살표 모두 호출 전달이며 원 Advice 누계는 한 번이다. 이 순수 Java 모델의 객체를 자동 Spring 프록시로 간주하지 않는다.
- RegistrationFlow를 필요한 워커 프록시로 조립한 경로다. Coordinator의 registerAll은 주입된 Worker 노출 참조로 registerOne을 부르고 그 프록시가 실제 워커에 위임한다. 클래스 분리만으로 프록시를 보장하지 않으며 원 인터페이스/조정자 발췌는 자동 컨텍스트나 저장 커밋을 구현하지 않는다.
- 위 경로는 outer 프록시, outer 대상, this.inner 대상이며 추가 inner advice는 0, inner 대상은 1이다. 아래는 Coordinator가 Worker 프록시를 거쳐 Worker 대상에 호출하는 경로이며 일치 관찰 advice 1, 대상 1이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 포인트컷이 맞아도 프록시를 다시 지나지 않으면 어드바이스는 없다 |
| 24 | 위 경로는 outer 프록시, outer 대상, this.inner 대상이며 추가 inner advice는 0, inner 대상은 1이다. 아래는 Coordinator가 Worker 프록시를 거쳐 Worker 대상에 호출하는 경로이며 일치 관찰 advice 1, 대상 1이다. |
| 42 | 독립 경계로 위임하고 일치 관찰 advice가 한 번 proceed하면 Worker advice 1·대상 1입니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| outer proxy 진입 SelfCallModel의 outer 메서드가 ProxyBoundary를 통과합니다. | advice 누계: 1 · target: 한 객체 | 외부 진입에서 advice가 실행됩니다. |
| target 내부 inner outer 메서드가 같은 target의 inner 메서드를 직접 호출합니다. | 호출: target 내부 · proxy 재진입: 없음 | 직접 호출은 새 경계를 열지 않습니다. |
| 한 번의 advice로 반환 inner와 outer가 반환합니다. | advice 누계: 1 · 모델: 순수 Java | 내부 호출에도 누계는 늘지 않습니다. |
그림의 위쪽은 SelfCallModel에서 외부의 outer 호출이 ProxyBoundary를 지나 카운터를 1 올린 뒤, 같은 Target 안에서 inner를 직접 부르는 경로입니다. 내부의 inner 호출은 새로운 프록시 진입이 아니므로 어드바이스 누계는 한 번에 머뭅니다. 아래쪽은 RegistrationFlow의 조정자(coordinator)가 주입받은 워커의 노출 참조로 registerOne을 부르고, 그 프록시가 실제 워커에 위임하는 경로입니다. 클래스를 나눈 것만으로 프록시가 보장되지는 않으며, 이 발췌는 자동 컨텍스트나 저장·커밋을 구현하지 않습니다.
외부 호출과 내부 호출 비교
다음 모델은 프록시 역할의 객체가 외부 호출에서만 카운터를 올리고, 대상의 outer가 inner를 직접 부르는 차이를 최소한의 코드로 보여 줍니다. 순수 자바 모델이므로 Spring의 자동 프록시와 같다고 보지는 않습니다.
package board.selfcall;
import java.util.concurrent.atomic.AtomicInteger;
public final class SelfCallModel {
static final class Target {
String outer() {
return "outer->" + inner();
}
String inner() {
return "inner";
}
}
static final class ProxyBoundary {
private final Target target;
private final AtomicInteger adviceCalls;
ProxyBoundary(Target target, AtomicInteger adviceCalls) {
this.target = target;
this.adviceCalls = adviceCalls;
}
String outer() {
adviceCalls.incrementAndGet();
return target.outer();
}
String inner() {
adviceCalls.incrementAndGet();
return target.inner();
}
}
public static void main(String[] args) {
var calls = new AtomicInteger();
var proxy = new ProxyBoundary(new Target(), calls);
System.out.println(proxy.outer());
System.out.println("afterOuter=" + calls.get());
System.out.println(proxy.inner());
System.out.println("afterExternalInner=" + calls.get());
}
}외부에서 outer()를 부르면 어드바이스는 1회, 대상의 outer와 inner는 각각 1회 실행됩니다. 이어서 외부에서 inner()를 따로 부르면 어드바이스 누계가 2회가 됩니다.
outer->inner
afterOuter=1
inner
afterExternalInner=2inner는 모두 2회 실행됐지만, 그중 프록시를 지난 호출은 1회뿐입니다. 두 실행 모두에 어드바이스가 적용됐다고 짐작하면 자기 호출 한 건을 놓치게 됩니다. 실제 Spring 컨텍스트에서는 AopUtils.isAopProxy, 어드바이저 카운터, 트랜잭션 활성 여부를 같은 방식으로 확인합니다.
내부 정책을 다른 객체로 나누기
바깥 메서드가 흐름을 조정하고 안쪽 메서드가 독립적인 트랜잭션이나 재시도의 단위라면, 둘은 서로 다른 애플리케이션 역할일 가능성이 큽니다. 안쪽 작업을 워커 빈으로 추출하고, 바깥 조정자는 주입받은 프록시 참조를 호출합니다. 클래스를 나누는 목적은 AOP의 요령이 아니라, 트랜잭션이나 재시도의 단위를 객체 그래프에 드러내는 데 있습니다.
package board.selfcall;
import java.util.List;
public final class RegistrationFlow {
public static class Coordinator {
private final RegistrationWorker worker;
public Coordinator(RegistrationWorker worker) {
this.worker = worker;
}
public int registerAll(List<String> titles) {
int completed = 0;
for (String title : titles) {
worker.registerOne(title);
completed++;
}
return completed;
}
}
public interface RegistrationWorker {
void registerOne(String title);
}
private RegistrationFlow() { }
}트랜잭션이나 재시도 메타데이터는 워커 구현의 공개 메서드에 둡니다. 조정자에 긴 트랜잭션을 두지 않으면, 워커의 REQUIRES_NEW가 바깥 트랜잭션의 연결과 동시에 새 연결을 점유하는 문제도 줄어듭니다.
자기 주입과 AopContext
자기 자신의 인터페이스 프록시를 지연 주입(lazy injection)받아 self.inner()로 호출할 수도 있습니다. 하지만 순환 의존과 초기화 순서 문제가 생기고, 단위 테스트에서 자기 참조를 따로 조립해야 합니다. 구조를 당장 나누기 어려운 레거시 코드를 잇는 임시 방편으로만 씁니다.
AopContext.currentProxy()는 @EnableAspectJAutoProxy(exposeProxy = true) 같은 프록시 노출 설정과, 스레드에 묶인 프록시 컨텍스트를 요구합니다. 애플리케이션 코드가 Spring AOP API를 직접 알게 되고, 리액티브나 비동기처럼 스레드가 바뀌는 환경에서는 동작을 기대하기 어렵습니다. 메서드 한 줄에 어드바이스를 걸려고 구조를 숨기지 않습니다.
프록시 객체를 필드에 캐시하거나 생성자에서 currentProxy를 읽으면, 아직 호출 컨텍스트가 없어 실패합니다. 이 실패를 삼키고 대상을 직접 호출하면 정책이 조용히 빠지므로, 즉시 실패시키는 편이 낫습니다.
프로그래밍 방식 템플릿
별도의 빈으로 나누기가 어색하고 트랜잭션 블록 하나만 필요하다면 TransactionTemplate을 호출합니다. 콜백이 트랜잭션 관리자를 직접 쓰므로 자기 호출 여부와 관계없이 동작합니다. 대신 전파 속성, 예외 처리, null을 허용하는 반환이 코드에 드러나고, 애스펙트로 통일하는 장점은 줄어듭니다.
재시도도 재사용할 수 있는 실행자 콜백을 주입해 처리할 수 있습니다. 이 방식은 대상 메서드의 애노테이션을 찾지 않으므로, 같은 클래스 안에서도 어디서 정책이 적용되는지 분명하게 보입니다. 다만 프로그래밍 방식의 트랜잭션 블록이 계속 늘어난다면, 조정 객체를 분리하는 방법을 다시 검토합니다.
AspectJ 위빙
컴파일 시점 위빙이나 적재 시점 위빙은 바이트코드 수준의 조인 포인트를 쓰므로 자기 호출에도 어드바이스를 적용할 수 있습니다. 도메인 객체와 생성자·필드 접근까지 범위를 넓힐 수 있지만, 빌드 플러그인, javaagent, 클래스 로더, IDE, 테스트, 네이티브 이미지에 미치는 영향이 큽니다.
프록시의 한계 한 건을 피하려고 위빙을 도입하면, 팀이 디버깅하는 방식 전체가 바뀝니다. 메서드가 아닌 조인 포인트가 정말 넓게 필요하고 도구 비용을 감수할 수 있을 때, 아키텍처 결정으로 선택합니다. 그 경우에도 같은 애스펙트가 두 번 적용되지 않는지 통합 테스트로 확인합니다.
더 깊이: 트랜잭션을 AspectJ 모드로 바꾸면 달라지는 것
Spring에서 트랜잭션에 위빙을 고른다는 것은 구체적으로 @EnableTransactionManagement(mode = AdviceMode.ASPECTJ)를 쓴다는 뜻입니다. Spring 레퍼런스에 따르면 이 모드에서는 처음부터 프록시를 만들지 않고, Spring의 AspectJ 트랜잭션 애스펙트를 대상 클래스의 바이트코드에 엮어 어떤 종류의 메서드 호출에도 적용합니다. 그래서 같은 객체 안의 자기 호출도 트랜잭션으로 감싸집니다. 대신 클래스 경로에 spring-aspects 모듈이 있어야 하고, 적재 시점 위빙이나 컴파일 시점 위빙이 실제로 켜져 있어야 합니다.
적재 시점 위빙은 @EnableLoadTimeWeaving으로 켜고, 어떤 클래스와 애스펙트를 엮을지는 클래스 경로의 META-INF/aop.xml이 정합니다. AspectJ 사용 문서는 전용 LoadTimeWeaver가 없는 환경이라면 -javaagent:/path/to/spring-instrument.jar로 JVM을 시작하라고 안내하는데, 이것이 앞에서 말한 실행 환경 비용의 실체입니다. 또 Java 애노테이션은 인터페이스에서 상속되지 않으므로, AspectJ 모드에서는 인터페이스 메서드에 붙인 @Transactional이 인식되지 않습니다. 프록시 모드에서 인터페이스에 애노테이션을 두었던 코드라면, 모드를 바꾸는 순간 트랜잭션이 조용히 빠질 수 있습니다.
어드바이스를 붙일 수 없는 지점
클래스 프록시가 재정의할 수 없는 final 메서드와, 바깥에서 부를 수 없는 private 메서드는 어드바이스를 적용할 진입점으로 삼지 않습니다. new로 만든 엔티티나 도우미 객체는 빈 후처리기를 거치지 않습니다. 이런 경우는 애스펙트 표현식을 넓혀도 해결되지 않습니다.
구성 코드가 대상의 구체 타입을 직접 반환해, 다른 코드가 원본 참조를 따로 보관하고 있지 않은지도 확인합니다. Spring 빈 팩토리가 최종 프록시를 유일한 공유 참조로 제공하게 합니다.
진입 경로 회귀 테스트
외부 호출자에서 바깥 메서드로, 외부 호출자에서 안쪽 메서드로, 바깥 메서드에서 안쪽 메서드로의 자기 호출이라는 세 경로에서 어드바이스와 대상의 횟수를 기록합니다. 트랜잭션이면 저장된 행과 트랜잭션 활성 상태를, 보안이면 거부된 호출에서 대상 실행이 0회인지를, 재시도면 시도 횟수를 확인합니다. 애노테이션을 리플렉션으로 확인하는 테스트로는 프록시 우회를 발견하지 못합니다.
코드 검토에서는 어드바이스가 붙은 메서드를 같은 클래스가 호출하는 곳을 검색해 경고할 수 있습니다. 다만 모든 자기 호출이 별도의 어드바이스를 원하는 것은 아니므로, 아키텍처 규칙에 예외를 허용하는 이유도 함께 적어 둡니다.
정리
- 프록시를 거친 외부 호출만 어드바이스를 실행하며, 대상 안의
this호출은 포인트컷이 맞아도 프록시로 돌아가지 않습니다. - 안쪽 작업이 독립된 트랜잭션이나 재시도 단위라면 워커 빈으로 나누고, 조정자가 주입받은 프록시를 호출하게 합니다.
- 자기 주입과
AopContext.currentProxy()는 임시 방편이며, 블록 하나라면TransactionTemplate이, 넓은 조인 포인트가 필요하면 AspectJ 위빙이 대안입니다. - 세 진입 경로에서 어드바이스와 대상 횟수를 세는 회귀 테스트로 우회를 발견합니다.
연습 문제
1. 아웃박스 저장을 내부 호출로 둔 코드
publish()가 내부의 saveOutbox()를 호출하고, saveOutbox()에 REQUIRES_NEW와 재시도가 붙어 있는 코드를 고치세요. 아웃박스는 애그리거트와 함께 커밋되어야 한다는 요구를 반영해, 애노테이션을 단순히 살리는 것보다 올바른 트랜잭션 구성을 제시하세요.
해설 보기
아웃박스는 독립적으로 커밋할 대상이 아니라, 애그리거트의 트랜잭션에 반드시 함께 참여해야 합니다. 별도의 저장소 포트를 같은 트랜잭션 안에서 호출하고, 이벤트를 외부로 보내는 중계만 트랜잭션 밖의 워커로 분리합니다.
package board.selfcall;
import org.springframework.transaction.annotation.Transactional;
public class PostCreation {
private final PostStore posts;
private final OutboxStore outbox;
public PostCreation(PostStore posts, OutboxStore outbox) {
this.posts = posts;
this.outbox = outbox;
}
@Transactional
public long register(String title, String eventId) {
long id = posts.insert(title);
outbox.append(eventId, "PostCreated", id);
return id;
}
public interface PostStore { long insert(String title); }
public interface OutboxStore {
void append(String eventId, String type, long aggregateId);
}
}중계의 재시도는 이벤트 ID로 멱등하게 처리하며, 애플리케이션 트랜잭션 프록시와는 다른 빈에서 실행합니다. 처음 코드의 REQUIRES_NEW를 살리려고 saveOutbox를 별도 빈으로 나누기만 하면, 함께 커밋해야 한다는 요구를 오히려 어기게 됩니다.
2. 조정자가 받는 예외
registerAll에 세 제목을 차례로 넘깁니다. 주입된 worker 참조는 필요한 정책이 적용된 프록시이고, 첫 registerOne은 정상 반환하며 둘째는 RuntimeException을 던집니다. 조정자는 예외를 잡지 않습니다. 워커 호출 수, completed가 늘어난 횟수, 셋째 호출 여부, registerAll이 정상 반환하는지를 구하세요. 저장과 트랜잭션의 성공은 따로 확인한다고 가정합니다.
해설 보기
워커는 2회 호출되고, completed는 첫 호출 뒤에만 1 늘어납니다. 둘째 호출이 예외를 던지면 루프가 끝나므로 셋째는 호출되지 않고, registerAll은 정수를 반환하지 못한 채 그 예외를 그대로 전달합니다.
이때의 completed=1은 예외가 난 시점의 지역 변수 값일 뿐입니다. 각 워커가 실제로 커밋한 행의 수나 바깥 트랜잭션의 최종 성공을 이 변수만으로 판단하지 않습니다.
3. 아웃박스 저장이 실패하면
연습 1의 PostCreation을 프록시로 호출하고, 두 저장소가 같은 트랜잭션 관리자의 자원에 참여한다고 가정합니다. posts.insert가 실행되어 ID를 받은 뒤 outbox.append가 RuntimeException을 던지면, 게시글 행은 어떻게 되나요? 별도의 변형으로 아웃박스만 독립된 REQUIRES_NEW에서 먼저 커밋하고 그 뒤 게시글 트랜잭션이 실패하면 무엇이 남나요?
해설 보기
outbox.append가 예외를 던지면 register는 정상 반환하지 못하고, 트랜잭션의 커밋에도 도달하지 않습니다. 기본 롤백 규칙에 따라 런타임 예외에서는 롤백하므로, 앞의 insert도 함께 롤백됩니다. insert가 한 번 실행됐다는 사실과 게시글 행이 커밋됐다는 사실은 구별해야 합니다.
아웃박스만 REQUIRES_NEW에서 먼저 커밋하면, 그 뒤 게시글 트랜잭션이 실패해도 아웃박스 행은 남습니다. 존재하지 않는 게시글에 대한 이벤트가 발행될 수 있으므로 함께 커밋해야 한다는 요구를 채우지 못합니다. 이것이 이벤트 중계의 독립적인 재시도와 이벤트 저장의 원자성을 나눠야 하는 이유입니다. 다른 저장소 자원이나 사용자 지정 롤백 규칙을 쓴다면 이 가정을 새로 검증해야 하며, 이 발췌만으로 서로 다른 원격 저장소 사이의 원자성이 보장되지는 않습니다.
이것으로 13장을 마칩니다. 13장에서는 횡단 관심사를 애스펙트로 분리하는 이유와 용어에서 시작해, @Aspect 구성과 어드바이스의 종류·순서, execution·within·bean()과 런타임 지시자, 로그와 재시도를 묶은 실제 프록시 예제, 그리고 자기 호출이 프록시를 우회하는 문제와 대안까지 살펴봤습니다. 다음 장의 Spring Boot의 역할부터는 지금까지 손으로 조립해 온 컨테이너와 서블릿, 프록시 설정을 Spring Boot가 어떻게 자동으로 구성하는지 다룹니다.