Java 개발 방법론, 변하지 않는 로직에 변하는 로직 넣기

60jong·2023년 7월 15일
1

Java

목록 보기
13/14
post-thumbnail

기록하고 싶은 상황은, 변하지 않는 로직에 변하는 로직 넣기. 다시 말해서

핵심 로직에 부수적인 기능을 추가하고 싶은 상황이다.

상황 가정

핵심 로직

핵심 로직은 단순하게

  1. query string으로 name을 받아서 저장
  2. 저장 sequence를 리턴

이다.

코드로는 Controller - Service - Repository 계층이다.

@RequiredArgsConstructor
@RestController
public class MainController {

    private final MainService mainService;

    @GetMapping("/main/signin")
    public String signin(String name) {
        long joinId = mainService.join(name);

        return String.format("ID = %d", joinId);
    }
}
@RequiredArgsConstructor
@Service
public class MainService {

    private final MainRepository mainRepository;

    public long join(String name) {
        return mainRepository.save(name);
    }
}

이 상황에서 주로 MainService.join()에 부수적인 기능을 추가할 것이다.

@Repository
public class MainRepository {

    private final Map<Long, String> nameStore = new ConcurrentHashMap<>();

    public Long save(String name) {
        long sequence = SequenceCounter.getSequence();
        nameStore.put(sequence, name);
        return sequence;
    }
}
public class SequenceCounter {

    private static AtomicLong sequence = new AtomicLong(1);

    public static long getSequence() {
        return sequence.getAndIncrement();
    }
}

부수적인 기능

부수적인 기능은 메서드 응답이 1000ms(1초)이상 걸리는 경우에 로그를 남기는 것이다.

WARN [performance] MainService.join(String) 1023ms 이런 형식으로.



10가지 방식

핵심 기능에 부수적인 기능을 넣는 방식을 10가지로 알아볼 것이다. 10가지는 다음과 같다.

  1. 일반적인
  2. 템플릿 메서드
  3. 전략 패턴 (템플릿 콜백 패턴)
  4. 프록시
    프록시 패턴
    데코레이터 패턴
  5. 인터페이스를 이용한 프록시
  6. 클래스를 이용한 프록시
  7. JDK 동적 프록시
  8. CGLIB 프록시
  9. 스프링 ProxyFactory
  10. 스프링 AOP

편의상 MainService의 join메서드만 표시할 것이고, 1 -> 10으로 갈수록 발전되는 방향이다.



1. 일반적인 방식

      public long join(String name) {
          return mainRepository.save(name);
      }
      
      // 메서드 내부를 변경
      
      public long join(String name) {
        long startTime = System.currentTimeMillis();
        
        // logic
        Long result = mainRepository.save(name);

        long resultTime = System.currentTimeMillis() - startTime;
        if (resultTime > 1000) {
            log.warn("[performance] MainService.join(String) {}ms", resultTime);
        }
        return result;
    }

가장 직관적으로 떠오르는 방식으로, 핵심 로직에 로깅 로직을 추가하는 방식이겠다.

하지만 모든 메서드를 변경해야하는 단점이 있다. 게다가 추후에 로깅 로직에 변화가 생기면 그것또한 모든 메서드를 변경해야한다.



2. 템플릿 메서드 패턴

템플릿 메서드 패턴을 사용하면 1번 방식보다는 더 나은 유지보수 상황이 된다.

// 템플릿 메서드
@Slf4j
public abstract class AbstractMethod<T> {
    public T execute() {
        long startTime = System.currentTimeMillis();

        // logic
        T result = call();

        long resultTime = System.currentTimeMillis() - startTime;
        if (resultTime > 1000) {
            log.warn("[performance] MainService.join(String) {}ms", resultTime);
        }

        return result;
    }

    protected abstract T call();
}

// 템플릿 메서드를 상속해 추상 메서드인 call() 구현으로 해결
@Slf4j
@RequiredArgsConstructor
@Service
public class MainService {

    private final MainRepository mainRepository;

    public long join(String name) {
        AbstractMethod<Long> templateMethod = new AbstractMethod<>() {
            @Override
            protected Long call() {
                return mainRepository.save(name);
            }
        };
        return templateMethod.execute();
    }
}

이 상황은 1 보다는 나은 상황 같다. 처음에 모든 메서드를 바꿔야하긴 해도, 추후에 로깅 로직에 변화가 생기면 AbstractMethod만 변경하면 될 것이다.

