템플릿 콜백 패턴으로 감싸면 추적기를 ScopedValue로 옮길 수 있다

앞 글의 로그 추적기는 컨트롤러·서비스·리포지토리마다 try/catch 안에서 begin()과 end()를 부르고, 요청 하나의 호출을 묶는 트랜잭션 ID(DB 트랜잭션과는 관계없다)를 ThreadLocal에 두었다가 가장 바깥 호출이 끝날 때 remove()로 비운다. 계층마다 핵심 호출 한 줄을 추적 코드 여러 줄이 둘러싸고, 그 모양이 클래스마다 되풀이된다. 바뀌지 않는 앞뒤 처리를 한곳에 두고 바뀌는 가운데 코드만 넘기는 방법으로는 상속으로 넘기는 템플릿 메서드 패턴, 객체로 넘기는 전략 패턴, 호출할 때 코드 조각을 넘기는 템플릿 콜백 패턴이 있다

추적기처럼 감쌀 코드가 호출마다 다르면 템플릿 콜백 패턴이 맞다. 그 모양이 되면 추적 문맥을 ScopedValue(정해 둔 실행 범위 안에서만 값을 보여 주는 JDK 클래스)에 맡겨 remove()와 깊이를 되돌리는 코드를 지울 수 있다. 스프링에서도 트랜잭션·재시도처럼 앞뒤를 감싸는 API는 이 모양으로 남았고, HTTP 요청을 조립하는 RestTemplate은 다른 방식의 클라이언트로 넘어가는 중이다

앞뒤 처리는 메서드로 뽑을 수 없어 가운데 코드를 밖에서 받아야 한다

계층마다 되풀이되는 것은 begin() 호출, 정상 종료의 end(), 예외를 받아 exception()을 부르고 다시 던지는 catch다. 되풀이되지만 보통의 메서드 추출로는 뽑아낼 수 없다. 이 코드가 try 블록 한가운데의 핵심 호출을 앞뒤로 감싸고 있어서, 앞부분과 뒷부분을 따로 뽑으면 try/catch가 깨진다. 그래서 바뀌지 않는 앞뒤를 한 메서드에 두고, 바뀌는 가운데를 그 메서드가 받아서 실행하게 만들어야 한다

가운데를 받는 첫 방법은 상속이다. 추상 클래스의 execute()에 앞뒤 처리를 두고, 가운데 단계인 call()만 하위 클래스가 구현한다. 알고리즘의 뼈대를 부모에 두고 일부 단계를 자식에게 맡기는 이 구조를 템플릿 메서드 패턴이라고 한다

public abstract class AbstractTemplate<T> {

    // ...

    public T execute(String message) {
        TraceStatus status = null;
        try {
            status = trace.begin(message);
            //로직 호출
            T result = call();
            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e; //예외를 꼭 다시 던져주어야 한다.
        }
    }

    protected abstract T call();
}

컨트롤러는 이 클래스를 익명 내부 클래스로 그 자리에서 구현해 쓴다

@GetMapping("/v4/request")
public String request(String itemId) {
    AbstractTemplate<String> template = new AbstractTemplate<>(trace) {
        @Override
        protected String call() {
            orderService.orderItem(itemId);
            return "ok";
        }
    };
    return template.execute("OrderControllerV4.request()");
}

try/catch가 사라졌고, 로그 형식을 바꿀 때는 AbstractTemplate 한 곳만 고치면 된다. 대신 두 가지 비용이 남는다

하나는 쓰는 자리마다 하위 클래스를 만들어야 한다는 점이다. 익명 내부 클래스로 소스 파일은 늘지 않았지만 new AbstractTemplate<>(trace) { @Override protected ... }라는 틀은 그대로 남고, 람다는 추상 메서드가 하나뿐인 인터페이스만 대신할 수 있어서 추상 클래스에는 쓸 수 없다. 돌려줄 값이 없는 서비스와 리포지토리도 AbstractTemplate<Void>로 선언하고 return null을 써야 한다

다른 하나는 상속이 만드는 결합이다. 익명 클래스는 call() 하나만 구현하고 부모의 다른 기능은 쓰지 않는데도 부모 클래스에 묶여서, 부모에 추상 메서드가 하나 늘면 모든 하위 클래스를 고쳐야 한다

