AOP
Aspect Oriented Programming : 관점 지향 프로그래밍
AOP의 사전적 정의는 다음과 같다. 횡단 관심사의 분리를 허용함으로써 모듈성을 증가시키는 것이 목적인 프로그래밍 패러다임. 이것만 보면 무슨 말인지 잘 모르겠지만, 우리에게 익숙한 OOP를 생각하면 보다 쉽게 이해할 수 있다.
OOP (객체 지향 프로그래밍)을 떠올려보자. OOP는 프로그램을 객체들의 모임이라는 관점에서 바라보자는 프로그래밍 패러다임으로, 비즈니스 로직의 모듈화를 중심으로 구현된다. 하지만 비즈니스 로직을 중심으로 구현되는 OOP에 한 가지 문제가 생긴다. 로깅, 실행시간 측정과 같은 부가 기능들을 처리하기 힘들다는 것이다. 프로그램은 객체들의 상호작용이라는 관점답게, OOP는 상속이나 위임 등을 이용해 중복되는 기능을 처리한다. 하지만 앞에서 언급한 부가 기능들은 상속이나 위임으로는 모듈화하기 어렵다는 문제를 갖는다.
이러한 문제를 해결하기 위해 제안된 것이 AOP다. 여러 객체에 공통적으로 필요한 기능, 즉 부가 기능을 하나의 모듈로 분리하여 중복을 줄이는 방식을 사용하는 것이다. 붕어빵을 사러가는 간단한 예시를 살펴보자.
위처럼 클래스를 그림으로 표현해보니, 붕어빵 구입과 관련된 클래스의 메인 기능은 모두 다르지만, 부가 기능들은 모두 동일하다는 것을 알 수 있다. 이 상황이 바로 AOP의 핵심인 횡단 관심사이다. 횡단 관심사를 찾았으니 이번엔 분리해보자.
이렇듯, AOP는 코드 그 자체를 수정하는게 아니라, 기존 코드에 부가 기능 모듈이라는 새로운 코드를 추가하는 방식으로 기능을 추가한다. 이런 특징 덕분에 AOP는 다음과 같은 장단점을 갖는다.
장점
- 클래스의 메인 기능과 부가 기능을 분리함으로써 각각의 사용과 변경에 대응하기 편해진다.
- 다양한 클래스에서 사용되는 공통 부가 기능을 한 곳으로 분리하므로 공통 작업의 처리가 간편한다.
단점
- 🤔 딱히 없는 것 같다. 부가 기능 모듈이 정확히 어떤 동작을 하는지 알고 사용해야 한다는 점 정도?
- 아직 경험한 적은 없지만, 특정 클래스에서만 부가 기능이 다른 방식으로 적용되어야 하는 경우가 발생한다면 대응하기 힘들 것 같다.
- ex. 모든 클래스에서 메소드 실행 시간을 ms 단위로 측정하는데, B 클래스만 s 단위로 측정해야 한다던가..
AOP 관련 용어
용어 | 의미 |
타겟 (Target) | 부가 기능을 부여할 대상 |
어드바이스 (Advice) | 부가 기능 |
애스펙트 (Aspect) | 부가 기능 + 부가 기능을 부여할 대상. AOP의 기본 모듈 단위 |
조인 포인트 (Join Point) | 프로그램 실행 중 부가 기능이 적용될 수 있는 모든 위치들 |
포인트컷 (Pointcut) | Join point에서 부가 기능을 어디에 적용할 지 선별하는 작업 |
위빙 (Weaving) | Pointcut으로 결정된 Target의 Join point에 부가 기능을 적용하는 작업 |
Java에서 공통 기능을 삽입하는 방법들
이제 우리는 AOP를 이용해 핵심 기능과 부가 기능 모듈을 분리하고, 핵심 비즈니스 로직에 부가 기능 코드를 추가하여 각 모듈의 유지 관리를 깔끔하게 처리할 수 있음을 안다. 하지만 원론적인 궁금증이 생긴다. 어떻게 이미 만들어진 코드에 필요에 따라 부가 기능 모듈을 추가할 수 있을까? 이 방법을 찾기 위해선 먼저 Java 언어가 실행되는 과정을 알아야 한다.
자바는 다음의 과정을 거쳐 실행된다.
- 컴파일 : javac를 이용, 자바 소스 코드를 자바 바이트 코드로 변환한다.
- 클래스 로드 : 클래스 로더를 이용, 자바 바이트 코드를 JVM 런타임 영역에 올린다.
- 런타임 : 실행 엔진을 통해 실행한다.
즉, 위의 세 과정에서 코드에 끼어들어 뭔가를 추가할 수 있다. 각 과정에서 어떻게 AOP를 구현할 수 있는지 하나씩 살펴보자.
1️⃣ 컴파일 시점에 코드에 공통 기능을 삽입한다.
- 대상 코드에 AspectJ 컴파일러를 이용해 자바 바이트 코드에 부가 기능 코드가 추가되는 방식이다.
- 사진에서 보이는 것 처럼 AspectJ라는 특별한 컴파일러가 필요하며, 구현도 복잡해 쉬운 방법은 아니다.
2️⃣ 클래스 로딩 시점에 바이트 코드에 공통 기능을 삽입한다.
- 자바 옵션 (java -javaagent)를 이용해 클래스 로더 조작기를 조작하여, 런타임 영역에 올리기 전에 바이트 코드에 부가 기능 호출 코드를 추가한다.
- 옵션을 이용해 조작하는 방식 자체가 번거롭고, 휴먼 에러가 발생하기 쉽고, 운영하기 어렵다.
3️⃣ 런타임 시점에 프록시 객체를 생성하여 공통 기능을 삽입한다.
- 런타임 시점에 Java 언어가 제공하는 프록시를 이용해 부가 기능을 적용하는 방식.
- 스프링을 사용할 경우, 스프링 빈을 이용하면 복잡한 옵션이나 커스텀 조작 설정이 없어도 얼마든지 AOP를 구현할 수 있다.
위 3가지 방법 중 Spring 프레임워크는 3번째 방법, 런타임 시점에 프록시를 이용하는 방식으로 AOP를 구현하고 있다 👀
프록시 (Proxy)
그럼 Spring이 AOP를 제공하는 방법을 알아보기 위해 프록시에 대해 간단히 정리해보자. 프록시는 자신이 어떤 대상인 것처럼 위장하여, 해당 대상에게 온 요청을 대신 처리하는 대리자이다.
개발자는 특정 클라이언트가 원본 객체에 직접 접근하지 않고, 프록시를 통해 상호작용하게 만들 수 있다. 즉, 프록시를 이용하면 원본 객체에 대한 접근을 제어하고 관리할 수 있다! 이를 이용해 요청이 원본 객체에게 전달되기 전 후에 무언가를 수행하게 할 수 있다. 💪
뿐만 아니라, 클라이언트 모르게 서비스 객체를 제어한다면 다음과 같은 이점을 얻을 수 있다.
- 원하는 기능이 여러 메소드에 퍼져 있을 경우, 이를 프록시로 뽑아 중복을 없앨 수 있다.
- 클라이언트 또는 기존 코드의 변경 없이 새로운 프록시 (= 기능)을 추가할 수 있다.
즉, 프록시는 SRP (단일 책임 원칙), OCP (개방 폐쇄 원칙)을 충족하여 객체지향 원칙을 지킨 사례로 구분할 수 있다.
위에서 우리는 AOP는 메인 기능에 부가 기능을 끼워넣는 방식을 사용한다고 정리했다. 부가 기능의 필요성이 생겼을 때, 클래스 메인 로직 전후에 프록시를 끼워넣어 처리하도록 하자.
Spring AOP
위에서 언급한 것처럼, Spring 프레임워크는 런타임 시점에 프록시 객체를 생성하여 공통 기능을 삽입하는 방식으로 AOP를 구현한다. 단, 아래의 특징을 갖는다.
- 프록시는 메서드 오버라이딩 개념으로 동작하기에, 메서드에만 적용 가능.
- 스프링 컨테이너가 관리할 수 있는 Spring Bean에만 AOP를 적용 가능.
뿐만 아니라, Spring AOP는 AspectJ 문법을 차용하는 동시에 프록시 방식의 AOP를 사용한다는 점을 알아야 한다. 즉, AspectJ가 제공하는 annotation이나 interface만 사용할 뿐, 컴파일, 로드타임 위버 등을 사용하지 않는다는 점에 주의하자.
그럼 Spring AOP의 동작 과정에 대해 간단히 알아보자.
1️⃣ @Aspect 어드바이저 생성
어드바이저 (Advisor) : Spring AOP에서만 사용되는 용어. Advice + Pointcut을 의미한다.
spring-boot-starter-aop 의존성을 추가하면, 자동 프록시 생성기를 사용할 수 있다.
자동 프록시 생성기를 이용하면, 개발자가 따로 AOP 관련 로직 처리를 해주지 않아도 된다는 장점이 있다. 빈 후처리기에서 @Aspect가 붙은 모든 빈을 조회하여 Advisor로 변환한 후, 이를 기반으로 프록시를 자동으로 생성해주기 때문이다. 대신 자동 프록시 생성기가 제대로 일 할 수 있도록 @Aspect 대상 객체는 무조건 스프링 빈으로 등록해줘야 한다.
다음 방법들 중 하나를 골라 스프링 빈을 등록할 수 있다.
- @Bean으로 수동 등록
- @Component로 컴포넌트 스캔을 이용해 자동 등록
- @Import를 이용해 파일을 추가
2️⃣ @Aspect 어드바이저 적용
- 모든 Advisor 빈, 그리고 @Aspect Advisor 빌더 내부에 저장된 모든 Advisor를 조회한다.
- 위의 Advisor에 포함된 포인트 컷을 이용, 프록시를 적용할 대상인지 아닌지를 판단한다.
- Advisor 중 하나라도 포인트 컷 조건을 만족한다면 프록시를 생성하고, 빈 저장소에 반환한다. 만약 하나도 만족하지 않는다면 (=프록시 생성 대상이 아니라면) 빈 그대로 빈 저장소에 반환한다.
- 빈 저장소는 객체를 받아 빈으로 등록한다.
🙌 Advice, Pointcut 정의하는 방법
어드바이저를 생성하기 전, Advice와 Pointcut은 어떻게 정의할 수 있을까? 스프링은 Advice와 Pointcut을 정의할 수 있는 5가지 어노테이션을 제공해준다.
해당 어노테이션들은 포인트 컷에 지정된 대상 메서드에서 Advice가 실행되는 시점을 정의한다. 개발자는 위의 어노테이션을 필요에 맞춰 메소드에 추가해, advice의 로직을 정의할 수 있다.
포인트컷은 Advice 어노테이션의 속성값을 이용하여 지정하면 된다. 포인트컷 표현식은 꽤 방법이 많아서, 잘 정리된 블로그를 첨부한다.
@Transactional
AOP에 대해 찾아보면 보통 로깅, 실행시간 측정 같은 부가 기능이나, 세션 & 토큰에서 로그인 사용자의 정보를 받아오는 등의 유틸성 기능의 예시가 많다. 하지만 Spring 프레임워크를 사용하고 있다면, 뭔가 새로운 기능을 직접 추가하지 않았더라도 아마 높은 확률로 AOP가 적용된 기능을 활용하고 있을 것이다. 선언적 트랜잭션 어노테이션인 @Transactional이 바로 AOP의 대표적인 구현 사례이기 때문이다.
public void create(MemberCreateRequest req) throws SQLException {
Connection connection = dataSource.getConnection();
connection.setAutoCommit(false);
try {
Password password = Password.of(req.getPassword());
Member member = new Member(req.getName(), password);
memberRepository.save(connection, member);
galleryRepository.save(connection, member.getId());
connection.commit();
} catch (Exception e) {
connection.rollback();
throw new IllegalArgumentException("멤버 생성에 실패했습니다.");
}
}
위 예시와 같이 여러 연산이 하나의 작업으로 처리되어야 하는 경우, 이들 연산이 모두 성공하거나 모두 실패하는 Atomic한 성질을 가져야 데이터 일관성을 유지할 수 있다. 따라서 도메인별 비즈니스 로직을 구현해야 하는 어플리케이션단에선 데이터 일관성 유지를 위해 DB에 접근할 때 트랜잭션 처리를 해주는 과정이 필수적이다.
하지만 트랜잭션 처리를 해주는 것은.. 제법 귀찮다! 예시처럼 커넥션 따고~ 커밋 설정 해주고~ try-catch로 묶어서 실패하면 롤백 성공하면 커밋 처리 해주고~ 를 트랜잭션 처리가 필요한 모든 메서드에서 반복적으로 수행해주어야 한다. 또한, 복잡한 트랜잭션 처리가 필요하다던지, 비즈니스 로직 자체가 길고 어렵다던지 여러 상황이 엮이면 예시보다 훨씬 까다로워질 것이다.
여기서 주목할 점은, 트랜잭션 처리를 위한 과정은 비즈니스 로직인 '메인 기능'이 아니라, 로직을 구현하는데 필요한 '부가 기능'이라는 점! 따라서 우리는 트랜잭션도 AOP의 횡단 관심사 개념을 이용해 분리하면 편하겠다는 생각을 할 수 있다. 그리고 이미 우리보다 훨씬 똑똑한 천재만재 선배님들이 이걸 이미 구현해두셨다! 정리하자면, AOP로 트랜잭션의 시작, 커밋 (or rollback) 과정을 프록시 객체를 이용해 분리한 것이 바로 @Transactional 어노테이션 ✨
@Transactional 어노테이션이 메서드에 붙어있다면, 해당 메서드는 다음과 같은 순서로 동작한다.
- target 호출이 발생할 경우 AOP proxy가 intercept 한다.
- AOP proxy는 등록된 Advisor를 통해 트랜잭션의 시작, 커밋, 롤백 등의 트랜잭션 처리를 수행한다.
- 이후 필요하다면 Custom Advisor에서 트랜잭션 외 다른 부가 기능들을 처리한다.
- 모든 부가 기능들의 처리가 완료되면 target method를 호출하여 비즈니스 로직을 수행한다.
- 과정이 완료되면 interceptor chain을 따라 맨 처음의 호출자에게 결과를 전달한다.
이렇듯 @Transactional은 Spring AOP 중 하나이기에 아래의 특징점을 갖는다.
- private 메소드는 트랜잭션 처리를 할 수 없다
- 타겟 객체 or 인터페이스를 상속받아 프록시를 구현하는 특징 때문
- 트랜잭션은 객체 외부에서 처음 진입하는 메서드를 기준으로 동작한다
- 트랜잭션은 프록시로 동작하기에, 외부에서 호출하면 순수 메서드가 아닌 프록시가 대신 호출되기 때문
레퍼런스
- 관점 지향 프로그래밍 : https://ko.wikipedia.org/wiki/%EA%B4%80%EC%A0%90_%EC%A7%80%ED%96%A5_%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D
- 스프링 핵심 원리 고급편 : https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8#
- [Spring] AOP와 @Transactional의 동작 원리 : https://velog.io/@ann0905/AOP%EC%99%80-Transactional%EC%9D%98-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC
'Develop > Spring' 카테고리의 다른 글
[Spring] 캐시란? + Spring Boot 내장 캐시 사용하기 (0) | 2024.07.29 |
---|---|
[Spring Boot/JPA] @Transactional을 썼는데도 Dirty Checking에 실패한다면? (feat. clearAutomatically = true) (7) | 2023.09.30 |
[Spring Boot] 외부 API 호출 도구 OpenFeign : RestTemplate, WebClient와 비교까지 (0) | 2023.09.20 |
[JPA] 영속성 전이 (CASCADE)와 고아 객체에 대하여 (0) | 2023.09.16 |
[Spring Boot] Auto Increment PK Id를 노출하지 않으면서 API에 활용하는 방법 (4) | 2023.06.28 |