[Spring Core] AOP

컨테이너·2025년 11월 29일

SpringFramework

목록 보기
16/16
post-thumbnail

스프링 AOP?

서비스 코드를 짜다 보면 반복되는 코드들이 있다.

  • 메서드 실행 시간 측정
  • 공통 로그 출력
  • 트랜잭션 처리
  • 권한 체크

이런 것들을 매번 메서드마다 붙여 쓰면, 코드가 지저분해지고 유지보수가 어려워진다.

그래서 등장한 개념이 AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)이다.

핵심 비즈니스 로직은 그대로 두고,

“공통으로 끼어들어야 하는 부가기능”만 따로 분리해서

메서드 실행 전/후에 끼워 넣어 주는 기술이라고 이해하면 된다.


1. AOP 핵심 개념 정리

AOP를 이해할 때 자주 나오는 용어들부터 정리해보자.

용어의미
Aspect핵심 로직과는 별도로 두는 “공통 관심사” 묶음 (예: 로깅, 트랜잭션)
Advice실제로 끼워 넣을 “부가기능” 코드
Join PointAdvice를 끼워 넣을 수 있는 지점 (메서드 호출 시점 등)
Pointcut수많은 Join Point 중에서 “여기에만 적용하겠다” 하고 고른 지점
어떤 클래스에 어떤 패턴의 메서드에 실행을 할건지
Weaving실제로 대상 객체에 Advice를 연결해서 끼워 넣는 작업

쉽게 말하면:

  • Aspect = 로그 찍는 기능(Point-cut + Advice 두 가지를 합친 것)
  • Advice = 로그를 이렇게 찍으라는 코드
  • Join Point = 모든 메서드 호출 시점
  • Pointcut = 서비스 클래스 아래의 메서드에만 적용 → 어디에 어떤 패턴을 실행할지.
  • Weaving = 실제 코드 실행 시, 메서드 앞뒤에 Advice를 섞어 넣는 것


2. Advice 종류

Advice는 끼어들 타이밍에 따라 종류가 나뉜다.

종류실행 시점
Before대상 메서드 실행 전에
After-returning메서드가 정상 종료된 후에
After-throwing메서드 실행 중 예외가 발생했을 때
After정상/예외 관계 없이 메서드가 끝난 후에
Around메서드 전/후를 모두 감싸서

3. 스프링 AOP의 특징

스프링이 제공하는 AOP는 다음 특징을 가진다.

  1. 프록시 기반
    • 실제 대상 객체를 감싼 “프록시 객체”를 만들어서, 그 프록시를 통해 메서드가 호출되도록 한다.
  2. 메서드 수준의 Join Point만 지원
    • 스프링 AOP는 “메서드 실행 시점”에만 부가기능을 끼울 수 있다. (필드 접근 같은 더 낮은 레벨은 AspectJ 전체 기능에서 다룬다)


4. 간단 예제 흐름 정리

4-1. 기본 서비스 코드

먼저 아주 간단한 예제를 생각해보자.

@Getter @Setter @ToString
@AllArgsConstructor
public class MemberDTO {
    private Long id;
    private String name;
}
@Repository
public class MemberDAO {

    private final Map<Long, MemberDTO> memberMap;

    public MemberDAO(){
        memberMap = new HashMap<>();
        memberMap.put(1L, new MemberDTO(1L, "유관순"));
        memberMap.put(2L, new MemberDTO(2L, "홍길동"));
    }

    public Map<Long, MemberDTO> selectMembers(){
        return memberMap;
    }

    public MemberDTO selectMember(Long id) {
        MemberDTO returnMember = memberMap.get(id);
        if(returnMember == null) throw new RuntimeException("해당하는 id의 회원이 없습니다.");
        return returnMember;
    }
}
@Service
public class MemberService {

    private final MemberDAO memberDAO;

    public MemberService(MemberDAO memberDAO) {
        this.memberDAO = memberDAO;
    }