추적기에는 실행할 코드를 호출할 때 넘기는 방식이 맞는다

가운데 단계를 인터페이스로 빼서 객체로 넘기면 상속 없이 같은 일을 할 수 있다. 앞뒤 처리를 가진 쪽(컨텍스트)은 단계 인터페이스(전략)에만 의존하고, 전략을 구현하는 쪽은 메서드 하나짜리 인터페이스만 알면 된다. 이 구조를 전략 패턴이라고 하고, 전략을 넘기는 시점에 따라 두 가지로 쓸 수 있다

/**
 * 필드에 전략을 보관하는 방식
 */
public class ContextV1 {

    private Strategy strategy;

    public ContextV1(Strategy strategy) {
        this.strategy = strategy;
    }

    public void execute() {
        // ...
        strategy.call(); // 위임
        // ...
    }
}

/**
 * 전략을 파라미터로 전달 받는 방식
 */
public class ContextV2 {

    public void execute(Strategy strategy) {
        // ...
        strategy.call(); // 위임
        // ...
    }
}

ContextV1은 만들 때 전략을 조립해 두고 실행할 때는 아무것도 넘기지 않는다. 생성자로 의존 객체를 받아 필드에 두는 스프링의 생성자 주입이 이 모양이다. 실행은 단순하지만, 조립한 뒤 전략을 바꾸려면 컨텍스트를 하나 더 만들어야 한다. 싱글톤 컨텍스트에 setter를 두고 바꾸면 그 컨텍스트로 실행 중인 다른 스레드까지 바뀐 전략을 볼 수 있다. ContextV2는 컨텍스트 하나로 실행할 때마다 다른 전략을 넘길 수 있고, 대신 매번 넘겨야 한다

로그 추적기는 컨트롤러·서비스·리포지토리마다 감쌀 코드가 다르고, 같은 클래스 안에서도 메서드마다 다르다. 미리 조립해 둘 전략이 하나로 정해지지 않으니 실행할 때 넘기는 ContextV2 쪽이 맞는다. 스프링에서는 이 모양을 템플릿 콜백 패턴이라고 부르고, 컨텍스트를 템플릿, 넘기는 전략을 콜백이라고 한다. GoF(디자인 패턴 책의 네 저자)가 정리한 디자인 패턴에는 없는 이름이다. 강의는 스프링이 이 방식을 자주 써서 스프링 쪽에서 쓰게 된 이름이고, 전략 패턴에서 템플릿과 콜백을 강조한 모양으로 보면 된다고 설명한다

추적기에 옮기면 다음과 같다. 콜백 인터페이스는 메서드가 call() 하나뿐이라 람다로 넘길 수 있다

public interface TraceCallback<T> {
    T call();
}

public class TraceTemplate {

    // ...

    public <T> T execute(String message, TraceCallback<T> callback) {
        TraceStatus status = null;
        try {
            status = trace.begin(message);
            //로직 호출
            T result = callback.call();
            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e; //예외를 꼭 다시 던져주어야 한다.
        }
    }
}

서비스는 호출하는 자리에서 감쌀 코드를 람다로 넘긴다

public void orderItem(String itemId) {
    template.execute("OrderServiceV5.orderItem()", () -> {
        orderRepository.save(itemId);
        return null;
    });
}

템플릿 메서드 때의 익명 클래스 틀이 람다 하나로 줄었고 상속도 없다. 추적 코드를 직접 쓴 버전(v3), 템플릿 메서드 버전(v4), 템플릿 콜백 버전(v5)에 같은 요청을 한 번씩 보낸 로그다. 메시지 앞 대괄호 안이 트랜잭션 ID이고, 화살표 앞에 붙는 |의 개수가 호출 깊이(컨트롤러 0, 서비스 1, 리포지토리 2)다

