NestJs는 AOP를 지원할까? Spring AOP와의 비교 분석

2025. 3. 3. 16:57Framework/NestJS

지난 몇 년간 Spring 생태계에 익숙해져 있었던 터라 Spring에서는 로깅, 트랜잭션, 예외처리등 공통로직을 '객체'로 바라보고 비즈니스로직에서 공통로직을 별도로 분리하는 AOP(Aspect Oriented Programming) 패턴을 지원하고 있음을 알고 있었습니다. 현재 근무 중인 회사에서는 새로운 프레임워크인 NestJs를 도입했고 저는 'NestJs도 로깅, 트랜잭션 등 공통로직을 구현하기 위해서 AOP 패턴을 도입하자고 주장'했습니다. 하지만, 저는 NestJs에서 AOP 패턴을 적용하려고 하면서 계속 한 가지 의문이 들었습니다. 

NestJs가 AOP를 지원하고 있는 게 맞나? 내가 알던 거랑 좀 다른데?

 

그렇습니다. 이번 포스팅의 주제는 NestJs에서 AOP를 지원하고 있는 게 맞나?라는 의문에서 시작해서 그 질문의 답을 찾기 위한 하나의 여정에 대한 이야기입니다. 그 과정에서 추가적으로 제가 얕게 알고 있던 Spring AOP의 실행 방식에 대해서 더 자세히 알 수 있었습니다. 그 지식을 공유해보려고 합니다. 이 글을 읽는 NestJs 사용자 분들도 혹시 저와 비슷한 고민을 하셨다면 이 글을 읽고 답을 찾을 수 있길 진심으로 바라겠습니다.


AOP(Aspect Oriented Programming)이란? 

AOP는 '관점' 지향 프로그래밍의 약자를 의미합니다. 여기서 말하는 '관점'은 조금 모호할 수 있지만, 주로 '공통 관심사(Cross-Cutting Concerns)'를 의미합니다. 즉, 핵심 비즈니스 로직과 부가적인 기능을 분리하여 코드의 모듈화를 향상하는 프로그래밍 패러다임입니다. 

예를 들어 특정 메서드의 실행 전후에 로그를 남긴다거나 하는 것을 하나의 모듈에서 관리할 수 있습니다. 


Spring AOP 실행 예시

이번 글이 'NestJs'와 AOP에 초점이 맞춰져 있지만, Spring AOP를 어떻게 지원하는지를 들여다보는 것이 매우 도움이 된다고 판단했기에 서론이 조금 길 수 있지만 관련 내용을 먼저 정리하고 넘어가려고 합니다. 해당 내용에 대해 미리 알고 계신 분들은 'Spring AOP vs NestJS AOP' 챕터를 바로 보셔도 좋을 것 같습니다. 

 

Spring에서는 공식적으로 AOP를 지원하고 하고 있습니다. 

org.springframework.boot:spring-boot-starter-aop

 

Spring AOP 사용의 간단한 예시를 들어보겠습니다. 

@Component
public class MyService {
    public void doSomething() {
        System.out.println("MyService: Doing something...");
    }
}

@Aspect
@Component
public class LoggingAspect {
    @Before("execution(* MyService.doSomething(..))")
    public void logBefore() {
        System.out.println("Before method execution...");
    }
}

@Service
public class SomeService {
    private final MyService myService;

    @Autowired
    public SomeService(MyService myService) {
        this.myService = myService;
    }

    public void process() {
        myService.doSomething();  // AOP 적용됨
    }
}

 

실행 결과를 보면 MyService.doSomething() 메서드를 호출했지만, 'Before method execution...' 로그가 추가적으로 출력됐음을 확인할 수 있습니다. LoggingAspect가 Myservice.doSomething() 메서드의 실행 흐름을 'intercept' 해서 로그를 찍도록 코드를 삽입했음을 알 수 있습니다. 

Before method execution...
MyService: Doing something...

 

그렇다면 Spring AOP는 어떻게 특정 메서드의 실행을 가로채서 추가적인 코드를 실행할 수 있을까요? 


Spring AOP와 프록시(Proxy) 기반 실행 방식

Spring AOP는 프록시 기반으로 AOP를 제공하고 있습니다. 'Proxy'(프록시)는 '대리'라는 사전적 의미를 갖고 있습니다. 즉, Spring AOP에서는 프록시 객체(Proxy Object)를 생성해서 대상 객체(Target Object)의 메서드 실행을 감싸고 추가 로직을 적용하는 방식으로 동작합니다. 

 

위의 예시에서 MyService의 인스턴스를 생성하고 doSomething() 메서 들어 직접 호출한 것처럼 보이지만 Spring AOP가 내부적으로 프록시 객체를 생성하고 해당 프록시 객체의 doSomething() 메서드를 호출해서 로그를 남기는 로직을 추가 실행한 것입니다. 

위 문장이 아직 크게 와닫지 않는 이유는 '어떻게'가 생략되었기 때문입니다. Spring AOP는 어떻게 Proxy 객체를 생성해서 로그를 실행하는 로직을 추가할 수 있었을까요? 


