[LG CNS AM Inspire Camp 1기] Spring (5) - AOP & Proxy Pattern

정성엽·2025년 1월 23일
2

LG CNS AM Inspire 1기

목록 보기
34/53

INTRO

이번 포스팅에서는 OOP의 한계를 해결하기 위해 등장한 AOP의 개념을 살펴보고, 스프링에서는 이 개념을 어떻게 사용하는지를 살펴보자 👀


1. AOP (Aspect-Oriented Programming)

AOP는 관점 지향 프로그래밍이라고 하며, 어플리케이션의 핵심 비즈니스 로직과 공통적인 기능 (횡단 관심사, cross-cutting concerns) 을 분리하여 모듈화하는 방법을 의미한다.

사실 우리가 일반적으로 사용하는 OOP(객체 지향 프로그래밍) 기법으로는 공통 관심사를 완벽하게 분리하기 어렵다는 한계점이 존재한다.

우선 이번에는 코드를 먼저 살펴보기 이전에 AOP를 이해하기 위한 개념을 몇 가지 정리해보자

💡 주요 개념

1. 횡단 관심사 (Cross-cutting concerns)

  • 비즈니스 로직과는 별도로 여러 모듈에 걸쳐 공통적으로 사용되는 기능이다.
  • 로깅, 보안(인증, 인가), 트랜잭션 관리 등이 이에 해당한다.

2. Aspect

  • 횡단 관심사를 모듈화한 클래스를 의미한다.
  • 한 개 이상의 포인트컷어드바이스의 조합으로 구성된다.

3. Join Point

  • Aspect가 적용될 수 있는 실행 지점을 의미한다.
  • 스프링에서는 메서드 호출 단계만 지원한다.

4. Pointcut

  • Aspect가 적용될 특정 조인포인트를 정의한다.
  • 정규표현식이나 AspectJ 문법으로 지정한다.

5. Advice

  • 포인트컷에서 정의한 지점에서 실행될 실제 작업을 의미한다.
  • 실행 시점에 따라 다음과 같이 구분한다.
어드바이스어노테이션설명
Before advice@Before대상 메서드가 실행되기 전에 적용할 어드바이스를 정의.
After returning advice@AfterReturning대상 메서드가 성공적으로 실행되고 결과값을 반환한 후 적용할 어드바이스를 정의
After throwing advice@AfterThrowing대상 메서드에서 예외가 발생했을 때 적용할 어드바이스를 정의
After advice@After대상 메서드의 정상 수행 여부와 상관없이 무조건 실행되는 어드바이스를 정의
Around advice@Around대상 메서드의 호출 전후, 예외 발생 등 모든 시점에 적용할 수 있는 어드바이스를 정의

6. Weaving

  • Aspect를 실제 대상 객체에 적용하여 Aspect와 비즈니스 로직을 결합하는 과정이다.
  • 위빙은 컴파일 타임, 로드 타임, 런타임에 수행될 수 있다.

이처럼 강의에서 수강한 내용을 한번 정리해봤다.

필자가 실제로 코드를 작성해보면서 느낀 것을 정리해보면 PointCut은 AOP를 실행하기 위한 엔트리 포인트를 지정하는 것, Advice는 실제 AOP에서 작업할 내용을 구현하는 것, JoinPoint는 프록시 패턴으로 실행할 대상이라고 이해할 수 있었다.

그리고 마지막으로 이러한 AOP 클래스의 구현체를 Aspect라고 이해하면 될 것 같다.


2. AOP 구현 - Proxy Pattern

이전에 AOP는 JoinPoint를 프록시 패턴으로 실행한다고 설명을 했다.

그렇다면 여기서 프록시 패턴이란 무엇을 의미하는걸까?

간단하게 프록시 패턴을 살펴보고 자바로 AOP와 비슷한 역할을 수행하도록 코드를 작성해보자

💡 프록시 패턴 (Proxy Pattern)

프록시 패턴은 실제 기능을 수행하는 객체(Real Subject) 대신 가상의 객체(Proxy)를 사용해 로직의 흐름을 제어하는 디자인 패턴이다.

예시 코드를 살펴보자

Sample Code

public interface Subject {
    void doAction();
}


public class RealSubject implements Subject {
	@Override
    public void doAction() {
        System.out.println("실제 작업 수행");
    }
}

public class Proxy implements Subject {
    private RealSubject realSubject;

    public Proxy (Subject subject) {
    	this.realSubject = subject;
    }

    @Override
    public void doAction() {
        System.out.println("작업 전 처리");
     
        realSubject.doAction();
    
        System.out.println("작업 후 처리");
    }
}