00:05:45.478 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ed919ef8] OrderControllerV3.request()
00:05:45.478 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ed919ef8] |-->OrderServiceV3.orderItem()
00:05:45.478 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ed919ef8] |    |-->OrderRepositoryV3.save()
00:05:46.480 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ed919ef8] |    |<--OrderRepositoryV3.save(), time=1002ms
00:05:46.480 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ed919ef8] |<--OrderServiceV3.orderItem(), time=1002ms
00:05:46.480 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ed919ef8] OrderControllerV3.request(), time=1002ms
00:05:46.805 [http-nio-18083-exec-4] ThreadLocalLogTrace : [fd681ebc] OrderControllerV4.request()
00:05:46.806 [http-nio-18083-exec-4] ThreadLocalLogTrace : [fd681ebc] |-->OrderServiceV4.orderItem()
00:05:46.806 [http-nio-18083-exec-4] ThreadLocalLogTrace : [fd681ebc] |    |-->OrderRepositoryV4.save()
00:05:47.806 [http-nio-18083-exec-4] ThreadLocalLogTrace : [fd681ebc] |    |<--OrderRepositoryV4.save(), time=1000ms
00:05:47.806 [http-nio-18083-exec-4] ThreadLocalLogTrace : [fd681ebc] |<--OrderServiceV4.orderItem(), time=1000ms
00:05:47.806 [http-nio-18083-exec-4] ThreadLocalLogTrace : [fd681ebc] OrderControllerV4.request(), time=1001ms
00:05:48.133 [http-nio-18083-exec-6] ThreadLocalLogTrace : [87f281d3] OrderControllerV5.request()
00:05:48.134 [http-nio-18083-exec-6] ThreadLocalLogTrace : [87f281d3] |-->OrderServiceV5.orderItem()
00:05:48.134 [http-nio-18083-exec-6] ThreadLocalLogTrace : [87f281d3] |    |-->OrderRepositoryV5.save()
00:05:49.139 [http-nio-18083-exec-6] ThreadLocalLogTrace : [87f281d3] |    |<--OrderRepositoryV5.save(), time=1005ms
00:05:49.139 [http-nio-18083-exec-6] ThreadLocalLogTrace : [87f281d3] |<--OrderServiceV5.orderItem(), time=1005ms
00:05:49.139 [http-nio-18083-exec-6] ThreadLocalLogTrace : [87f281d3] OrderControllerV5.request(), time=1006ms

세 요청 모두 여섯 줄이고 화살표 들여쓰기가 같다. 시각·스레드·걸린 시간과 요청마다 새로 만든 트랜잭션 ID를 빼면 클래스 이름 끝의 버전 숫자만 다르다. 코드를 두 번 바꾸는 동안 추적 결과는 그대로였다. 다만 TraceTemplate이 받는 LogTrace는 여전히 앞 글의 ThreadLocalLogTrace라서, 가장 바깥 호출이 끝나면 remove()로 비우고 안쪽 호출이 끝날 때마다 깊이를 하나씩 되돌린다. 달라진 것은 이 일이 이제 execute() 한 메서드 안에서 시작하고 끝난다는 점이다

콜백 모양이 되면 추적 문맥을 ScopedValue에 맡길 수 있다

앞 글은 JDK 25에서 정식 기능이 된 ScopedValue(JEP 506: Scoped Values)로 옮기려면 추적기 API가 “메시지와 실행할 코드를 받아 그 범위 안에서 실행한다”는 모양이어야 한다는 데서 멈췄다. TraceTemplate.execute(message, callback)이 바로 그 모양이다. begin()에서 end()까지가 한 메서드 안에서 차례로 실행되고 그 사이에 콜백이 실행되므로, ThreadLocal에 값을 넣었다 빼는 대신 콜백을 ScopedValue 범위로 감싸면 된다. 그렇게 바꾼 추적기는 다음과 같다. 트랜잭션 ID와 깊이는 바꿀 수 없는 record 하나로 묶는다

// ... (클래스 선언과 log 필드 생략)

record Trace(String id, int level) {
    Trace next() {
        return new Trace(id, level + 1);
    }
}

static final ScopedValue<Trace> CURRENT = ScopedValue.newInstance();