그러나 이 역시 문제점이 있다.
모든 메서드마다 추상 클래스를 상속한 클래스를 구현하거나 익명 내부 클래스를 만들어야 한다. 또한, 상속을 하고 있기에 execute() 메서드에 대한 정보를 잘 알고 있어야만 작업이 가능하다. 즉, 자식과 부모간의 의존성이 너무 강하다.

따라서 상속 대신 인터페이스를 사용하는 쪽으로 가보자.



3. 전략 패턴 (템플릿 콜백 패턴)

// 전략 인터페이스
public interface Strategy<T> {
    T call();
}

// 템플릿 (전략을 필드 / 파라미터 로 받는 방식 등 여러 가지 존재)
@Slf4j
@RequiredArgsConstructor
public class Context {

    public <T> T execute(Strategy<T> strategy) {
        long startTime = System.currentTimeMillis();

        // logic
        T result = strategy.call();

        long resultTime = System.currentTimeMillis() - startTime;
        if (resultTime > 1000) {
            log.warn("[performance] MainService.join(String) {}ms", resultTime);
        }

        return result;
    }
}

// MainService 적용
@Slf4j
@RequiredArgsConstructor
@Service
public class MainService {

    private final MainRepository mainRepository;
    private final Context context = new Context();
    
    public long join(String name) {
        return context.execute(() -> mainRepository.save(name));
    }
}

람다식을 통해 훨씬 깔끔하게 코드를 바꾸었다. 전략을 인터페이스로 선언하고, 템플릿이 전략을 compositon으로 갖게 하거나 파라미터로 받음으로써 의존성을 분리했다.

많이 간단해보이지만, 결국에는 핵심 로직을 수정해야하는 단점은 여전하다.
이를 해결하기 위해 Proxy를 도입하자. -- 원본 코드를 수정 X가 핵심



4. 프록시


프록시란, 대신 해주는 객체로 가장 주요한 특징으로는 클라이언트 입장에서는 요청을 보내는 것이 프록시인지, 실제 객체인지 구분이 안간다. 이는 OOP의 다형성 (인터페이스 / 상속)을 통해 구현한다.

  • no proxy

client -> real object

  • with proxy

client -> proxy (proxy chain) -> real object

프록시를 사용하는 디자인 패턴에는 프록시 패턴과 데코레이터 패턴이 있다.

프록시 패턴

프록시 패턴은 접근 제어의 목적을 가졌다. 빠른 응답을 위해 캐싱, 혹은 권한 확인 등을 할 수 있겠다.

데코레이터 패턴

말 그대로 꾸며주는 패턴으로, 프록시를 이용해 부가기능 (로깅 등)을 추가할 때 사용하는 패턴이다.



우리는 로깅 작업을 추가하는 것이니, 데코레이터 패턴이 되겠다.

5. 인터페이스를 이용한 프록시

위의 코드 상황은 MainService가 인터페이스를 구현하지 않은 상황이다. 임의로
MainService <- MainServiceImpl (인터페이스 <- 구현체) 가정하겠다.

@RequiredArgsConstructor
public class MainSerivceInterfaceProxy implements MainService { 
        
    private final MainService target;

    @Override
    public long join(String name) {
        long startTime = System.currentTimeMillis();

        // logic
        long result = target.join(name);

        long resultTime = System.currentTimeMillis() - startTime;
        if (resultTime > 1000) {
            log.warn("[performance] MainService.join(String) {}ms", resultTime);
        }

        return result;
    }
}

인터페이스를 상속한 프록시를 만듦으로써 클라이언트 입장에서는 MainService의 다형성에 의해 프록시를 호출했는지, 실체 객체(MainServiceImpl)를 호출했는지 모르게 된다.
(물론 getClass()하면 들키긴 하겠다.)



6. 클래스를 이용한 프록시

대상 객체가 인터페이스를 구현하지 않았다면, 대상 클래스를 상속함으로써 프록시를 만들 수 있다.

@Slf4j
public class MainServiceClassProxy extends MainService {

    private final MainService target;

    public MainServiceClassProxy(MainService target) {
        super(null);
        this.target = target;
    }

    @Override
    public long join(String name) {
        long startTime = System.currentTimeMillis();

        // logic
        long result = target.join(name);

        long resultTime = System.currentTimeMillis() - startTime;
        if (resultTime > 1000) {
            log.warn("[performance] MainService.join(String) {}ms", resultTime);
        }

        return result;
    }
}

프록시를 만들 때, 실제 객체인 target을 주입 받아서 사용하게 된다.


프록시 정리

