관점 지향 프로그래밍 (AOP)
등장 배경
여러 클래스에 공통으로 들어가는 기능이 있다. 메서드가 호출될 때마다 로그를 남기거나, 트랜잭션을 시작하고 커밋하거나, 실행 시간을 측정하는 코드가 대표적이다. 이런 부가 기능을 핵심 비즈니스 로직 코드 사이사이에 직접 끼워 넣으면, 정작 중요한 로직은 부가 기능 코드에 파묻혀 알아보기 어려워지고, 같은 코드가 여러 클래스에 중복해서 흩어지게 된다. 이렇게 여러 모듈에 공통으로 걸쳐 있는 관심사를 횡단 관심사(Cross-Cutting Concern)라 부르는데, 상속이나 위임 같은 객체지향 기법만으로는 이 문제를 깔끔하게 해결하기 어렵다.
관점 지향 프로그래밍(AOP, Aspect Oriented Programming)은 이 횡단 관심사를 핵심 로직에서 분리해 하나의 모듈, 즉 관점(Aspect)으로 모듈화하는 프로그래밍 패러다임이다. 핵심 로직은 핵심 로직대로 깔끔하게 유지하고, 로깅이나 트랜잭션 같은 부가 기능은 별도로 관리하다가 필요한 시점에 자동으로 적용(위빙)한다.
핵심 용어
AOP를 이해하려면 몇 가지 용어를 먼저 알아야 한다. Aspect는 횡단 관심사를 모듈화한 하나의 단위로, Advice와 Pointcut을 함께 갖는다. Target은 Aspect가 적용될 대상, 즉 부가 기능이 부여될 원래의 객체를 말한다.
Advice는 Aspect가 실제로 수행할 부가 기능 자체이자 그 기능이 실행되는 시점을 가리키는데, 시점에 따라 다섯 가지로 나뉜다. Before는 대상 메서드가 호출되기 전에 실행되고, After는 대상 메서드의 결과(정상 종료든 예외든)와 무관하게 실행되며, After Returning은 대상 메서드가 정상적으로 값을 반환한 뒤에만 실행되고, After Throwing은 대상 메서드가 예외를 던졌을 때만 실행되며, Around는 대상 메서드 호출 전후를 모두 감싸 실행 자체를 제어할 수 있어 다섯 가지 중 가장 강력하고 유연하다.
JoinPoint는 Advice가 적용될 수 있는 모든 지점을 말한다. 메서드 호출, 필드 접근, 생성자 호출 등이 모두 JoinPoint가 될 수 있지만, Spring AOP는 이 중 메서드 실행 지점만 JoinPoint로 지원한다. Pointcut은 JoinPoint 중에서 실제로 Advice를 적용할 지점을 선별하는 조건식이다. 예를 들어 "service 패키지 하위의 모든 클래스에서 이름이 find로 시작하는 메서드"처럼 조건을 표현식으로 지정한다. Weaving은 Aspect를 Target에 적용해 새로운 프록시 객체를 만들어내는 과정을 말하며, 이 위빙이 언제 일어나는지에 따라 AOP 구현 방식이 나뉜다.
Weaving 시점에 따른 구현 방식
위빙은 크게 세 시점에 이루어질 수 있다. 컴파일 타임 위빙은 자바 소스 코드를 컴파일할 때 바이트코드에 직접 Aspect 코드를 삽입하는 방식으로, AspectJ가 대표적이다. 이 방식은 메서드 호출뿐 아니라 필드 접근, 생성자 호출 등 모든 JoinPoint에 적용할 수 있어 가장 강력하지만, 별도의 컴파일러가 필요하다는 부담이 있다. 로드 타임 위빙은 클래스 로더가 클래스 파일을 JVM에 로딩하는 시점에 바이트코드를 조작해 Aspect를 삽입하는 방식이다. 런타임 위빙은 애플리케이션이 실행되는 도중에 프록시 객체를 동적으로 생성해 Aspect를 적용하는 방식으로, Spring AOP가 이 방식을 쓴다.
Spring AOP의 특징
Spring AOP는 프록시 기반으로 동작한다. Target 객체를 감싸는 프록시 객체를 런타임에 생성한 뒤, 클라이언트가 이 프록시 객체를 통해 Target의 메서드를 호출하도록 만든다. 프록시는 실제 메서드를 호출하기 전후로 등록된 Advice를 실행하고, 이 과정을 개발자는 신경 쓸 필요가 없다. 컴파일 타임 위빙 방식인 AspectJ와 달리 별도의 컴파일 과정이 필요 없고 Spring의 Bean 설정만으로 적용할 수 있다는 점이 장점이지만, 그만큼 제약도 있다. 프록시가 메서드 호출을 가로채는 방식이다 보니 메서드 실행 지점만 JoinPoint로 지원하며, 필드 접근이나 생성자 호출에는 적용할 수 없다. 또한 같은 클래스 내부에서 메서드를 호출하는 **자기 자신 호출(self-invocation)**은 프록시를 거치지 않기 때문에 Advice가 적용되지 않는다는 점도 자주 걸려 넘어지는 함정이다.
Spring이 프록시 객체를 만드는 방식은 두 가지다. Target이 인터페이스를 구현하고 있다면 **JDK 동적 프록시(JDK Dynamic Proxy)**를 사용해 그 인터페이스를 구현하는 프록시를 만들고, 인터페이스가 없다면 CGLIB를 이용해 Target 클래스를 상속하는 프록시를 만든다. Spring Boot 2.x부터는 인터페이스 유무와 관계없이 기본적으로 CGLIB를 사용하도록 설정이 바뀌었다.
코드로 보는 Spring AOP
로그를 남기는 Aspect를 예로 들면 다음과 같다.
1 |
|
@Aspect는 이 클래스가 Aspect임을 선언하고, @Around의 표현식은 com.example.service 패키지 하위의 모든 클래스, 모든 메서드를 Pointcut으로 지정한다. ProceedingJoinPoint의 proceed()를 호출하는 시점이 실제 Target 메서드가 실행되는 지점이며, 그 앞뒤로 원하는 코드를 자유롭게 추가할 수 있다.
@Transactional 어노테이션도 내부적으로는 이 원리를 그대로 따른다. 트랜잭션을 시작하고 커밋 또는 롤백하는 코드를 매번 직접 작성하는 대신, 스프링이 프록시를 통해 메서드 실행 전후로 트랜잭션 처리 Aspect를 자동으로 적용해주는 것이다.
실전에서 — 세션이 만료된 주문 막기
실제로 칼라미(COLORMI)에서 이 방식으로 버그를 고친 적이 있다. 로그인 세션이 만료된 상태로 주문을 저장하면, 주문자 ID가 빈 채로 주문 데이터가 쌓이는 문제였다. 화면은 로그인 상태를 처음 한 번만 서버에 확인하고 캐시해두는 구조라, 세션이 끝나도 화면은 계속 로그인된 것처럼 보였다. 사용자도 운영자도 알아채지 못했다.
주문을 만드는 메서드는 상품 유형별로 세 개(save, saveSingle, saveLetterPadSingle)였다. 이 셋의 입구에서 세션을 검사하면 되는데, 어떤 방식으로 막을지부터 골라야 했다.
| 방법 | 적용 단위 | 문제 |
|---|---|---|
세 메서드 각각에 if 추가 |
메서드 | 같은 코드가 세 번 반복되고, 메서드가 하나 더 생기면 검사를 빠뜨리는 게 기본값이 된다 |
| 서블릿 필터 / 인터셉터 | URL | 같은 컨트롤러에 막으면 안 되는 요청(조회, 비회원 진입)이 섞여 있어 URL 패턴이 지저분해진다 |
| 화면(JS)에서 로그인 상태 재확인 | 브라우저 | 서버가 막지 않으면 직접 호출 같은 다른 경로로 계속 들어온다. 보조 수단일 뿐이다 |
AOP(@Around) |
메서드 | 막을 대상이 정확히 이 세 메서드로 정해져 있어서, 컨트롤러 코드를 한 줄도 바꾸지 않고 입구에 검사를 끼워 넣을 수 있다 |
다섯 가지 Advice 중 @Before가 아니라 @Around를 고른 이유도 분명하다. 검사에 실패하면 원래 메서드를 아예 실행하지 않아야 하는데, 이걸 결정할 수 있는 건 proceed()를 직접 호출하는 @Around뿐이다.
1 |
|
세션이 만료됐으면 SessionExpiredException을 던지고, 전역 예외 처리기가 이를 받아 Ajax 요청이면 401과 함께 LOGIN_REQUIRED를, 페이지 요청이면 로그인 화면을 돌려준다. 화면은 401을 받으면 로그인 캐시를 비우고 로그인 창을 띄운 뒤, 성공하면 사용자가 고른 옵션과 첨부 파일을 그대로 들고 같은 저장 요청을 다시 보낸다.
하루 반나절 뒤 발견한 회귀 — 비회원 주문이 막혔다
가드를 "식별자가 없으면 세션 만료"로 읽게 만들었더니, 애초에 식별자가 없는 게 정상인 비회원 주문 생성까지 막혀버렸다. 비회원은 주문을 만든 뒤 결제 단계에서 휴대폰 인증을 거치기 때문에, 생성 시점에는 식별자가 없는 게 맞는 흐름이었다 — 처음 설계에서 이 경로를 놓친 거다.
결제 흐름 전체를 바꾸거나 가드를 끄고 메서드 안에서 다시 판단하는 대신, 요청 헤더(X-NonMember-Order)로 비회원 주문임을 표시하고 가드가 그 표시만 보면 생성을 통과시키게 했다. 헤더는 클라이언트가 보내는 값이라 위조할 수 있지만, 이 헤더로 얻을 수 있는 건 "주문 생성"뿐이고 결제·내역 조회는 여전히 인증된 식별자를 요구하는 함수를 타므로, 위조해도 결제할 수 없는 빈 주문만 남는다.
자기 호출 — 앞서 말한 함정을 다른 프로젝트에서 실제로 겪은 사례
앞서 "Spring AOP의 특징"에서 같은 클래스 내부의 자기 자신 호출(self-invocation)은 프록시를 거치지 않아 Advice가 적용되지 않는다고 설명했는데, 다른 프로젝트(구글 시트 연동 배치)에서 이걸 그대로 겪었다.
1 | syncSheet("교재_디지털", …, this::syncDigital); // 같은 클래스 안에서 직접 호출 |
syncDigital에는 @Transactional이 붙어 있었지만, 스케줄러로 실행될 때 이 어노테이션이 무시됐다. this::syncDigital로 같은 객체 안에서 직접 호출했기 때문에 프록시를 거치지 않은 탓이다. 그 결과 알림 적재와 발송 기록이 한 트랜잭션으로 묶이지 않아, “알림만 나가고 기록은 안 남아서 다음 회차에 또 나가는” 위험이 있었다. 호출을 다른 빈을 거치도록 분리하거나, 자기 자신의 프록시를 주입받아 그걸로 호출해야 해결된다.
결과
컨트롤러의 저장 메서드 3개는 한 줄도 고치지 않고, 검사 로직은 약 70줄짜리 클래스 하나로 모았다. 세션 만료 상태의 주문 저장은 서버에서 401로 막히고, 주문자 ID가 빈 주문은 더 이상 쌓이지 않는다. 비회원 주문 생성도 정상화됐다.
AOP를 쓰는 이유
AOP가 자주 쓰이는 대표적인 영역은 로깅, 트랜잭션 관리, 보안(인증·인가), 예외 처리, 성능 측정처럼 애플리케이션 전반에 반복적으로 등장하지만 핵심 비즈니스 로직과는 무관한 부가 기능들이다. 이런 기능을 Aspect로 분리해두면, 핵심 로직 코드는 그 자체의 관심사에만 집중할 수 있고, 부가 기능은 한 곳에서 관리되므로 수정이나 확장도 훨씬 쉬워진다.




