728x90
반응형
SMALL

JDK 동적 프록시의 한계

JDK 동적 프록시를 사용할 때는 인터페이스가 필수이다. 그렇다면, 이전 포스팅에서 만들었던 V2 애플리케이션처럼 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 있을까? 이것은 일반적인 방법으로는 어렵고 CGLIB라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야 한다. 

 

CGLIB - 소개

CGLIB: Code Generator Library

  • CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다.
  • CGLIB를 사용하면 인터페이스가 없어도 구체클래스만 가지고 동적 프록시를 만들어낼 수 있다.
  • CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있다. 

참고로, 우리가 CGLIB를 직접 사용하는 경우는 거의 없다. 이후에 설명할 스프링의 ProxyFactory라는 것이 이 기술을 편리하게 사용하게 도와주기 때문에, 너무 깊이있게 파기보다는 CGLIB가 무엇인지 대략 개념만 잡으면 된다. 예제 코드로 CGLIB를 간단히 이해해보자.

 

공통 예제 코드

앞으로 다양한 상황을 설명하기 위해서 먼저 공통으로 사용할 예제 코드를 만들어보자.

  • 인터페이스와 구현이 있는 서비스 클래스 - ServiceInterface, ServiceImpl
  • 구체 클래스만 있는 서비스 클래스 - ConcreteService

ServiceInterface

package cwchoiit.springadvanced.proxy.common.service;

public interface ServiceInterface {
    void save();

    void find();
}

ServiceImpl

package cwchoiit.springadvanced.proxy.common.service;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ServiceImpl implements ServiceInterface{
    @Override
    public void save() {
        log.info("save 호출");
    }

    @Override
    public void find() {
        log.info("find 호출");
    }
}

ConcreteService

package cwchoiit.springadvanced.proxy.common.service;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ConcreteService {
    public void call() {
        log.info("ConcreteService call 호출");
    }
}

 

CGLIB - 예제 코드

JDK 동적 프록시에서 공통으로 사용되는 코드들을 작성하기 위해 InvocationHandler를 제공했듯이, CGLIBMethodInterceptor를 제공한다. 

MethodInterceptor - CGLIB 제공

package org.springframework.cglib.proxy;

import java.lang.reflect.Method;

public interface MethodInterceptor extends Callback {
    Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
  • obj → CGLIB가 적용된 객체
  • method 호출된 메서드
  • args 메서드를 호출하면서 전달된 인수
  • proxy 메서드 호출에 사용

TimeMethodInterceptor

package cwchoiit.springadvanced.proxy.cglib.code;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

@Slf4j
@RequiredArgsConstructor
public class TimeMethodInterceptor implements MethodInterceptor {

    private final Object target;

    @Override
    public Object intercept(Object obj,
                            Method method,
                            Object[] args,
                            MethodProxy proxy) throws Throwable {
        log.info("TimeMethodInterceptor 실행");
        long startTime = System.currentTimeMillis();
        Object result = proxy.invoke(target, args);
        long endTime = System.currentTimeMillis();
        log.info("TimeMethodInterceptor 종료 {} ms", endTime - startTime);
        return result;
    }
}
  • TimeMethodInterceptorMethodInterceptor 인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다. 
  • JDK 동적 프록시를 만들어낼 때 예제와 거의 같은 코드이다. 여기에다가 프록시가 적용해줄 공통 기능 코드를 작성하고 실제 객체를 호출하면 된다.
  • Object target → 프록시가 호출할 실제 대상
  • proxy.invoke(target, args) → 실제 대상을 동적으로 호출한다. JDK 동적 프록시는 전달받는 Method를 사용해서 method.invoke(...)를 호출했는데, 여기에서는 그렇게 해도 무방하지만 CGLIB는 성능상 MethodProxy를 사용하는 것을 권장한다.

이제 테스트 코드로 CGLIB를 사용해서 프록시를 동적으로 만들어보자!

CglibTest

package cwchoiit.springadvanced.proxy.cglib;

import cwchoiit.springadvanced.proxy.cglib.code.TimeMethodInterceptor;
import cwchoiit.springadvanced.proxy.common.service.ConcreteService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.cglib.proxy.Enhancer;

@Slf4j
public class CglibTest {

    @Test
    void cglib() {
        ConcreteService target = new ConcreteService();

        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(ConcreteService.class);
        enhancer.setCallback(new TimeMethodInterceptor(target));
        ConcreteService proxy = (ConcreteService) enhancer.create();

        log.info("target: {}", target.getClass());
        log.info("proxy: {}", proxy.getClass());
    }
}
  • ConcreteService는 인터페이스가 없는 구체 클래스이다. 여기에 CGLIB를 사용해서 프록시를 생성해보자.
  • Enhancer → CGLIBEnhancer를 사용해서 프록시를 생성한다.
  • enhancer.setSuperclass(ConcreteService.class) → CGLIB는 구체 클래스를 상속받아서 프록시를 생성할 수 있다. 따라서, 어떤 구체 클래스를 상속 받을지 지정해야한다.
  • enhancer.create() → 프록시를 생성한다. 앞서 할당한 enhancer.setSuperclass(ConcreteService.class)에서 지정한 클래스를 상속 받아서 프록시가 만들어진다.

실행 결과

14:16:55.264 [Test worker] INFO cwchoiit.springadvanced.proxy.cglib.CglibTest -- target: class cwchoiit.springadvanced.proxy.common.service.ConcreteService
14:16:55.266 [Test worker] INFO cwchoiit.springadvanced.proxy.cglib.CglibTest -- proxy: class cwchoiit.springadvanced.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$48ab274a
  • 실행 결과를 보면 프록시가 정상 적용된 것을 확인할 수 있다.
  • CGLIB가 생성한 프록시 이름 → ConcreteService$$EnhancerByCGLIB$$48ab274a
  • 참고로, JDK 동적 프록시가 생성한 클래스 이름은 이렇게 생겼다 → jdk.proxy3.$Proxy12

 

CGLIB의 제약

클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.

  • 부모 클래스의 생성자를 체크해야 한다. → CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다.
  • 클래스에 final 키워드가 붙으면 상속이 불가능하다. → 그 말은 CGLIB로 프록시를 만들어낼 수 없다.
  • 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다. → 그 말은 CGLIB로 프록시를 만들어내도 그 메서드에는 프록시 로직이 동작하지 않는다.

정리 

JDK 동적 프록시와 CGLIB를 사용해서 동적으로 프록시를 만들어 낼 수 있게 됐는데 문제는 JDK 동적 프록시는 인터페이스가 필수라서 구체클래스만 있는 경우 CGLIB를 사용해야 하고, 반대로 CGLIB로 프록시를 만들때는 구체 클래스를 기반으로 상속을 받아 프록시를 만들어내니 어쩔땐 이걸 쓰고 어쩔땐 저걸 쓰고 이래야 할까? 그러면 어쩔땐 InvocationHandler를 만들고, 어쩔땐 MethodInterceptor를 만들고 이래야 할까? 이렇게 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되면 얼마나 좋을까? 그리고 보통 자바와 스프링은 이렇게 같은 역할을 두고 서로 다른 기술을 사용할때 항상 뭐다? 역할과 구현을 나눠서 표준을 제공하고 그 표준을 구현한 구현체를 제공해준다. 이제 스프링이 제공하는 ProxyFactory를 알아보자!

프록시 팩토리 - 소개

문제점을 다시 리마인드 해보자!

  • 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야 할까?
  • 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해 JDK 동적 프록시가 제공하는 InvocationHandlerCGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야 할까?
  • 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되었으면!?

스프링은 유사한 구체적인 기술들이 있을 때, 그것들을 통합해서 일관성 있게 접근할 수 있고, 더욱 편리하게 사용할 수 있는 추상화된 기술을 제공한다. 스프링은 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리(ProxyFactory)라는 기능을 제공한다. 이전에는 상황에 따라서 JDK 동적 프록시를 사용하거나, CGLIB를 사용해야 했다면, 이제는 이 프록시 팩토리 하나로 편리하게 동적 프록시를 생성할 수 있다. 프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를 사용하고 구체 클래스만 있다면 CGLIB를 사용한다. 그리고 이 설정을 변경할 수도 있다.

 

 

그럼 추상화로 어떤 것을 사용해도 상관없게 해주는 것 까지는 이해했는데, 결국 JDK 동적 프록시는 InvocationHandler를 만들어야 하고 CGLIBMethodInterceptor를 각각 중복으로 만들어야 하는 건 변함없을까? 스프링은 이 문제를 해결하기 위해 부가 기능을 적용할 때 Advice라는 새로운 개념을 도입했다. 개발자는 InvocationHandler, MethodInterceptor를 신경쓰지 않고 Advice만 만들면 된다. 결과적으로 InvocationHandlerMethodInterceptor는 우리가 만든 Advice를 호출하게 된다. 프록시 팩토리를 사용하면, Advice를 호출하는 전용 InvocationHandler, MethodInterceptor를 내부에서 사용한다. 

 

그럼, 특정 조건에 맞을 때 프록시 로직을 적용하는 기능은 어떻게 하면 좋을까? 위에서 /no-log로 요청하는 것은 프록시 로직이 동작하지 않게끔 하게 했던 것 처럼 말이다. 어떤 경우엔 프록시를 적용하고 어떤 경우에는 프록시를 적용하지 않을지에 대한 기능을 스프링은 Pointcut 이라는 개념을 도입해서 이 문제를 일관성있게 해결한다.

 

프록시 팩토리 - 예제 코드1

Advice 만들기

Advice는 프록시에 적용하는 부가 기능 로직이다. 이것은 JDK 동적 프록시가 제공하는 InvocationHandlerCGLIB가 제공하는 MethodInterceptor의 개념과 유사하다. 둘을 개념적으로 추상화 한 것이다. 프록시 팩토리를 사용하면 둘 대신에 Advice를 사용하면 된다. 

 

Advice를 만드는 방법은 여러가지가 있지만, 기본적인 방법은 MethodInterceptor 인터페이스를 구현하면 된다.

MethodInterceptor

package org.aopalliance.intercept;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

@FunctionalInterface
public interface MethodInterceptor extends Interceptor {
    @Nullable
    Object invoke(@Nonnull MethodInvocation invocation) throws Throwable;
}
  • 엥?! MethodInterceptorCGLIB꺼 아닌가요? → 이름이 똑같은데 패키지를 잘 보자. CGLIB것이 아니라 org.aopalliance.intercept이다. 
  • 그리고 생김새도 살짝 다르다. 얘는 MethodInvocation 이라는 딱 하나의 파라미터만 받는다. 이 내부에 실제 객체를 호출하는 것, 현재 프록시 객체 인스턴스, args, 메서드 정보 등이 다 포함되어 있다. JDK 동적 프록시의 InvocationHandler나, CGLIBMethodInterceptor에서 파라미터로 제공되는 부분들이 이 안으로 다 들어갔다고 생각하면 된다.
  • 근데 Advice를 만든다더니 왜 이걸 구현해야 하나요? → 이 MethodInterceptorInterceptor를 상속받고, InterceptorAdvice를 상속한다. 그래서 괜찮다.

TimeAdvice

package cwchoiit.springadvanced.proxy.common.advice;

import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

@Slf4j
public class TimeAdvice implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("TimeAdvice 실행");
        long startTime = System.currentTimeMillis();
        Object result = invocation.proceed();
        long endTime = System.currentTimeMillis();
        log.info("TimeAdvice 종료 {} ms", endTime - startTime);
        return result;
    }
}

 

  • invoke()에서 invocation.proceed()를 호출하는데 이게 실제 객체의 메서드를 호출하는 부분이라고 보면 된다. 원래 기존에는 프록시는 항상 실제 객체를 주입받아야 한다고 했는데 여기에는 그런 코드가 없다. 이는 ProxyFactory를 만들 때 전달해주기 때문이다. 말보단 코드. ProxyFactory를 만들어내는 코드를 보면 자연스럽게 이해가 될 것이다.
  • invoke()안에 프록시가 공통으로 수행할 로직을 작성하면 된다.

ProxyFactoryTest

package cwchoiit.springadvanced.proxy.proxyfactory;

import cwchoiit.springadvanced.proxy.common.advice.TimeAdvice;
import cwchoiit.springadvanced.proxy.common.service.ServiceImpl;
import cwchoiit.springadvanced.proxy.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;

import static org.assertj.core.api.Assertions.*;

@Slf4j
public class ProxyFactoryTest {

    @Test
    @DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
    void interfaceProxy() {
        ServiceInterface target = new ServiceImpl();

        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvice(new TimeAdvice());
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
        log.info("target : {}", target.getClass());
        log.info("proxy : {}", proxy.getClass());

        proxy.find();

        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
        assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
    }
}
  • new ProxyFactory(target) → 프록시 팩토리를 생성할 때, 생성자에 프록시의 호출 대상을 함께 넘겨준다. 프록시 팩토리는 이 인스턴스 정보를 기반으로 프록시를 만들어낸다. 만약, 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용하고, 인터페이스가 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성한다. 여기서는 targetnew ServiceImpl()의 인스턴스이기 때문에 ServiceInterface 인터페이스가 있다. 따라서 이 인터페이스를 기반으로 JDK 동적 프록시를 생성한다. 그리고 이렇게 실제 호출 대상을 여기서 알려주기 때문에 Advice에는 실제 호출 대상을 받을 필요가 없는 것이다.
  • proxyFactory.addAdvice(new TimeAdvice()) → 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다. JDK 동적 프록시가 제공하는 InvocationHandlerCGLIB가 제공하는 MethodInterceptor의 개념과 유사하다. 이렇게 프록시가 제공하는 부가 기능 로직을 Advice라 한다.
  • proxyFactory.getProxy() → 프록시 객체를 생성하고 그 결과를 받는다. 

실행 결과

16:35:27.253 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl
16:35:27.255 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class jdk.proxy3.$Proxy13
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ServiceImpl -- find 호출
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms
  • 실행 결과를 보면 프록시가 정상 적용된 것을 확인할 수 있다. JDK 동적 프록시로 잘 적용되었다.
  • 프록시 팩토리를 통해서 프록시 적용을 확인할 수도 있는데, 프록시 팩토리를 통해서 프록시가 생성되면 AopUtils라는 유틸리티 클래스를 사용해서 JDK 동적 프록시인지 CGLIB인지 확인할 수 있다. 

 

프록시 팩토리 - 예제 코드2

이제 인터페이스 말고 구체 클래스를 넘겨줘보자.

@Test
@DisplayName("구체 클래스만 있으면 CGLIB 사용")
void concreteProxy() {
    ConcreteService target = new ConcreteService();

    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvice(new TimeAdvice());
    ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();
    log.info("target : {}", target.getClass());
    log.info("proxy : {}", proxy.getClass());

    proxy.call();

    assertThat(AopUtils.isAopProxy(proxy)).isTrue();
    assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
    assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}

 

  • ConcreteService는 구체 클래스다. 이 객체를 target으로 넘기면 ProxyFactoryCGLIB로 동적 프록시를 만든다.
  • 나머지 내용은 위 인터페이스를 넘긴것과 동일하다.

실행 결과

09:56:14.466 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ConcreteService
09:56:14.468 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class cwchoiit.springadvanced.proxy.common.service.ConcreteService$$SpringCGLIB$$0
09:56:14.469 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
09:56:14.470 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ConcreteService -- ConcreteService call 호출
09:56:14.470 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms

 

 

proxyTargetClass 옵션

ProxyFactory한테 인터페이스를 넘겨도 CGLIB로 만들어달라고 할 수 있다.

ProxyFactory가 가지고 있는 setProxyTargetClass() 메서드를 사용하면 된다. 이 메서드는 이름 그대로 ProxyTarget의 클래스로 설정하겠다는 의미다. 파라미터로 true를 넘기면 된다.

@Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고, 클래스 기반 프록시 생성")
void proxyTargetClass() {
    ServiceInterface target = new ServiceImpl();

    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvice(new TimeAdvice());
    proxyFactory.setProxyTargetClass(true);
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
    log.info("target : {}", target.getClass());
    log.info("proxy : {}", proxy.getClass());

    proxy.find();

    assertThat(AopUtils.isAopProxy(proxy)).isTrue();
    assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
    assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
  • 이번엔 target이 인터페이스가 있지만, 프록시팩토리의 setProxyTargetClass(true) 옵션을 설정해서, CGLIB로 프록시를 만들고 클래스 기반 프록시를 생성하는 코드이다.

실행 결과

09:59:08.286 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl
09:59:08.288 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl$$SpringCGLIB$$0
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ServiceImpl -- find 호출
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms
  • 실행 결과를 보면 프록시 클래스가 ServiceImpl$$SpringCGLIB$$...으로 되어있는 것을 볼 수 있다.

 

프록시 팩토리의 기술 선택 방법

  • 대상에 인터페이스가 있으면 JDK 동적 프록시, 인터페이스 기반 프록시
  • 대상에 인터페이스가 없으면 CGLIB, 구체 클래스 기반 프록시
  • setProxyTargetClass(true)를 사용하면 CGLIB, 구체 클래스 기반 프록시, 인터페이스 여부와 상관없음.

정리

  • 프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고 매우 편리하게 동적 프록시를 생성할 수 있다.
  • 프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게 Advice 하나로 편리하게 사용할 수 있었다. 이것은 프록시 팩토리가 내부에서 JDK 동적 프록시인 경우 InvocationHandlerAdvice를 호출하도록 개발해두고, CGLIB인 경우 MethodInterceptorAdvice를 호출하도록 기능을 개발해두었기 때문이다. 
  • 참고로, 스프링 부트는 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용한다. 따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다.

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

빈 후처리기(BeanPostProcessor)  (2) 2023.12.27
Advisor, Advice, Pointcut  (0) 2023.12.15
Proxy/Decorator Pattern 2 (JDK 동적 프록시)  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Template Callback Pattern  (0) 2023.12.12
728x90
반응형
SMALL

참고자료

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com

 

JDK 동적 프록시 이전 문제점

이 전 포스팅에서 프록시 패턴을 공부했는데, 문제가 여전히 있었다. 문제는 프록시 클래스를 일일이 다 하나씩 만들어 줘야 하는것. 다시 말해 프록시로 만들어 줄 클래스가 100개면 프록시 클래스도 100개가 있어야 한다는 것. 이 문제를 해결하기 위해 동적 프록시를 만들어서 단 하나의 프록시 클래스로 여러개의 클래스를 프록시화 할 수 있다.

SMALL

JDK 동적 프록시

JDK 동적 프록시는 내부적으로 리플렉션 기술을 사용한다. 그리고 JDK 동적 프록시는 한가지 제약이 있다. 반드시 인터페이스가 있어야 한다. 반드시. 그래서 인터페이스를 두 개 만들고 그 인터페이스를 구현한 구현 클래스 두 개, 동적 프록시 클래스 한 개를 만들어 보겠다. 

AInterface

package cwchoiit.springadvanced.proxy.jdkdynamic.code;

@FunctionalInterface
public interface AInterface {
    String call();
}

 

BInterface

package cwchoiit.springadvanced.proxy.jdkdynamic.code;

@FunctionalInterface
public interface BInterface {
    String call();
}

 

AImpl

package cwchoiit.springadvanced.proxy.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class AImpl implements AInterface {
    @Override
    public String call() {
        log.info("A 호출");
        return "A";
    }
}

 

BImpl

package cwchoiit.springadvanced.proxy.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class BImpl implements BInterface {
    @Override
    public String call() {
        log.info("B 호출");
        return "B";
    }
}
  • 아주 간단하게 AInterface, BInterface를 만들고 그것들을 구현한 AImpl, BImpl 클래스를 만들었다. 이제 동적 프록시를 만들어 보자.

 

JDK 동적 프록시 - 예제 코드

JDK 동적 프록시를 만드려면 java.lang.reflect.InvocationHandler를 구현해야 한다. 코드를 통해 바로 알아보자.

 

TimeInvocationHandler

package cwchoiit.springadvanced.proxy.jdkdynamic.code;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

@Slf4j
@RequiredArgsConstructor
public class TimeInvocationHandler implements InvocationHandler {

    private final Object target;

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();
        Object result = method.invoke(target, args);
        long endTime = System.currentTimeMillis();
        log.info("TimeProxy 종료 {} ms", endTime - startTime);
        return result;
    }
}
  • 구현할 메서드는 하나, invoke(Object proxy, Method method, Object[] args)이다. 여기서 proxy는 프록시 자신을 가리키고, method는 호출한 메서드를 말한다. args는 메서드를 호출할 때 전달한 인수들을 담고 있다. 없을수도 있다.
  • TimeInvocationHandlerInvocationHandler라는 인터페이스를 구현하고 이렇게 하면 JDK 동적 프록시에 적용할 공통 로직을 개발할 수 있다.
  • 이렇게 동적 프록시 하나를 만들어 두면 어떻게 뭘 프록시를 만들 수 있을까?

