백앤드(스프링)

AOP 1편 [디자인 패턴 - 1]

유승혁 2022. 10. 28. 15:19

      [AOP의 모든 내용은 김영한님의 [스프링 핵심원리 - 고급편]의 내용을 공부하며 정리한 것입니다. 강의 링크는 하단에 있어요.]

 AOP는 aspect of programming이라는 의미로 관점 지향 프로그래밍이다. 이게 무엇이냐면, 많은 메서드에 공통된 로직을 넣어야 할때 해당 코드를 넣어버린 후에 나중에 로직이 조금 수정 된다면 이를 사용한 메서드 마다 수정해야 한다. 단순히 공통 로직을 클래스와 메서드로 만들어서 의존관계를 주입받아 호출할 수 는 있지만, 매서드 인자가 바뀐 다던지 아니면 공통된 로직이 한 10개 정도 되면 메서드마다 10개의 줄이 추가되야 한다. 정말 불상사.. 이런 방식은 SRP를 당당하게 위반한다. 이를 위해 AOP가 나왔다. 마치 코드를 위한 코드? 요구사항이 코드 추가인 느낌?

 

0. 흐름

 AOP는 흔히들 어렵다고 하니깐 흐름을 잘 따라 와야 한다. AOP 1편의 흐름은 아래와 같다.

디자인 패턴 -> 동적 프록시(CGLIB, jdk 프록시) -> 프록시 팩토리 -> 어드바이저(포인트컷, 어드바이스) -> 빈 후처리기 -> @Aspect

 공통된 로직 코드 작성을 간단하기 위해 다양한 요구사항이 있을 것이다. 그 요구사항을 하나씩 충족해 나가는 과정이다. 

 예시 요구사항은 다음과 같다. Controller -> Service -> Repository 순으로 움직이는 client의 요청에 메서드마다 시간이 얼마나 걸렸는지 측정을 하고 싶다. 아래 사진 처럼.

 앞 [~~~]는 요청 별 해시 ID이고 아래 계층으로 내려갈수록 --> 모양으로 깊어지게 하였다. 만약 에러가 나면 돌아오는(<--)모양에서 중간에 x를 넣는 방식(<--x--) 와 같이 띄워야 한다. 이런 로그 클래스를 만드는 것은 굳이 다루지 않겠다.

 

0.1 구현 방법

 이를 단순하게 구현하려면 어떻게 해야할까?

@RestController
@RequiredArgsConstructor
public class OrderControllerV3 {
    private final OrderServiceV3 service;
    private final LogTrace trace;

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

}

 LogTrace안에는 [~~~]안에 있던 hash id값이 필드로서 있다. 딱 봐도 문제가 생길 수 밖에 없다. 싱글톤에서 필드 값을 공유하면 동시성 문제가 발생한다는 것은 유명하다!!

 그렇다면 Controller->Service로 logTrace의 id 값을 함수 인자로 넘겨줄까? 이것 만큼 미친 짓도 없다.... 그럼 음.. 싱글톤이 아닌 웹 스코프인 request bean scope를 이용해서 요청 별로 분해하고 DI가 아닌 DL을 이용하면 되지 않을까? 맞아! 이거랑 비슷하게 변수 타입을 Thread local로 설정하면 요청 별로 별도의 메모리를 유지할 수 있다. 아래 그림처럼.

스레드 로컬

 이렇게 하면 LogTrace자체는 싱글톤으로 유지하고 LogTrace에서 hash id 값을 Thread Local 타입으로 선언하면 된다. 코드상으로는 아래와 같다.

@Slf4j
public class ThreadLocalService {
      private ThreadLocal<String> nameStore = new ThreadLocal<>();
      
      ...생성자 로직 등등...
 }

 물론 Bean을 웹 스코프로 만들고 DL을 사용할 수 있지만 이런 방식은 코스트가 아무래도 적을 수 밖에 없다. 마치 프로세스와 스레드의 차이랄까? 우리의 요구사항에서 Thread Local 덕분에 LogTrace를 주입받아 단순히 사용하면 되게 되었다.

 

