[WWCode Seoul 오리지널 콘텐츠 #4] 스프링 프레임워크(Spring Framework) 알아보기 : AOP[1]

Sabai
wwcodeseoul
Published in
16 min readSep 14, 2022

이 글은 간단한 Java 코드를 읽고 이해할 수 있고 AOP 개념이 생소한 스프링을 시작하는 분들을 대상으로 하고 있습니다.

출저 : https://spring.io

서비스 구현 시 로깅, 권한 체크와 같이 중복되는 기능이 계속 발생한다면 이를 어떻게 개선할 수 있을까요?

동일한 코드를 계속 추가 시 이후 유지 보수하는데 어려움이 발생할 수 있고 객체 지향 설계의 원칙에 어긋날 수 있습니다.
이를 해결하기 위해 스프링 AOP에 대해 알아보고자 합니다.

1. 핵심 기능과 부가 기능의 분리

예를 들어, 카페에서 주문받고 음료를 만들 때 매번 로그를 추가하는 로직이 추가된다고 가정해봅니다.
라떼뿐만 아니라 모든 메뉴 제조 시 로그를 남기는 코드가 중복될 것입니다.

public class LatteService {
private final Shot shot;
private final SteamMachine steamMachine;
private static final Logger log = LoggerFactory.getLogger(..);

public LatteService(Shot shot, SteamMachine steamMachine) {
this.shot = shot;
this.steamMachine = steamMachine;
}

public Latte make() {
Shot extractedShot = shot.extract();
steamMachine.froth();
// ...

log.info(..);

return latte;
}
}

음료 제조라는 책임과 로깅이라는 책임을 가지는 각각의 클래스를 만들고 공통 인터페이스를 구현해 각 클래스 호출 시 책임을 위임한다면 클라이언트 입장에서는 이 책임들이 하나의 기능을 하는 것처럼 보입니다.

public interface DrinkService {
Latte make();
}

public class LatteService implements DrinkService {
private final Shot shot;
private final SteamMachine steamMachine;
public LatteService(Shot shot, SteamMachine steamMachine) {
this.shot = shot;
this.steamMachine = steamMachine;
}
public Latte make() {
Shot extractedShot = shot.extract();
steamMachine.froth();
// ...
return new Latte();
}
}
public class LatteLoggingService implements DrinkService {
private final LatteService latteService;
private static final Logger log = LoggerFactory.getLogger(..);
public LatteLoggingService(LatteService latteService) {
this.latteService = latteService;
}
@Override
public Latte make() {
Latte latte = latteService.make();
log.info(...);
return latte;
}
}

동일한 인터페이스를 구현한 클래스인 LatteLoggingService 를 통해 부가 기능인 로깅을 적용하고 핵심 기능인 라떼 제조로 요청을 위임할 수 있습니다.

2. 데코레이터 패턴과 프록시 패턴

핵심 기능 인터페이스
프록시는 타깃으로 위장할 수 있도록 이 인터페이스를 구현해야 합니다.

프록시
다른 객체의 실제 대상인 것처럼 대체될 수 있는 클래스를 의미하며 타깃을 가리키는 참조 필드가 있습니다.
원본 객체와 동일한 인터페이스를 구현하여 이 객체에 대한 액세스를 제어할 수 있고 요청이 원본 객체에 전달되기 전이나 후에 작업을 수행할 수 있습니다.

타깃/실체
프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 의미합니다.

클라이언트
서비스와 동일한 인터페이스를 통해 프록시를 사용합니다.
실제 사용할 오브젝트 클래스 정체를 감춘 채 클라이언트가 인터페이스를 통해 간접적으로 접근한다면 구현 클래스는 얼마든지 외부에서 변경할 수 있고 클라이언트와의 결합이 약해지게 됩니다.

위의 그림에서 두 가지 패턴이 적용된 것을 확인할 수 있습니다.

1) 데코레이터 패턴

주어진 상황 및 용도에 따라 어떤 객체에 책임을 덧붙이는 패턴으로, 여기에선 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시 사용한다는 것을 의미합니다.
상속을 사용하여 개체의 동작을 확장하는 것이 어색하거나 불가능할 때 확장을 원하는 클래스를 래핑하여 사용할 수 있으며 새 하위 클래스를 만들지 않고 객체의 동작을 확장할 수 있고 객체를 여러 데코레이터로 래핑하여 여러 동작을 결합할 수 있습니다.
또한, 가능한 많은 동작 변형을 구현하는 모놀리식 클래스를 여러 개의 작은 클래스로 나눌 수 있어 단일 책임 원칙에 부합합니다.
다만, 컴파일 시 코드상에서 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않고 프록시는 N개 가능하며 프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근함으로 몇 번째 단계에 대상 혹은 데코레이터 프록시로 위임하는지 모릅니다.