JdkDynamicProxyTest

package cwchoiit.springadvanced.proxy.jdkdynamic;

import cwchoiit.springadvanced.proxy.jdkdynamic.code.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import java.lang.reflect.Proxy;

@Slf4j
public class JdkDynamicProxyTest {

    @Test
    void dynamicA() {
        AInterface target = new AImpl();

        TimeInvocationHandler handler = new TimeInvocationHandler(target);

        AInterface proxy = (AInterface) Proxy.newProxyInstance(
                AInterface.class.getClassLoader(),
                new Class[]{AInterface.class},
                handler);

        proxy.call();

        log.info("targetClass = {}", target.getClass());
        log.info("proxyClass = {}", proxy.getClass());
    }

    @Test
    void dynamicB() {
        BInterface target = new BImpl();

        TimeInvocationHandler handler = new TimeInvocationHandler(target);

        BInterface proxy = (BInterface) Proxy.newProxyInstance(
                BInterface.class.getClassLoader(),
                new Class[]{BInterface.class},
                handler);

        proxy.call();

        log.info("targetClass = {}", target.getClass());
        log.info("proxyClass = {}", proxy.getClass());
    }
}
  • new TimeInvocationHandler(target) → 동적 프록시에 적용할 핸들러 로직이다. 모든 프록시는 결국 실제 객체가 필요하기 때문에 실제 객체인 target을 받아야 한다.
  • Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler) → 동적 프록시는 java.lang.reflect.Proxy를 통해서 생성할 수 있다. 클래스 로더 정보, 인터페이스, 그리고 핸들러 로직을 넣어주면 된다. 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.
  • 원래 반환하는 반환타입은 Object이지만, 인터페이스를 사용한 프록시도 역시나 인터페이스를 구현한 구현체 중 하나이므로 이 반환된 프록시를 형변환해서 사용할 수 있다.
  • proxy.call()을 호출하면, 기존 기능에 프록시 기능이 추가된 모습을 확인할 수 있다. 참고로 call() 메서드는 AInterface, BInterface가 가지고 있는 메서드이다.

실행 결과

18:32:50.497 [Test worker] INFO cwchoiit.springadvanced.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 실행
18:32:50.499 [Test worker] INFO cwchoiit.springadvanced.proxy.jdkdynamic.code.AImpl -- A 호출
18:32:50.499 [Test worker] INFO cwchoiit.springadvanced.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 종료 1 ms
18:32:50.499 [Test worker] INFO cwchoiit.springadvanced.proxy.jdkdynamic.JdkDynamicProxyTest -- targetClass = class cwchoiit.springadvanced.proxy.jdkdynamic.code.AImpl
18:32:50.499 [Test worker] INFO cwchoiit.springadvanced.proxy.jdkdynamic.JdkDynamicProxyTest -- proxyClass = class jdk.proxy3.$Proxy12

 

생성된 JDK 동적 프록시

proxyClass = class jdk.proxy3.$Proxy12 이 부분이 동적으로 생성된 프록시 클래스 정보이다. 이것은 우리가 직접 만든 클래스가 아니라 JDK 동적 프록시가 이름 그대로 동적으로 만들어준 프록시이다.

 

실행 순서

  • 클라이언트는 JDK 동적 프록시의 call()을 실행한다.
  • JDK 동적 프록시는 InvocationHandler.invoke()를 호출한다. TimeInvocationHandler가 구현체로 있으므로 TimeInvocationHandler.invoke()가 호출된다. 이때 파라미터로 받는 Method method는 클라이언트가 호출한 call() 메서드이다.
  • TimeInvocationHandler가 내부 로직을 수행하고, method.invoke(target, args)를 호출해서 target인 실제 객체(AImpl) 호출한다.
  • AImpl 인스턴스의 call()이 실행된다.
  • AImpl 인스턴스의 call()의 실행이 끝나면 TimeInvocationHandler로 응답이 돌아온다. 시간 로그를 출력하고 결과를 반환한다.

실행 순서 그림

  • 그림으로 보면 알겠지만, JDK 동적 프록시가 하는 일은 InvocationHandlerinvoke()를 호출하는 일만 한다. invoke(Object proxy, Method method, Object[] args)를 호출할 때 전달해주는 method는 클라이언트가 호출한 메서드인 call()call()을 호출할 때 만약 파라미터가 있었다면 그 값을 args로 전달한다.

 

정리

예제를 보면, AImpl, BImpl의 프록시를 만든적이 없다. 프록시는 JDK 동적 프록시를 사용해서 동적으로 만들고 TimeInvocationHandler는 공통으로 사용했다. JDK 동적 프록시 기술 덕분에 적용 대상 만큼 프록시 객체를 만들지 않아도 된다. 그리고 같은 부가 기능 로직을 한번만 개발해서 공통으로 적용할 수 있다. 만약 적용 대상이 100개여도 동적 프록시를 통해서 생성하고, 각각 필요한 InvocationHandler만 만들어서 넣어주면 된다. 

 

결과적으로, 프록시 클래스를 클래스 수만큼 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬수 있게 됐다.

  • 최종 그림이다. 점선은 개발자가 직접 만든 클래스가 아니다. 

 

JDK 동적 프록시 - 적용1

마찬가지로 InvocationHandler를 구현하는 구현체 하나를 만들면 된다.

 

LogTraceBasicHandler

package cwchoiit.springadvanced.proxy.config.dynamicproxy.handler;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class LogTraceBasicHandler implements InvocationHandler {

    private Object target;
    private LogTrace trace;

    public LogTraceBasicHandler(Object target, LogTrace trace) {
        this.target = target;
        this.trace = trace;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        TraceStatus status = null;
        try {
            String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
            status = trace.begin(message);
            Object result = method.invoke(target, args);
            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

  • 필요한 실제 객체와 LogTrace 객체를 주입받는다. 그 외 내용은 전부 이전과 동일하다.
  • 그런데, 그럼 메시지는 어떻게 남길까? "어떤 클래스의 어떤 메서드가 호출됐는가?"에 대한 메시지를 이전에는 문자로 입력했는데 이젠 그렇게 할 수가 없다. 동적 프록시로 만들때 이 LogTraceBasicHandler는 딱 하나니까. 그 해결법은 위 코드처럼 실행된 메서드를 통해서 구할 수 있다. 실행된 메서드에는 자기의 클래스 정보와 자기 자신의 이름 정보가 다 들어있다.
  • 그리고 이제 이 핸들러를 이용해서 빈으로 프록시를 등록하자.

DynamicProxyBasicConfig

package cwchoiit.springadvanced.proxy.config.dynamicproxy;

import cwchoiit.springadvanced.proxy.app.v1.*;
import cwchoiit.springadvanced.proxy.config.dynamicproxy.handler.LogTraceBasicHandler;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.lang.reflect.Proxy;

@Configuration
public class DynamicProxyBasicConfig {

    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace trace) {
        OrderRepositoryV1Impl target = new OrderRepositoryV1Impl();

        return (OrderRepositoryV1) Proxy.newProxyInstance(
                OrderRepositoryV1.class.getClassLoader(),
                new Class[]{OrderRepositoryV1.class},
                new LogTraceBasicHandler(target, trace));
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace trace) {
        OrderServiceV1Impl target = new OrderServiceV1Impl(orderRepositoryV1(trace));

        return (OrderServiceV1) Proxy.newProxyInstance(
                OrderServiceV1.class.getClassLoader(),
                new Class[]{OrderServiceV1.class},
                new LogTraceBasicHandler(target, trace));
    }

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace trace) {
        OrderControllerV1Impl target = new OrderControllerV1Impl(orderServiceV1(trace));

        return (OrderControllerV1) Proxy.newProxyInstance(
                OrderControllerV1.class.getClassLoader(),
                new Class[]{OrderControllerV1.class},
                new LogTraceBasicHandler(target, trace));
    }
}

 

  • 프록시를 만들어내는 코드 역시 위와 동일하다. 이렇게 실제 코드에도 적용해보았다. 이 동적 프록시의 단점이라고 한다면 인터페이스가 반드시 존재해야 한다는 것이다. 구체 클래스에는 적용이 불가하다.
  • 이제 이렇게 만든 동적 프록시로 실제 클라이언트가 요청을 보내보자. 요청을 보내면 요청 시간에 대한 시간이 로그로 남아야 한다.
  • 그 전에 이 과정을 그림으로 보면 다음과 같다.

 

실행 결과

[5c7aea8c] OrderControllerV1.request()
[5c7aea8c] |--->OrderServiceV1.orderItem()
[5c7aea8c] |    |--->OrderRepositoryV1.save()
[5c7aea8c] |    |<---OrderRepositoryV1.save() time=1001ms
[5c7aea8c] |<---OrderServiceV1.orderItem() time=1002ms
[5c7aea8c] OrderControllerV1.request() time=1002ms

 

  • 원하던대로 소요 시간에 대한 로그가 잘 남았다. 하지만 문제가 있다. 어떤 문제냐면, /v1/no-log로 요청했을 땐 로그가 남지 않길 원하는 경우에도 로그가 남는다. 왜냐하면 우리의 동적 프록시에는 no-log를 걸러내는 부분이 없기 때문이다. 이를 간단히 해결할 수 있다. 필터링을 사용해서 말이다.

 

JDK 동적 프록시 - 적용2

위에서 말한 문제인, 로그가 남으면 안되는 메서드까지도 로그가 남았다. 그 이유는 실제 객체를 넘겨주고 프록시로 만들면 프록시는 어떤 메서드가 호출되든지 InvocationHandlerinvoke()가 호출되기 때문이다. 이 문제를 간단하게 필터링을 통해 해결해보자.

 

LogTraceFilterHandler

package cwchoiit.springadvanced.proxy.config.dynamicproxy.handler;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.util.PatternMatchUtils;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class LogTraceFilterHandler implements InvocationHandler {

    private Object target;
    private LogTrace trace;
    private final String[] patterns;

    public LogTraceFilterHandler(Object target, LogTrace trace, String[] patterns) {
        this.target = target;
        this.trace = trace;
        this.patterns = patterns;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        String methodName = method.getName();
        if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
            return method.invoke(target, args);
        }

        TraceStatus status = null;
        try {
            String message = method.getDeclaringClass().getSimpleName() + "." + methodName + "()";
            status = trace.begin(message);
            Object result = method.invoke(target, args);
            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

  • 이 필터 기능이 추가된 동적 프록시는 생성자에서 파라미터로 patterns를 추가적으로 받는다. 이 패턴에 부합하는 요청만 로그기능을 추가해주고 부합하지 않는 경우엔 그냥 바로 실제 객체의 결과만을 리턴한다. 그 부분이 다음 부분이다.
String methodName = method.getName();
if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
    return method.invoke(target, args);
}

 

  • invoke()의 파라미터로 넘어오는 method의 이름을 받는다. 그 이름이 patterns에 일치하지 않으면 로그 기능은 추가하지 않는 코드이다. PatternMatchUtils는 스프링에서 제공해주는 유틸리티성 클래스이다.
  • 이제 이 필터 기능이 있는 동적 프록시로 다시 빈을 등록해보자.

DynamicProxyFilterConfig

package cwchoiit.springadvanced.proxy.config.dynamicproxy;

import cwchoiit.springadvanced.proxy.app.v1.*;
import cwchoiit.springadvanced.proxy.config.dynamicproxy.handler.LogTraceBasicHandler;
import cwchoiit.springadvanced.proxy.config.dynamicproxy.handler.LogTraceFilterHandler;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.lang.reflect.Proxy;

@Configuration
public class DynamicProxyFilterConfig {

    private static final String[] PATTERNS = {"request*", "order*", "save*"};

    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace trace) {
        OrderRepositoryV1Impl target = new OrderRepositoryV1Impl();

        return (OrderRepositoryV1) Proxy.newProxyInstance(
                OrderRepositoryV1.class.getClassLoader(),
                new Class[]{OrderRepositoryV1.class},
                new LogTraceFilterHandler(target, trace, PATTERNS));
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace trace) {
        OrderServiceV1Impl target = new OrderServiceV1Impl(orderRepositoryV1(trace));

        return (OrderServiceV1) Proxy.newProxyInstance(
                OrderServiceV1.class.getClassLoader(),
                new Class[]{OrderServiceV1.class},
                new LogTraceFilterHandler(target, trace, PATTERNS));
    }

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace trace) {
        OrderControllerV1Impl target = new OrderControllerV1Impl(orderServiceV1(trace));

        return (OrderControllerV1) Proxy.newProxyInstance(
                OrderControllerV1.class.getClassLoader(),
                new Class[]{OrderControllerV1.class},
                new LogTraceFilterHandler(target, trace, PATTERNS));
    }
}

 

  • 패턴이 추가됐다. no-log는 패턴에 들어가지 않는다. 이렇게 동적 프록시를 빈으로 등록하면 이제 /v1/no-log 로 요청해도 로그는 남지 않는다.

 

결론

JDK 동적 프록시를 이용해서 프록시를 일일이 다 만드는게 아니라 프록시가 처리해야 하는 공통 기능 코드를 InvocationHandler 하나만 만들어서 필요한 클래스마다 JDK 동적 프록시를 통해 프록시를 입혀봤다. 이 JDK 동적 프록시는 인터페이스가 필수이기 때문에 인터페이스가 없으면 안된다는 제약이 있다. 그래서 구체 클래스에는 적용하지 못한다는 단점이 있다. 이를 해결하기 위해서는 CGLIB라는 기술을 사용하면 된다.

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Advisor, Advice, Pointcut  (0) 2023.12.15
CGLIB,스프링이 지원하는 ProxyFactory  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Template Callback Pattern  (0) 2023.12.12
Strategy Pattern  (2) 2023.12.12
728x90
반응형
SMALL
SMALL

참고자료

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com

프록시, 프록시 패턴, 데코레이터 패턴 소개

이전 포스팅까지 로그 추적기를 만들어서 기존 요구사항을 모두 만족했지만, 결국엔 기존 코드를 많이 수정해야 한다는 한계가 여전히 남아있다. 아무리 코드 수정을 최소화하기 위해 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴을 사용해서 줄이고 줄였다만, 결과적으로 로그를 남기고 싶은 클래스가 수백개라면 수백개의 클래스를 모두 고쳐야 한다. 로그를 남길 때 기존 원본 코드를 변경해야 한다는 사실 그 자체가 개발자에게는 가장 큰 문제로 남는다. 

 

그래서 새로운 요구사항이 들어왔다.

  • 원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용해라.
  • 특정 메서드는 로그를 출력하지 않는 기능 (보안상 일부는 로그를 출력하면 안되기 때문)
  • 다음과 같은 다양한 케이스에 적용할 수 있어야 한다.
    • 인터페이스가 있는 구현 클래스에 적용
    • 인터페이스가 없는 구체 클래스에 적용
    • 컴포넌트 스캔 대상에 기능 적용

가장 어려운 문제는 원본 코드를 전혀 수정하지 않고 로그 추적기를 도입하는 것이다. 이 문제를 해결하려면 프록시가 필요하다!

프록시(Proxy)란?

프록시는 정말 자주 사용되는 용어이다. 다음 그림을 보자.

클라이언트와 서버가 있다. 클라이언트는 꼭 고객이나 사용자를 의미하는 게 아니고 서버는 꼭 어떤 웹 서버를 의미하는 것이 아니라 넓게 보아 클라이언트는 요청을 하는 쪽, 서버는 요청을 처리하는 쪽이다. 이 개념을 컴퓨터 네트워크에 도입하면 클라이언트는 웹 브라우저가 되는 것이고 서버는 웹 서버가 되는 것이다. 

 

일반적으로 클라이언트와 서버 간 호출에 있어 클라이언트가 서버에 직접 호출을 하고 호출의 결과를 직접 받는다. 이런 흐름을 직접 호출이라고 한다. 그런데 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 이때 대리자를 영어로 프록시(Proxy)라고 한다.

 

더 나아가 프록시는 또 다른 프록시를 호출할 수도 있다. 즉, 중간 대리자가 체인으로 엮일 수 있다는 소리다.

 

 

클라이언트가 프록시를 사용할 때

위 개념을 객체에 도입할 수 있다. 객체 입장에서 프록시가 되려면 클라이언트는 서버에게 요청한 것인지 프록시에게 요청한 것인지 조차 몰라야 한다. 언어적으로 풀면 서버와 프록시는 같은 인터페이스를 사용해야 한다. 그리고 클라이언트는 인터페이스에만 의존하면 된다. 그리고 클라이언트가 누구에게 요청했는지조차 몰라도 된다는 말은 다시 말해 어떤 쪽으로 요청하더라도 클라이언트 코드는 변경되면 안 된다.

다음 그림이 이를 설명한다.

 

클래스 의존 관계를 보면 클라이언트는 인터페이스에만 의존한다. 그리고 서버와 프록시가 같은 인터페이스를 사용한다. 따라서 DI를 통해 대체가 가능하다. 

 

프록시의 주요 기능

프록시를 통해서 할 수 있는 일은 크게 2가지로 구분할 수 있다.

  • 접근제어
    • 권한에 따른 접근 차단
    • 캐싱
    • 지연 로딩
  • 부가 기능 추가
    • 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
    • 예) 요청 값이나 응답 값을 중간에 변형한다.
    • 예) 실행 시간을 측정해서 추가 로그를 남긴다.

 

접근제어와 부가 기능 추가 모두 프록시를 사용하지만 이 둘을 의도에 따라 프록시 패턴과 데코레이터 패턴으로 구분한다.

  • 프록시 패턴: 접근 제어가 목적
  • 데코레이터 패턴: 새로운 기능 추가 목적

둘 다 프록시를 사용하지만, 의도가 다르다는 점이 핵심이다. 프록시 패턴이라해서 이 패턴만 프록시를 사용하는 것이 아니라 데코레이터 패턴도 프록시를 사용한다. 

 

 

프록시 패턴 사용 - 예제 코드1

프록시를 도입하기 전 다음은 프록시를 도입하기 전 클라이언트가 서버에게 요청하는 흐름이다.

 

클라이언트는 인터페이스(Subject)를 의존한다. 그 인터페이스의 구현체인 RealSubject 클래스로부터 실제 구현 메서드를 호출한다. 

Subject

package cwchoiit.springadvanced.proxy.pure.code;

@FunctionalInterface
public interface Subject {
    String operation();
}

RealSubject