5, 6을 통해서 프록시를 만들어 실제 객체를 대체하였다.
정말 발전한 점은 실제 객체를 전혀 손대지 않았다는 점이다. 하지만... 또 문제가 있다. 결국 모든 객체에 대해 프록시를 일일이 생생해야 한다는 점이다...

우리는 이렇게 일일이 프록시 객체를 만들지 말고, 동적으로 프록시를 만들어보자.

!!!
동적으로 프록시를 만드는 방법으로, JDK 동적 프록시CGLIB가 있다. 이 둘은 자바의 reflection을 이용해 런타임에서 프록시를 만들고 호출한다.
!!!

7번 부터는 스프링 빈으로 실제 객체 대신 프록시 객체를 등록하는 방식으로 하겠다.



7. JDK 동적 프록시

JDK 동적 프록시는 인터페이스를 구현하는 프록시이다. 따라서 인터페이스가 필수이다.
reflection을 사용하기에 메서드의 메타 데이터를 불러온 뒤 해당 메서드를 target으로 실행하게 된다. InvocationHandler를 구현할 때 target을 필드로 받아 JDK 동적 프록시를 만들때 InvocationHandler 구현체를 넣게 된다.

그러면 메서드 호출이 발생했을 때 InvocationHandler.invoke(target)이 실행 된다.

@Slf4j
@RequiredArgsConstructor
public LogIfSlowInvocationHandler implements InvocationHandler {
	
	private final Object target;
    
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) {
		long startTime = System.currentTimeMillis();

        // logic
        Object result = method.invoke(target, args);

        long resultTime = System.currentTimeMillis() - startTime;
        if (resultTime > 1000) {
            log.warn("[performance] MainService.join(String) {}ms", resultTime);
        }

        return result;
    }   
}

@Configuration
public class JdkDynamicProxyConfig {
	@Bean
    public MainService mainService() {
    	MainService target = new MainServiceImpl();
        InvocationHandler handler = new LogIfSlowInvocationHandler(target);
    	
        MainService proxy = (MainService) Proxy.newProxyInstance(
        		MainService.class.getClassLoader(),
        		new Class[]{MainService.class}, --- 여러 interface를 상속 받은 경우 대비
                handler
        );
       
        return proxy;
    }
}

JDK 동적 프록시는 인터페이스가 필수이다. 따라서 인터페이스가 없는 클래스는 CGLIB라이브러리를 사용해야한다.



8. CGLIB

JDK 동적 프록시와 CGLIB는 크게 인터페이스 구현 프록시 / 클래스 구현 프록시의 차이점이 있다. (CGLIB는 인터페이 구현 프록시도 지원한다.)

JDK 동적 프록시의 InvocationHandler와 비슷하게 MethodInterceptor를 구현해야 한다.

@Slf4j
@RequiredArgsConstructor
public LogIfSlowMethodInterceptor implements MethodInterceptor {
	
	private final Object target;
    
    @Override
    public Object invoke(Object proxy, Method method, Object[] objects, MethodProxy methodProxy) {
		long startTime = System.currentTimeMillis();

        // logic
        Object result = method.invoke(target, objects);
		// Object result = methodProxy.invoke(target, objects);	-- 더 빠르다고 함.

        long resultTime = System.currentTimeMillis() - startTime;
        if (resultTime > 1000) {
            log.warn("[performance] MainService.join(String) {}ms", resultTime);
        }

        return result;
    }   
}

@Configuration
public class CglibProxyConfig {
	@Bean
    public MainService mainService() {
    	MainService target = new MainServiceImpl();
        MethodInterceptor interceptor = new LogIfSlowMethodInterceptor(target);
    	
        Enhancer enhancer = new Enhancer();
        enhaner.setSuperClass(MainService.class); -- 클래스 구현 프록시
        // enhaner.setInterfaces(new Class[]{MainService.class{); -- 인터페이스 구현 프록시
        enhancer.setCallback(interceptor); -- MethodInterceptor extends Callback
        
        MainService proxy = (MainService) enhancer.create();
        
        return proxy;
    }
}


프록시 확인

JDK 동적 프록시를 통해 생긴 프록시의 class는 class com.sun.proxy.$Proxy1 이런 형식이고

CGLIB를 통해 생긴 프록시의 class는 class MainService$$EnhancerByCGLIB$$25d6b0e 이런 형식이다.

참고로 프록시에 대한 유틸리티 클래스는 스프링에서 제공하는 AopUtils를 사용하면 된다.

assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
profile
울릉도에 별장 짓고 싶다

0개의 댓글