횡단 관심과 AOP
게시판 업무 규칙과 로깅·트랜잭션 같은 횡단 관심을 분리하고 프록시 AOP의 적용 범위를 판단합니다.
게시글 등록 메서드 안에 트랜잭션, 권한, 로그, 실행 시간 측정이 반복해서 들어가면 정작 “게시글을 저장한다”는 업무 규칙을 찾기 어렵습니다.
여러 기능을 가로질러 반복되는 이런 부가 책임을 횡단 관심사라고 합니다.
AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 횡단 관심사를 애스펙트라는 별도 구성으로 모으고, 선택한 메서드 호출 앞뒤에 적용하는 방법입니다.
Spring AOP는 보통 대상 객체를 대신 받는 프록시를 만들어 이 동작을 추가합니다.
AOP는 중복을 줄이는 도구이지 모든 부가 로직을 숨기는 마법이나 분산 트랜잭션 도구는 아닙니다.
핵심 기능
게시글 등록의 핵심은 회원 소유권 확인, 제목과 본문 규칙, 중복 요청 처리, 게시글 저장입니다.
로깅 백엔드 교체나 메트릭 이름 변경과 다른 이유로 바뀝니다.
핵심 코드가 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);
}
}이 클래스는 트랜잭션이 필요하다는 인프라 사실을 애노테이션이나 구성에서 받을 수 있지만, 커밋·롤백 코드를 직접 쓰지 않습니다.
업무 예외 분류 체계는 핵심 계약에 남고 공급자 예외 변환은 어댑터가 맡습니다.
횡단 관심의 정책 책임
관심을 애스펙트로 옮기기 전에 질문합니다.
어떤 조인 지점에 적용되는가, 대상을 몇 번 호출하는가, 대상 실패를 바꾸는가, 순서는 무엇인가, 어드바이스 자체 실패를 어떻게 처리하는가.
이 답이 없는 로깅 애스펙트는 운영 환경 동작을 숨깁니다.
인가는 대상 전에 호출을 차단할 수 있고 트랜잭션은 대상과 리포지토리 작업을 커밋 단위으로 감쌉니다.
타이머는 성공과 실패 모두 기록하되 예외를 그대로 재전파합니다.
재시도는 대상을 여러 번 호출하므로 멱등성과 트랜잭션 순서가 필수입니다.
애스펙트로 분리해도 정책은 테스트 대상입니다.
코드가 짧아졌다는 이유로 적용 범위와 실패 동작을 문서에서 지우지 않습니다.
프록시 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의 다음 단계입니다.
관찰 규칙은 같고 프록시와 포인트컷이 연결 반복을 줄입니다.
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");
}
}
}아웃박스 삽입은 트랜잭션 안의 애플리케이션 동작이고 중계는 어댑터입니다.
이를 로깅 애스펙트와 같은 범주로 숨기지 않습니다.