1. 디자인 패턴

 디자인 패턴이란 무엇일까? MVC 패턴도 하나의 디자인 패턴이다. 프론트 컨트롤 패턴이라 불리는데 디스페처 서블릿 같은 녀석이 모든 요청을 가로채어 내부 로직(핸들러 매핑, 핸들러 어뎁터 매핑, 핸들러 어뎁터를 통한 핸들러 사용, 뷰 리졸버, 뷰.. 중간중간 error handling이라던지 prehandle.. 등등)을 진행한다. 즉, 코딩하는 방식이랄까? controller-service-repository 계층으로 나눈 것 마저도 디자인 패턴이 된다. 

 디자인 패턴에서 가장 중요한 것은 의도(intent)이다. 같은 모양의 디자인이라 하더라도 각각이 어떤 역할을 하느냐에 따라 정말 달라질 것이다! 그래서 오해하면 안되는게 모든 요청을 가로채는 녀석이 있다고 무조건 프론트 컨트롤러 패턴이 아니라 요청을 가로채어 model을 controller에서 가공하고 보여줄 view를 연결하기 위해 최전방에서 역할을 담당하는 그런 의도를 가진 것이 프론트 컨트롤러 이다.

 이제 요구사항을 해결할 디자인 패턴을 알아보며 진화 시켜 보자.

 

1.1 탬플릿 메서드 패턴

 스레드 로컬을 사용한다 하더라도 코드는 아래와 같아 졌다.

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

 Controller는 보다 시피 service를 호출하는 한 줄이면 되지만 로그를 찍고 에러 로그를 별도로 띄우기 위해 try catch로 감싸는 과정을 거쳐야 한다. 하나의 메서드에 두 가지의 기능이 있다.

 핵심 기능 - Service 코드를 호출.
 부가 기능 - 로그 찍기

 뿐만 아니라 이런 부가 기능이 100개의 메서드가 필요하다면 100개의 메서드에 이 짓을 하고 있어야 한다. 핵심 기능은 메서드 별 변하는 부분이지만 부가 기능은 메서드와 상관 없이 변하지 않는 부분이다. 이 변하는 부분과 변하지 않는 부분을 나누기 위해 템플릿 메서드 패턴이 소개되었다.

 변하지 않는 부분(부가기능)을 모은 것을 template이라고 부르고 각 변하는 부분(핵심 기능)을 사용하는 클래스에서는 template을 상속 받아 사용하자. 디자인은 아래와 같다.

 즉, excute 메서드에는 부가기능과 call() 함수가 있다. 클라이언트가 excute 메서드를 호출하면 부가 기능 실행 후 call() 함수가 동작 하면서 핵심 기능을 호출한다. 코드로 간단히 나타내면 아래와 같겠죠?

 

 

 

 

 

 

 

 

 

void templateMethodPatternTest{
AbstractTemplate template1 = new SubClassLogic1();
      template1.execute();
      AbstractTemplate template2 = new SubClassLogic2();
      template2.execute();
 }

 

이 디자인 패턴을 우리의 로그 추적 요구사항에 적용시켜 보자!

 

 @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("OrderService.orderItem()");
      }
}

public abstract class AbstractTemplate<T> {
    private final LogTrace trace;

    public AbstractTemplate(LogTrace trace){
        this.trace = trace;
    }