예시) InputStream 이라는 인터페이스를 구현한 타깃 FileInputStream에 데코레이터인 BufferedInputStream 적용

InputStream is = new BufferedInputStream(new FileInputStream("a.txt");

2) 프록시 패턴

여기서 프록시란 타깃에 접근하는 방법을 제어하려는 목적을 가지고 다른 무언가와 이어지는 인터페이스 역할을 하는 클래스를 의미합니다.
클라이언트에게 타깃에 대한 레퍼런스를 넘겨줄 때 실제 오브젝트를 만드는 대신 프록시를 넘겨주고, 프록시의 메소드를 통해 타깃을 사용하려고 시도 시 프록시가 타깃 오브젝트를 생성하고 요청을 위임합니다.
객체의 수명 주기를 관리하는 것뿐만 아니라 객체가 준비되지 않았거나 사용할 수 없는 경우에도 작동하고 서비스나 클라이언트를 변경하지 않고 새 프록시를 도입할 수 있어 개방/폐쇄 원칙에 부합합니다.
하지만, 많은 새 클래스를 도입해야 하므로 코드가 더 복잡해질 수 있고 서비스 응답이 늦어질 수 있습니다.

예시) 수정할 수 있는 오브젝트를 특정 레이어로 넘어가서는 읽기 전용으로 강제

Collection<Character> immutableList = Collections
.unmodifiableCollection(mutablrList);

//immutableList.add('X');
//immutableList.remove(1);
//파라미터로 전달된 Collection 오브젝트 프록시를 만들어
//add() / remove()시 UnsupportedOperationException 예외 발생

3. 동적 프록시

핵심 기능과 부가 기능을 분리하여도 아래와 같은 문제점이 남아 있습니다.

1. 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기 번거롭고 부가 기능이 필요 없는 메서드도 구현해서 타깃으로 위임하는 코드를 일일이 만들어줘야 합니다.
2. 프록시를 활용할 만한 부가 기능, 접근제어 기능은 일반적인 성격을 띤 것들이 많아 다양한 타깃 클래스와 메서드에 중복되어서 나타날 가능성이 높습니다.

1) JDK 동적 프록시

프록시 팩토리에 의해 런타임에 다이내믹하게 만들어지는 오브젝트로 프록시 팩토리에 인터페이스 정보만 제공하면 이를 구현한 클래스의 오브젝트를 자동으로 만들어 클래스를 정의할 필요가 없습니다.
프록시로써 필요한 부가 기능 제공 코드는 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담습니다.

InvocationHandler

public Object invoke(Object proxy, Method method, Object[] args)

프록시 인스턴스의 호출 핸들러에 의해 구현되는 인터페이스
각 프록시 인스턴스에는 연관된 호출 핸들러가 있고 프록시 인스턴스에서 메서드가 호출되면 메서드 호출이 인코딩되어 호출 핸들러의 호출 메서드로 디스패치 됩니다.
proxy — 메서드가 호출된 프록시 인스턴스
method — 프록시 인스턴스에서 호출된 인터페이스 메서드에 해당하는 메서드
args — 프록시 인스턴스의 메서드 호출에 전달된 인수 값

public class LoggingHandler implements InvocationHandler {
private final Object target;

private final Logger log;
private String pattern;

public LoggingHandler(Object target, Logger log, String pattern) {
this.target = target;
this.log = log
this.pattern = pattern;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object result = method.invoke(target, args);

if(method.getName().startsWith(pattern)) {
log.info(..);
}

return result;
}
}

순서
1. 클라이언트는 동적 프록시의 메소드를 호출합니다.
2. 동적 프록시 오브젝트는 클라이언트의 모든 요청을 리플랙션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메서드로 넘깁니다.
3. InvocationHandler 구현 오브젝트가 타깃 오브젝트 레퍼런스를 갖고 있다면 리플랙션을 이용해 간단히 위임할 수 있습니다.
4. 반환된 값은 다이내믹 프록시가 받아서 최종적으로 클라이언트에게 전달합니다.

인터페이스의 메소드가 늘어도 다이내믹 프록시를 생성해서 사용하는 코드는 수정할 것이 없고 타깃의 종류에 상관없이 적용이 가능합니다.
또한, 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중됨으로 중복 기능을 효과적으로 제공할 수 있고 단일 책임 원칙을 준수합니다.
그리고 다이내믹 프록시를 통해 요청이 전달되면 리플랙션 API를 통해 타깃 오브젝트의 메소드를 호출함으로 타입뿐만 아니라 호출하는 메소드의 이름, 파라미터의 개수와 타입, 리턴 타입 등의 정보를 가지고 부가적인 기능을 적용할 메소드를 선택할 수 있습니다.

프록시 생성

DrinkService proxiedHello = (DrinkService) Proxy.newProxyInstance(getClass().getClassLoader(), //1)
new Class[]{DrinkService.class}, //2)
new LoggingHandler(new LatteService())); //3)

