본문 바로가기

횡단 관심과 AOP

게시판 업무 규칙과 로깅·트랜잭션 같은 횡단 관심을 분리하고 프록시 AOP의 적용 범위를 판단합니다.

게시글 등록 메서드 안에 트랜잭션, 권한, 로그, 실행 시간 측정이 반복해서 들어가면, 정작 게시글을 저장한다는 업무 규칙을 찾기 어렵습니다. 이처럼 여러 기능을 가로질러 반복되는 부가 기능을 횡단 관심사(cross-cutting concern)라고 합니다.

AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 횡단 관심사를 애스펙트(aspect)라는 별도의 구성으로 모으고, 선택한 메서드 호출의 앞뒤에 적용하는 방법입니다. Spring AOP는 보통 대상 객체 대신 호출되는 프록시를 만들어 이 동작을 더합니다. 다만 AOP는 중복을 줄이는 도구일 뿐, 모든 부가 로직을 감춰 주는 마법이나 분산 트랜잭션 도구는 아닙니다.

이 장은 앞 장의 BeanPostProcessor까지 다룬 프록시와 포인트컷, 빈 후처리를 바탕으로 합니다. 호출자가 가진 참조와 실제 업무 객체를 구별해 두면 이 장의 내용을 따라가기 쉽습니다. 이 글의 ObservedOperation은 Spring 없이 Callable을 직접 감싸는 예제이고, 다음 글들에서 보는 @Aspect는 그 연결을 컨테이너가 고르게 하는 방식입니다. 콜백을 실행해 봤다고 해서 자동 프록시 구성까지 검증된 것은 아닙니다.

AOP · POLICY AND BUSINESS OWNERSHIP

프록시는 공통 정책을 감싸고 업무 결정은 대상에 남긴다

제목·본문·소유권과 요청 중복 처리는 use case의 책임입니다. 인가·관찰·트랜잭션 같은 횡단 정책은 공개 작업의 프록시 경계에 연결합니다.

공통 정책과 업무 대상의 호출 전달화살표는 명령을 받은 프록시가 공통 정책을 적용하고 업무 대상을 호출하는 전달이다. 업무 소유권·중복 처리는 대상의 계약이며 모든 해당 검사가 원 RegisterPost 발췌에 구현된 것은 아니다. 원격 원자성은 별도다.
명령 전달actor·요청 key
연결 관계명령 전달 · actor·요청 key → 프록시 정책 · 인가·관찰·TX
프록시 정책인가·관찰·TX
연결 관계프록시 정책 · 인가·관찰·TX → 업무 대상 · 검증·소유권·저장
업무 대상검증·소유권·저장
명령에서 프록시 정책을 거쳐 업무 대상으로 가는 경로명령을 전달한 클라이언트가 인가, 관찰, 트랜잭션의 프록시 체인을 거쳐 업무 대상을 호출한다. 업무와 아웃박스 삽입은 대상의 애플리케이션 동작이며 같은 로컬 트랜잭션에 저장한다.명령 전달actor · request key프록시 정책인가 · 관찰 · TX업무 대상검증 · 소유권 · 저장프록시 적용은 업무 분기나 원격 원자성을 대신하지 않는다
  1. 명령 → 프록시 정책 → 업무 대상

    공개 작업을 호출하면 프록시가 공통 정책을 적용합니다. 검증·소유권·중복 처리와 저장 결정은 use case에 남습니다.

로컬 원자성 · 애플리케이션 쓰기

business row와 outbox 삽입은 한 로컬 트랜잭션의 업무 동작입니다. relay와 멱등 소비자가 commit 이후 원격 전달을 처리합니다.

정책마다 다른 호출 계약

인가는 대상 0/1회, 관찰은 1회, 재시도는 조건부 여러 회입니다. 멱등성과 순서·실패 정책, advisor·대상·트랜잭션 수를 검증합니다.

원격 API와 로컬 DB의 원자성은 별도 설계가 필요합니다. 관찰 비용·카디널리티와 수집 대상 실패 정책도 경계의 책임입니다.