/** 메시지와 실행할 코드를 받아 그 범위 안에서만 Trace를 보이게 한다 */
static <T> T execute(String message, ScopedValue.CallableOp<T, RuntimeException> callback) {
    Trace trace = CURRENT.isBound()
        ? CURRENT.get().next()
        : new Trace(UUID.randomUUID().toString().substring(0, 8), 0);
    long startTimeMs = System.currentTimeMillis();
    log.info("[{}] {}{}", trace.id(), addSpace("-->", trace.level()), message);
    try {
        T result = ScopedValue.where(CURRENT, trace).call(callback);
        log.info("[{}] {}{}, time={}ms", trace.id(), addSpace("<--", trace.level()), message,
            System.currentTimeMillis() - startTimeMs);
        return result;
    } catch (RuntimeException e) {
        log.info("[{}] {}{}, time={}ms ex={}", trace.id(), addSpace("<X-", trace.level()), message,
            System.currentTimeMillis() - startTimeMs, e.toString());
        throw e;
    }
}

// addSpace()는 ThreadLocalLogTrace의 것과 같다

isBound()로 바깥 호출인지 안쪽 호출인지 가르고, 안쪽이면 바깥 값에서 깊이만 하나 올린 새 값을 만든다. 핵심은 ScopedValue.where(CURRENT, trace).call(callback) 한 줄이다. 콜백이 실행되는 동안에만 CURRENT가 trace를 돌려주고, 콜백이 끝나면 정상 종료든 예외든 바깥 범위의 값으로 돌아가거나 값이 없는 상태가 된다(ScopedValue.Carrier.call — Java SE 25 API). ThreadLocalLogTrace의 remove()와 깊이를 되돌리는 createPreviousId()에 해당하는 코드가 여기에는 없다

스프링 애플리케이션 대신 테스트 안에서 컨트롤러·서비스·리포지토리 호출을 람다 세 겹으로 흉내 내 확인했다. 리포지토리는 0.2초 쉬고, itemId가 ex면 예외를 던진다

private String request(String itemId) {
    return execute("OrderController.request()", () -> {
        execute("OrderService.orderItem()", () ->
            execute("OrderRepository.save()", () -> {
                if (itemId.equals("ex")) {
                    throw new IllegalStateException("예외 발생!");
                }
                sleep(200);
                return null;
            }));
        return "ok";
    });
}

정상 요청 한 번과, 요청이 끝난 뒤 isBound()를 찍은 로그다

00:05:51.721 [Test worker] ScopedTraceTemplateTest : [ddc858f7] OrderController.request()
00:05:51.721 [Test worker] ScopedTraceTemplateTest : [ddc858f7] |-->OrderService.orderItem()
00:05:51.721 [Test worker] ScopedTraceTemplateTest : [ddc858f7] |    |-->OrderRepository.save()
00:05:51.926 [Test worker] ScopedTraceTemplateTest : [ddc858f7] |    |<--OrderRepository.save(), time=205ms
00:05:51.926 [Test worker] ScopedTraceTemplateTest : [ddc858f7] |<--OrderService.orderItem(), time=205ms
00:05:51.926 [Test worker] ScopedTraceTemplateTest : [ddc858f7] OrderController.request(), time=205ms
00:05:51.927 [Test worker] ScopedTraceTemplateTest : after request, isBound=false

여섯 줄의 트랜잭션 ID가 모두 같고 깊이가 0에서 2까지 들어갔다가 0으로 나온다. 앞의 TraceTemplate 로그와 같은 모양이다. 요청이 끝난 뒤에는 isBound=false로, 비우는 코드 없이도 남은 값이 없다

예외 요청과, 끝난 뒤 isBound()를 찍은 로그다

00:05:51.718 [Test worker] ScopedTraceTemplateTest : [3a4218d6] OrderController.request()
00:05:51.718 [Test worker] ScopedTraceTemplateTest : [3a4218d6] |-->OrderService.orderItem()
00:05:51.718 [Test worker] ScopedTraceTemplateTest : [3a4218d6] |    |-->OrderRepository.save()
00:05:51.718 [Test worker] ScopedTraceTemplateTest : [3a4218d6] |    |<X-OrderRepository.save(), time=0ms ex=java.lang.IllegalStateException: 예외 발생!
00:05:51.718 [Test worker] ScopedTraceTemplateTest : [3a4218d6] |<X-OrderService.orderItem(), time=0ms ex=java.lang.IllegalStateException: 예외 발생!
00:05:51.718 [Test worker] ScopedTraceTemplateTest : [3a4218d6] OrderController.request(), time=0ms ex=java.lang.IllegalStateException: 예외 발생!
00:05:51.719 [Test worker] ScopedTraceTemplateTest : after exception, isBound=false