1. 다이내믹 프록시가 정의되는 클래스 로더를 지정합니다. 이때, 클래스로더는 프록시를 생성할 수 있는지 검증하는 목적을 띄고 있으며 JDK 다이내믹 프록시는 클래스 로더를 통해 부가기능을 부착할 인터페이스의 구조를 검증하고 런타임시 프록시 객체를 생성합니다.
2. 다이내믹 프록시가 구현해야 하는 인터페이스(N개)를 지정합니다.
3. 부가 기능과 위임 관련 코드를 가지는 InvocationHandler 구현 오브젝트와 타깃 오브젝트를 지정합니다.

Proxy.newProxyInstance를 사용하는 이유
Spring Bean은 기본적으로 클래스 이름과 프로퍼티로 정의되어 있고 Spring은 지정된 클래스 이름을 가지고 리플랙션 API를 통해 내부적으로 오브젝트를 만듭니다.
예)

Latte latte = (Latte) Class.forName("java.util.Latte").newInstance();

하지만, JDK 동적 프록시는 클래스 자체를 내부적으로 지정된 호출 시점에 동적으로 재정의하여 사용함으로 동적 프록시 오브젝트가 어떤 것인지 알 수 없어 사전에 스프링 빈을 정의할 수 없습니다.

2) 팩토리빈

public interface FactoryBean<T> {
//...
@Nullable
T getObject() throws Exception; //1) 빈 오브젝트를 생성해서 돌려줍니다.

@Nullable
Class<?> getObjectType(); //2)생성되는 오브젝트 타입을 알려줍니다.

default boolean isSingleton() { //3)getObject()가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려줍니다.
return true;
}
}

스프링을 대신해서 오브젝트의 생성 로직을 담당하도록 만들어진 특별한 빈으로 이를 구현한 클래스는 스프링 빈으로 등록 시 팩토리 빈으로 동작합니다.

프록시 클래스를 작성하지 않고 JDK 동적 프록시와 스프링의 팩토링 빈을 이용하여 타깃등을 DI 하는 방법으로 한번 부가 기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있습니다.
또한, 하나의 핸들러 메소드를 구현하는 것만으로 수많은 메소드에 부가 기능 부여가 가능함으로 중복 코드가 줄어들고 동일한 프록시 팩토리 빈을 여러 개의 빈으로 등록해도 빈의 타입은 타깃 인터페이스와 동일함으로 가능합니다.

public class LogProxyFactoryBean implements FactoryBean<Object> {
Object target;
String pattern;
Logger log;

Class<?> serviceInterface;

public void setTarget(Object target) {
this.target = target;
}
public void setLog(Logger log) {
this.log = log;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
public void setServiceInterface(Class<?> serviceInterface) {
this.serviceInterface = serviceInterface;
}

public Object getObject() throws Exception {
LoggingHandler logHandler = new LoggingHandler(target, log, pattern);
return Proxy.newProxyInstance(
getClass().getClassLoader(), new Class[]{ serviceInterface },
logHandler);
}
public Class<?> getObjectType() {
return serviceInterface;
}

public boolean isSingleton {
return false;
}
}
@Configuration
public class SampleConfig {
@Bean
public LogProxyFactoryBean logProxyFactoryBean() {
LogProxyFactoryBean logProxyFactoryBean = new LogProxyFactoryBean();
logProxyFactoryBean.setTarget(new LatteService());
logProxyFactoryBean.setLog(LoggerFactory.getLogger(..));
logProxyFactoryBean.setPattern("make");
logProxyFactoryBean.setServiceInterface(DrinkService.class);
return logProxyFactoryBean;
}
}

위와 같은 장점이 있지만 타깃 오브젝트를 프로퍼티로 가지고 있으므로 동일한 부가 기능을 제공하는 코드임에도 타깃 오브젝트가 달라지면 새로운 Handler 오브젝트를 생성해야 하며 하나의 타깃에 여러 개의 부가 기능을 추가 시 프록시 빈 설정이 부가 기능만큼 증가하는 등의 한계가 존재합니다.

글쓴이 이슬기

해당 글은 WWCode Seoul의 입장을 대변하고 있지 않습니다.

출저 : 이일민 저, 토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리』, 에이콘 출판사
https://ko.wikipedia.org/wiki/%EB%8D%B0%EC%BD%94%EB%A0%88%EC%9D%B4%ED%84%B0_%ED%8C%A8%ED%84%B4
https://ko.wikipedia.org/wiki/%ED%94%84%EB%A1%9D%EC%8B%9C_%ED%8C%A8%ED%84%B4
https://refactoring.guru/design-patterns/decorator
https://refactoring.guru/design-patterns/proxy
https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/lang/reflect/InvocationHandler.html
https://gmoon92.github.io/spring/aop/2019/02/23/spring-aop-proxy-bean.html

--

--