package cwchoiit.springadvanced.proxy.pure.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class RealSubject implements Subject {
    @Override
    public String operation() {
        log.info("실제 객채 호출");
        sleep(1000);
        return "DATA";
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

ProxyPatternClient

package com.example.advanced.pureproxy.proxy.code;

public class ProxyPatternClient {

    private Subject subject;

    public ProxyPatternClient(Subject subject) {
        this.subject = subject;
    }

    public void execute() {
        subject.operation();
    }
}
  • 코드는 매우 간단하다. Client 코드에서 Subject를 주입받는다. Subject를 구현한 클래스가 주입된다. 

ProxyPatternTest

package cwchoiit.springadvanced.proxy.pure;

import cwchoiit.springadvanced.proxy.pure.code.ProxyPatternClient;
import cwchoiit.springadvanced.proxy.pure.code.RealSubject;
import org.junit.jupiter.api.Test;

public class ProxyPatternTest {

    @Test
    void noProxyTest() {
        RealSubject realSubject = new RealSubject();
        ProxyPatternClient client = new ProxyPatternClient(realSubject);

        client.execute();
        client.execute();
        client.execute();
    }
}

 

  • 이제 클라이언트가 서버에게 직접 요청해서 응답받는 코드를 수행해 보자. ProxyPatternClientSubject를 주입받아야 하므로 RealSubject 객체를 만들어 전달해 준다. 이후 clientexecute()를 세 번 실행한다. execute()는 내부 로직에 1초의 대기 시간이 있어서 최소 3초의 시간이 소요된다.

위 결과 화면으로부터 3초 127ms의 소요시간을 확인할 수 있다. 이제 프록시를 도입해 보자.

 

 

프록시 패턴 - 예제 코드2

다음은 프록시를 도입했을 때의 구조이다.

 

프록시를 도입한 후 클라이언트가 주입받을 Subject의 구현체로 Proxy가 추가가 된다.

이제 클라이언트는 중간에 프록시가 끼워져 있는지 끼워져 있지 않은지를 알 필요도 없고 아무런 클라이언트 코드에 대한 변경을 취할 필요가 없다. 프록시를 도입해 보자. 위 코드에서는 같은 데이터를 반환하는데 로직 상 1초의 대기시간(조회할 때 걸리는 시간이라고 가정하자)이 있기 때문에 총 3초의 긴 대기시간이 소요된다. 그렇다면 이러한 데이터를 조회하는 과정의 소요시간을 줄이기 위해 캐싱을 사용할 수 있을 것이다. 캐싱 기술 역시 프록시에서 해줄 수 있다. 

 

CacheProxy

package cwchoiit.springadvanced.proxy.pure.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CacheProxy implements Subject {

    private final Subject target;
    private String cacheValue;

    public CacheProxy(Subject target) {
        this.target = target;
    }

    @Override
    public String operation() {
        log.info("프록시 호출");
        if (cacheValue == null) {
            cacheValue = target.operation();
        }
        return cacheValue;
    }
}

 

  • 프록시는 통상 실제 객체를 target이라고 칭한다. 그 실제 객체를 프록시 객체가 주입을 받아야 실제 객체의 로직을 수행할 수 있고 거기에 플러스 프록시가 제공하는 기능을 덧붙일 수 있다. 그래서 같은 operation()에서 캐시 저장소에 캐시 데이터가 있다면 그 값을 바로 반환하고 없다면 실제 객체로부터 데이터를 받아와 반환한다. 이렇게 되면 3회의 요청 동안 한 번의 실제 객체를 호출할 것이므로 1초 언저리로 응답해 줄 수 있다.

ProxyPatternTest

package cwchoiit.springadvanced.proxy.pure;

import cwchoiit.springadvanced.proxy.pure.code.CacheProxy;
import cwchoiit.springadvanced.proxy.pure.code.ProxyPatternClient;
import cwchoiit.springadvanced.proxy.pure.code.RealSubject;
import org.junit.jupiter.api.Test;

public class ProxyPatternTest {
    @Test
    void cacheProxyTest() {
        RealSubject realSubject = new RealSubject(); // 실제 객체
        CacheProxy cacheProxy = new CacheProxy(realSubject); // 프록시

        ProxyPatternClient client = new ProxyPatternClient(cacheProxy); // 클라이언트에게 프록시가 주입됨

        client.execute();
        client.execute();
        client.execute();
    }
}

 

  • cacheProxyTest()는 프록시를 사용한다. 프록시를 사용한다고 해서 클라이언트 코드에 변경을 가하지 않는다. 이것이 핵심이다. 클라이언트 코드는 똑같이 Subject를 구현한 구현체만을 전달받고 그것이 프록시가 될 뿐이다. execute()를 세 번 실행해 보면 1초 언저리로 응답받는 것을 확인할 수 있다.

이렇게 간단하게 프록시를 통해 클라이언트의 간접 요청을 이해해봤다. 이후에 실제 프로젝트 코드에 프록시 패턴을 적용해서 공통적으로 처리될 부분에 대한 추가 기능을 기존 로직의 아무런 변경 없이 적용해 보자. 그전에 데코레이터 패턴도 알아봐야 한다.

 

데코레이터 패턴 - 예제 코드 1

데코레이터 패턴 적용 전 코드의 관계는 다음과 같다.

 

클라이언트는 Component 인터페이스에 의존하고 그 인터페이스를 구현한 구현체 클래스의 메서드를 호출한다.

 

Component

package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;

@FunctionalInterface
public interface Component {
    String operation();
}

 

RealComponent

package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class RealComponent implements Component {
    @Override
    public String operation() {
        log.info("RealComponent 실행");
        return "DATA";
    }
}

DecoratorPatternClient

package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class DecoratorPatternClient {

    private final Component component;

    public void execute() {
        String result = component.operation();
        log.info("result = {}", result);
    }
}

 

  • 위 코드까지가 데코레이터 패턴을 적용하기 전이다. 클라이언트는 Component를 주입받는다. 그 Component를 구현한 클래스의 메서드인 operation()을 호출하고 끝난다.
  • 이제 데코레이터 패턴을 적용해 보자. 이 패턴은 프록시 패턴과 구조가 동일하다. 클라이언트는 인터페이스인 Component를 상속받으며 Component를 구현한 구현체가 프록시든 실제 객체든 클라이언트 코드는 아무런 변경사항을 주지 않는다. 이 역시 이것이 핵심이다.

DecoratorPatternTest

package cwchoiit.springadvanced.proxy.pureproxy.decorator;

import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.RealComponent;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class DecoratorPatternTest {

    @Test
    void noDecorator() {
        RealComponent realComponent = new RealComponent();
        DecoratorPatternClient client = new DecoratorPatternClient(realComponent);

        client.execute();
    }
}

 

  • 실제 객체를 생성한 후 클라이언트에게 주입한다. 클라이언트는 그대로 execute()를 실행한다. 결과는 다음과 같다.
14:52:11.063 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
14:52:11.067 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=data

 

 

데코레이터 패턴 - 예제 코드 2

부가 기능 추가

앞서 설명한 것처럼 프록시를 통해서 할 수 있는 기능은 크게 접근 제어와 부가 기능 추가라는 2가지로 구분한다. 앞서 프록시 패턴에서 캐시를 통한 접근 제어를 알아보았다. 이번에는 프록시를 활용해서 부가 기능을 추가해보자. 이렇게 프록시로 부가 기능을 추가하는 것을 데코레이터 패턴이라 한다. 

 

응답 값을 꾸며주는 데코레이터

응답 값을 꾸며주는 데코레이터 프록시를 만들어보자.

 

MessageDecorator

package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class MessageDecorator implements Component{

    private final Component component;

    @Override
    public String operation() {
        log.info("MessageDecorator 실행");
        String operation = component.operation();
        String decoString = "*****" + operation + "*****";
        log.info("MessageDecorator 꾸미기 적용 전 = {}, 적용 후 ={}", operation, decoString);
        return decoString;
    }
}

 

  • 데코레이터에서는 실제 객체를 주입받는다. 그 실제 객체의 로직을 수행하는데 그 로직에 플러스되는 부가 기능을 데코레이터에서 처리할 뿐이다. 바로 테스트 코드를 보자.

DecoratorPatternTest

package cwchoiit.springadvanced.proxy.pureproxy.decorator;

import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.MessageDecorator;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.RealComponent;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class DecoratorPatternTest {

    @Test
    void decorator1() {
        RealComponent realComponent = new RealComponent();
        MessageDecorator decorator = new MessageDecorator(realComponent);

        DecoratorPatternClient client = new DecoratorPatternClient(decorator);

        client.execute();
    }


}

 

  • 이젠 데코레이터 객체를 생성한 후 그 객체에 실제 객체를 주입해 준다. 그리고 클라이언트는 역시 Component를 주입받으면 그만이다. 이 구현체가 데코레이터든 실제 객체든 상관이 없다. 이것이 핵심!

실행 결과

14:56:04.918 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator Start
14:56:04.924 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
14:56:04.924 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator not accepted=data, accepted=****data****
14:56:04.927 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=****data****

 

실제 객체가 응답해 준 데이터에 데코레이터가 주는 부가 기능을 추가한 응답 결과를 확인할 수 있다. 이것이 데코레이터 패턴.

 

 

데코레이터 패턴 - 예제 코드 3 (체인 프록시)

프록시(데코레이터) 패턴은 체인이 될 수 있다 했다. 그러니까 다음과 같은 그림도 가능하다.

 

기존에 사용했던 messageDecorator보다 앞에 timeDecorator가 추가됐다. 이렇게 여러 개의 데코레이터가 들어가도 클라이언트 코드는 변경되는 것이 없다. 마찬가지로 서버 코드도 변경되는 것이 없다. 이것이 핵심!

 

TimeDecorator

package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class TimeDecorator implements Component {

    private final Component component;

    @Override
    public String operation() {
        log.info("TimeDecorator 실행");
        long startTime = System.currentTimeMillis();
        String result = component.operation();
        long endTime = System.currentTimeMillis();
        log.info("TimeDecorator 종료 resultTime = {} ms", endTime - startTime);
        return result;
    }
}

 

  • 새롭게 추가된 TimeDecorator는 마찬가지로 실제 객체를 주입받는데, 이때 실제 객체는 RealComponent가 아닌 messageDecorator가 된다. TimeDecorator 입장에서는 실제 객체가 messageDecorator가 될 뿐이다.
  • 이 상태에서 테스트 코드를 수행해 보자.

DecoratorPatternTest

package cwchoiit.springadvanced.proxy.pureproxy.decorator;

import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.MessageDecorator;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.RealComponent;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.TimeDecorator;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class DecoratorPatternTest {

    @Test
    void decorator2() {
        RealComponent realComponent = new RealComponent();
        MessageDecorator messageDecorator = new MessageDecorator(realComponent);
        TimeDecorator timeDecorator = new TimeDecorator(messageDecorator);

        DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);
        client.execute();
    }
}

 

  • 실제 객체 RealComponent를 생성하면 그 객체를 MessageDecorator가 전달받는다. 그리고 TimeDecorator MessageDecorator를 전달받는다. 이 TimeDecorator가 클라이언트에게 전달된다. 클라이언트는 변경 사항 없이 그저 본인의 execute()를 실행할 뿐이다.

실행 결과

15:02:48.305 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.TimeDecorator -- TimeDecorator Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator not accepted=data, accepted=****data****
15:02:48.314 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.TimeDecorator -- TimeDecorator End, resultTime=4ms
15:02:48.315 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=****data****

 

기존 messageDecorator가 추가해 준 부가기능에 더해 timeDecorator가 추가해 준 부가기능까지 있다. 이렇게 체인으로도 가능하다.

 

프록시 패턴 결론

결론은 프록시 패턴에서 프록시는 기존 서버가 해주는 기능에 부가적인 기능을 추가해 주는 그 이상 이하도 아닌 것이다. 그 기존 기능을 똑같이 수행하기 위해 프록시 객체는 실제 객체를 주입받아야 한다. 이 말은 생각해보면 꾸며주는 역할을 하는 Decorator들은 스스로 존재할 수 없고 항상 꾸며줄 대상이 있어야 한다. 따라서 내부에 호출 대상인 실제 객체를 항상 가지고 있어야 한다. 그리고 이 실제 객체(또는 또다른 프록시가 될 수도 있음)를 항상 호출해야 한다. 그리고 가장 중요한 핵심은 클라이언트와 서버 모두 프록시의 추가로 인해 변경되는 부분은 없다는 점이다. 그런데 프록시 패턴을 사용해야 하나? 데코레이터 패턴을 사용해야 하나?

 

그런데 이 둘은 모양이 거의 같고, 상황에 따라 정말 똑같을 수도 있다. 그러면 이 둘을 구분하는 방법은 의도다 의도!

  • 프록시 패턴의 의도: 다른 객체에 대한 접근을 제어하기 위해 대리자를 제공
  • 데코레이터 패턴의 의도: 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공

 

스프링에서 이 프록시 패턴이 굉장히 중요하다. 왜냐하면 스프링에선 등록한 빈이 프록시로 등록되는게 거의대부분이기 때문이다. 그럼 실제로 프록시가 어떻게 생성되는걸까? 그것을 알아보자.

 

스프링에서 프록시 패턴

스프링에서는 3가지 경우의 프록시를 만들어내는 방법이 있다. 

  • 인터페이스가 있는 구현 클래스에 적용
  • 인터페이스가 없는 구체 클래스에 적용
  • 컴포넌트 스캔 대상에 기능 적용

실무에서는 스프링 빈으로 등록할 클래스는 인터페이스가 있는 경우도 있고 없는 경우도 있다. 그리고 스프링 빈을 수동으로 직접 등록하는 경우도 있고, 컴포넌트 스캔으로 자동으로 등록하는 경우도 있다. 이런 다양한 케이스에 프록시를 어떻게 적용하는지 알아보기 위해 다양한 예제를 준비해보자. 

인터페이스가 있는 구현 클래스

먼저, 인터페이스가 있는 구현 클래스에 프록시를 적용해보기 위해, Controller, Service, Repository 인터페이스와 구현체를 각각 만들어보자. 

OrderControllerV1

package cwchoiit.springadvanced.proxy.app.v1;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public interface OrderControllerV1 {

    @GetMapping("/v1/request")
    String request(@RequestParam("itemId") String itemId);

    @GetMapping("/v1/no-log")
    String noLog();
}
  • 실제로는 Controller는 인터페이스가 있는 경우가 거의 없다. 그런데 지금은 이 경우에 공부를 할 필요가 있기 때문에 약간 억지스럽긴 하지만, 일단 만들어보자. 

OrderControllerV1Impl

package cwchoiit.springadvanced.proxy.app.v1;

public class OrderControllerV1Impl implements OrderControllerV1 {

    private final OrderServiceV1 orderServiceV1;

    public OrderControllerV1Impl(OrderServiceV1 orderServiceV1) {
        this.orderServiceV1 = orderServiceV1;
    }

    @Override
    public String request(String itemId) {
        orderServiceV1.orderItem(itemId);
        return "ok";
    }

    @Override
    public String noLog() {
        return "noLog";
    }
}

 

OrderServiceV1

package cwchoiit.springadvanced.proxy.app.v1;

public interface OrderServiceV1 {
    void orderItem(String itemId);
}

OrderServiceV1Impl

package cwchoiit.springadvanced.proxy.app.v1;

public class OrderServiceV1Impl implements OrderServiceV1 {

    private final OrderRepositoryV1 orderRepositoryV1;

    public OrderServiceV1Impl(OrderRepositoryV1 orderRepositoryV1) {
        this.orderRepositoryV1 = orderRepositoryV1;
    }

    @Override
    public void orderItem(String itemId) {
        orderRepositoryV1.save(itemId);
    }
}

 

OrderRepositoryV1

package cwchoiit.springadvanced.proxy.app.v1;

public interface OrderRepositoryV1 {
    void save(String itemId);
}

OrderRepositoryV1Impl

package cwchoiit.springadvanced.proxy.app.v1;

public class OrderRepositoryV1Impl implements OrderRepositoryV1 {
    @Override
    public void save(String itemId) {
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        sleep(1000);
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 이런 식으로 컨트롤러, 서비스, 레포지토리가 있다고 생각하자. 컨트롤러는 보통은 인터페이스까지 사용하지 않는 게 일반적이지만 인터페이스를 이용해서 프록시를 만드는 것을 연습하기 위해 만들었다. 

이 세 개의 컴포넌트들을 수동으로 빈으로 등록해야한다.

AppV1Config

package cwchoiit.springadvanced.proxy.config;

import cwchoiit.springadvanced.proxy.app.v1.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppV1Config {

    @Bean
    public OrderControllerV1 orderControllerV1() {
        return new OrderControllerV1Impl(orderServiceV1());
    }

    @Bean
    public OrderServiceV1 orderServiceV1() {
        return new OrderServiceV1Impl(orderRepositoryV1());
    }

    @Bean
    public OrderRepositoryV1 orderRepositoryV1() {
        return new OrderRepositoryV1Impl();
    }
}
  • 왜 이렇게 수동으로 등록해야 하느냐? V1만 있는게 아니라 V2, V3모두 다 존재하고 설정값을 그때 그때 달리해서 빈으로 등록하다가 등록하지 않다가 하기 위함이다. 

SpringAdvancedApplication

package cwchoiit.springadvanced;

import cwchoiit.springadvanced.proxy.config.AppV1Config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;

@Import(AppV1Config.class)
@SpringBootApplication(scanBasePackages = "cwchoiit.springadvanced.proxy.app")
public class SpringAdvancedApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringAdvancedApplication.class, args);
    }

}
  • 그리고, 엔트리 클래스에 이제 @Import 애노테이션으로 수동으로 설정 클래스를 추가한다. 
  • 왜 이렇게 해야하냐면 지금 보면 scanBasePackages가 해당 패키지를 검사하지 않게 설정했기 때문이다.

 

인터페이스가 있는 구현 클래스의 프록시 만들기

이제, 원래 애플리케이션으로 돌아와서, 요구사항을 만족하는 프록시를 만들어보자! 요구사항에서 가장 중요한 건 원본 코드를 손대지 않고 로그 추적기를 도입하는 것이었다. 프록시를 도입하면 이 요구사항을 만족시킬 수 있다.

 

OrderControllerInterfaceProxy

package cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy;

import cwchoiit.springadvanced.proxy.app.v1.OrderControllerV1;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class OrderControllerInterfaceProxy implements OrderControllerV1 {

    private final OrderControllerV1 target;
    private final LogTrace trace;

    @Override
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderController.request(String itemId)");
            String result = target.request(itemId);
            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }

    @Override
    public String noLog() {
        return target.noLog();
    }
}

 

  • 컨트롤러 프록시 클래스이다. 이 프록시는 OrderControllerV1을 구현한다. 해당 인터페이스를 구현하므로써 이 프록시 클래스는 실제 구현 클래스를 대체할 수 있게 됐다. 클라이언트는 이제 컨트롤러로의 요청을 이 프록시로 하게 된다. 그것이 가능한 이유는 클라이언트는 인터페이스를 의존하기 때문이다.
  • 그리고 프록시 객체는 반드시 실제 객체를 주입받는다. 그래야 실제 객체의 로직을 수행할 수 있기 때문에. 그래서 실제 구현 클래스를 받을 필드 target을 선언한다. 이 프록시 클래스는 실제 로직 수행의 시작과 끝 사이의 소요시간을 구해주는 추가 기능이 있다.
  • noLog()는 로그를 남기지 않는 메서드이다. 따라서, 그냥 실제 객체를 바로 호출하면 된다. 

OrderRepositoryInterfaceProxy

package cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy;