    public Map<Long, MemberDTO> selectMembers(){
        System.out.println("selectMembers 메소드 실행");
        return memberDAO.selectMembers();
    }

    public MemberDTO selectMember(Long id) {
        System.out.println("selectMember 메소드 실행");
        return memberDAO.selectMember(id);
    }
}
public class Application {
    public static void main(String[] args) {
        ApplicationContext context =
                new AnnotationConfigApplicationContext("com.ohgiraffers.section01.aop");

        MemberService memberService = context.getBean("memberService", MemberService.class);

        System.out.println("=============== selectMembers ===============");
        System.out.println(memberService.selectMembers());

        System.out.println("=============== selectMember ===============");
        System.out.println(memberService.selectMember(3L)); // 일부러 예외 발생
    }
}

여기까지는 평범한 서비스 코드.

이제 여기에 AOP를 통해 “공통 로그 기능”을 끼워넣어 보자.


5. 스프링 AOP 세팅 과정

5-1. 의존성 추가

AOP를 쓰려면 AspectJ 관련 라이브러리를 추가해줘야 한다.

dependencies {
    implementation "org.aspectj:aspectjweaver:1.9.19"
    implementation "org.aspectj:aspectjrt:1.9.19"
}

5-2. AutoProxy 설정

스프링에게 “AspectJ 스타일 AOP를 사용하겠다”라고 알려줘야 한다.