리포지토리에서 던진 예외가 서비스와 컨트롤러를 거쳐 나가면서 세 깊이 모두 예외 줄(ex=)을 남겼고(가장 바깥 컨트롤러 줄에는 화살표가 붙지 않는다), 예외로 끝난 뒤에도 값이 남지 않았다

가상 스레드 두 개에서 요청을 0.05초 차이로 겹쳐 실행한 로그다

00:05:51.454 [virtual-37] ScopedTraceTemplateTest : [7185286c] OrderController.request()
00:05:51.456 [virtual-37] ScopedTraceTemplateTest : [7185286c] |-->OrderService.orderItem()
00:05:51.457 [virtual-37] ScopedTraceTemplateTest : [7185286c] |    |-->OrderRepository.save()
00:05:51.507 [virtual-40] ScopedTraceTemplateTest : [1e905152] OrderController.request()
00:05:51.507 [virtual-40] ScopedTraceTemplateTest : [1e905152] |-->OrderService.orderItem()
00:05:51.507 [virtual-40] ScopedTraceTemplateTest : [1e905152] |    |-->OrderRepository.save()
00:05:51.662 [virtual-37] ScopedTraceTemplateTest : [7185286c] |    |<--OrderRepository.save(), time=205ms
00:05:51.662 [virtual-37] ScopedTraceTemplateTest : [7185286c] |<--OrderService.orderItem(), time=206ms
00:05:51.663 [virtual-37] ScopedTraceTemplateTest : [7185286c] OrderController.request(), time=209ms
00:05:51.711 [virtual-40] ScopedTraceTemplateTest : [1e905152] |    |<--OrderRepository.save(), time=204ms
00:05:51.711 [virtual-40] ScopedTraceTemplateTest : [1e905152] |<--OrderService.orderItem(), time=204ms
00:05:51.711 [virtual-40] ScopedTraceTemplateTest : [1e905152] OrderController.request(), time=204ms

두 요청의 줄이 섞여 찍혔지만 스레드마다 트랜잭션 ID가 다르고 둘 다 깊이 0에서 시작한다. 앞 글의 ThreadLocal 추적기와 같은 결과이고, 싱글톤 필드에 ID를 두었을 때와 달리 뒤 요청이 앞 요청의 ID를 이어 쓰지 않았다

이 추적기는 테스트에서만 확인했고, v5의 TraceTemplate 자리에 넣어 애플리케이션에서 실행해 보지는 않았다. 옮기려면 모든 추적 지점이 execute(message, callback)을 거쳐야 한다. where(...).call(...)로 묶은 값은 call()이 끝나면 풀리므로 begin()에서 묶어 다른 줄의 end()까지 이어 둘 수 없고, v3처럼 begin()이 돌려준 상태를 들고 있다가 다른 줄에서 end()를 부르는 코드가 하나라도 남아 있으면 그 자리는 이 방식으로 바꿀 수 없다. 템플릿 콜백 패턴으로 바꾸는 일이 곧 ScopedValue로 옮길 수 있는 조건을 만드는 일이다

스프링에서도 앞뒤를 감싸는 API는 콜백으로 남았다

강의는 스프링에서 이름이 xxxTemplate인 클래스를 보면 이 패턴으로 만들어졌다고 생각하라고 한다. 지금 스프링에서도 맞는 말이다. TransactionTemplate은 트랜잭션 안에서 실행할 코드를 콜백으로 받고, RestTemplate에서 가장 일반화된 메서드인 execute()도 요청을 쓰는 RequestCallback과 응답을 읽는 ResponseExtractor를 받는다(RestTemplate — Spring Framework 7 API). 다만 이름은 같아도 지금 가는 길은 갈렸다