import cwchoiit.springadvanced.proxy.app.v1.OrderRepositoryV1;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class OrderRepositoryInterfaceProxy implements OrderRepositoryV1 {

    private final OrderRepositoryV1 target;
    private final LogTrace trace;

    @Override
    public void save(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderRepository.save(String itemId)");
            target.save(itemId);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

OrderServiceInterfaceProxy

package com.example.advanced.app.proxy.config.v1_proxy.interface_proxy;

import com.example.advanced.app.proxy.v1.OrderServiceV1;
import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class OrderServiceInterfaceProxy implements OrderServiceV1 {

    private final OrderServiceV1 target;
    private final LogTrace logTrace;

    @Override
    public void orderItem(String itemId) {
        TraceStatus status = null;

        try {
            status = logTrace.begin("OrderService.orderItem()");

            target.orderItem(itemId);

            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

 

이제 실제 구현체 대신에 프록시를 스프링 빈으로 등록해야 한다. 그래서 클라이언트는 인터페이스를 의존하고 사용하지만 주입받는 것은 그 인터페이스의 구현체 중 프록시가 주입되도록 해야 한다. 그 설정을 해보자.

InterfaceProxyConfig

package cwchoiit.springadvanced.proxy.config.v1_proxy;

import cwchoiit.springadvanced.proxy.app.v1.*;
import cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy.OrderControllerInterfaceProxy;
import cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy.OrderRepositoryInterfaceProxy;
import cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy.OrderServiceInterfaceProxy;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class InterfaceProxyConfig {

    @Bean
    public OrderControllerV1 orderController(LogTrace trace) {
        OrderControllerV1Impl target = new OrderControllerV1Impl(orderService(trace));
        return new OrderControllerInterfaceProxy(target, trace);
    }

    @Bean
    public OrderServiceV1 orderService(LogTrace trace) {
        OrderServiceV1Impl target = new OrderServiceV1Impl(orderRepository(trace));
        return new OrderServiceInterfaceProxy(target, trace);
    }

    @Bean
    public OrderRepositoryV1 orderRepository(LogTrace trace) {
        OrderRepositoryV1Impl target = new OrderRepositoryV1Impl();
        return new OrderRepositoryInterfaceProxy(target, trace);
    }
}

 

  • OrderServiceV1 인터페이스의 구현체를 등록하고자 할 때, 실제 구현 클래스가 아닌 프록시를 반환한다. 프록시는 실제 구현체와 LogTrace 객체가 필요한데, 실제 구현체는 새로운 인스턴스로 전달하고 LogTrace는 다른곳에서 스프링 빈으로 등록되어 있기 때문에 파라미터로 그대로 가져다가 쓸 수 있다. 나머지 컨트롤러와 레포지토리도 마찬가지다. 이게 바로 프록시를 스프링 컨테이너에 등록하는 방법이다. 

귀찮지만, V1, V2, V3가 다 있기 때문에 지금 등록하고 사용할 것들만 따로 빈을 등록해줘야 한다. 그래서 아래 코드와 같이 @Import 애노테이션을 사용해서 원하는 것만 설정을 읽게 해주자.

SpringAdvancedApplication

package cwchoiit.springadvanced;

import cwchoiit.springadvanced.proxy.config.AppV2Config;
import cwchoiit.springadvanced.proxy.config.v1_proxy.InterfaceProxyConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;

@Import({InterfaceProxyConfig.class, AppV2Config.class, LogTraceConfig.class})
@SpringBootApplication(scanBasePackages = "cwchoiit.springadvanced.proxy.app")
public class SpringAdvancedApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringAdvancedApplication.class, args);
    }
}

 

  • 이렇게 등록된 프록시 객체로 실제 동작하는지 확인해보자.
### OrderControllerV1.request
GET http://localhost:8080/v1/request?itemId=Good
[a9e1d383] OrderController.request(String itemId)
[a9e1d383] |--->OrderService.orderItem(String itemId)
[a9e1d383] |    |--->OrderRepository.save(String itemId)
[a9e1d383] |    |<---OrderRepository.save(String itemId) time=1002ms
[a9e1d383] |<---OrderService.orderItem(String itemId) time=1003ms
[a9e1d383] OrderController.request(String itemId) time=1005ms
  • 정상적으로 찍히는 모습을 확인할 수 있다. 이게 어떤 식으로 찍히냐가 중요한 게 아니고 프록시 패턴을 이용해서 추가적인 기능을 원래 구현체에 어떠한 변화도 주지 않고 넣어줄 수 있다는 것이다. 그리고 지금 알아본 것은 인터페이스가 있는 구현 클래스에 적용한 버전이다.
  • 인터페이스가 있는 구현 클래스를 프록시로 만들려면 같은 인터페이스를 구현한 프록시가 있으면 된다. 그러면 클라이언트는 인터페이스에만 의존하기 때문에 어떤 구현체가 오더라도 아무런 상관이 없다. 즉, 클라이언트 코드에 전혀 손을 대지 않고도 추가적인 기능을 제공할 수 있다는 것. 이게 핵심이다!

 

V1 프록시 (인터페이스가 있는 구현 클래스에 프록시 적용) 정리

  • 기존에는 스프링 빈이 orderControllerV1Impl, orderServiceV1Impl같은 실제 객체를 반환했다. 하지만 이제는 프록시를 반환해야 한다. 그렇기에 프록시를 만들었고 프록시를 실제 객체 대신 빈으로 등록한다. 실제 객체는 스프링 빈으로 등록하지 않는다. 
  • 프록시는 내부에 실제 객체를 참조하고 있다. 예를 들어, OrderServiceInterfaceProxy는 내부에 실제 대상 객체인 OrderServiceV1Impl을 가지고 있다.
  • 스프링 빈으로 실제 객체 대신 프록시 객체를 등록했기 때문에 앞으로 스프링 빈을 주입 받으면 실제 객체 대신에 프록시 객체가 주입된다. 
  • 실제 객체가 스프링 빈으로 등록되지 않는다고 해서 사라지는 것이 아니다. 프록시 객체가 실제 객체를 참조하고 있기 때문에 프록시를 통해서 실제 객체를 호출할 수 있다. 쉽게 이야기해서 프록시 객체 안에 실제 객체가 있는 것이다. 실제 객체는 스프링 빈으로는 등록되지 않지만 자바 힙 메모리에는 버젓이 살아있다. 

  • 프록시를 적용하기 전에는 당연히 스프링 빈으로 등록되는 것은 실제 객체이다. 

  • InterfaceProxyConfig를 통해 프록시를 적용하면, 스프링 컨테이너에 위 그림과 같이 프록시 객체가 등록된다.
  • 이제 실제 객체는 스프링 컨테이너와는 상관이 없다. 실제 객체는 프록시 객체를 통해서 참조될 뿐이다.
  • 프록시 객체는 스프링 컨테이너가 관리하고 자바 힙 메모리에도 올라간다. 반면에 실제 객체는 자바 힙 메모리에는 올라가지만 스프링 컨테이너가 관리하지는 않는다.

다음은 인터페이스가 없는 구체 클래스에 적용해 보자. 이는 어떻게 가능할까? 다형성이다.

인터페이스가 없는 구체 클래스

이제 인터페이스가 존재하지 않는 구체클래스에 프록시를 적용해보자. 프록시를 어떻게 적용할 수 있을까? 프록시가 구체 클래스를 상속받으면 된다.

 

OrderControllerV2

package cwchoiit.springadvanced.proxy.app.v2;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OrderControllerV2 {
    private final OrderServiceV2 orderServiceV2;

    @GetMapping("/v2/request")
    public String request(String itemId) {
        orderServiceV2.orderItem(itemId);
        return "ok";
    }

    @GetMapping("/v2/no-log")
    public String noLog() {
        return "noLog";
    }
}

 

OrderRepositoryV2

package cwchoiit.springadvanced.proxy.app.v2;

public class OrderRepositoryV2 {
    public void save(String itemId) {
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        sleep(1000);
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

OrderServiceV2

package cwchoiit.springadvanced.proxy.app.v2;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class OrderServiceV2 {
    private final OrderRepositoryV2 orderRepositoryV2;

    public void orderItem(String itemId) {
        orderRepositoryV2.save(itemId);
    }
}

 

 

AppV2Config

package cwchoiit.springadvanced.proxy.config;

import cwchoiit.springadvanced.proxy.app.v2.OrderControllerV2;
import cwchoiit.springadvanced.proxy.app.v2.OrderRepositoryV2;
import cwchoiit.springadvanced.proxy.app.v2.OrderServiceV2;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppV2Config {

    @Bean
    public OrderControllerV2 orderControllerV2() {
        return new OrderControllerV2(orderServiceV2());
    }

    @Bean
    public OrderServiceV2 orderServiceV2() {
        return new OrderServiceV2(orderRepositoryV2());
    }

    @Bean
    public OrderRepositoryV2 orderRepositoryV2() {
        return new OrderRepositoryV2();
    }
}

 

SpringAdvancedApplication

package cwchoiit.springadvanced;

import cwchoiit.springadvanced.proxy.config.AppV1Config;
import cwchoiit.springadvanced.proxy.config.AppV2Config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;

@Import({AppV1Config.class, AppV2Config.class})
@SpringBootApplication(scanBasePackages = "cwchoiit.springadvanced.proxy.app")
public class SpringAdvancedApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringAdvancedApplication.class, args);
    }

}

 

인터페이스가 없는 구체 클래스로 프록시 만들기

OrderRepositoryConcreteProxy

package cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy;

import cwchoiit.springadvanced.proxy.app.v2.OrderRepositoryV2;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class OrderRepositoryConcreteProxy extends OrderRepositoryV2 {

    private final OrderRepositoryV2 target;
    private final LogTrace trace;

    @Override
    public void save(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderRepository.save(String itemId)");
            target.save(itemId);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

  • 이 프록시 클래스는 구체 클래스를 상속받는다. 이렇게 되면 다형성을 이용해서 클라이언트가 역시나 어떠한 코드 변경 없이 기존 코드 그대로 레포지토리를 주입받을 수 있게 된다.
  • 그리고 프록시의 기본인 실제 객체를 주입받는다. 프록시가 제공할 추가 기능인 LogTrace 클래스도 주입받는다.
  • 그리고 메서드를 오버라이딩해서 프록시 기능 + 실제 객체의 원래 기능이 합쳐진 새로운 메서드가 만들어진다.

OrderServiceConcreteProxy

package cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy;

import cwchoiit.springadvanced.proxy.app.v2.OrderServiceV2;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;

public class OrderServiceConcreteProxy extends OrderServiceV2 {

    private final OrderServiceV2 target;
    private final LogTrace trace;

    public OrderServiceConcreteProxy(OrderServiceV2 target, LogTrace trace) {
        super(null);
        this.target = target;
        this.trace = trace;
    }

    @Override
    public void orderItem(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderService.orderItem(String itemId)");
            target.orderItem(itemId);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

  • 마찬가지로 프록시 클래스는 구체 클래스를 상속을 받는다. 여기서 한 가지 주의할 점이 있는데 구체 클래스인 OrderServiceV2는 기본 생성자가 존재하지 않고 OrderRepository를 주입받는 생성자밖에 없다. 그래서 상속받을 때 super()를 사용할 수가 없다
  • super()는 슈퍼 클래스의 기본 생성자를 호출하는 코드이고 상속받을 땐 반드시 슈퍼 클래스의 생성자를 호출하기 때문이다. 이 super()는 생략이 가능하기 때문에 없으면 기본 생성자를 무조건 호출한다. 그럼 상속받을 때 슈퍼 클래스의 생성자를 호출해야 하는데 이 프록시 클래스는 OrderRepository가 없다. 대신 실제 객체를 주입하면서 그 실제 구체 클래스가 가진 OrderRepository를 사용하면 된다. 그래서 super(null)을 작성해 준다. 나머지는 모두 동일하다.  

OrderControllerConcreteProxy

package cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy;

import cwchoiit.springadvanced.proxy.app.v2.OrderControllerV2;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;

public class OrderControllerConcreteProxy extends OrderControllerV2 {

    private final OrderControllerV2 target;
    private final LogTrace trace;

    public OrderControllerConcreteProxy(OrderControllerV2 target, LogTrace trace) {
        super(null);
        this.target = target;
        this.trace = trace;
    }

    @Override
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderController.request(String itemId)");
            String result = target.request(itemId);
            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }

    @Override
    public String noLog() {
        return target.noLog();
    }
}

 

  • OrderControllerConcreteProxy는 위 OrderServiceConcreteProxy를 설명할 때와 동일하다. 

 

이렇게 구체 클래스를 상속받은 프록시를 만들면 끝난다. 마찬가지로 클라이언트 코드에는 어떠한 변경사항도 없이 프록시가 제공하는 추가 기능을 사용할 수 있게 된다. 이제 이 프록시를 빈으로 등록하자.

 

ConcreteProxyConfig

package cwchoiit.springadvanced.proxy.config.v2_proxy;

import cwchoiit.springadvanced.proxy.app.v2.OrderControllerV2;
import cwchoiit.springadvanced.proxy.app.v2.OrderRepositoryV2;
import cwchoiit.springadvanced.proxy.app.v2.OrderServiceV2;
import cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy.OrderControllerConcreteProxy;
import cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy.OrderRepositoryConcreteProxy;
import cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy.OrderServiceConcreteProxy;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ConcreteProxyConfig {

    @Bean
    public OrderRepositoryV2 orderRepository(LogTrace trace) {
        OrderRepositoryV2 target = new OrderRepositoryV2();
        return new OrderRepositoryConcreteProxy(target, trace);
    }

    @Bean
    public OrderServiceV2 orderService(LogTrace trace) {
        OrderServiceV2 target = new OrderServiceV2(orderRepository(trace));
        return new OrderServiceConcreteProxy(target, trace);
    }

    @Bean
    public OrderControllerV2 orderControllerV2(LogTrace trace) {
        OrderControllerV2 target = new OrderControllerV2(orderService(trace));
        return new OrderControllerConcreteProxy(target, trace);
    }
}

 

 

결과

[f6be055c] OrderController.request(String itemId)
[f6be055c] |--->OrderService.orderItem(String itemId)
[f6be055c] |    |--->OrderRepository.save(String itemId)
[f6be055c] |    |<---OrderRepository.save(String itemId) time=1002ms
[f6be055c] |<---OrderService.orderItem(String itemId) time=1002ms
[f6be055c] OrderController.request(String itemId) time=1003ms

 

인터페이스 기반 프록시와 클래스 기반 프록시

프록시를 사용한 덕분에 원본 코드를 전혀 손대지 않고 V1, V2 애플리케이션에 LogTrace 기능을 적용할 수 있었다. 

 

인터페이스 기반 프록시 vs 클래스 기반 프록시

  • 인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있다.
  • 클래스 기반 프록시는 해당 클래스에만 적용할 수 있다. 인터페이스 기반 프록시는 인터페이스만 같으면 모든 곳에 적용할 수 있다.
  • 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
    • 부모 클래스의 생성자를 호출해야 한다.
    • 클래스에 final 키워드가 붙으면 상속이 불가능하다 (다른 말로, 프록시를 만드는 게 불가능)
    • 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다. (다른 말로, 프록시를 만들 순 있어도 추가 기능을 부여 못함)

이렇게 보면, 인터페이스 기반의 프록시가 더 좋아보인다. 맞다. 인터페이스 기반의 프록시는 상속이라는 제약에서 자유롭다. 프로그래밍 관점에서도 인터페이스를 사용하는 것이 역할과 구현을 명확하게 나누기 때문에 더 좋다. 인터페이스 기반 프록시의 단점은 인터페이스가 필요하다는 그 자체이다. 인터페이스가 없으면 인터페이스 기반 프록시를 만들 수 없다. 

 

좋은 설계 얘기

이론적으로는 모든 객체에 인터페이스를 도입해서 역할과 구현을 나누는 것이 좋다. 이렇게 하면 역할과 구현을 나누어서 구현체를 매우 편리하게 변경할 수 있다. 하지만, 실제로는 구현을 거의 변경할 일이 없는 클래스도 많다. 인터페이스를 도입하는 것은 구현을 변경할 가능성이 있을 때 효과적인데, 구현을 변경할 가능성이 거의 없는 코드에 무작정 인터페이스를 사용하는 것은 번거롭고 그렇게 실용적이지 않다. 이런곳에는 실용적인 관점에서 인터페이스를 사용하지 않고 구체 클래스를 바로 사용하는 것이 좋다 생각한다. 

 

결론

실무에서는 프록시를 적용할 때 V1처럼 인터페이스도 있고, V2처럼 구체 클래스도 있다. 따라서 2가지 상황을 모두 대응할 수 있어야 한다.

 

너무 많은 프록시 클래스

지금까지 프록시를 사용해서 기존 코드를 변경하지 않고, 로그 추적기라는 부가 기능을 적용할 수 있었다. 그런데 문제는 프록시 클래스를 너무 많이 만들어야 한다는 점이다. 잘보면 프록시 클래스가 하는 일은 LogTrace를 사용하는 것인데 그 로직이 모두 똑같다. 대상 클래스만 다를 뿐이다. 만약, 적용해야 하는 대상 클래스가 100개라면 프록시 클래스도 100개를 만들어야 한다. 프록시 클래스를 하나만 만들어서 모든 곳에 적용하는 방법은 없을까? 이 방법이 바로 JDK 동적 프록시이다.

 

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

CGLIB,스프링이 지원하는 ProxyFactory  (0) 2023.12.14
Proxy/Decorator Pattern 2 (JDK 동적 프록시)  (0) 2023.12.14
Template Callback Pattern  (0) 2023.12.12
Strategy Pattern  (2) 2023.12.12
Template Method Pattern  (2) 2023.12.12
728x90
반응형
SMALL
SMALL

참고자료

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com

 

템플릿 콜백 패턴 - 시작

템플릿 콜백 패턴은 굉장히 자주 사용되는 패턴이다. 이전 포스팅에서 배웠던 전략 패턴에서 ContextV2는 변하지 않는 템플릿 역할을 한다. 그리고 변하는 부분은 파라미터로 넘어온 Strategy의 코드를 실행해서 처리한다. 이렇게 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 콜백이라고 한다.

프로그래밍에서 콜백(callback) 또는 콜애프터 함수는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 좀 더 직관적으로 말하면 파라미터로 실행 가능한 코드를 넘겨주면 받는 쪽에서 그 코드를 실행하는 것이고, 이 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다.

 

근데 자바에서는 실행 가능한 코드를 인수로 넘기려면 객체가 필요하다. 자바8 이전에는 하나의 메소드를 가진 인터페이스를 구현하고 주로 익명 내부 클래스를 사용했다. 자바8 이후로 람다를 사용할 수 있기 때문에 최근에는 주로 람다를 사용한다. 

 

템플릿 콜백 패턴

  • 스프링에서는 이전 포스팅에서 본 ContextV2와 같은 방식의 전략 패턴을 템플릿 콜백 패턴이라고 한다. 전략 패턴에서 Context가 템플릿 역할을 하고, Strategy 부분이 콜백으로 넘어온다 생각하면 된다. 그러니까 이전에 봤던 ContextV2과 완벽하게 템플릿 콜백 패턴이라고 생각하면 된다.
  • 참고로, 템플릿 콜백 패턴은 GOF 패턴은 아니고, 스프링 내부에서 이런 방식을 자주 사용하기 때문에 스프링 안에서만 이렇게 부른다. 전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴이라 생각하면 된다.
  • 스프링에서는 JdbcTemplate, RestTemplate, TransactionTemplate, RedisTemplate처럼 다양한 템플릿 콜백 패턴이 사용된다. 스프링에서 이름에 xxxTemplate이 있다면 템플릿 콜백 패턴으로 만들어져 있다 생각하면 된다.

 

템플릿 콜백 패턴 - 예제

템플릿 콜백 패턴을 구현해보자. ContextV2와 내용이 같고 이름만 다르다고 보면 된다.

  • Context → Template
  • Strategy → Callback 

Callback

package cwchoiit.springadvanced.trace.strategy.code.template;

@FunctionalInterface
public interface Callback {
    void call();
}

 

 

TimeLogTemplate

package cwchoiit.springadvanced.trace.strategy.code.template;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class TimeLogTemplate {

    public void execute(Callback callback) {
        long startTime = System.currentTimeMillis();
        callback.call();
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("resultTime = {}ms", resultTime);
    }
}

 

  • execute()는 파라미터로 Callback 객체를 받는다. (자바는 실행 가능한 코드를 인수로 넘기려면 객체를 전달해야 한다 했다.)
  • 받은 객체가 가지고 있는 call()을 실행한다. 

TemplateCallbackTest

package cwchoiit.springadvanced.trace.strategy;

import cwchoiit.springadvanced.trace.strategy.code.template.TimeLogTemplate;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class TemplateCallbackTest {

    @Test
    void callbackV1() {
        TimeLogTemplate template = new TimeLogTemplate();
        template.execute(() -> log.info("비즈니스 로직1 실행"));

        TimeLogTemplate template2 = new TimeLogTemplate();
        template2.execute(() -> log.info("비즈니스 로직2 실행"));
    }
}

 

  • 템플릿 콜백 패턴을 테스트할 코드다. 콜백함수를 실행할 TimeLogTemplate 객체를 만든다. 이 객체가 가지고 있는 execute()는 파라미터로 Callback 객체를 받는다. Callback 객체를 전달하기 위해 익명 내부 클래스로 전달하거나, 람다로 전달하면 된다. 물론 별도의 클래스를 따로 만들어서 전달해도 무방하다. 재사용성이 빈번한 경우 그게 더 효율적이다. 그러나 그게 아니라면 위와 같이 람다를 사용하는 것이 좀 더 편리할 것 같다.

실행 결과

16:44:54.855 [Test worker] INFO com.example.advanced.trace.strategy.TemplateCallbackTest -- 비즈니스 로직1 실행
16:44:54.862 [Test worker] INFO com.example.advanced.trace.strategy.code.template.TimeLogTemplate -- resultTime=9
16:44:54.865 [Test worker] INFO com.example.advanced.trace.strategy.TemplateCallbackTest -- 비즈니스 로직2 실행
16:44:54.865 [Test worker] INFO com.example.advanced.trace.strategy.code.template.TimeLogTemplate -- resultTime=0

 

템플릿 콜백 패턴 - 적용

이제 템플릿 콜백 패턴을 애플리케이션에 적용해보자. LogTrace를 사용하는 것 말이다.

 

TraceCallback

package cwchoiit.springadvanced.trace.templatecallback;

@FunctionalInterface
public interface TraceCallback<T> {
    T call();
}
  • 콜백을 전달하는 인터페이스이다.
  • <T> 제네릭을 사용했다. 콜백의 반환 타입을 정의한다.

 

TraceTemplate

package cwchoiit.springadvanced.trace.templatecallback;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class TraceTemplate {

    private final LogTrace trace;

    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;
        }
    }
}

 

  • 콜백 함수를 받아 실행할 템플릿 클래스이다.
  • 콜백 함수를 받는 execute()는 공통 부분인 로그 추적 코드가 있고 그 사이에 콜백 함수를 실행한다. 콜백 함수의 반환값이 execute()의 반환값이 되고 그 반환값이 제네릭이므로 메서드 또한 제네릭이다.

OrderControllerV5

package cwchoiit.springadvanced.app.v5;

import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.templatecallback.TraceTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderControllerV5 {

    private final OrderServiceV5 orderService;
    private final TraceTemplate traceTemplate;

    public OrderControllerV5(OrderServiceV5 orderService, LogTrace trace) {
        this.traceTemplate = new TraceTemplate(trace);
        this.orderService = orderService;
    }

    @GetMapping("/v5/request")
    public String request(String itemId) {
        return traceTemplate.execute("OrderControllerV1.request(String itemId)", () -> {
            orderService.orderItem(itemId);
            return "ok";
        });
    }
}
  • TraceTemplate을 주입 받는 방법은 다양하지만, 여기서는 생성자에서 new를 선언해서 인스턴스를 만든다. 그리고 필요한 LogTrace를 받아야한다. 
  • 물론, 이렇게 말고 TraceTemplate을 스프링 빈으로 아예 등록해서 주입받는 방법도 있다. 이렇게 하면 이 TraceTemplate도 딱 하나의 싱글톤 객체로 만들어진다.
  • 장점과 단점이 있는데, 위 코드처럼 하면 장점은 테스트 코드 작성이 매우 수월해진다는 것이다. 테스트 코드를 작성할 때 TraceTemplate을 빈으로 등록해서 스프링 컨테이너에 추가할 필요가 없으니까. 단점은 이 클래스의 개수만큼 TraceTemplate이 객체로 생성된다는 점이다. 
  • 템플릿 콜백 패턴을 사용했더니 코드가 더 더 깔끔해졌다. 

OrderServiceV5

package cwchoiit.springadvanced.app.v5;

import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.templatecallback.TraceTemplate;
import org.springframework.stereotype.Service;

@Service
public class OrderServiceV5 {

    private final OrderRepositoryV5 orderRepository;
    private final TraceTemplate traceTemplate;

    public OrderServiceV5(OrderRepositoryV5 orderRepository, LogTrace trace) {
        this.orderRepository = orderRepository;
        this.traceTemplate = new TraceTemplate(trace);
    }

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

OrderRepositoryV5

package cwchoiit.springadvanced.app.v5;

import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.templatecallback.TraceTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class OrderRepositoryV5 {

    private final TraceTemplate traceTemplate;

    public OrderRepositoryV5(LogTrace trace) {
        this.traceTemplate = new TraceTemplate(trace);
    }

    public void save(String itemId) {
        traceTemplate.execute("OrderRepositoryV1.save(String itemId)", () -> {
            if (itemId.equals("ex")) {
                throw new IllegalStateException("예외 발생!");
            }
            sleep(1000);
            return null;
        });
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

정리

지금까지 우리는 변하는 코드와 변하지 않는 코드를 분리하고, 더 적은 코드로 로그 추적기를 적용하기 위해 고군분투했다. 

템플릿 메서드 패턴, 전략 패턴, 그리고 템플릿 콜백 패턴까지 진행하면서 변하는 코드와 변하지 않는 코드를 분리했다. 그리고 최종적으로 템플릿 콜백 패턴을 적용하고 콜백으로 람다를 사용해서 코드 사용도 최소화 할 수 있었다. 

 

한계

그런데 지금까지 설명한 방식의 한계는 아무리 최적화를 해도 결국 로그 추적기를 적용하기 위해서 원본 코드를 수정해야 한다는 점이다. 클래스가 수백개면 수백개를 더 힘들게 수정하는가 조금 덜 힘들게 수정하는가의 차이가 있을 뿐, 본질적으로 코드를 다 수정해야 하는 것은 마찬가지다. 

 

개발자들의 게으름에 대한 욕심은 끝이 없다. 수많은 개발자들이 이 문제에 대해서 집요하게 고민해왔고, 여러가지 방향으로 해결책을 만들어왔다. 지금부터 원본 코드를 손대지 않고 로그 추적기를 적용할 수 있는 방법을 알아보자. 그게 바로 AOP가 될 것이다. 

 

참고로,
템플릿 콜백 패턴은 실제 스프링 안에서 많이 사용되는 방식이다. xxxTemplate을 만나면 이번에 학습한 템플릿 콜백 패턴을 떠올려보면 어떻게 돌아가는지 쉽게 이해할 수 있을 것이다.

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Proxy/Decorator Pattern 2 (JDK 동적 프록시)  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Strategy Pattern  (2) 2023.12.12
Template Method Pattern  (2) 2023.12.12
ThreadLocal  (0) 2023.12.12
728x90
반응형
SMALL
SMALL

참고자료

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com

 

전략 패턴 - 시작

전략 패턴은 템플릿 메서드 패턴의 단점을 극복할 수 있는 또다른 디자인 패턴이다. 이 패턴 역시 공통 부분을 한곳에 두고 중복 코드를 제거하고 변경되는 부분만을 유연하게 작성해서 사용하는 패턴인데 어떻게 템플릿 메서드 패턴의 단점을 극복할까?

 

전략 패턴이란 말이 좀 한번에 와닿지 않을 수 있는데 내가 이해한 전략이란건 이 공통 로직을 제외한 변경되는 로직을 처리하는 그 방법을 말한다. 즉, 이 변경되는 로직을 전략이라 말하고 그 전략을 전달받아 실행하는 코드가 있는 것이라고 생각하면 된다. 변하지 않는 부분을 Context라는 곳에 두고, 변하는 부분을 Strategy라는 인터페이스를 만들고 해당 인터페이스를 구현하도록 해서 문제를 해결한다. 상속이 아니라 위임으로 문제를 해결하는 것이다. 전략 패턴에서 Context는 변하지 않는 템플릿 역할을 하고, Strategy는 변하는 알고리즘 역할을 한다. 

 

GOF 디자인 패턴에서 정의한 전략 패턴의 의도는 다음과 같다.

알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.

 

 

Strategy

package cwchoiit.springadvanced.trace.strategy.code.strategy;

@FunctionalInterface
public interface Strategy {
    void call();
}

 

  • Strategy 라는 함수형 인터페이스를 하나 선언하고 그 인터페이스가 가지는 메서드는 call()이다.

StrategyLogic1

package cwchoiit.springadvanced.trace.strategy.code.strategy;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class StrategyLogic1 implements Strategy {
    @Override
    public void call() {
        log.info("비즈니스 로직1 실행");
    }
}

StrategyLogic2

package cwchoiit.springadvanced.trace.strategy.code.strategy;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class StrategyLogic2 implements Strategy {
    @Override
    public void call() {
        log.info("비즈니스 로직2 실행");
    }
}

 

  • Strategy 인터페이스를 구현하는 구현체 두 개가 각각 다른 로직의 call() 메서드가 있다. 그럼 하나는 전략A, 하나는 전략B가 되는 셈이고 이 전략을 가져다가 사용할 녀석 하나만 만들면 된다. 그리고 그때그때마다 전략을 갈아 끼우는 것 = 전략 패턴이다.

 

전략 패턴 - 필드로 전략을 조립

이제 실제 전략들을 받아서 사용할 클래스를 하나 만들면 되는데 통상적으로 이 클래스를 가지고 Context라고 칭한다. 

ContextV1

package cwchoiit.springadvanced.trace.strategy.code.strategy;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class ContextV1 {

    private final Strategy strategy;

    public void execute() {
        long startTime = System.currentTimeMillis();
        strategy.call();
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("resultTime = {}ms", resultTime);
    }
}

 

  • ContextV1 클래스는 전략을 필드로 가지고 있는 방식이다. 즉, 필드로 받기 위해서 전략을 외부에서 주입받게 된다.
  • 그리고 실제 실행 코드는 이 클래스가 가지고 있는 execute() 메서드이다.
  • 변경되는 비즈니스 로직 부분은 전략이 가지고 있는 call() 메서드에 있다.
  • 전략 패턴의 핵심은 ContextStrategy 인터페이스에만 의존한다는 점이다. 덕분에 Strategy의 구현체를 변경하거나 새로 만들어도 Context의 코드에는 영향을 전혀 주지 않는다.

ContextV1Test

package cwchoiit.springadvanced.trace.strategy;

import cwchoiit.springadvanced.trace.strategy.code.strategy.ContextV1;
import cwchoiit.springadvanced.trace.strategy.code.strategy.StrategyLogic1;
import cwchoiit.springadvanced.trace.strategy.code.strategy.StrategyLogic2;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class ContextV1Test {

    @Test
    void strategyV1() {
        StrategyLogic1 logic1 = new StrategyLogic1();
        ContextV1 contextV1 = new ContextV1(logic1);
        contextV1.execute();

        StrategyLogic2 logic2 = new StrategyLogic2();
        ContextV1 contextV2 = new ContextV1(logic2);
        contextV2.execute();
    }
}
  • 전략 패턴을 사용해서 테스트 해보자. 코드를 보면 의존관계 주입을 통해 ContextV1Strategy의 구현체인 logic1을 주입하는 것을 확인할 수 있다.
  • 이렇게 해서 Context안에 원하는 전략을 주입한다. 이렇게 원하는 모양으로 조립을 완료하고 난 다음에 context1.execute()를 호출해서 context를 실행한다.

 

전략 패턴 - 익명 내부 클래스, 람다 사용

@Test
void strategyV2() {
    Strategy strategyLogic1 = new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직1 실행");
        }
    };
    ContextV1 context1 = new ContextV1(strategyLogic1);
    context1.execute();

    Strategy strategyLogic2 = new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직2 실행");
        }
    };
    ContextV1 context2 = new ContextV1(strategyLogic2);
    context2.execute();
}

@Test
void strategyV3() {
    ContextV1 context1 = new ContextV1(new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직1 실행");
        }
    });
    context1.execute();

    ContextV1 context2 = new ContextV1(new Strategy() {
        @Override
        public void call() {
            log.info("비즈니스 로직2 실행");
        }
    });
    context2.execute();
}