그림은 행위자와 요청 키를 담은 명령이 프록시를 거쳐 업무 대상으로 전달되는 경로입니다. 프록시는 인가, 관찰, 트랜잭션 같은 공통 정책을 적용하고, 제목·본문 검증과 소유권 확인, 저장 결정은 업무 대상에 남습니다. 아래 칸들은 업무 행과 아웃박스(outbox) 행의 삽입이 하나의 로컬 트랜잭션에 속하는 애플리케이션 동작이라는 점, 그리고 인가는 대상을 0회 또는 1회, 관찰은 1회, 재시도는 조건에 따라 여러 번 호출한다는 차이를 보여 줍니다. 원격 API와 로컬 DB의 원자성은 프록시가 대신해 주지 않는 별도의 설계 문제입니다.


핵심 기능

게시글 등록의 핵심은 회원 소유권 확인, 제목과 본문 규칙, 중복 요청 처리, 게시글 저장입니다. 이 규칙들은 로깅 백엔드를 바꾸거나 메트릭 이름을 바꾸는 것과는 다른 이유로 바뀝니다. 핵심 코드가 MeterRegistry, HTTP 요청, SQL 예외의 세부를 직접 알면, 서로 다른 이유로 바뀌는 코드가 다시 한곳에 묶입니다.

src/main/java/board/application/RegisterPost.java
package board.application;

import java.time.LocalDate;

public final class RegisterPost {
    private final PostStore posts;

    public RegisterPost(PostStore posts) {
        this.posts = posts;
    }

    public CreatedPost register(Command command) {
        if (command.content() == null || command.content().isBlank()
                || command.content().length() > 5_000) {
            throw new IllegalArgumentException("content is blank or too long");
        }
        if (command.title().isBlank()) {
            throw new IllegalArgumentException("title is blank");
        }
        return posts.create(command);
    }

    public record Command(
            long authorId,
            String title,
            String content,
            LocalDate createdOn,
            String requestKey
    ) { }

    public record CreatedPost(long id, long version) { }

    public interface PostStore {
        CreatedPost create(Command command);
    }
}

이 클래스는 트랜잭션이 필요하다는 인프라 정보를 애노테이션이나 구성으로 받을 수 있지만, 커밋과 롤백 코드를 직접 쓰지 않습니다. 업무 예외의 분류는 핵심 코드에 남고, 저장소 공급자의 예외를 바꾸는 일은 어댑터가 맡습니다.

위 발췌는 content와 title을 검사하고 PostStore를 호출하는 부분만 보여 줍니다. 명령에 authorId와 requestKey를 넣었다고 해서 실제 소유권 검사나 중복 요청 기록까지 구현된 것은 아닙니다. title은 null이 아니라는 전제로 읽으며, 외부 입력의 검증과 저장소의 규칙은 따로 연결해야 합니다.

또 이 클래스는 final이고 업무 포트 인터페이스를 구현하지 않으므로, 그대로는 클래스 프록시의 대상이 될 수 없습니다. Spring의 프록시 제약에 맞게 공개 포트 인터페이스를 두거나 상속할 수 있는 서비스를 정한 뒤 AOP를 적용합니다. 이 발췌를 자동 프록시 설정까지 갖춘 완성 예제로 오해하지 않습니다.


횡단 관심을 옮기기 전에 정할 것

관심사를 애스펙트로 옮기기 전에 먼저 다음을 묻습니다. 어떤 조인 포인트에 적용되는가, 대상을 몇 번 호출하는가, 대상의 실패를 바꾸는가, 다른 애스펙트와의 순서는 무엇인가, 어드바이스 자신이 실패하면 어떻게 하는가. 이 답이 없는 로깅 애스펙트는 운영 환경의 동작을 오히려 숨깁니다.

정책마다 대상과 맺는 관계가 다릅니다. 인가는 대상을 부르기 전에 호출을 막을 수 있고, 트랜잭션은 대상과 리포지토리 작업을 하나의 커밋 단위로 감쌉니다. 타이머는 성공과 실패를 모두 기록하되 예외를 그대로 다시 던집니다. 재시도는 대상을 여러 번 호출하므로 멱등성과 트랜잭션과의 순서를 반드시 정해야 합니다.

애스펙트로 분리해도 정책은 여전히 테스트 대상입니다. 코드가 짧아졌다는 이유로 적용 범위와 실패 동작을 문서에서 지우지 않습니다.


프록시 AOP의 적용 범위