트랜잭션과 재시도는 지금도 콜백을 받는다. Spring Framework 레퍼런스는 지금도 명령형 코드에서 트랜잭션을 코드로 관리할 때 TransactionTemplate을 권하고, 트랜잭션 자원을 얻고 놓는 반복 코드를 애플리케이션 코드에서 덜어 내려고 콜백 방식을 쓴다고 설명한다(Programmatic Transaction Management). 재시도는 7.0부터 같은 모양으로 프레임워크 core에 들어왔다. RetryTemplate은 재시도할 작업을 람다로 받아 정해 둔 정책에 따라 다시 실행한다(Resilience Features — Programmatic Retry Support). 둘 다 앞 절의 추적기처럼 가운데 코드의 앞뒤에서 자원을 얻고 놓거나, 실패하면 가운데 코드를 다시 부르는 일을 맡는다

요청을 조립하는 API는 다른 길로 갔다. Spring 팀은 2025년 9월 블로그에서 RestTemplate을 다음 일정으로 정리한다고 밝혔다(The state of HTTP clients in Spring)

  • 7.0(2025년 11월): 폐기할 계획을 알린다
  • 7.1(잠정 2026년 11월): @Deprecated로 표시하고 제거 대상으로 지정한다
  • 8.0(시기 미정): 없앤다

대신 쓰라는 RestClient는 메서드를 점으로 이어 호출하며 요청을 조립하는 fluent API다. 2026년 11월 9일 현재 7.1 정식판은 나오지 않았다. 출시 후보판인 7.1.0-RC2의 RestTemplate에는 @Deprecated(since = "7.1", forRemoval = true)가 붙어 있고(RestTemplate.java — Spring Framework v7.1.0-RC2 소스), 정식판 7.0.10에는 아직 없다

JDBC 쪽에는 6.1부터 JdbcClient가 있다. JdbcTemplate의 위치 기반 파라미터 문장과 NamedParameterJdbcTemplate의 이름 기반 파라미터 문장을 fluent API 하나로 쓰게 한 조회·변경용 파사드(여러 클래스를 감싸 단순하게 쓰게 한 창구)다(JdbcClient — Spring Framework 레퍼런스). JdbcTemplate이 폐기 대상이 된 것은 아니고, 레퍼런스도 배치 삽입이나 저장 프로시저 호출처럼 JdbcClient에 없는 기능에는 SimpleJdbcInsert·SimpleJdbcCall이나 JdbcTemplate을 직접 쓰는 쪽을 권한다

HTTP 요청 하나를 만드는 데는 URL·헤더·본문·응답 변환처럼 정할 것이 많고, Spring 블로그가 템플릿 방식의 한계로 짚은 대로, 그 조합을 메서드 인자와 오버로드로 받으면 기능이 늘 때마다 메서드가 쌓이고 메서드 이름이 붐빈다. fluent API는 그 조합을 메서드 연쇄로 풀어 준다. 트랜잭션·재시도·추적처럼 “이 코드를 감싸서 실행하라”는 API는 감쌀 코드 하나만 받으면 되므로 콜백 하나로 충분하다. 템플릿 콜백을 쓸지는 클래스 이름이 아니라 그 API가 가운데 코드를 감싸는 일을 하는지로 정한다

템플릿 콜백으로 줄였어도 추적을 붙이려면 모든 클래스의 메서드를 열어 template.execute(...)로 감싸야 한다. 강의도 이 한계를 짚는다. 클래스가 수백 개라면 수백 개를 고쳐야 한다는 점은 처음의 v3와 다르지 않다. 원본 코드를 고치지 않고 추적을 붙이려면 호출을 가로채 대신 받아 주는 객체가 필요하고, 다음 편에서 프록시로 이어 간다


출처와 범위

인프런 김영한님의 「스프링 핵심 원리 – 고급편」 강의를 듣고, 강의를 따라 만든 예제를 Spring Boot 4.1.1(Tomcat 11.0.24)과 Java 25로 옮겨 실행하며 썼다. 애플리케이션 로그는 추적 로그가 없는 요청을 한 번 보내 서버 초기화를 마친 뒤 버전마다 요청을 한 번씩 보내 얻었고, ScopedValue 추적기는 테스트로 한 번 실행했다. 두 로그 모두 패턴을 시각·스레드·로거·메시지로 맞췄다. 2026년 11월 9일 기준 공식 문서 대조, ScopedValue 추적기와 테스트·실험 스크립트 작성, 원고 정리에 AI(Claude)를 썼다

참고 자료