위 코드에서 구현한 Proxy와 RealSubject는 동일한 인터페이스를 구현하고 있다.

Proxy 객체는 생성자를 통해 RealSubject의 인스턴스를 주입받아 내부 필드로 유지한다.

Proxy의 doAction() 메서드가 호출되면, RealSubject의 doAction() 메서드를 실행하면서 그 전후로 추가적인 작업을 수행할 수 있다.

이처럼 다른 객체의 기능을 대신 호출하면서 추가 작업을 수행하는 것이 프록시 패턴의 핵심이다!

💡 메서드 실행 시간 측정 - No Proxy Pattern

프록시 패턴을 사용하지 않고 메서드의 실행시간을 측정하는 코드를 작성해보자

우선 필자가 작성하고자하는 것은 다음과 같다.

개발 코드 설명
1. for문을 이용한 factorial 구현
2. 재귀문을 이용한 factorial 구현
3. 각 로직의 실행 시간을 비교

우선 for문과 재귀문을 이용한 factorial을 구현해보면 다음과 같다.

Sample Code

public interface Calculator {
    public long factorial(long num);
}

public class ImplCalculator implements Calculator {
    @Override
    public long factorial(long num) {
        long result = 1;
        for (long i = 1; i <= num; i++) {
            result *= i;
        }
        return result;
    }
}

public class RecCalculator implements Calculator {
    @Override
    public long factorial(long num) {
        if (num == 1) return 1;
        return num * factorial(num - 1);
    }
}

다음으로 각 로직의 실행시간을 측정하는 코드를 작성해보자

우선 프록시 패턴을 사용하지 않는다면 어떻게 될까?

Sample Code - No Proxy Pattern

public class ImplCalculator implements Calculator {
    @Override
    public long factorial(long num) {
        long start = System.nanoTime();

        long result = 1;
        for (long i = 1; i <= num; i++) {
            result *= i;
        }

        long end = System.nanoTime();
        System.out.printf("ImpCalculator.factorial(%d) 실행시간 = %d\n", num, (end - start));
        return result;
    }

    public static void main(String[] args) {
        ImplCalculator calculator = new ImplCalculator();
        calculator.factorial(1000000);
    }
}

Result View

ImplCalculator는 for문을 이용하여 팩토리얼을 구현했다.

따라서, for문 시작과 전에 시간을 측정해서 CLI에 출력하도록 하면 개발자가 로직 수행 시간을 확인할 수 있었다.

하지만, 재귀문을 이용한 팩토리얼은 시작과 끝을 메서드 내부에서 정의하기 어렵다.

따라서, 외부에서 실행하는 로직을 작성하면 된다!

Sample Code - 외부에서 측정

public class MainForCalculator {
    public static void main(String[] args) {
        final long num = 1000L;

        ImplCalculator imp = new ImplCalculator();
        long start1 = System.nanoTime();
        imp.factorial(num);
        long end1 = System.nanoTime();
        System.out.printf("ImplCalculator.factorial(%d) 실행시간 - %d\n", num, (end1 - start1));

        RecCalculator rec = new RecCalculator();
        long start2 = System.nanoTime();
        rec.factorial(num);
        long end2 = System.nanoTime();
        System.out.printf("RecCalculator.factorial(%d) 실행시간 - %d\n", num, (end2 - start2));
    }
}

Result View

이렇게 로직의 수행시간을 측정하기 위한 코드를 외부에서 정의하면 쉽게 구현할 수 있다.

하지만, 중복되는 코드가 발생하여 일관되게 정책을 반영하는 것이 꽤 번거롭다.

💡 메서드 실행 시간 측정 - Proxy Pattern

위에서 발생하는 코드 중복과 유지보수의 어려움 해결하기 위해 Proxy Pattern을 사용하려고 한다.

핵심 기능과 공통 기능을 분리한 AOP 개념을 사용해서 개발해보자!

Sample Code

public class ExeTimeCalculator implements Calculator {
    private Calculator delegate;

    public ExeTimeCalculator(Calculator delegate) {
        this.delegate = delegate;
    }

    @Override
    public long factorial(long num) {
        long start = System.nanoTime();
        long result = delegate.factorial(num);
        long end = System.nanoTime();
        System.out.printf("%s.factorial(%d) 실행시간 = %d\n", this.delegate.getClass().getSimpleName(), num, (end - start));
        return result;
    }
}