@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class ContextConfiguration {
}
  • @EnableAspectJAutoProxy : AOP 활성화(얘를 붙이지 않으면 일반 코드와 다를 것이 없다.)
  • proxyTargetClass = true : CGLIB 기반 프록시 사용 (인터페이스가 없고 목표 객체가 클래스인 경우에도 proxy 객체 생성 가능.

6. Aspect 클래스 만들기

6-1. 기본 틀

@Aspect       // Aspect : Point-cut(진입시점) + Advice(부가코드)
@Component    // 스프링 빈으로 등록
public class LoggingAspect {
}

6-2. Pointcut 정의

어디에 AOP를 걸지 지정하는 부분이다.

@Pointcut("execution(* com.ohgiraffers.section01.aop.*Service.*(..))")
public void logPointcut() {} // 

설명:

  • execution(...) : 메서드 실행 시점에 대한 포인트컷 표현식
  • (* : 타입을 의미함.
  • com.ohgiraffers.section01.aop.*Service.*(..)
    • com.ohgiraffers.section01.aop. : 특정 패키지 안에
    • .*Service : 이름이 ~Service로 끝나는 클래스의
    • Service.*: 모든 메서드에(메소드 명이 들어오는 자리)
    • .*(..) : 파라미터는 상관없이 적용

execution([접근제한자패턴] [리턴타입패턴] [클래스이름패턴] [메서드이름패턴](https://www.notion.so/%5B%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0%ED%83%80%EC%9E%85%ED%8C%A8%ED%84%B4%5D))

execution([접근제한자패턴] [리턴타입패턴] [클래스이름패턴] [메서드이름패턴]([파라미터타입패턴]))

7. Advice 예시들

부가기능을 추가해준다. 미리 선언한 logPointcut() 을 통해 어느 시점에 들어갈지 정해준다.

7-1. Before

메서드가 실행되기 “직전”에 실행된다.

@Before("LoggingAspect.logPointcut()")
public void logBefore(JoinPoint joinPoint) {
    System.out.println("Before Target: " + joinPoint.getTarget());
    System.out.println("Before Signature: " + joinPoint.getSignature());
    if (joinPoint.getArgs().length > 0) {
        System.out.println("Before First Arg: " + joinPoint.getArgs()[0]);
    }
}
  • JoinPoint를 통해 point-cut으로 패치한 메서드 이름, 인자 값 등의 정보에 접근할 수 있다.

결과

Before Target: com.ohgiraffers.section01.aop.MemberService@3e44f2a5
Before Signature: Map com.ohgiraffers.section01.aop.MemberService.selectMembers()
selectMembers 메소드 실행

7-2. After

정상 종료든 예외든, 메서드가 “끝난 뒤”에 항상 실행된다.

@After("logPointcut()")
public void logAfter(JoinPoint joinPoint) {
    System.out.println("After Target: " + joinPoint.getTarget());
    System.out.println("After Signature: " + joinPoint.getSignature());
}
  • 같은 클래스내에 Aspect가 있으면 생략해도 된다. 다른 패키지 인 경우 패키지명까지 기술한다.

결과

selectMembers 메소드 실행
After Returning result: {1=MemberDTO(id=1, name=유관순), 2=MemberDTO(id=2, name=홍길동)}
After Target: com.ohgiraffers.section01.aop.MemberService@3e44f2a5
After Signature: Map com.ohgiraffers.section01.aop.MemberService.selectMembers()

7-3. AfterReturning

메서드가 정상적으로 반환된 후 실행된다.

반환값을 파라미터로 받아와 없었던 내용을 가공/조작할 수도 있다.

@AfterReturning(pointcut = "logPointcut()", returning = "result")
public void logAfterReturning(JoinPoint joinPoint, Object result) {
    System.out.println("After Returning result: " + result);
    if (result instanceof Map) {
        ((Map<Long, MemberDTO>) result).put(100L, new MemberDTO(100L, "반환 값 가공"));
    }
}
  • @AfterReturnning(pointcut="", returning="") : 이름과 값을 정해준다.
  • 새로운 값을 추가해줄 수 있다.

결과

After Returning result: {1=MemberDTO(id=1, name=유관순), 2=MemberDTO(id=2, name=홍길동)}
↓
{1=MemberDTO(id=1, name=유관순), 2=MemberDTO(id=2, name=홍길동), 100=MemberDTO(id=100, name=반환 값 가공)}

7-4. AfterThrowing

메서드 실행 중 예외가 발생했을 때만 실행된다.

@AfterThrowing(pointcut = "logPointcut()", throwing = "exception")
public void logAfterThrowing(Throwable exception) {
    System.out.println("After Throwing exception: " + exception);
}

결과

After Throwing exception: java.lang.RuntimeException: 해당하는 id의 회원이 없습니다.

7-5. Around

메서드 전/후 전체를 감싸는 가장 강력한 Advice.

@Around("logPointcut()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println("Around Before " + joinPoint.getSignature().getName());

    // 실제 타겟 메서드 실행
    Object result = joinPoint.proceed();

    System.out.println("Around After " + joinPoint.getSignature().getName());
    return result;
}
  • 반드시 joinPoint.proceed()를 호출해야 실제 메서드가 실행된다.
    • After,Before 보다 훨씬 강력한 어노테이션이기 때문에 원본 joinpoint를 실행하는 코드를 미리 저장해두어야 한다.
  • 호출을 빼먹으면 타겟 메서드가 아예 실행되지 않는다.
  • 하지만 양쪽(Before, After)을 모두 다루다 보니 권장되지는 않는다.

결과

=============== selectMembers ===============
Around Before selectMembers
.
.
Around After selectMembers
{1=MemberDTO(id=1, name=유관순), 2=MemberDTO(id=2, name=홍길동), 100=MemberDTO(id=100, name=반환 값 가공)}
=============== selectMember ===============
Around Before selectMember
.
.
After Target: com.ohgiraffers.section01.aop.MemberService@3e44f2a5
After Signature: MemberDTO com.ohgiraffers.section01.aop.MemberService.selectMember(Long)

8. 자바 Reflection – 런타임에 클래스 들여다보기

실행 시간에 객체의 정보(메소드, 변수 등)를 동적으로 다룰 수 있게 하는 기법이다. 내부적으로 인터페이스가 있고, 스스로 객체를 생성할 수 없으니, 클래스를 만든다. Reflection을 통해 만든 클래스에 메소드를 구현하여 사용할 수 있게 한다.

리플랙션은 private 로 막아놔도 그 정보를 끌어다가 조작하고 새롭게 생성할 수 있도록 할 수 있다. 하지만 무분별하게 사용할 경우 성능 저하의 문제점이 야기될 수 있다.

스프링은 리플렉션을 이용해서 설정 파일/어노테이션을 보고 해당 클래스의 인스턴스를 만들고 의존성 주입하고 메서드를 찾아 호출해준다.

→ Bean 객체를 IoC 컨테이너에서 만들고 사용하는 것이 Reflection을 사용한 기법이다.

8-1. Reflection이란?

Reflection은

실행 중인 자바 프로그램 안에서 클래스, 필드, 메서드, 생성자 정보를 들여다보고 다루는 기술이다.

  • 객체의 타입을 런타임에 확인
  • 필드 목록, 메서드 목록, 생성자 목록을 가져오기
  • 심지어 “이름 문자열”로 메서드를 찾아서 실행까지 가능

스프링은 Reflection을 활용해서

“설정에 적힌 클래스 이름 → 실제 객체 생성 → 빈으로 등록 → 의존성 주입”

이런 과정을 런타임에 처리한다.


8-2. 예제 클래스: Account

Reflection 실습 대상이 되는 간단한 클래스부터 보자.

public class Account {
    private String bankCode;
    private String accNo;
    private String accPwd;
    private int balance;

    public Account() {}

    public Account(String bankCode, String accNo, String accPwd) {
        this.bankCode = bankCode;
        this.accNo = accNo;
        this.accPwd = accPwd;
    }

    public Account(String bankCode, String accNo, String accPwd, int balance) {
        this(bankCode, accNo, accPwd);
        this.balance = balance;
    }

    public String getBalance() {
        return this.accNo + " 계좌의 현재 잔액은 " + this.balance + "원 입니다.";
    }

    public String deposit(int money) { ... }

    public String withDraw(int money) { ... }
}

이제 이 클래스를 가지고 Reflection으로 놀아보자.


8-3. Class 객체 얻기 (Class<?>)

모든 자바 클래스는 JVM 안에서 Class 타입의 메타정보 객체를 하나 가지고 있다.

// 1) .class 사용
Class class1 = Account.class;

// 2) 객체에서 getClass() 사용
Class class2 = new Account().getClass();

// 3) 문자열(풀네임)로 로딩
Class class3 = Class.forName("com.example.Account");

결과

class1 = class com.ohgiraffers.section02.reflection.Account
class2 = class com.ohgiraffers.section02.reflection.Account
class3 = class com.ohgiraffers.section02.reflection.Account

이렇게 얻은 Class 객체로 할 수 있는 것들:

// 부모 클래스 정보
Class superClass = class1.getSuperclass();  // 보통 java.lang.Object

// 기본 타입
Class c1 = Double.TYPE; // double
Class c2 = Void.TYPE;   // void

// 배열 타입도 가능
Class doubleArray1 = Class.forName("[D");
Class doubleArray2 = double[].class;
arr = [D@3a71f4dd
class4 = class [D
class5 = class [D
superClass = class java.lang.Object
  • 클래스의 메타 정보를 이용해서 여러가지 정보를 반환하는 메소드를 제공한다.
    • getSuperclass() : 상속된 부모 클래스를 반환

8-4. 필드 정보 읽기 (Field)

클래스가 가진 멤버 변수 목록을 Reflection으로 확인할 수 있다.

Field[] fields = Account.class.getDeclaredFields();

for (Field field : fields) {
    System.out.println("modifiers : " + Modifier.toString(field.getModifiers())
            + ", type : " + field.getType()
            + ", name : " + field.getName());
}

예상 출력:

  • private String bankCode
  • private String accNo
  • private String accPwd
  • private int balance

이걸 한 단계 더 나가면, field.setAccessible(true) 로 private 필드에 값 넣기/꺼내기도 가능하다.

(프레임워크에서 종종 사용한다.)


8-5. 생성자 정보 & 객체 생성 (Constructor)

생성자 목록을 가져올 수 있다.

Constructor[] constructors = Account.class.getConstructors();

for (Constructor con : constructors) {
    System.out.println("name : " + con.getName());
    Class[] params = con.getParameterTypes();
    for (Class<param : params) {
        System.out.println("paramType : " + param.getTypeName());
    }
}

그리고 리플렉션으로 직접 객체를 생성할 수도 있다. 특정 생성자를 이용해서 객체를 생성해보자.

Account acc = (Account) constructors[0]
        .newInstance("20", "110-223-123456", "1234", 10000);

System.out.println(acc.getBalance());
// -> "110-223-123456 계좌의 현재 잔액은 10000원 입니다."

스프링이 new를 직접 안 쓰고도 객체를 생성할 수 있는 이유가 바로 이 Reflection 덕분이다.


8-6. 메서드 정보 & 메서드 실행 (Method)

메서드 목록 가져오기:

Method[] methods = Account.class.getMethods();
Method getBalanceMethod = null;

for (Method method : methods) {
    System.out.println(Modifier.toString(method.getModifiers()) + " "
            + method.getReturnType().getSimpleName() + " "
            + method.getName());

    if ("getBalance".equals(method.getName())) {
        getBalanceMethod = method;
    }
}

그리고 이름으로 찾은 메서드를 실행할 수 있다.

Account acc = (Account) constructors[2].newInstance(); // 매개변수 없는 생성자
String result = (String) getBalanceMethod.invoke(acc);
System.out.println(result);
// -> "null 계좌의 현재 잔액은 0원 입니다."

Reflection의 패턴

  1. Class 얻기
  2. Field / Constructor / Method 정보 얻기
  3. 필요하다면 newInstance/invoke로 직접 만들고 호출

9. Proxy

9-1. Proxy란?

Proxy(프록시)는 말 그대로 “대리인”이다.

  • 클라이언트 → 프록시 → 실제 타겟 객체 호출
  • 프록시는 호출 전/후에 추가 작업을 할 수 있다.
    • 로그 출력
    • 권한 체크
    • 트랜잭션 시작/종료
    • 캐싱 등

AOP에서 쓰는 방식 자체가 프록시 기반 구조다.

  • 서비스 객체를 그대로 쓰는 것이 아니라
  • “서비스를 감싼 프록시”를 만들어서
  • 프록시가 먼저 호출을 받고, 그 안에서 부가기능 + 실제 메서드 호출을 처리한다.

9-2. 프록시 생성 방식 두 가지

  1. JDK Dynamic Proxy
    • 인터페이스 기반 프록시
    • java.lang.reflect.Proxy + InvocationHandler 사용
    • 타겟이 인터페이스를 구현하고 있어야 함
  2. CGLIB Proxy
    • 클래스 기반 프록시
    • 인터페이스 없어도, 구체 클래스만 가지고 프록시 생성 가능
    • 바이트코드를 조작해서 성능이 좋고 유연함
    • 스프링 4.3 / 스프링부트 1.3 이후부터는 스프링 core에 포함, 기본 전략으로 많이 사용

스프링 AOP는 내부적으로 이 둘을 알아서 선택해서 사용한다.

  • 인터페이스만 있으면 JDK Dynamic Proxy
  • 클래스만 있거나 proxyTargetClass = true면 CGLIB

9-3. JDK Dynamic Proxy 예제

1) 타겟 인터페이스 & 구현체

public interface Student {
    void study(int hours);
}

public class SchoolStudent implements Student {
    @Override
    public void study(int hours) {
        System.out.println(hours + "시간 동안 열심히 공부합니다.");
    }
}

2) InvocationHandler 구현

public class Handler implements InvocationHandler {

    private final Student student;  // 실제 타겟

    public Handler(Student student) {
        this.student = student;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args)
            throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {

        System.out.println("============ 공부가 너무 하고 싶습니다. ==============");
        System.out.println("호출 대상 메소드 : " + method);

        for (Object arg : args) {
            System.out.println("전달된 인자 : " + arg);
        }

        // 실제 타겟 메서드 호출
        method.invoke(student, args);

        System.out.println("============ 공부를 마치고 수면 학습을 시작합니다. ============");
        return proxy;   // 여기서는 반환값이 없으니 proxy 리턴
    }
}

3) 프록시 생성 & 사용

Student student = new SchoolStudent();
Handler handler = new Handler(student);

Student proxy = (Student) Proxy.newProxyInstance(
        Student.class.getClassLoader(),   // 클래스로더
        new Class[]{Student.class},       // 어떤 인터페이스를 프록시로 만들지
        handler                           // 호출 로직을 가진 핸들러
);

proxy.study(16);

실행 흐름:

  1. proxy.study(16) 호출
  2. 사실은 Handler.invoke()가 먼저 실행
  3. 로그 출력
  4. method.invoke(student, args)로 실제 OhgiraffersStudent.study(16) 실행
  5. 마지막 로그 출력

이 패턴이 그대로 AOP의 원리와 겹친다.

결과

============= 공부가 하고 싶어요 =============
호출 대상 메소드 : public abstract void com.daniel.section03.proxy.common.Student.study(int)
전달 인자 : 16
16시간 동안 열심히 공부합니다.
============ 공부를 마치고 수면 학습을 시작합니다 ===========

9-4. CGLIB Proxy 예제

CGLIB은 “클래스 자체를 상속받아서 프록시”를 만들기 때문에 인터페이스가 없어도 된다.

1) 타겟 클래스

public class OhgiraffersStudent {
    public void study(int hours) {
        System.out.println(hours + "시간 동안 열심히 공부합니다.");
    }
}

2) InvocationHandler (CGLIB용)

public class Handler implements org.springframework.cglib.proxy.InvocationHandler {

    private final OhgiraffersStudent student;

    public Handler(OhgiraffersStudent student) {
        this.student = student;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args)
            throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {

        System.out.println("============ 공부가 너무 하고 싶습니다. ==============");
        System.out.println("호출 대상 메소드 : " + method);

        for (Object arg : args) {
            System.out.println("전달된 인자 : " + arg);
        }

        method.invoke(student, args);

        System.out.println("============ 공부를 마치고 수면 학습을 시작합니다. ============");
        return proxy;
    }
}

3) 프록시 생성

OhgiraffersStudent proxy =
        (OhgiraffersStudent) Enhancer.create(
                OhgiraffersStudent.class,      // 타겟 클래스
                new Handler(new OhgiraffersStudent()) // 핸들러
        );

proxy.study(20);

역시 흐름은 동일:

  • 프록시 메서드 호출 → 핸들러 → 타겟 메서드 호출 + 부가기능

정리: AOP, Reflection, Proxy의 관계

마지막으로 세 개를 한 문장씩으로 정리해보면:

  • AOP “공통 기능을 비즈니스 로직에 깔끔하게 끼워 넣기 위한 개념/패턴”
  • Reflection “클래스/메서드/필드를 런타임에 분석하고 호출하기 위한 기술”
  • Proxy “실제 객체를 감싸서 메서드 호출 전후로 부가기능을 추가하는 구조”

스프링은:

  1. Reflection으로 설정 정보와 클래스를 읽고

  2. Reflection/프록시 기술로 런타임에 프록시 객체를 생성한 뒤

  3. AOP 설정(Aspect, Pointcut, Advice)에 맞게

    메서드 호출 전/후에 부가기능을 추가한다.

그래서 우리가 AOP를 쓸 때는

@Aspect, @Around, @Before만 쓰고 편하게 개발하지만,

그 안쪽에서는 Reflection + Proxy가 열심히 굴러가고 있는 것이다.


profile
백엔드

0개의 댓글