@Test
void strategyV4() {
    ContextV1 context1 = new ContextV1(() -> log.info("비즈니스 로직1 실행"));
    context1.execute();

    ContextV1 context2 = new ContextV1(() -> log.info("비즈니스 로직2 실행"));
    context2.execute();
}
  • 익명 내부 클래스 사용 및 람다를 사용한 코드이다. 훨씬 코드가 깔끔해진 모습이다. 

선조립 후실행

여기서는 Context의 내부 필드에 Strategy를 두고 사용하고 있다. 이 방식은 ContextStrategy를 실행 전에 원하는 모양으로 조립해두고, 그 다음에 Context를 실행하는 선조립, 후실행 방식에서 유용하다. 한번 조립해놓고 이후에 계속해서 재사용이 가능한 형태이다. 그래서 아래와 같은 행위가 가능하다.

@Test
void strategyV4() {
    ContextV1 context1 = new ContextV1(() -> log.info("비즈니스 로직1 실행"));
    context1.execute();

    // 코드 만 줄 ...

    context1.execute();
}

한번만 조립해두면 계속 재사용할 수 있다는 것을 표현했다. 그런데 이 방식의 단점은 ContextStrategy를 조립한 이후에는 전략을 변경하기가 번거롭다는 점이다. 물론 ContextSetter를 제공해서 Strategy를 넘겨 받아 변경하면 되지만, Context를 싱글톤으로 사용할 때는 동시성 이슈 등 고려할 점이 많다. 그래서 전략을 실시간으로 변경해야 하면 차라리 위에서 본 것처럼 Context를 하나 더 생성해서 그곳에 다른 Strategy를 주입하는 것이 더 나은 선택일 수 있다. 그럼 이렇게 먼저 조립하고 사용하는 방식보다 더 유연하게 전략 패턴을 사용하는 방법은 없을까? 

 

전략 패턴 - 전략을 파라미터로 전달

위에서 말한 단점을 다른 방법으로 해결하는 방법이다. 이전에는 Context의 필드에 Strategy를 주입해서 사용했다. 이번에는 전략을 실행할 때 직접 파라미터로 전달해서 사용해보자.

 

ContextV2

package cwchoiit.springadvanced.trace.strategy.code.strategy;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ContextV2 {

    public void execute(Strategy strategy) {
        long startTime = System.currentTimeMillis();
        strategy.call();
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("resultTime = {}ms", resultTime);
    }
}

 

  • 이젠 Context는 필드에 전략을 보관하지 않는다. 대신 실행 코드에서 파라미터로 전략을 전달받는다. 이렇게 되면 그때 그때 전략이 계속 변경될 경우 한번의 초기화만으로 파라미터에 전략만 바꿔 실행하면 된다. 바로 다음 코드처럼.

ContextV2Test

package cwchoiit.springadvanced.trace.strategy;

import cwchoiit.springadvanced.trace.strategy.code.strategy.ContextV2;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class ContextV2Test {

    @Test
    void strategy_parameter() {
        ContextV2 ctx = new ContextV2();
        ctx.execute(() -> log.info("비즈니스 로직1 실행"));
        ctx.execute(() -> log.info("비즈니스 로직2 실행"));
    }
}

 

  • Context 객체는 이제 단 한번만 생성하면 된다. 전략의 변경이 있어도 파라미터로 넘겨주면 그만이다. 이러한 장점이 있는 반면, 단점은 같은 전략을 사용한다해도 계속 파라미터로 넘겨줘야 하는 번거로움이 있다. 즉, All-in-One이 아니라 Trade-Off가 있다. 각 상황에 맞게 사용하면 된다.

템플릿

지금 우리가 해결하고 싶은 문제는 변하는 부분과 변하지 않는 부분을 분리하는 것이다. 변하지 않는 부분을 템플릿이라고 하고, 그 템플릿 안에서 변하는 부분에 약간 다른 코드 조각을 넘겨서 실행하는 것이 목적이다. ContextV1, ContextV2 두 가지 방식 다 문제를 해결할 수 있지만 어떤 방식이 조금 더 나아 보이는가? 지금 우리가 원하는 것은 애플리케이션 의존 관계를 설정하는 것처럼 선조립, 후실행이 아니다. 단순히 코드를 실행할 때 변하지 않는 템플릿이 있고 그 템플릿 안에서 원하는 부분만 살짝 다른 코드를 실행하고 싶을 뿐이다. 따라서 우리가 고민하는 문제는 실행 시점에 유연하게 실행 코드 조각을 전달하는 ContextV2가 더 적합하다.

 

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Proxy/Decorator Pattern 2 (JDK 동적 프록시)  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Template Callback Pattern  (0) 2023.12.12
Template Method Pattern  (2) 2023.12.12
ThreadLocal  (0) 2023.12.12
728x90
반응형
SMALL
SMALL

참고자료

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com

 

템플릿 메서드 패턴 - 시작

스프링에서 굉장히 자주 사용되는 패턴 중 하나인 템플릿 메서드 패턴에 대해서 알아보자.

템플릿 메서드 패턴은 다형성을 이용하여 공통 부분을 하나로 정의하고 변하는 부분만을 유연하게 변경하게 하는 방법이다.

 

이전 포스팅에서 만들어봤던 로그 추적기를 막상 프로젝트에 도입하려고 하니 개발자들의 반대의 목소리가 높다. 로그 추적기 도입 전과 도입 후의 코드를 비교해보자.

 

로그 추적기 도입 전 - OrderControllerV0

@GetMapping("/v0/request")
public String request(String itemId) {
    orderService.orderItem(itemId);
    return "ok";
}

로그 추적기 도입 전 - OrderServiceV0

public void orderItem(String itemId) {
    orderRepository.save(itemId);
}

로그 추적기 도입 전 - OrderRepositoryV0

public void save(String itemId) {
    if (itemId.equals("ex")) {
        throw new IllegalStateException("예외 발생!");
    }
    sleep(1000);
}

 

로그 추적기 도입 후 - OrderControllerV3

@GetMapping("/v3/request")
public String request(String itemId) {
    TraceStatus status = null;
    try {
        status = trace.begin("OrderControllerV1.request(String itemId)");
        orderService.orderItem(itemId);
        trace.end(status);
        return "ok";
    } catch (Exception e) {
        trace.exception(status, e);
        throw e;
    }
}

로그 추적기 도입 후 - OrderServiceV3

public void orderItem(String itemId) {
    TraceStatus status = null;
    try {
        status = trace.begin("OrderServiceV1.orderItem(String itemId)");
        orderRepository.save(itemId);
        trace.end(status);
    } catch (Exception e) {
        trace.exception(status, e);
        throw e;
    }
}

로그 추적기 도입 후 - OrderRepositoryV3

public void save(String itemId) {
    TraceStatus status = null;
    try {
        status = trace.begin("OrderRepositoryV1.save(String itemId)");
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        sleep(1000);
        trace.end(status);
    } catch (Exception e) {
        trace.exception(status, e);
        throw e;
    }
}
  • V0 코드와 V3 코드를 비교해보자. V0은 해당 메서드가 실제 처리해야 하는 핵심 기능만 깔끔하게 있다. 반면, V3는 핵심 기능보다 로그를 출력해야 하는 부가 기능 코드가 훨씬 더 많고 복잡하다. 

핵심 기능 vs 부가 기능

  • 핵심 기능은 해당 객체가 제공하는 고유의 기능이다. 예를 들어, OrderService의 핵심 기능은 주문 로직이다. 메서드 단위로 보면 orderItem()의 핵심 기능은 주문 데이터를 저장하기 위해 리포지토리를 호출하는 orderRepository.save(itemId) 코드가 핵심 기능이다.
  • 부가 기능은 핵심 기능을 보조하기 위해 제공되는 기능이다. 예를 들어, 로그 추적 로직, 트랜잭션 기능이 있다. 이러한 부가 기능은 단독으로 사용되지는 않고, 핵심 기능과 함께 사용된다. 예를 들어서 로그 추적 기능은 어떤 핵심 기능이 호출되었는지 로그를 남기기 위해 사용한다. 그러니까 핵심 기능을 보조하기 위해 존재한다.

V3 코드는 그러니까, 배보다 배꼽이 더 큰 상황이다. 만약 클래스가 수백개고 메서드가 수천개면 어떻게 하겠는가? 개발자들이 반대할만하다.  이 문제를 좀 더 효율적으로 처리할 수 있는 방법이 있을까? V3 코드를 보면 다음과 같이 동일한 패턴이 보인다.