Spring AOP에서 일반적인 조인 포인트(join point)는 프록시를 거친 메서드 실행입니다. 필드 읽기, 생성자 본문, 직접 new로 만든 객체, 같은 대상 안의 자기 호출까지 가로채는 AspectJ의 전체 위빙(weaving)과는 범위가 다릅니다. 컨테이너가 관리하지 않는 객체에는 어드바이저가 적용되지 않습니다.

src/main/java/board/aspect/ObservedOperation.java
package board.aspect;

import java.util.concurrent.Callable;

public final class ObservedOperation {
    public <T> T invoke(String name, Callable<T> target) throws Exception {
        long started = System.nanoTime();
        String outcome = "success";
        try {
            return target.call();
        } catch (Exception failure) {
            outcome = failure.getClass().getSimpleName();
            throw failure;
        } finally {
            long elapsed = System.nanoTime() - started;
            System.out.printf("operation=%s outcome=%s elapsedPositive=%s%n",
                    name, outcome, elapsed >= 0);
        }
    }

    public static void main(String[] args) throws Exception {
        var observed = new ObservedOperation();
        long result = observed.invoke("post.create", () -> 41L);
        System.out.println("id=" + result);
    }
}

main은 invoke를 한 번 호출하고, target.call()이 41을 반환합니다. 반환값이 main에 전달되기 전에 finally가 operation 줄을 출력하고, 그 뒤에 main이 id=41을 출력합니다. 대상을 다시 실행하는 코드가 없으므로 업무 호출은 한 번입니다.

ObservedOperation 실행
operation=post.create outcome=success elapsedPositive=true
id=41

이 콜백 래퍼를 어드바이저로 만들어 자동으로 적용하는 것이 Spring AOP의 다음 단계입니다. 기록하는 규칙은 같고, 프록시와 포인트컷이 래퍼를 연결하는 반복을 줄여 줍니다.


AOP로 풀 수 없는 문제

두 데이터베이스의 동시 커밋, 원격 API와 로컬 행의 원자성, 메시지의 정확히 한 번 처리는 메서드 래퍼로 해결되지 않습니다. 트랜잭션 애스펙트가 원격 호출까지 감싸도, 원격 시스템은 로컬 롤백에 참여하지 않습니다. 이런 문제에는 아웃박스, 사가(saga), 멱등 소비자(idempotent consumer)를 설계합니다.

업무 분기 자체를 포인트컷으로 숨기지도 않습니다. 프리미엄 회원이면 점수를 두 배로 준다는 규칙은 도메인 코드에서 읽혀야 합니다. 애스펙트는 여러 사용 사례를 같은 정책으로 가로지르는 관심사에 어울립니다.

호출 빈도가 높은 게터 전부에 상세 로그를 적용하면 비용과 정보량이 급격히 늘어납니다. 적용 범위를 공개 애플리케이션 작업처럼 의미 있는 진입점으로 제한하고, 표본 추출(sampling)과 카디널리티 예산을 둡니다.


분리의 효과 평가

트랜잭션 정책을 바꿀 때 서비스 30개를 고치지 않고 애스펙트나 애노테이션 구성만 바꿀 수 있어야 분리한 의미가 있습니다. 반대로 사용 사례 하나에만 특별한 규칙이 필요하다면, 메서드 수준의 메타데이터로 그 차이를 드러냅니다. 하나의 거대한 애스펙트 안에서 메서드 이름에 따른 분기를 늘리지 않습니다.

테스트와 메트릭으로는 애스펙트의 적용 수, 대상 호출 수, 실패 결과, 어드바이저 순서를 검증합니다. 애스펙트 자신이 낸 예외의 수는 따로 세어, 핵심 기능의 실패와 섞이지 않게 합니다.

분리한 뒤에도 디버거로 업무 흐름을 따라갈 수 있어야 합니다. 작업 하나에 걸린 어드바이저 목록과 실제 순서를 진단 명령으로 확인하고, 새 애스펙트가 대상 호출 횟수나 트랜잭션 수를 바꿨다면 배포 전에 테스트가 실패하게 둡니다. 코드에서 보이지 않는다는 AOP의 비용은 실행 가능한 구조 문서와 횟수 검증으로 메웁니다.


