횡단 관심과 AOP
게시판 업무 규칙과 로깅·트랜잭션 같은 횡단 관심을 분리하고 프록시 AOP의 적용 범위를 판단합니다.
게시글 등록 메서드 안에 트랜잭션, 권한, 로그, 실행 시간 측정이 반복해서 들어가면 정작 “게시글을 저장한다”는 업무 규칙을 찾기 어렵습니다.
여러 기능을 가로질러 반복되는 이런 부가 책임을 횡단 관심사라고 합니다.
AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 횡단 관심사를 애스펙트라는 별도 구성으로 모으고, 선택한 메서드 호출 앞뒤에 적용하는 방법입니다.
Spring AOP는 보통 대상 객체 대신 호출되는 프록시를 만들어 이 동작을 추가합니다.
AOP는 중복을 줄이는 도구이지 모든 부가 로직을 숨기는 마법이나 분산 트랜잭션 도구는 아닙니다.
먼저 앞 장의 프록시·포인트컷·빈 후처리를 읽고, 호출자가 가진 참조와 실제 업무 객체를 구별하세요. 이 절의 ObservedOperation은 Spring 없이 Callable을 직접 감싸는 예제입니다. 뒤 절의 @Aspect는 그 연결을 컨테이너가 선택하게 하는 방식이며, 콜백 실행만으로 자동 프록시 구성이 검증되지는 않습니다.
AOP · POLICY AND BUSINESS OWNERSHIP
프록시는 공통 정책을 감싸고 업무 결정은 대상에 남긴다
제목·본문·소유권과 요청 중복 처리는 use case의 책임입니다. 인가·관찰·트랜잭션 같은 횡단 정책은 공개 작업의 프록시 경계에 연결합니다.
- 명령 → 프록시 정책 → 업무 대상
공개 작업을 호출하면 프록시가 공통 정책을 적용합니다. 검증·소유권·중복 처리와 저장 결정은 use case에 남습니다.
로컬 원자성 · 애플리케이션 쓰기
business row와 outbox 삽입은 한 로컬 트랜잭션의 업무 동작입니다. relay와 멱등 소비자가 commit 이후 원격 전달을 처리합니다.
정책마다 다른 호출 계약
인가는 대상 0/1회, 관찰은 1회, 재시도는 조건부 여러 회입니다. 멱등성과 순서·실패 정책, advisor·대상·트랜잭션 수를 검증합니다.
원격 API와 로컬 DB의 원자성은 별도 설계가 필요합니다. 관찰 비용·카디널리티와 수집 대상 실패 정책도 경계의 책임입니다.
핵심 기능
게시글 등록의 핵심은 회원 소유권 확인, 제목과 본문 규칙, 중복 요청 처리, 게시글 저장입니다.
로깅 백엔드 교체나 메트릭 이름 변경과 다른 이유로 바뀝니다.
핵심 코드가 MeterRegistry, HTTP 요청, SQL 예외 세부를 직접 알면 변경 축이 다시 결합됩니다.
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);
}
}이 클래스는 트랜잭션이 필요하다는 인프라 사실을 애노테이션이나 구성에서 받을 수 있지만, 커밋·롤백 코드를 직접 쓰지 않습니다.
업무 예외 분류 체계는 핵심 계약에 남고 공급자 예외 변환은 어댑터가 맡습니다.
위 RegisterPost 발췌는 content·title 검사와 PostStore 호출을 보여 줍니다. authorId·requestKey를 명령에 넣었다고 실제 소유권 검사나 중복 기록까지 구현한 것은 아닙니다. title은 null이 아닌 입력이라는 전제로 읽고, 신뢰 경계의 입력 검증과 저장소 계약을 따로 연결해야 합니다. 또한 이 클래스는 final이고 업무 포트 인터페이스를 구현하지 않습니다. 그대로 클래스 프록시 대상으로 사용할 수는 없으므로, Spring의 프록시 제약에 맞게 공개 포트를 가진 경계나 상속 가능한 별도 서비스를 정한 뒤 AOP를 적용합니다. 원 발췌를 자동 프록시 설정까지 갖춘 완성 예제로 오해하지 마세요.
횡단 관심의 정책 책임
관심을 애스펙트로 옮기기 전에 질문합니다.
어떤 조인 지점에 적용되는가, 대상을 몇 번 호출하는가, 대상 실패를 바꾸는가, 순서는 무엇인가, 어드바이스 자체 실패를 어떻게 처리하는가.
이 답이 없는 로깅 애스펙트는 운영 환경 동작을 숨깁니다.
인가는 대상 전에 호출을 차단할 수 있고 트랜잭션은 대상과 리포지토리 작업을 커밋 단위로 감쌉니다.
타이머는 성공과 실패 모두 기록하되 예외를 그대로 재전파합니다.
재시도는 대상을 여러 번 호출하므로 멱등성과 트랜잭션 순서가 필수입니다.
애스펙트로 분리해도 정책은 테스트 대상입니다.
코드가 짧아졌다는 이유로 적용 범위와 실패 동작을 문서에서 지우지 않습니다.
프록시 AOP 적용 경계
Spring AOP의 일반 조인 지점은 프록시를 통한 메서드 실행입니다.
필드 읽기, 생성자 본문, 임의로 생성한 객체, 같은 대상 내부의 자기 호출까지 모두 가로채는 전체 AspectJ 위빙과 범위가 다릅니다.
프레임워크가 관리하지 않은 객체에는 어드바이저가 적용되지 않습니다.
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);
}
}operation=post.create outcome=success elapsedPositive=true
id=41이 콜백 예제를 어드바이저로 자동 적용하는 것이 Spring AOP의 다음 단계입니다.
관찰 규칙은 같고 프록시와 포인트컷이 연결 반복을 줄입니다.
예시 원 main은 invoke를 한 번 호출하고 target.call이 41L을 반환합니다. 반환값을 꺼내기 전에 finally가 operation 행을 출력하고, 그 뒤 main이 id=41을 출력합니다. 대상을 다시 실행하는 코드가 없으므로 업무 호출은 한 번입니다.
같은 래퍼에서 target.call이 IllegalArgumentException 객체 E를 던지면 catch가 outcome을 바꾸고 동일한 E를 던집니다. finally는 그 실패 경로에서도 한 번 실행됩니다. 다만 원 catch는 Exception이므로 Error를 분류하는 코드는 없습니다. Error 경로에서도 finally는 실행될 수 있지만 outcome은 success로 남으므로 이 발췌를 모든 Throwable의 정확한 결과 분류기로 사용하지는 않습니다.
독립 확인: 기록 출력은 실패하지 않는다고 가정합니다. 첫 호출은 41L을 반환하고 둘째 호출은 IllegalArgumentException 객체 E를 던집니다. 두 invoke 전체의 대상 호출 수·operation 출력 수를 구하고, 둘째 호출 뒤 main이 id 행을 출력할 수 있는지 판단하세요.
풀이 확인
대상은 각 invoke에서 한 번씩이므로 총 2회이고 operation 행도 finally에서 각각 한 번, 총 2회입니다. 둘째 invoke는 E를 그대로 던지므로 그것을 처리하지 않은 main은 정상 반환값으로 id 행을 이어 출력할 수 없습니다. 관찰 행을 남겼다는 사실이 업무 실패를 성공 반환으로 바꾸지는 않습니다.
AOP 부적합 문제
두 데이터베이스 커밋, 원격 API와 로컬 행의 원자성, 메시지 정확히 한 번 처리는 메서드 래퍼로 해결되지 않습니다.
트랜잭션 애스펙트가 원격 호출까지 감싸도 원격 시스템은 로컬 롤백에 참여하지 않습니다.
아웃박스, 사가, 멱등 소비자를 설계합니다.
업무 분기 자체를 포인트컷으로 숨기지 않습니다.
“프리미엄 회원이면 점수를 두 배” 같은 규칙은 도메인 코드에서 읽혀야 합니다.
애스펙트는 여러 사용 사례를 같은 정책으로 가로지르는 관심사에 적합합니다.
호출 빈도가 높은 게터 모두에 상세 로그를 적용하면 비용과 정보량이 폭증합니다.
적용 범위를 공개 애플리케이션 작업처럼 의미 있는 경계로 제한하고 표본 추출과 카디널리티 예산을 둡니다.
분리 효과 평가
트랜잭션 정책을 바꿀 때 서비스 30개를 수정하지 않고 애스펙트 또는 애노테이션 구성을 바꿀 수 있어야 합니다.
반대로 한 사용 사례만 특별 규칙이 필요하면 메서드 수준 메타데이터로 차이를 드러냅니다.
하나의 거대한 애스펙트에서 메서드 이름 분기를 늘리지 않습니다.
메트릭은 애스펙트 적용 수, 대상 호출 수, 실패 결과, 어드바이저 순서를 검증합니다.
애스펙트 자체 예외 개수를 분리해 핵심 실패와 혼동하지 않습니다.
분리 이후에도 디버거에서 업무 흐름을 따라갈 수 있어야 합니다.
작업 하나의 어드바이저 목록과 실제 순서를 진단 명령으로 확인하고, 신규 애스펙트가 대상 호출 횟수나 트랜잭션 수를 바꿨다면 배포 전 계약 테스트가 실패하게 둡니다.
“코드에서 보이지 않는다”는 AOP의 비용을 실행 가능한 구조 문서와 개수 검증으로 상쇄합니다.
연습 문제
게시글 등록 사용 사례에서 트랜잭션, 책임 주체 인가, 타이머, 재시도, 아웃박스를 분류하세요.
핵심에 남을 규칙과 애스펙트 후보, 어댑터 책임을 표로 만들고 래퍼 순서를 정당화하세요.
해설 보기
책임 주체 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");
}
}
}아웃박스 삽입은 트랜잭션 안의 애플리케이션 동작이고 중계는 어댑터입니다.
이를 로깅 애스펙트와 같은 범주로 숨기지 않습니다.