TraceStatus status = null;
try {
    status = trace.begin("메시지 입력");
    // 핵심 기능 호출
    trace.end(status);
} catch (Exception e) {
    trace.exception(status, e);
    throw e;
}
  • Controller, Service, Repository의 코드를 보면, 로그 추적기를 사용하는 구조는 모두 동일하다. 중간에 핵심 기능을 사용하는 코드만 다를 뿐이다. 부가 기능과 관련된 코드가 중복이니 중복을 별도의 메서드로 뽑아낼까? 그런데, try - catch는 물론이고 핵심 기능 부분이 중간에 있어서 단순하게 메서드로 추출하는 것은 꽤나 어렵다.
  • 따라서, 이럴땐 변하는 것과 변하지 않는 것을 분리하는 것이다! 좋은 설계는 변하는 것과 변하지 않는 것을 분리하는 것이다. 구현체를 아무리 갈아끼우고 사용하는 기술을 바꿔도 인터페이스에만 의존하는 클라이언트 코드는 변하지 않는다. 변하는 것은 구현체일뿐이다. DI는 스프링의 핵심 중 하나인 것이다. 이 코드 또한 마찬가지다. 여기서 핵심 기능 부분은 변하고, 로그 추적기를 사용하는 부분은 변하지 않는 부분이다. 이 둘을 분리해서 모듈화해야 한다. 어디서 많이 들어본 내용같다. 맞다. 템플릿 메서드 패턴이 이 문제를 해결해줄 수 있다!

템플릿 메서드 패턴 - 예제1

TemplateMethodTest

package cwchoiit.springadvanced.trace.template;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class TemplateMethodTest {

    @Test
    void templateMethodV0() {
        logic1();
        logic2();
    }

    private void logic1() {
        long startTime = System.currentTimeMillis();
        log.info("비즈니스 로직1 실행");
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("resultTime = {}ms", resultTime);
    }

    private void logic2() {
        long startTime = System.currentTimeMillis();
        log.info("비즈니스 로직2 실행");
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("resultTime = {}ms", resultTime);
    }
}
  • 템플릿 메서드 패턴을 쉽게 이해하기 위해 단순한 예제 코드를 만들었다.
  • logic1()logic2()는 비즈니스 로직이라는 로그 출력 부분만 다르지 그 외 모든것이 동일하다. 
  • 즉, 변하는 부분은 비즈니스 로직이고 변하지 않는 부분은 시간 측정이다. 
  • 이걸 템플릿 메서드 패턴을 사용해서 변하는 부분과 변하지 않는 부분을 분리해보자!

 

템플릿 메서드 패턴 - 예제2

  • 부모 클래스를 abstract로 하나 만들고, 그 부모 클래스를 상속받는 자식 클래스에서 달라지는 부분을 구현하는 그림이라고 생각하면 된다.

AbstractTemplate

package cwchoiit.springadvanced.trace.template.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public abstract class AbstractTemplate {

    public void execute() {
        long startTime = System.currentTimeMillis();
        call();
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("resultTime = {}ms", resultTime);
    }

    protected abstract void call();
}
  • 템플릿 메서드 패턴은 이름 그대로 템플릿을 사용하는 방식이다. 템플릿은 기준이 되는 거대한 틀이다. 템플릿이라는 틀에 변하지 않는 부분을 몰아둔다. 그리고 일부 변하는 부분을 별도로 호출해서 해결한다.
  • AbstractTemplate 코드를 보자. 변하지 않는 부분인 시간 측정 로직을 몰아둔 것을 확인할 수 있다. 이제 이것이 하나의 템플릿이 된다. 그리고 템플릿 안에서 변하는 부분은 call() 메서드를 호출해서 처리한다. 템플릿 메서드 패턴은 부모 클래스에 변하지 않는 템플릿 코드를 둔다. 그리고 변하는 부분은 자식 클래스에 두고 상속과 오버라이딩을 사용해서 처리한다.

SubClassLogic1

package cwchoiit.springadvanced.trace.template.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class SubClassLogic1 extends AbstractTemplate {

    @Override
    protected void call() {
        log.info("비즈니스 로직1 실행");
    }
}

 

SubClassLogic2

package cwchoiit.springadvanced.trace.template.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class SubClassLogic2 extends AbstractTemplate {

    @Override
    protected void call() {
        log.info("비즈니스 로직2 실행");
    }
}
  • 각각 변하는 부분인 비즈니스 로직을 처리하는 자식 클래스이다. 템플릿이 호출하는 대상인 call() 메서드를 오버라이딩 한다.

TemplateMethodTest

package cwchoiit.springadvanced.trace.template;

import cwchoiit.springadvanced.trace.template.code.AbstractTemplate;
import cwchoiit.springadvanced.trace.template.code.SubClassLogic1;
import cwchoiit.springadvanced.trace.template.code.SubClassLogic2;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class TemplateMethodTest {

    @Test
    void templateMethodV0() {
        logic1();
        logic2();
    }

    private void logic1() {
        long startTime = System.currentTimeMillis();
        log.info("비즈니스 로직1 실행");
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("resultTime = {}ms", resultTime);
    }

    private void logic2() {
        long startTime = System.currentTimeMillis();
        log.info("비즈니스 로직2 실행");
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("resultTime = {}ms", resultTime);
    }

    @Test
    void templateMethodV1() {
        AbstractTemplate template = new SubClassLogic1();
        template.execute();

        AbstractTemplate template2 = new SubClassLogic2();
        template2.execute();
    }
}

 

  • 위 코드에서 먼저 템플릿 메서드 패턴을 적용하지 않은 코드를 보자. 공통 부분인 메서드의 시작 시간, 종료 시간, 소요 시간을 구하는 코드와 각 메서드 별 가지는 비즈니스 로직을 메서드 별로 만들었다면 logic1(), logic2()처럼 표현된다.
  • 그리고 그 메서드를 실행하는 templateMethodV0()이 있다. 단 두개의 메서드만으로도 얼마나 비효율적인지 알 수 있다. 거의 최악의 코드라고 보면 된다. 유지보수성이 '0'에 가까운
  • 반면에 템플릿 메서드 패턴을 적용한 templateMethodV1() 메서드를 보자.
  • 메서드 별 달리 적용되는 부분을 상속받는 각 클래스가 상속받아 구현하고 부모 클래스가 가지고 있는 execute() 메서드를 실행만 하면 된다. 부모 타입의 변수를 만들었기 때문에 부모 클래스가 가진 메서드execute()를 호출할 수 있고, 그 안에서 call() 메서드를 호출하는데 그 메서드는 자식이 오버라이딩 했다면 무조건 오버라이딩 메서드가 우선순위를 가지는 다형성이 주는 강력함을 이용해서 공통부분과 달라지는 부분을 분리할 수 있다. 추후에 공통 부분에 변경 사항이 생기더라도 딱 한 개의 클래스인 추상 클래스(부모 클래스)만 수정하면 된다.

실행 결과

비즈니스 로직1 실행 
resultTime=0
비즈니스 로직2 실행 
resultTime=1
  • 아까와 동일한 결과지만 코드는 천차만별이다. 그러나, 이 코드도 단점이 있다. 메서드가 100개면 100개를 수정하듯, 서로 다른 로직이 필요한 부분이 100개라면 100개의 클래스를 만들어야 한다. 이 부분을 간단하게 해결하려면 익명 내부 클래스를 사용하면 된다.

 

템플릿 메서드 패턴 - 예제3

익명 내부 클래스는 위 문제를 해결해준다. 즉, 클래스를 사전에 정의해서 호출하는 방법이 아니라, 추상 클래스를 만드는 시점에 정의하는 것이다. 이는 100개의 서로 다른 로직이 필요할 때 100개의 클래스 파일을 만들어야 하는게 아니라 그 때마다 익명 내부 클래스를 선언해주면 된다.

@Test
void templateMethodV2() {
    AbstractTemplate template = new AbstractTemplate() {
        @Override
        protected void call() {
            log.info("비즈니스 로직1 실행");
        }
    };
    template.execute();

    AbstractTemplate template2 = new AbstractTemplate() {
        @Override
        protected void call() {
            log.info("비즈니스 로직2 실행");
        }
    };
    template2.execute();
}

 

  • templateMethodV2() 메서드를 보자. 추상 클래스인 AbstractTemplate를 구현한 클래스를 호출하는 게 아니라 추상 클래스를 사용하고자 할 때마다 상속받을 클래스를 정의하고 있다. 이렇게 되면 클래스를 하나하나 정의하여 가져다가 사용하지 않아도 된다.

 

템플릿 메서드 패턴 - 적용1

이제 우리가 만든 애플리케이션의 로그 추적기 로직에 템플릿 메서드 패턴을 적용해보자.

 

AbstractTemplate

package cwchoiit.springadvanced.trace.template;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public abstract class AbstractTemplate<T> {

    private final LogTrace trace;

    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();
}
  • AbstractTemplate은 템플릿 메서드 패턴에서 부모 클래스이고, 템플릿 역할을 한다.
  • <T> 제네릭을 사용했다. 반환 타입을 정의한다.
  • 객체를 생성할 때 내부에서 사용할 LogTrace trace를 전달받는다.
  • 로그에 출력할 message를 외부에서 파라미터로 전달받는다.
  • 템플릿 코드 중간에 call() 메서드를 통해서 변하는 부분을 처리한다.
  • abstract T call()은 변하는 부분을 처리하는 메서드이다. 이 부분은 상속으로 구현해야 한다.

OrderControllerV4

package cwchoiit.springadvanced.app.v4;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.template.AbstractTemplate;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OrderControllerV4 {

    private final OrderServiceV4 orderService;
    private final LogTrace trace;

    @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("OrderControllerV1.request(String itemId)");
    }
}

 

OrderServiceV4

package cwchoiit.springadvanced.app.v4;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.template.AbstractTemplate;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OrderServiceV4 {

    private final OrderRepositoryV4 orderRepository;
    private final LogTrace trace;

    public void orderItem(String itemId) {
        AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
            @Override
            protected Void call() {
                orderRepository.save(itemId);
                return null;
            }
        };
        template.execute("OrderServiceV1.orderItem(String itemId)");
    }
}

 

OrderRepositoryV4