정리

  • 횡단 관심사는 여러 기능에 반복되는 부가 기능이며, AOP는 이를 애스펙트로 모아 선택한 메서드 호출 앞뒤에 적용합니다.
  • Spring AOP의 조인 포인트는 프록시를 거친 메서드 실행이므로, 자기 호출이나 컨테이너 밖에서 만든 객체에는 적용되지 않습니다.
  • 애스펙트로 옮기기 전에 적용 범위, 대상 호출 수, 실패 처리, 순서를 먼저 정합니다.
  • 분산 원자성과 업무 분기는 AOP로 풀지 않으며, 아웃박스나 도메인 코드로 드러냅니다.

연습 문제

1. 관심사 분류

게시글 등록 사용 사례에서 트랜잭션, 소유자 인가, 타이머, 재시도, 아웃박스를 분류하세요. 핵심에 남을 규칙과 애스펙트 후보, 어댑터가 맡을 일을 표로 만들고, 래퍼 순서를 정한 이유를 설명하세요.

해설 보기

소유자 ID와 명령의 불변식은 핵심에 남깁니다. 트랜잭션과 타이머는 애스펙트 후보이고, 재시도는 멱등성 키와 일시적 실패가 확인된 작업에만 적용합니다. 분류 결과를 다음과 같은 값으로 기록해 두면, 도메인 규칙을 애스펙트로 분류하는 실수를 생성 시점에 막을 수 있습니다.

package board.aspect;

public record ConcernDecision(
        String concern,
        Owner owner,
        boolean crossCutting
) {
    public enum Owner { DOMAIN, APPLICATION, ADAPTER, ASPECT }

    public ConcernDecision {
        if (concern.isBlank()) {
            throw new IllegalArgumentException("concern is blank");
        }
        if (crossCutting && owner == Owner.DOMAIN) {
            throw new IllegalArgumentException("domain rule is not an aspect");
        }
    }
}

아웃박스 행의 삽입은 트랜잭션 안에서 일어나는 애플리케이션 동작이고, 아웃박스를 읽어 외부로 보내는 중계는 어댑터입니다. 이를 로깅 애스펙트와 같은 범주로 숨기지 않습니다. 순서는 인가를 가장 바깥에 두어 거절된 요청이 트랜잭션을 열지 않게 하고, 재시도를 트랜잭션 바깥에 두어 시도마다 새 트랜잭션을 쓰게 하는 구성을 기본으로 생각할 수 있습니다.

2. 실패 경로의 기록

ObservedOperation에서 target.call()이 IllegalArgumentException 객체 E를 던지면 operation 줄의 outcome은 무엇이고, 호출자는 무엇을 받나요? 대상이 Error를 던지는 경우에는 무엇이 달라지는지도 설명하세요.

해설 보기

catch가 outcome을 IllegalArgumentException으로 바꾸고 같은 E를 다시 던집니다. finally는 이 실패 경로에서도 한 번 실행되므로 outcome=IllegalArgumentException이 출력되고, 호출자는 E를 그대로 받습니다.

catch가 받는 타입은 Exception이므로 Error는 잡지 않습니다. Error 경로에서도 finally는 실행되지만 outcome은 바뀌지 않아 success로 출력됩니다. 그러므로 이 발췌를 모든 Throwable의 결과를 정확히 분류하는 기록기로 쓰지는 않습니다.

3. 성공과 실패를 차례로

기록 출력은 실패하지 않는다고 가정합니다. invoke를 두 번 호출해 첫 호출은 41을 반환하고, 둘째 호출은 IllegalArgumentException 객체 E를 던집니다. 두 호출 전체의 대상 호출 수와 operation 출력 수를 구하고, 둘째 호출 뒤에 main이 id 줄을 출력할 수 있는지 판단하세요.

해설 보기

대상은 각 invoke에서 한 번씩이므로 총 2회이고, operation 줄도 finally에서 한 번씩 총 2회 출력됩니다. 둘째 invoke는 E를 그대로 던지므로, 그것을 처리하지 않는 main은 정상 반환값을 받아 id 줄을 이어서 출력할 수 없습니다. 기록 줄을 남겼다고 해서 업무 실패가 성공 반환으로 바뀌지는 않습니다.

다음 글 AOP 핵심 용어에서는 조인 포인트, 포인트컷, 어드바이스, 애스펙트, 위빙 같은 용어를 앞 장과 이 글의 예제에 하나씩 대응시켜 정리합니다.