public class MainForCalculator {
    public static void main(String[] args) {
        final long num = 1000L;

        Calculator imp = new ExeTimeCalculator(new ImplCalculator());
        imp.factorial(num);

        Calculator rec = new ExeTimeCalculator(new RecCalculator());
        rec.factorial(num);
    }
}

Result View

ExeTimeCalculator라는 프록시 역할을 수행하는 클래스를 정의했다.

이전에 설명한 프록시 패턴과 마찬가지로 내부에서 공통 로직을 작성해주고 외부에서는 의존성만 주입해주면 실행할 수 있다!


3. Spring AOP

스프링 프레임워크는 AOP를 지원한다.

여기서 스프링 AOP는 마찬가지로 프록시 패턴을 사용해서 런타임에 위빙을 수행한다.

스프링 프레임워크에서 AOP 기능을 사용하기 위해서는 spring-aop 모듈이 필요한데, spring-context 모듈을 추가하면 자동으로 추가된다.

그럼 이전 코드를 Spring AOP를 사용해서 리팩토링해보자

Sample Code - AOP 정의

@Aspect
public class ExeTimeAspect {
    @Pointcut("execution(public * main.ex04..*(..))")
    private void publicTarget() {
    }

    @Around("publicTarget()")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();
        Object result;
        try {
            result = joinPoint.proceed();
        } finally {
            long end = System.nanoTime();
            System.out.printf("%s.%s(%s) 실행결과 = %d \n",
                    joinPoint.getClass().getSimpleName(),
                    joinPoint.getSignature().getName(),
                    Arrays.toString(joinPoint.getArgs()),
                    end - start);
        }
        return result;
    }
}

ExeTimeAspect는 실행 시간을 측정하는 AOP 클래스이며, @Aspect 어노테이션으로 이 클래스가 AOP임을 명시하자!

다음으로 @Pointcut 으로 AOP가 적용될 대상을 지정한다.

여기서 "execution(public * main.ex04..*(..)))"는 AspectJ 표현식으로 main.ex04 패키지와 하위 패키지의 모든 public 메서드를 대상으로 한다.

@Around 어노테이션은 이전에 소개한 Advice에 해당하는 부분이다.

@Around 어노테이션은 타겟 메서드 실행 전후에 로직을 실행할 수 있게 해준다.

마지막으로 ProceedingJoinPoint는 실제 실행 대상(실행 메서드)을 의미하며, 이를 통해 실제 메서드를 호출하고 메서드의 실행 시간을 측정해 출력한다.

Sample Code - 설정

@Configuration
@EnableAspectJAutoProxy
public class AppCtxAspect {
    @Bean
    public ExeTimeAspect exeTimeAspect() {
        return new ExeTimeAspect();
    }

    @Bean
    public Calculator impCalculator() {
        return new ImplCalculator();
    }

    @Bean
    public Calculator recCalculator() {
        return new RecCalculator();
    }
}

설정 클래스에서는 @EnableAspectJAutoProxy 어노테이션으로 AOP 기능을 활성화하는데, 이는 스프링이 AOP를 위한 프록시 객체를 자동으로 생성하게 해준다.

여기서 각각의 Calculator 구현체와 Aspect 클래스를 빈으로 등록하면 된다.

Sample Code - 실행

public class MainForAspect {
    private static AbstractApplicationContext ctx;

    public static void main(String[] args) {
        ctx = new AnnotationConfigApplicationContext(AppCtxAspect.class);

        Calculator imp = ctx.getBean("impCalculator", Calculator.class);
        imp.factorial(10L);
        Calculator rec = ctx.getBean("recCalculator", Calculator.class);
        rec.factorial(10L);

        ctx.close();
    }
}

실행 코드에서는 스프링 컨테이너를 생성하고 빈을 가져와 실행한다.

이전에 직접 구현했던 프록시 패턴과 동일한 결과를 얻을 수 있지만, 코드가 훨씬 깔끔해졌다.

Spring AOP를 사용하면 프록시 클래스를 직접 구현할 필요 없이 어노테이션 기반으로 간단하게 AOP를 구현할 수 있다.

이렇게 AOP를 사용하면 비즈니스 로직과 공통 관심사를 완벽하게 분리할 수 있다!


OUTRO

이번 포스팅에서는 스프링에서 사용하는 AOP를 이해하기 위해 프록시 패턴과 프록시 객체를 직접 구현해보면서 어떤 개념을 사용하는지 살펴봤다.

이후 스프링부트로 게시판 만들기 프로젝트를 수행하고 있는데, AOP는 계속해서 사용되니 꼭 알아두자 👊

profile
코린이

0개의 댓글

관련 채용 정보