package cwchoiit.springadvanced.app.v4;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.template.AbstractTemplate;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class OrderRepositoryV4 {

    private final LogTrace trace;

    public void save(String itemId) {
        AbstractTemplate<Void> template = new AbstractTemplate<>(trace) {
            @Override
            protected Void call() {
                if (itemId.equals("ex")) {
                    throw new IllegalStateException("예외 발생!");
                }
                sleep(1000);
                return null;
            }
        };
        template.execute("OrderRepositoryV1.save(String itemId)");
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 템플릿 메서드 패턴을 적용하니, 기존에 지저분한 로그를 남기는 코드들이 깔끔하게 지워졌다. 그런데, 뭐 그렇다고 이 코드가 깔끔해보이냐? 그것은 또 아니다. 템플릿 메서드 패턴을 위한 코드 자체가 있기 때문에 또 코드 라인을 차지하고 가장 보기 싫은 건 저 익명 내부 클래스 부분들이 줄줄이 나열하고 있는게 그렇게 이뻐보이지는 않는다.
  • 그래도 그 이전보다는 코드가 좀 더 깔끔해졌다. 

 

좋은 설계란?

좋은 설계라는 것은 무엇일까? 수많은 멋진 정의가 있겠지만, 진정한 좋은 설계는 바로 변경이 일어날 때 자연스럽게 드러난다. 지금까지 로그를 남기는 부분을 모아서 하나로 모듈화하고, 비즈니스 로직 부분을 분리했다. 여기서 만약, 로그를 남기는 로직을 변경해야 한다고 생각해보자. 그래서 AbstractTemplate 코드를 변경해야 한다 가정해보자. 단순히 AbstractTemplate 코드만 변경하면 된다. 템플릿이 없는 V3 상태에서 로그를 남기는 로직을 변경해야 한다고 생각해보자. 이 경우, 모든 클래스를 다 찾아서 고쳐야 한다. 클래스가 수백개라면 생각만해도 끔찍하다.

 

단일 책임 원칙(SRP)

V4는 단순히 템플릿 메서드 패턴을 적용해서 소스코드 몇 줄을 줄인 것이 전부가 아니다. 로그를 남기는 부분에 단일 책임 원칙을 지킨것이다. 변경 지점을 하나로 모아서 변경에 쉽게 대처할 수 있는 구조를 만든 것이다.

 

템플릿 메서드 패턴 - 정의

GOF 디자인 패턴에서는 템플릿 메서드 패턴을 다음과 같이 정의했다.

 

템플릿 메서드 디자인 패턴의 목적은 다음과 같습니다.
"작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기합니다. 템플릿 메서드를 사용하면 하위 클래스가 알고리즘의 구조를 변경하지 않고도 알고리즘의 특정 단계를 재정의할 수 있습니다." [GOF]

 

GOF 템플릿 메서드 패턴 정의

위 내용을 풀어서 설명하면 다음과 같다.

  • 부모 클래스에 알고리즘의 골격인 템플릿을 정의하고, 일부 변경되는 로직은 자식 클래스에 정의하는 것이다. 이렇게 하면 자식 클래스가 알고리즘의 전체 구조를 변경하지 않고, 특정 부분만 재정의할 수 있다. 결국 상속과 오버라이딩을 통한 다형성으로 문제를 해결하는 것이다. 

하지만,

템플릿 메서드 패턴은 상속을 사용한다. 따라서 상속에서 오는 단점을 그대로 안고간다. 특히 자식 클래스가 부모 클래스와 컴파일 시점에 강하게 결합되는 문제가 있다. 이것은 의존관계에 대한 문제이다. 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는다. 이번 포스팅에서 지금까지 작성했던 코드를 떠올려보자. 자식 클래스를 작성할 때 부모 클래스의 기능을 사용한 것이 있었던가? 그럼에도 불구하고 템플릿 메서드 패턴을 위해 자식 클래스는 부모 클래스를 상속 받고 있다.

 

상속을 받는다는 것은 특정 부모 클래스를 의존하고 있다는 것이다. 자식 클래스의 extends 다음에 바로 부모 클래스가 코드상에 지정되어 있다. 따라서, 부모 클래스의 기능을 사용하든 사용하지 않든 간에 부모 클래스를 강하게 의존하게 된다. 여기서 강하게 의존한다는 뜻은 자식 클래스의 코드에 부모 클래스의 코드가 명확하게 적혀 있다는 뜻이다. UML에서 상속을 받으면 삼각형 화살표가 자식 -> 부모를 향하고 있는 것은 이런 의존관계를 반영하는 것이다. 참고로 화살표(->)의 방향은 이런 의미다. "내가 얘를 알고 있다."

 

자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는데, 부모 클래스를 알아야 한다. 이것은 좋은 설계라고 할 수 없다. 왜냐하면 지금 같은 경우에도 부모 클래스에 만약, abstract 메서드가 하나 더 추가되면 이 부모 클래스를 상속받은 자식 클래스는 무조건 이 메서드를 구현해야만 컴파일 할 때 문제가 발생하지 않는다. 즉, 부모 클래스의 기능을 전혀 사용하지 않지만 부모 클래스를 수정하는 순간 자식 클래스가 강하게 영향을 받게 된다. 

 

추가로, 템플릿 메서드 패턴은 상속 구조를 사용하기 때문에, 별도의 클래스나 익명 내부 클래스를 만들어야 하는 부분도 복잡하다. 지금까지 설명한 이런 부분들을 더 깔끔하게 개선하려면 어떻게 해야할까? 템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거할 수 있는 디자인 패턴이 바로 전략 패턴(Strategy Pattern)이다. 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Proxy/Decorator Pattern 2 (JDK 동적 프록시)  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Template Callback Pattern  (0) 2023.12.12
Strategy Pattern  (2) 2023.12.12
ThreadLocal  (0) 2023.12.12
728x90
반응형
SMALL
SMALL

참고자료

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com

목표

ThreadLocal이 무엇인지, 언제 사용되는지를 알아보는 포스팅이다. 한단계씩 차근 차근 알아가보자. 이 ThreadLocal에 대해 공부하기 위해 정말 긴 서사가 있을 예정이다. 그런데 꽤나 그 과정이 다 의미가 있고, 왜 ThreadLocal을 사용해야만 했는지를 알아가는 과정이니까 하나씩 차근차근 공부해보자!

 

예제 프로젝트 만들기 V0

우선, 하나씩 차근차근 따라해보자. 아래 코드를 우선 만들어보자.

 

OrderRepositoryV0

package cwchoiit.springadvanced.app.v0;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class OrderRepositoryV0 {

    public void save(String itemId) {
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        sleep(1000);
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

OrderServiceV0

package cwchoiit.springadvanced.app.v0;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OrderServiceV0 {

    private final OrderRepositoryV0 orderRepository;

    public void orderItem(String itemId) {
        orderRepository.save(itemId);
    }
}

 

OrderControllerV0

package cwchoiit.springadvanced.app.v0;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OrderControllerV0 {

    private final OrderServiceV0 orderService;

    @GetMapping("/v0/request")
    public String request(String itemId) {
        orderService.orderItem(itemId);
        return "ok";
    }
}

 

로그 추적기 - 요구사항 분석

애플리케이션이 커지면서 점점 모니터링과 운영이 중요해지는 단계이다. 어떤 부분에서 병목이 발생하는지, 어떤 부분에서 예외가 발생하는지를 로그를 통해 확인하는 것이 점점 중요해지고 있다. 기존에는 개발자가 문제가 발생한 다음에 관련 부분을 어렵게 찾아서 로그를 하나하나 직접 만들어서 남겼다. 로그를 미리 남겨둔다면 이런 부분을 손쉽게 찾을 수 있을 것이다. 이 부분을 개선하고 자동화하는 것이 미션이다.

 

예시

정상 요청
[796bccd9] OrderController.request()
[796bccd9] |-->OrderService.orderItem()
[796bccd9] |   |-->OrderRepository.save()
[796bccd9] |   |<--OrderRepository.save() time=1004ms
[796bccd9] |<--OrderService.orderItem() time=1014ms
[796bccd9] OrderController.request() time=1016ms

예외 발생
[b7119f27] OrderController.request()
[b7119f27] |-->OrderService.orderItem()
[b7119f27] | |-->OrderRepository.save() 
[b7119f27] | |<X-OrderRepository.save() time=0ms ex=java.lang.IllegalStateException: 예외 발생! [b7119f27] |<X-OrderService.orderItem() time=10ms ex=java.lang.IllegalStateException: 예외 발생! [b7119f27] OrderController.request() time=11ms ex=java.lang.IllegalStateException: 예외 발생!

 

로그 추적기 V1 - 프로토타입 개발

애플리케이션의 모든 로직에 직접 로그를 남겨도 되지만, 그것보다는 더 효율적인 개발 방법이 필요하다. 특히 트랜잭션 ID와 깊이를 표현하는 방법은 기존 정보를 이어 받아야 하기 때문에 단순히 로그만 남긴다고 해결할 수 있는 것은 아니다. 요구사항에 맞추어 애플리케이션에 효과적으로 로그를 남기기 위한 로그 추적기를 개발해보자. 먼저 프로토타입 버전을 개발해보자. 아마 코드를 모두 작성하고 테스트 코드까지 실행해보아야 어떤 것을 하는지 감이 올 것이다. 

 

TraceId

package cwchoiit.springadvanced.trace;

import java.util.UUID;

public class TraceId {

    private String id;
    private int level;

    public TraceId() {
        this.id = createId();
        this.level = 0;
    }

    private TraceId(String id, int level) {
        this.id = id;
        this.level = level;
    }

    private String createId() {
        return UUID.randomUUID().toString().substring(0, 8);
    }

    public TraceId createNextId() {
        return new TraceId(id, level + 1);
    }

    public TraceId createPrevId() {
        return new TraceId(id, level - 1);
    }

    public boolean isFirstLevel() {
        return level == 0;
    }

    public String getId() {
        return id;
    }

    public int getLevel() {
        return level;
    }
}
  • 로그 추적기는 트랜잭션ID와 깊이를 표현하는 방법이 필요하다. 여기서는 트랜잭션 ID와 깊이를 표현하는 level을 묶어서 TraceId라는 개념을 만들었다. TraceId는 단순히 id(트랜잭션 ID)와 level 정보를 함께 가지고 있다.

TraceStatus

package cwchoiit.springadvanced.trace;

public class TraceStatus {

    private TraceId traceId;
    private Long startTimeMs;
    private String message;

    public TraceStatus(TraceId traceId, Long startTimeMs, String message) {
        this.traceId = traceId;
        this.startTimeMs = startTimeMs;
        this.message = message;
    }

    public TraceId getTraceId() {
        return traceId;
    }

    public Long getStartTimeMs() {
        return startTimeMs;
    }

    public String getMessage() {
        return message;
    }
}
  • 로그의 상태 정보를 나타낸다.

HelloTraceV1

package cwchoiit.springadvanced.trace.hellotrace;

import cwchoiit.springadvanced.trace.TraceId;
import cwchoiit.springadvanced.trace.TraceStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class HelloTraceV1 {

    private static final String START_PREFIX = "--->";
    private static final String COMPLETE_PREFIX = "<---";
    private static final String EX_PREFIX = "<X--";

    public TraceStatus begin(String message) {
        TraceId traceId = new TraceId();
        long startTime = System.currentTimeMillis();

        log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
        return new TraceStatus(traceId, startTime, message);
    }

    public void end(TraceStatus status) {
        complete(status, null);
    }

    public void exception(TraceStatus status, Throwable ex) {
        complete(status, ex);
    }

    private void complete(TraceStatus status, Throwable ex) {
        long stopTime = System.currentTimeMillis();
        long resultTime = stopTime - status.getStartTimeMs();
        TraceId traceId = status.getTraceId();

        if (ex == null) {
            log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(), resultTime);
        } else {
            log.info("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTime, ex.getMessage());
        }
    }

    private static String addSpace(String prefix, int level) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < level; i++) {
            sb.append((i == level - 1) ? "|" + prefix : "|    ");
        }
        return sb.toString();
    }
}
  • HelloTraceV1을 사용해서 실제 로그를 시작하고, 종료할 수 있다. 그리고 로그를 출력하고 실행시간도 측정할 수 있다. 어디서 이 코드를 호출하는지는 이후에 차차 알게된다.
  • @Component 애노테이션을 사용해서 스프링 빈으로 등록한다. 컴포넌트 스캔의 대상이 되고 싱글톤으로 만들어진다.

공개 메서드

로그 추적기에서 사용되는 공개 메서드는 다음 3가지이다.

  • begin(...)
  • end(...)
  • exception(...)

하나씩 자세히 알아보자.

  • TraceStatus begin(String message)
    • 로그를 시작한다.
    • 로그 메세지를 파라미터로 받아서 시작 로그를 출력한다.
    • 응답 결과로 현재 로그의 상태인 TraceStatus를 반환한다.
  • void end(TraceStatus status)
    • 로그를 정상 종료한다.
    • 파라미터로 시작 로그의 상태(TraceStatus)를 전달 받는다. 이 값을 활용해서 실행 시간을 계산하고, 종료시에도 시작할 때와 동일한 로그 메시지를 출력할 수 있다.
    • 정상 흐름에서 호출한다.
  • void exception(TraceStatus status, Throwable ex)
    • 로그를 예외 상황으로 종료한다.
    • TraceStatus, Throwable 정보를 함께 전달 받아서 실행시간, 예외 정보를 포함한 결과 로그를 출력한다.
    • 예외가 발생했을 때 호출한다.

 

비공개 메서드

  • complete(TraceStatus status, Throwable ex)
    • end(), exception()의 요청 흐름을 한곳에서 편리하게 처리한다. 실행 시간을 측정하고 로그를 남긴다.
  • String addSpace(String prefix, int level)
    • 다음과 같은 결과를 출력한다.
    • prefix: --->
      • level 0: 
      • level 1: |--->
      • level 2: |       |--->
    • prefix: <---
      • level 0:
      • level 1: |<---
      • level 2: |       |<---
    • prefix: <X--
      • level 0:
      • level 1: |<X--
      • level 2: |       |<X--

 

 

로그 추적기 V1 - 적용

이렇게 만든 로그 추적기를 이제 애플리케이션에 적용해보자. 

기존에 V0으로 만들었던 Controller, Service, Repository를 V1로 새로 만들어보자.

 

OrderControllerV1

package cwchoiit.springadvanced.app.v1;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OrderControllerV1 {

    private final OrderServiceV1 orderService;
    private final HelloTraceV1 trace;

    @GetMapping("/v1/request")
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderControllerV1.request(String itemId)");
            orderService.orderItem(itemId);
            trace.end(status);
            return "ok";
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}
  • HelloTraceV1을 주입받는다. 어떤 요청이 들어왔을 때, 우선 가장 먼저 trace.begin("OrderControllerV1.request(String itemId)")를 실행한다. 이렇게 하면 어떤 컨트롤러와 메서드가 호출되었는지 로그로 편리하게 확인할 수 있다. 물론 간단해보이지는 않는다. 이후에 어떻게 이 코드가 점진적으로 아름다워 지는지 확인해보자.
  • 단순하게 trace.begin(), trace.end() 코드 두 줄만 적용하면 될 줄 알았지만, 실상은 그렇지 않다. trace.exception()으로 예외까지 처리해야 하므로 지저분한 try - catch 코드가 추가된다. 
  • begin()의 결과값으로 받은 TraceStatus status 값을 end(), exception()에 넘겨야 한다. 결국 try - catch 블록 모두에 이 값을 넘겨야 하므로 try 상위에 TraceStatus status 코드를 선언해야 한다.
  • catch 블록에서는 throw e를 통해 예외를 꼭 다시 던져주어야 한다. 그렇지 않으면 여기서 예외를 먹어버리고, 이후에 정상 흐름으로 동작한다. 로그는 애플리케이션 흐름에 영향을 주면 안된다. 로그 때문에 예외가 사라지면 안된다.

OrderServiceV1

package cwchoiit.springadvanced.app.v1;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OrderServiceV1 {

    private final OrderRepositoryV1 orderRepository;
    private final HelloTraceV1 trace;

    public void orderItem(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderServiceV1.orderItem(String itemId)");
            orderRepository.save(itemId);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

OrderRepositoryV1

package cwchoiit.springadvanced.app.v1;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class OrderRepositoryV1 {

    private final HelloTraceV1 trace;

    public void save(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderRepositoryV1.save(String itemId)");
            if (itemId.equals("ex")) {
                throw new IllegalStateException("예외 발생!");
            }
            sleep(1000);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

이렇게 만든 Controller, Service, Repository를 가지고 이제 정상 실행과 예외 실행을 해보자. 아래와 같이 요청을 해보면 로그가 남는다.

# OrderControllerV1.request
GET http://localhost:8080/v1/request?itemId=ex
[df082bae] OrderControllerV1.request(String itemId)
[5e8339a2] OrderServiceV1.orderItem(String itemId)
[7a5354ed] OrderRepositoryV1.save(String itemId)
[7a5354ed] OrderRepositoryV1.save(String itemId) time=0ms ex=예외 발생!
[5e8339a2] OrderServiceV1.orderItem(String itemId) time=1ms ex=예외 발생!
[df082bae] OrderControllerV1.request(String itemId) time=2ms ex=예외 발생!
  • 생각했던 것과 달리 정말 안 예쁘다. 하지만 지금은 이렇게 나오는게 맞다. 레벨 관련 기능을 개발하지 않았기 때문에. 또한 트랜잭션 ID도 다 다르다. 이 부분 역시 아직 개발되지 않았다. 결국 Controller, Service, Repository 다 다른 트랜잭션 ID를 가지게 된다.
  • 쉽게 말해, Controller가 최초 호출 지점이라면 이 지점에서 만들어진 트랜잭션 ID가 컨트롤러가 호출하는 서비스, 서비스가 호출하는 레포지토리를 거쳐 다시 컨트롤러로 돌아오는 동안 유지되어야 한다. 그리고 레벨은 하나씩 증가되어야 한다. 이를 해결하는 가장 간단한 방법은 무엇일까? 파라미터 전달이다. 

 

로그 추적기 V2 - 파라미터로 동기화 개발

트랜잭션 ID와 메서드 호출의 깊이를 표현하는 가장 단순한 방법은 첫 로그에서 사용한 트랜잭션 IDlevel을 다음 로그에 넘겨주면 된다. 현재 로그의 상태 정보인 트랜잭션 IDlevelTraceId에 포함되어 있다. 따라서 TraceId를 다음 로그에 넘겨주면 된다. 이 기능을 추가한 HelloTraceV2를 개발해보자.

 

HelloTraceV2

package cwchoiit.springadvanced.trace.hellotrace;

import cwchoiit.springadvanced.trace.TraceId;
import cwchoiit.springadvanced.trace.TraceStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class HelloTraceV2 {

    private static final String START_PREFIX = "--->";
    private static final String COMPLETE_PREFIX = "<---";
    private static final String EX_PREFIX = "<X--";

    public TraceStatus begin(String message) {
        TraceId traceId = new TraceId();
        long startTime = System.currentTimeMillis();

        log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
        return new TraceStatus(traceId, startTime, message);
    }

    public TraceStatus beginSync(TraceId beforeTraceId, String message) {
        TraceId nextId = beforeTraceId.createNextId();
        long startTime = System.currentTimeMillis();

        log.info("[{}] {}{}", nextId.getId(), addSpace(START_PREFIX, nextId.getLevel()), message);
        return new TraceStatus(nextId, startTime, message);
    }

    public void end(TraceStatus status) {
        complete(status, null);
    }

    public void exception(TraceStatus status, Throwable ex) {
        complete(status, ex);
    }

    private void complete(TraceStatus status, Throwable ex) {
        long stopTime = System.currentTimeMillis();
        long resultTime = stopTime - status.getStartTimeMs();
        TraceId traceId = status.getTraceId();

        if (ex == null) {
            log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(), resultTime);
        } else {
            log.info("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTime, ex.getMessage());
        }
    }

    private static String addSpace(String prefix, int level) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < level; i++) {
            sb.append((i == level - 1) ? "|" + prefix : "|    ");
        }
        return sb.toString();
    }
}
  • 기존 코드와 모두 동일한데 딱 하나의 추가된 메서드가 있다.
public TraceStatus beginSync(TraceId beforeTraceId, String message) {
    TraceId nextId = beforeTraceId.createNextId();
    long startTime = System.currentTimeMillis();

    log.info("[{}] {}{}", nextId.getId(), addSpace(START_PREFIX, nextId.getLevel()), message);
    return new TraceStatus(nextId, startTime, message);
}
  • 이전 TraceId를 전달받는 beginSync 메서드. begin()과 차이점은 새로 TraceId를 만들어서 level 0을 가지는 TraceId가 아닌, 기존의 TraceId를 받아 createNextId() 메서드를 호출해서 현재 레벨에 + 1 한 TraceId를 받는것이다.

 

로그 추적기 V2 - 적용

OrderControllerV2

package cwchoiit.springadvanced.app.v2;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV1;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV2;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OrderControllerV2 {

    private final OrderServiceV2 orderService;
    private final HelloTraceV2 trace;

    @GetMapping("/v2/request")
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderControllerV1.request(String itemId)");
            orderService.orderItem(status.getTraceId(), itemId);
            trace.end(status);
            return "ok";
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}
  • TraceStatus status = trace.begin()에서 반환 받은 TraceStatus에는 트랜잭션 IDlevel 정보가 있는 TraceId가 있다. orderSerivce.orderItem()을 호출할 때 이 TraceId를 파라미터로 전달한다. 

OrderServiceV2

package cwchoiit.springadvanced.app.v2;

import cwchoiit.springadvanced.trace.TraceId;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV2;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OrderServiceV2 {

    private final OrderRepositoryV2 orderRepository;
    private final HelloTraceV2 trace;

    public void orderItem(TraceId traceId, String itemId) {
        TraceStatus status = null;
        try {
            status = trace.beginSync(traceId, "OrderServiceV1.orderItem(String itemId)");
            orderRepository.save(status.getTraceId(), itemId);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}
  • orderItem()은 파라미터로 전달 받은 traceId를 사용해서 trace.beginSync()를 실행한다. beginSync()는 내부에서 다음 traceId를 생성하면서 트랜잭션 ID는 유지하고 level은 하나 증가시킨다.
  • beginSync()가 반환한 새로운 TraceStatusorderRepository.save()를 호출하면서 파라미터로 전달한다.

OrderRepositoryV2

package cwchoiit.springadvanced.app.v2;

import cwchoiit.springadvanced.trace.TraceId;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV2;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class OrderRepositoryV2 {

    private final HelloTraceV2 trace;

    public void save(TraceId traceId, String itemId) {
        TraceStatus status = null;
        try {
            status = trace.beginSync(traceId, "OrderRepositoryV1.save(String itemId)");
            if (itemId.equals("ex")) {
                throw new IllegalStateException("예외 발생!");
            }
            sleep(1000);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

정상 실행 로그

 [c80f5dbb] OrderController.request()
 [c80f5dbb] |-->OrderService.orderItem()
 [c80f5dbb] |   |-->OrderRepository.save()
 [c80f5dbb] |   |<--OrderRepository.save() time=1005ms
 [c80f5dbb] |<--OrderService.orderItem() time=1014ms
 [c80f5dbb] OrderController.request() time=1017ms
  • 이제야 좀 이쁜 로그가 찍히기 시작한다. 그런데 이 짓을 위해 관련 메서드의 모든 파라미터를 수정하는 작업부터 시작해서, 로그를 처음 시작할 때는 begin()을 호출하고 처음이 아닐때는 beginSync()를 호출해야 한다. 이런 짓거리를 어떻게 할까? 안될것 같다. 더 좋은 대안이 필요하다.

 

필드 동기화 - 개발

앞서 로그 추적기를 만들면서 다음 로그를 출력할 때 트랜잭션 IDlevel을 동기화하는 문제가 있었다. 이 문제를 해결하기 위해 TraceId를 파라미터로 넘기도록 구현했다. 이렇게해서 동기화는 성공했지만, 로그를 출력하는 모든 메서드에 TraceId 파라미터를 추가해야 하는 문제가 발생했다. TraceId를 파라미터로 넘기지 않고 이 문제를 해결할 수 있는 방법은 없을까?

 

이런 문제를 해결할 목적으로 새로운 로그 추적기를 만들어보자. 이제 프로토타입 버전이 아닌 정식 버전으로 제대로 개발해보자. 향후 다양한 구현체로 변경할 수 있도록 LogTrace 인터페이스를 먼저 만들고 구현해보자.

 

LogTrace

package cwchoiit.springadvanced.trace.logtrace;

import cwchoiit.springadvanced.trace.TraceStatus;

public interface LogTrace {
    TraceStatus begin(String message);
    void end(TraceStatus status);
    void exception(TraceStatus status, Throwable throwable);
}
  • LogTrace 인터페이스에는 로그 추적기를 위한 최소한의 기능인 begin(), end(), exception()을 정의했다. 이제 파라미터를 넘기지 않고 TraceId를 동기화할 수 있는 FieldLogTrace 구현체를 만들어보자.

FieldLogTrace

package cwchoiit.springadvanced.trace.logtrace;

import cwchoiit.springadvanced.trace.TraceId;
import cwchoiit.springadvanced.trace.TraceStatus;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class FieldLogTrace implements LogTrace {

    private static final String START_PREFIX = "--->";
    private static final String COMPLETE_PREFIX = "<---";
    private static final String EX_PREFIX = "<X--";

    private TraceId traceIdHolder; // traceId 동기화

    @Override
    public TraceStatus begin(String message) {
        syncTraceId();
        TraceId traceId = traceIdHolder;
        long startTime = System.currentTimeMillis();

        log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
        return new TraceStatus(traceId, startTime, message);
    }

    @Override
    public void end(TraceStatus status) {
        complete(status, null);
    }

    @Override
    public void exception(TraceStatus status, Throwable throwable) {
        complete(status, throwable);
    }

    private void complete(TraceStatus status, Throwable ex) {
        long stopTime = System.currentTimeMillis();
        long resultTime = stopTime - status.getStartTimeMs();
        TraceId traceId = status.getTraceId();

        if (ex == null) {
            log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(), resultTime);
        } else {
            log.info("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTime, ex.getMessage());
        }

        releaseTraceId();
    }

    private void syncTraceId() {
        if (traceIdHolder == null) {
            traceIdHolder = new TraceId();
        } else {
            traceIdHolder = traceIdHolder.createNextId();
        }
    }

    private void releaseTraceId() {
        if (traceIdHolder.isFirstLevel()) {
            traceIdHolder = null;
        } else {
            traceIdHolder = traceIdHolder.createPrevId();
        }
    }

    private static String addSpace(String prefix, int level) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < level; i++) {
            sb.append((i == level - 1) ? "|" + prefix : "|    ");
        }
        return sb.toString();
    }
}
  • FieldLogTrace는 기존에 만들었던 HelloTraceV2와 거의 같은 기능을 한다. 
  • TraceId를 동기화 하는 부분만 파라미터를 사용하는 것에서 TraceId traceIdHolder 필드를 사용하도록 변경되었다.
  • 이제 직전 로그의 TraceId는 파라미터로 전달되는 것이 아니라 FieldLogTrace의 필드인 traceIdHolder에 저장된다.
  • 여기서 중요한 부분은 로그를 시작할 때 호출하는 syncTraceId()와 로그를 종료할 때 호출하는 releaseTraceId()이다. 
private void syncTraceId() {
    if (traceIdHolder == null) {
        traceIdHolder = new TraceId();
    } else {
        traceIdHolder = traceIdHolder.createNextId();
    }
}
  • TraceId를 새로 만들거나 앞선 로그의 TraceId를 참고해서 동기화하고, level도 증가시킨다.
  • 최초 호출이면 TraceId를 새로 만든다.
  • 직전 로그가 있으면 해당 로그의 TraceId를 참고해서 동기화하고, level도 하나 증가한다.
  • 결과를 traceIdHolder에 보관한다.
private void releaseTraceId() {
    if (traceIdHolder.isFirstLevel()) {
        traceIdHolder = null;
    } else {
        traceIdHolder = traceIdHolder.createPrevId();
    }
}
  • 메서드를 추가로 호출할 때는 level이 하나 증가해야 하지만, 메서드 호출이 끝나면 level이 하나 감소해야 한다.
  • releaseTraceId()level을 하나 감소한다.
  • 만약, 최초 호출(level == 0)이면, 내부에서 관리하는 traceId를 제거한다.
  • 그러니까, 아래와 같은 흐름이라고 생각하면 되겠다.
[c80f5dbb] OrderController.request() //syncTraceId(): 최초 호출 level=0
[c80f5dbb] |-->OrderService.orderItem() //syncTraceId(): 직전 로그 있음 level=1 증가
[c80f5dbb] | |-->OrderRepository.save() //syncTraceId(): 직전 로그 있음 level=2 증가
[c80f5dbb] |   |<--OrderRepository.save() time=1005ms //releaseTraceId(): level=2->1 감소
[c80f5dbb] |<--OrderService.orderItem() time=1014ms level=1->0 감소
[c80f5dbb] OrderController.request() time=1017ms level==0, traceId 제거

 

필드 동기화 - 적용

지금까지 만든 FieldLogTrace를 애플리케이션에 적용해보자. 

 

LogTrace 스프링 빈 등록

FieldLogTrace를 수동으로 스프링 빈으로 등록하자. 수동으로 등록하면 향후 구현체를 편리하게 변경할 수 있다는 장점이 있다.

 

LogTraceConfig

package cwchoiit.springadvanced;

import cwchoiit.springadvanced.trace.logtrace.FieldLogTrace;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LogTraceConfig {

    @Bean
    public LogTrace logTrace() {
        return new FieldLogTrace();
    }
}

 

V2 → V3 변경

이전에 사용했던 V2 버전을 V3로 모두 변경하자.

OrderControllerV3

package cwchoiit.springadvanced.app.v3;

import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV2;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OrderControllerV3 {

    private final OrderServiceV3 orderService;
    private final LogTrace trace;

    @GetMapping("/v3/request")
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderControllerV1.request(String itemId)");
            orderService.orderItem(itemId);
            trace.end(status);
            return "ok";
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

OrderServiceV3

package cwchoiit.springadvanced.app.v3;

import cwchoiit.springadvanced.trace.TraceId;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV2;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OrderServiceV3 {

    private final OrderRepositoryV3 orderRepository;
    private final LogTrace trace;

    public void orderItem(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderServiceV1.orderItem(String itemId)");
            orderRepository.save(itemId);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

 

OrderRepositoryV3

package cwchoiit.springadvanced.app.v3;

import cwchoiit.springadvanced.trace.TraceId;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.hellotrace.HelloTraceV2;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class OrderRepositoryV3 {

    private final LogTrace trace;

    public void save(String itemId) {
        TraceStatus status = null;
        try {
            status = trace.begin("OrderRepositoryV1.save(String itemId)");
            if (itemId.equals("ex")) {
                throw new IllegalStateException("예외 발생!");
            }
            sleep(1000);
            trace.end(status);
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

 

이렇게 만들어 둔 상태에서 아래와 같이 실행해보자.

실행 결과

### OrderControllerV3.request
GET http://localhost:8080/v3/request?itemId=Good
[86537cdd] OrderControllerV1.request(String itemId)
[86537cdd] |--->OrderServiceV1.orderItem(String itemId)
[86537cdd] |    |--->OrderRepositoryV1.save(String itemId)
[86537cdd] |    |<---OrderRepositoryV1.save(String itemId) time=1001ms
[86537cdd] |<---OrderServiceV1.orderItem(String itemId) time=1002ms
[86537cdd] OrderControllerV1.request(String itemId) time=1002ms
  • 잘 되는것 같다. 이제야 좀 그래도 가져다가 쓸 만한 코드를 작성한 것 같은데.. 과연 아무 문제가 없을까? 

 

필드 동기화 - 동시성 문제

잘 만든 로그 추적기를 실제 서비스에 배포했다 가정해보자. 테스트 할 때는 문제가 없는 것 처럼 보였다. 사실 직전에 만든 FieldLogTrace는 심각한 동시성 문제를 가지고 있다. 동시성 문제를 확인하려면 다음과 같이 동시에 여러번 호출하면 된다.

 

동시성 문제 확인 - 1초에 2번 실행

### OrderControllerV3.request
GET http://localhost:8080/v3/request?itemId=Good
[f0854d53] OrderControllerV1.request(String itemId)
[f0854d53] |--->OrderServiceV1.orderItem(String itemId)
[f0854d53] |    |--->OrderRepositoryV1.save(String itemId)
[f0854d53] |    |    |--->OrderControllerV1.request(String itemId)
[f0854d53] |    |    |    |--->OrderServiceV1.orderItem(String itemId)
[f0854d53] |    |    |    |    |--->OrderRepositoryV1.save(String itemId)
[f0854d53] |    |<---OrderRepositoryV1.save(String itemId) time=1001ms
[f0854d53] |<---OrderServiceV1.orderItem(String itemId) time=1002ms
[f0854d53] OrderControllerV1.request(String itemId) time=1002ms
[f0854d53] |    |    |    |    |<---OrderRepositoryV1.save(String itemId) time=1001ms
[f0854d53] |    |    |    |<---OrderServiceV1.orderItem(String itemId) time=1001ms
[f0854d53] |    |    |<---OrderControllerV1.request(String itemId) time=1001ms
  • 동시에 여러 사용자가 요청하면, 여러 쓰레드가 동시에 애플리케이션 로직을 호출하게 된다. 따라서 로그는 이렇게 이상하게 찍혀버린다. 물론 동시에 여러 요청이 들어오면 로그가 섞여 찍힐 순 있지만 위처럼 트랜잭션 ID가 동일해서는 안된다. 하지만 서로 다른 요청이 같은 트랜잭션 ID를 가지고 있다.

동시성 문제

왜 이런 문제가 발생할까? 바로 FieldLogTrace는 싱글톤으로 등록된 스프링 빈이고, 이 객체의 인스턴스가 애플리케이션에 딱 하나만 존재하는데 이렇게 하나만 존재하는 인스턴스의 전역 변수(필드)인 traceIdHolder에 여러 쓰레드가 동시에 접근하기 때문이다. 동시에 접근하더라도 읽기만 한다면 동시성 문제는 발생하지 않는다. 그러나 지금은 읽기만 하는게 아니라 쓰기를 하고 있기 때문에 동시성 문제가 발생한다.

 

동시성 문제가 발생할 수 있는 환경

  • 지역 변수가 아닌 전역 변수 또는 클래스 멤버(변수)
  • 읽기 작업만 일어나는 게 아니라 쓰기 작업이 가해지는 변수
참고로, 지역 변수(예를 들면, 메서드안에서 새로 만들어지는 변수와 같은)는 동시성 문제가 발생하지 않는다. 너무 기본적인 내용이지만 한번 더 리마인드하면 좋으니까! 왜 동시성 문제가 발생하지 않냐면 지역변수는 스레드 별로 새로 만들어진다. 스택 영역에 메서드를 호출한 순서대로 스택 프레임이 쌓이고 그 스택 프레임에는 메서드 안에서 만들어야 하는 지역변수도 담고 있다. 

 

 

다음 코드로 동시성 문제에 대한 예시를 살펴보자.

FieldService

package com.example.advanced.trace.threadlocal.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class FieldService {

    private String nameStore;

    public String logic(String name) {
        log.info("저장 name={} -> nameStore={}", name, nameStore);

        nameStore = name;

        sleep(1000);

        log.info("조회 nameStore={}", nameStore);

        return nameStore;
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

  • 위 서비스 코드를 보면, nameStore라는 필드가 있고, logic() 메소드가 있다. 이 메소드에서 파라미터로 name을 받으면 그 namenameStore에 저장하는 간단한 코드이다. 저장한 후 1초 뒤에 저장된 값을 조회하는 로그가 있고 로직은 종료된다.

FieldServiceTest

package com.example.advanced.trace.threadlocal;

import com.example.advanced.trace.threadlocal.code.FieldService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class FieldServiceTest {

    private FieldService fieldService = new FieldService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            fieldService.logic("userA");
        };

        Runnable userB = () -> {
            fieldService.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");

        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(2000);
        threadB.start();
        sleep(3000);

        log.info("main exit");
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 독립적인 Thread 두 개를 생성한다. 생성한 Thread가 실행하는 코드는 new로 새로 만든 인스턴스인 FieldService logic() 메소드이다. 이 두 개의 쓰레드를 실행할 때 threadA를 실행한 후 2초 뒤 threadB를 실행한다.

지금 위 코드로는 동시성 문제는 발생하지 않는다. 왜냐하면 FieldService.logic() 메소드는 nameStore에 저장한 뒤 1초뒤에 조회를 하는데 threadAthreadB의 각 실행 사이의 간격이 2초이기 때문이다. 실행해보면 다음과 같은 결과를 볼 수 있다.

09:03:03.389 [Test worker] INFO com.example.advanced.trace.threadlocal.FieldServiceTest -- main start
09:03:03.395 [thread-A] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 저장 name=userA -> nameStore=null
09:03:04.402 [thread-A] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 조회 nameStore=userA
09:03:05.397 [thread-B] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 저장 name=userB -> nameStore=userA
09:03:06.403 [thread-B] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 조회 nameStore=userB
09:03:08.397 [Test worker] INFO com.example.advanced.trace.threadlocal.FieldServiceTest -- main exit
  • 문제 없이 본인이 저장한 값으로 조회되는 것을 확인할 수 있다.

 

그러나, 여기서 threadAthreadB 실행 사이의 간격을 0.1초로 변경한다면 동시성 문제가 발생한다. 

왜 그럴까? 이유는 필드에 값을 저장하고 조회하기까지 걸리는 시간은 1초인데 새로운 쓰레드가 동일한 필드에 접근하는 시간이 0.1초이기 때문이다. 

....
        threadA.start();
        sleep(100); // 동시성 문제 발생 원인 코드
        threadB.start();
        sleep(3000);
....

이렇게 코드를 변경하고 실행해보자. 예상 못했던 결과가 도출된다.

09:06:46.886 [Test worker] INFO com.example.advanced.trace.threadlocal.FieldServiceTest -- main start
09:06:46.891 [thread-A] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 저장 name=userA -> nameStore=null
09:06:46.992 [thread-B] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 저장 name=userB -> nameStore=userA
09:06:47.897 [thread-A] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 조회 nameStore=userB
09:06:47.993 [thread-B] INFO com.example.advanced.trace.threadlocal.code.FieldService -- 조회 nameStore=userB
09:06:49.993 [Test worker] INFO com.example.advanced.trace.threadlocal.FieldServiceTest -- main exit

 

  • 결과는 두 쓰레드 모두 조회값이 "userB"로 출력된다. 이유는 "userA"라는 값을 저장한 후 1초뒤 값을 조회하는 코드가 실행되는데 조회하기 전 다른 쓰레드에서 "userB"라는 값을 저장했기 때문이다. 이게 '동시성 문제'이다. 이런 동시성 문제를 해결하기 위해 어떠한 조치를 취할 수 있을까? 방법은 다양하겠지만 여기서 설명하고자 하는 건 ThreadLocal이다.

 

ThreadLocal

ThreadLocal은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말한다. 즉, 찜질방에서 생김새와 모양이 완전히 똑같은 여러개의 락커가 있지만 그 락커마다의 주인이 딱 한명인 것처럼 말이다. 그럼 그림을 통해 일반적인 변수 필드와 쓰레드 로컬을 사용한 변수에 어떤 차이가 있는지 확인해보자.

 

일반적인 변수 필드

여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있다.

 

쓰레드 로컬

쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공한다. 따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제가 없다.

  • 쓰레드 로컬을 통해서 데이터를 조회할 때도 thread-A가 조회하면 쓰레드 로컬은 thread-A 전용 보관소에서 "userA" 데이터를 반환해준다. 물론, thread-B가 조회하면 thread-B 전용 보관소에서 "userB" 데이터를 반환해준다.
  • 자바는 언어차원에서 쓰레드 로컬을 지원하기 위한 `java.lang.ThreadLocal` 클래스를 제공한다.

 

이 쓰레드 로컬을 코드로 좀 더 깊이 있게 이해해보자.

ThreadLocalService

package com.example.advanced.trace.threadlocal.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ThreadLocalService {

    private final ThreadLocal<String> nameStore = new ThreadLocal<>();

    public String logic(String name) {
        log.info("저장 name={} -> nameStore={}", name, nameStore.get());

        nameStore.set(name);

        sleep(1000);

        log.info("조회 nameStore={}", nameStore.get());

        return nameStore.get();
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 기존 FieldService에서 ThreadLocalService로 변경되면서 바뀌는 부분은 nameStore가 ThreadLocal 타입이라는 점이다. 그리고 이 ThreadLocal은 값을 세팅하고 조회하기 위해 .set(), .get()을 사용한다.

 

ThreadLocalServiceTest

package com.example.advanced.trace.threadlocal;

import com.example.advanced.trace.threadlocal.code.FieldService;
import com.example.advanced.trace.threadlocal.code.ThreadLocalService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class ThreadLocalServiceTest {

    private ThreadLocalService service = new ThreadLocalService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            service.logic("userA");
        };

        Runnable userB = () -> {
            service.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");

        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(100);
        threadB.start();
        sleep(3000);

        log.info("main exit");
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • FieldServiceTest에서 ThreadLocalServiceTest로 변경했다. 바뀌는 부분은 서비스가 ThreadLocalService라는 점. 그리고 기존 코드에서 동시성 이슈를 발생했던 sleep(100)을 그대로 두고 실행해도 동시성 문제는 발생하지 않는다. 결과를 보자.
09:13:41.235 [Test worker] INFO com.example.advanced.trace.threadlocal.ThreadLocalServiceTest -- main start
09:13:41.240 [thread-A] INFO com.example.advanced.trace.threadlocal.code.ThreadLocalService -- 저장 name=userA -> nameStore=null
09:13:41.340 [thread-B] INFO com.example.advanced.trace.threadlocal.code.ThreadLocalService -- 저장 name=userB -> nameStore=null
09:13:42.244 [thread-A] INFO com.example.advanced.trace.threadlocal.code.ThreadLocalService -- 조회 nameStore=userA
09:13:42.345 [thread-B] INFO com.example.advanced.trace.threadlocal.code.ThreadLocalService -- 조회 nameStore=userB
09:13:44.344 [Test worker] INFO com.example.advanced.trace.threadlocal.ThreadLocalServiceTest -- main exit

 

서로 다른 쓰레드가 모두 독립적인 ThreadLocal 필드를 가지기 때문에 최초 조회 시 모두 값은 null이다. 그리고 1초 뒤 조회해도 본인이 저장한 값으로 조회된다.ThreadLocal을 이용해서 동시성 문제를 해결했다. 

 

ThreadLocal 사용 시 주의점

쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있다. 다음 예시를 통해서 알아보자.

 

  • 사용자A가 저장 관련 HTTP 요청을 했다.
  • WAS는 쓰레드 풀에서 쓰레드 하나를 조회한다.
  • 쓰레드(thread-A)가 할당되었다.
  • thread-A는 사용자A의 데이터를 쓰레드 로컬에 저장한다.
  • 쓰레드 로컬의 thread-A 전용 보관소에 사용자A 데이터를 보관한다.

 

사용자A 저장 요청 종료

  • 사용자A의 HTTP 응답이 끝난다.
  • WAS는 사용이 끝난 thread-A쓰레드 풀에 반환한다. 쓰레드를 생성하는 비용은 비싸기 때문에 쓰레드를 제거하지 않고, 보통 쓰레드 풀을 통해서 쓰레드를 재사용한다.
  • thread-A는 쓰레드 풀에 아직 살아있다. 따라서, 쓰레드 로컬의 thread-A 전용 보관소에 사용자A 데이터도 함께 살아있게 된다.

 

  • 사용자B가 조회를 위한 새로운 HTTP 요청을 한다.
  • WAS는 쓰레드 풀에서 쓰레드 하나를 조회한다.
  • 하필! 쓰레드(thread-A)가 할당되었다. (물론 다른 쓰레드가 할당될 수 있다 운이 좋다면!)
  • 이번에는 조회하는 요청이다. thread-A는 쓰레드 로컬에서 데이터를 조회한다.
  • 쓰레드 로컬은 thread-A 전용 보관소에 있는 사용자A값을 반환한다.
  • 결과적으로 사용자A의 값이 사용자B에게 반환된다.

결과적으로, 사용자B는 사용자A의 데이터를 확인하게 되는 매우 심각한 문제가 발생하게 된다. 이런 문제를 예방하려면 사용자A의 요청이 끝날 때, 쓰레드 로컬의 값을 ThreadLocal.remove()를 호출해서 반드시! 반드시 제거해야 한다. 쓰레드 로컬을 사용할 때는 이 부분을 매우매우 중요하게 꼭! 기억하자.

 

쓰레드 로컬 동기화 - 개발

FieldLogTrace에서 발생했던 동시성 문제를 ThreadLocal로 해결해보자. TraceId traceIdHolder 필드를 쓰레드 로컬을 사용하도록 ThreadLocal<TraceId> traceIdHolder로 변경하면 된다.

 

ThreadLocalLogTrace

package cwchoiit.springadvanced.trace.logtrace;

import cwchoiit.springadvanced.trace.TraceId;
import cwchoiit.springadvanced.trace.TraceStatus;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ThreadLocalLogTrace implements LogTrace {

    private static final String START_PREFIX = "--->";
    private static final String COMPLETE_PREFIX = "<---";
    private static final String EX_PREFIX = "<X--";

    private final ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>(); // traceId 동기화

    @Override
    public TraceStatus begin(String message) {
        syncTraceId();
        TraceId traceId = traceIdHolder.get();
        long startTime = System.currentTimeMillis();

        log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
        return new TraceStatus(traceId, startTime, message);
    }

    @Override
    public void end(TraceStatus status) {
        complete(status, null);
    }

    @Override
    public void exception(TraceStatus status, Throwable throwable) {
        complete(status, throwable);
    }

    private void complete(TraceStatus status, Throwable ex) {
        long stopTime = System.currentTimeMillis();
        long resultTime = stopTime - status.getStartTimeMs();
        TraceId traceId = status.getTraceId();

        if (ex == null) {
            log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(), resultTime);
        } else {
            log.info("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTime, ex.getMessage());
        }

        releaseTraceId();
    }

    private void syncTraceId() {
        TraceId traceId = traceIdHolder.get();
        if (traceId == null) {
            traceIdHolder.set(new TraceId());
        } else {
            traceIdHolder.set(traceId.createNextId());
        }
    }

    private void releaseTraceId() {
        TraceId traceId = traceIdHolder.get();
        if (traceId.isFirstLevel()) {
            traceIdHolder.remove();
        } else {
            traceIdHolder.set(traceId.createPrevId());
        }
    }

    private static String addSpace(String prefix, int level) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < level; i++) {
            sb.append((i == level - 1) ? "|" + prefix : "|    ");
        }
        return sb.toString();
    }
}
  • traceIdHolder가 필드에서 ThreadLocal로 변경되었다. 따라서, 값을 저장할 때는 set(...)을 사용하고, 값을 조회할 때는 get()을 사용한다. 
  • 이 코드에서는 위에서 말했던 주의점인 다 사용한 다음에는 ThreadLocal.remove()를 호출하는 코드가 반영되어 있다.
private void releaseTraceId() {
    TraceId traceId = traceIdHolder.get();
    if (traceId.isFirstLevel()) {
        traceIdHolder.remove();
    } else {
        traceIdHolder.set(traceId.createPrevId());
    }
}

 

 

쓰레드 로컬 동기화 - 적용

LogTraceConfig - 수정

package cwchoiit.springadvanced;

import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.logtrace.ThreadLocalLogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LogTraceConfig {

    @Bean
    public LogTrace logTrace() {
        return new ThreadLocalLogTrace();
    }
}
  • 동시성 문제가 있는 FieldLogTrace 대신에 문제를 해결한 ThreadLocalLogTrace를 스프링 빈으로 등록하자.
  • 이게 바로 스프링의 막강한 장점인, 구현체만 갈아끼우면 되는 DI이다. 클라이언트 코드는 손 댈 게 없다. 왜냐? 클라이언트 코드는 구현체에 직접적으로 의존하는게 아니라 구현체의 기준인 인터페이스에만 의존하고 있기 때문에!
  • 이제 연속으로도 호출해보고 막 해보자 막!

 

실행 결과

[nio-8080-exec-1] [ce909512] OrderControllerV1.request(String itemId)
[nio-8080-exec-1] [ce909512] |--->OrderServiceV1.orderItem(String itemId)
[nio-8080-exec-1] [ce909512] |    |--->OrderRepositoryV1.save(String itemId)
[nio-8080-exec-2] [bc43b1df] OrderControllerV1.request(String itemId)
[nio-8080-exec-2] [bc43b1df] |--->OrderServiceV1.orderItem(String itemId)
[nio-8080-exec-2] [bc43b1df] |    |--->OrderRepositoryV1.save(String itemId)
[nio-8080-exec-1] [ce909512] |    |<---OrderRepositoryV1.save(String itemId) time=1001ms
[nio-8080-exec-1] [ce909512] |<---OrderServiceV1.orderItem(String itemId) time=1002ms
[nio-8080-exec-1] [ce909512] OrderControllerV1.request(String itemId) time=1003ms
[nio-8080-exec-2] [bc43b1df] |    |<---OrderRepositoryV1.save(String itemId) time=1000ms
[nio-8080-exec-2] [bc43b1df] |<---OrderServiceV1.orderItem(String itemId) time=1001ms
[nio-8080-exec-2] [bc43b1df] OrderControllerV1.request(String itemId) time=1001ms
  • 로그가 출력되는 순서는 살짝 뒤죽박죽이지만, 이제는 트랜잭션 ID도 명확하게 분리되어 있고, 레벨도 아무런 문제없이 잘 표현된다. 이렇게 되면 요청당 어떤 흐름이 진행되는지 명확하게 구분하고 확인할 수 있다.

 

 

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Proxy/Decorator Pattern 2 (JDK 동적 프록시)  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Template Callback Pattern  (0) 2023.12.12
Strategy Pattern  (2) 2023.12.12
Template Method Pattern  (2) 2023.12.12

+ Recent posts