JDK Dynamic Proxy vs CGLIB Proxy

Spring AOP는 IoC 컨테이너에 의해 AOP에 대상이 되는 대상 객체에 대해서 프록시 객체가 됩니다. 동적으로 생성된 프록시 객체는 메서드가 호출되는 시점에 부가기능이 주입됩니다. 이처럼 프로그램이 실행되는 동안(런타임)에 Aspect(부가기능)을 핵심 기능에 동적으로 결합(삽입)하는 것을 런타임 위빙(Runtime Weaving)이라고 합니다. 

 

Weving이라는 뜻은 '천을 짜다' 라는 뜻을 갖는데 핵심 기능에 부가적인 기능을 더하는 행태를 천을 짜는 행위와 비슷해서 아마 런타임 위빙이라는 이름이 명명된 것 같다는 생각이 듭니다. 위빙은 '시점'에 따라서 여러 방식으로 나뉩니다. 

  • 컴파일 타임 위빙(Compile-time Weaving): 자바 소스 코드를 컴파일할 때 Aspect를 결합하는 방식
  • 클래스 로드 타임 위빙(Load-time Weaving): 클래스가 JVM에 로드될 때 바이트코드를 조작하여 Aspect를 적용하는 방식
  • 런타임 위빙(Runtime Weaving): 실행 중에 프록시 객체를 동적으로 생성해서 부가 기능을 결합하는 방식

Spring AOP는 이중에서 런타임 위빙을 사용해서 프록시 기반 AOP를 구현합니다. 프록시 기반의 AOP는 상황에 따라서 또다시 두 가지 방식으로 구분됩니다. 

  • 인터페이스 기반: JDK Dynamic Proxy
  • 클래스 기반: CGLIB Proxy

JDK Dynamic Proxy

JDK Dynamic Proxy는 인터페이스 기반 프록시로 대상 객체가 어떠한 종류에 상관없이 '인터페이스'를 '구현'하고 있으면 사용되는 방식입니다. 'Dynamic'이라는 이름처럼 프록시 객체는 컴파일 시점이 아니라 런타임에 동적으로 프록시 객체를 생성합니다. (반대로 정적으로 생성한다는 건 직접 프록시 클래스를 작성하는 것을 의미합니다.)

 

즉, 아래 예시 처럼 Service라는 인터페이스를 구현한 RealService는 동적으로  JDK Dynamic Proxy 방식으로 프록시 객체가 동적으로 생성됩니다. Proxy.newProxyInstance()를 호출하면 JVM이 자동으로 프록시 클래스를 생성하여 사용합니다. 원본 객체와 동일한 인터페이슬 구현한 프록시 객체가 만들어지고 InvocationHandler를 통해 메서드 호출을 가로채서 부가기능을 수행할 수 있게 됩니다. 

 

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

// 대상 객체의 인터페이스
interface Service {
    void doSomething();
}

// 실제 서비스 구현체
class RealService implements Service {
    public void doSomething() {
        System.out.println("RealService: Doing something...");
    }
}

// 프록시 핸들러
class LoggingHandler implements InvocationHandler {
    private final Object target;

    public LoggingHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("Before method execution...");
        Object result = method.invoke(target, args);  // 실제 메서드 실행
        System.out.println("After method execution...");
        return result;
    }
}

// 실행 코드
public class JdkProxyExample {
    public static void main(String[] args) {
        Service realService = new RealService();
        Service proxyService = (Service) Proxy.newProxyInstance(
                Service.class.getClassLoader(),
                new Class[]{Service.class},
                new LoggingHandler(realService)
        );

        proxyService.doSomething();
    }
}

CGLIB Proxy(Code Generator Library)

CGLIB는 Code Generator Library의 약자로 바이트코드를 조작하여 대상 객체를 상속하는 프록시 클래스를 동적을 생성하는 방법을 의미합니다. 바이트 코드(bytecode)란 특정 하드웨어가 아닌 가상 컴퓨터에서 돌아가는 실행 프로그램을 위한 이진 표현법을 말합니다. 자바의 경우 JVM위에서 실행할 바이트 코드 명령어 집합을 의미하는 것이죠. CGLIB는 이런 바이트 코드를 조작해서 프록시 클래스를 동적을 생성합니다. 

public class HelloWorld {
    public void sayHello() {
        System.out.println("Hello, Bytecode!");
    }
}

 

예를 들어, 위 코드를 수행하기 위한 바이트 코드를 javap 명령어를 통해 확인해 보면 아래와 같습니다. 

Compiled from "HelloWorld.java"
public class HelloWorld {
  public HelloWorld();
    Code:
       0: aload_0
       1: invokespecial #1 // Method java/lang/Object."<init>":()V
       4: return

  public void sayHello();
    Code:
       0: getstatic     #7  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #13 // String Hello, Bytecode!
       5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}

 

자바에서는 Javassist 또는 ASM 같은 라이브러리를 추가해서 바이트코드를 조작할 수 있도록 지원하고 있습니다. 

import javassist.*;

public class ModifyBytecode {
    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        CtClass ctClass = pool.get("HelloWorld");
        CtMethod method = ctClass.getDeclaredMethod("sayHello");

        // 기존 println 내용을 바꾸기
        method.setBody("{ System.out.println(\"Hello, Modified Bytecode!\"); }");

        // 변경된 클래스를 로드
        Class<?> modifiedClass = ctClass.toClass();
        Object instance = modifiedClass.getDeclaredConstructor().newInstance();
        modifiedClass.getMethod("sayHello").invoke(instance);
    }
}
Hello, Modified Bytecode!

 

CGLIB 방법은 위와 같이 바이트코드를 직접 조작해서 클래스 기반의 동적으로 프록시 객체를 생성하는 방식입니다. (즉, 프록시 객체를 생성할 때 원본 클래스의 바이트코드를 조작해서 Aspect 기능을 더한 프록시 객체를 생성하는 것입니다.)

 

 Spring Boot 2.0부터는 기본적으로 인터페이스를 구현한 클래스의 경우 JDK Dynamic Proxy 방식을 통해 프록시 객체를 생성하고 인터페이스가 없는 클래스의 경우 CGLIB 방식으로 프록시 객체를 생성합니다. 

https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/transaction/annotation/EnableTransactionManagement.html#proxyTargetClass()


Spring AOP vs NestJS AOP

Spring AOP는 객체 지향 프로그래밍과(OOP)와 강하게 결합되어 있고 AOP를 통해 비즈니스 로직과 공통 관심사항을 분리하려는 강한 의도를 확인 할 수 있었습니다. 

NestJs Inteceptor 관련 공식문서를 보면 NestJs interceptor는 AOP에 영감을 받았다고 언급하고 있습니다. 다만, Spring AOP처럼 공식적으로 'AOP'에 최적화되어 있는 의존성을 직접 제공하고 있지 않습니다만, 여러 기능을 활용해서 AOP와 비슷한 효과를 낼 수 있도록 지원하고 있습니다.  

https://docs.nestjs.com/interceptors

 

즉, 로깅, 트랜잭션, 인증 같은 공통 관심사를 Spring AOP에서는 '프록시 패턴'을 적용해서 기능을 '직접' 지원하고 있다면 NestJs에서는 부가 기능을 여러 기능을 통해 직접 구현하도록 우회(?) 하고 있습니다.

 

예를 들어, 아래와 같이 미들웨어들을 통해서 공통 관심사를 모듈로 분리할 수 있습니다.

  • Interceptor → 메서드 실행 전/후를 가로채는 역할 (Spring Interceptor와 유사)
  • Middleware → 요청을 전처리하는 역할 (Spring의 Filter와 유사)
  • Guards → 인증, 권한 검사 역할 (Spring Security의 Filter/Interceptor와 유사)
  • Pipes → 요청 데이터 변환 및 유효성 검사 역할 (Spring의 @Valid & Converter와 유사) 

정리하자면, Spring AOP에서는 프록시 패턴을 통해 AOP를 직접 지원하지만, NestJs에서는 미들웨어 사용을 통해 AOP를 직접 구현하도록 우회하고 있습니다.


NestJs에서 AOP  로깅 구현하기

마지막으로 NestJs에서 AOP 로깅을 구현하는 예제를 통해 글을 마무리 하겠습니다. 

일반적으로 Spring AOP에서 로깅은 Aspect, PointCut, Around(Before, After etc..) 등의 어노테이션을 사용해서 특정 service, controller의 메서드 실행흐름을 가로채서 로깅할 수 있습니다.

@Aspect
@Component
public class LoggingAspect {

    @Pointcut("execution(* com.example.service.*.*(..))") 
    public void serviceMethods() {} // 특정 패키지 내 모든 메서드에 적용

    @Before("serviceMethods()") // 메서드 실행 전에 실행
    public void logBefore(JoinPoint joinPoint) {
        System.out.println("Executing: " + joinPoint.getSignature());
    }

    @After("serviceMethods()") // 메서드 실행 후 실행
    public void logAfter(JoinPoint joinPoint) {
        System.out.println("Finished: " + joinPoint.getSignature());
    }
}

 

 반면에 NestJs에서는 아래와 같이 interceptor + RxJs를 사용해서 로깅을 구현할 수 있습니다.  

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before method execution');

    return next.handle().pipe(
      tap(() => console.log('After method execution')),
    );
  }
}
import { Controller, Get, UseInterceptors } from '@nestjs/common';

@Controller('users')
export class UserController {
  
  @Get()
  @UseInterceptors(LoggingInterceptor)
  getUsers() {
    return ['user1', 'user2'];
  }
}

 

위의 예시는 controller로 들어오기전 Http 요청과 응답에 대한 로그를 기록합니다. 


이번 글에서는 NestJs에서 AOP를 공식적으로 지원하는가? 에 대한 답에서 시작해서 '아니다'라는 답을 얻기 까지의 여정을 정리했습니다. 제 글에 많은 부분이 부족할 수 있지만 끝까지 읽어 주셔서 감사합니다. 

 

참고: https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html