    public T excute(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 클래스를 상속받은 클래스는 call() 함수를 반드시 구현해야하고 call 메서드가 핵심 기능이 들어갈 곳이다. 굳이 Controller, Service, Repository의 메서드 별로 각각의 클래스에 AbstractTemplate을 상속받는 클래스를 만드는 것은 상당히 비효율 적이니 익명 함수 클래스를 통해서 만들어 내는 것을 볼 수 있다.

 

 하지만 이게 좋은 패턴일까? 물론 위 방식 덕에 로그 남기는 로직을 수정하게 될 경우 AbstractTemplate 코드만 변경하면 된다. 하지만 '상속'을 사용하기 때문에 클래스간의 강한 결합은 어찌할 수 없다. 하지만 이때, 자식 클래스는 부모 클래스의 기능이 전혀 필요하지 않다. excute 메서드를 호출할 일이 없다.. 자식은 부모 기능을 사용하지 않는데 자식이 부모 기능을 받아야 하는 것은 정말 큰 단점이다. 이를 보안하기 위해 나온 것이 전략 패턴이다.

 

1.2 전략 패턴

 전략 패턴은 디자인 패턴의 강한 부모 자식 결합을 해결하기 위해 나왔으니 어떤 걸 사용할까?! 바로 인터페이스. 아래 사진이 바로 전략 패턴이다. 중요한 것은 의도이니 그림을 기준으로 의도 설명을 잘 이해해야 한다.

 부가기능을 Context에 두고 Strategy에 핵심 기능을 구현한다. 이때 Strategy는 인터페이스이고 각각의 로직은 인터페이스의 기능을 구현해야 한다.

 코드 상으로는 당연히 디자인 패턴과 큰 차이는 안느껴진다.

 하지만 중요한 것은 StrategyLogic1,2 모두 Strategy라는 인터페이스를 구현한 구체 클래스라는 점이 다르다.

 

 

 

@Test
  void strategyV1() {
      Strategy strategyLogic1 = new StrategyLogic1();
      ContextV1 context1 = new ContextV1(strategyLogic1);
      context1.execute();
      
      Strategy strategyLogic2 = new StrategyLogic2();
      ContextV1 context2 = new ContextV1(strategyLogic2);
      context2.execute();
}

 클라이언트는 Context에 원하는 Strategy를 주입하고 Context의 excute메서드를 실행하여 excute 메서드의 부가기능을 실행하고 내부에 주입해 둔 Strategy를 사용한다. 이 패턴도 당연히 익명 내부 클래스를 사용할 수 있다. 추가적으로 익명 내부 클래스를 람다로 표현하면 아래 코드와 같다.

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

 이 전략 패턴의 주 컨셉은 선 조립 후 실행이다. Context에 Strategy를 주입해서 사용하고 interface를 이용해서 유연하게 다른 strategy 편하게 조립해서 사용할 수 있다. 마치 우리가 익숙한 할인 정책을 고정 할인 또는 비율 할인을 적용한 거랄까. 하지만 이 방식의 단점은 한번 조립 해 놓으면 바꿀 수 없다는 것이다. setter 주입 같은 방식이 있다고 하지만 Context를 싱글톤으로 사용하게 된다면 동시성 문제가 발생할 수 있다.

 

1.3 전략 패턴 파라미터(탬플릿 콜백 패턴)

 생각해보면 excute를 실행하는 순간에 Strategy가 필요한 것이니 excute 메서드에 Strategy의 구체 클래스를 넘겨주자! 아래 코드와 같다.

@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={}", resultTime);
    }
}

//context를 사용하는 클라이언트 코드
@Test
void strategyV1() {
      ContextV2 context = new ContextV2();
      context.execute(new StrategyLogic1());
      context.execute(new StrategyLogic2());
}

 ContextV2는 변하지 않는 부분을 담당한다. 그리고 변하는 부분인 Strategy는 파라미터로 넘겨 받아 핵심 기능을 처리한다. 이처럼 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 콜백(callback)이라 한다.

 콜백을 더 자세히 말하자면 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 의미한다. 콜백을 받은 코드는 이 콜백을 바로 사용하기도 하고 추후에 사용하기도 한다. 스프링에서는 ContextV2 와 같은 방식의 전략 패턴을 템플릿 콜백 패턴이라 한다. 전략 패턴에서 Context 가 템플릿 역할을 하고, Strategy 부분이 콜백으로 넘어온다 생각하면 된다.

 이제 이 패턴을 우리의 요구사항에 접목시키면 아래와 같다.

@RestController
public class OrderControllerV5 {
    private final OrderServiceV5 service;
    private final TraceTemplate template;

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

    @GetMapping("/v5/request")
    public String request(String itemId){
        return template.execute("OrderController.request()", new TraceCallback<>() {
            @Override
            public String call() {
                service.orderItem(itemId);
                return "ok";
            }
        });
    }

}

 초기 버전과 굉장히 달라진 것을 볼 수 있다. 부가기능에 대한 코드가 완전히 사라졌고 TraceTemplate을 싱글톤으로 둘 수 도 있게 되었다.TraceCallback의 call 메서드를 구현을 익명 내부 클래스로 하였다. 물론 람다 식으로도 가능할 것이다. 이러한 최적화에도 불구하고 콜백을 만들어내는 코드는 필수적이게 되었다. 이 코드 마저도 중복될 수 밖에 없다. 이렇게 말고 원본 코드에는 정말 딱 핵심 기능만 남기고 부가 기능을 완전히 분리해 내기 위해서는 프록시에 대한 개념을 이해해야 한다. 이 글은 다음에 다루겠당!

 

[참고]

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard