Spring_04) Aspect-Oriented Programming
2025. 10. 28. 21:42

Spring Boot AOP를 활용한 프로젝트 개선

학습 목표

  • Filter와 Interceptor의 한계점을 이해한다
  • AOP의 개념과 작동 원리를 학습한다
  • 기존 코드를 AOP로 리팩토링하는 방법을 익힌다
  • 커스텀 어노테이션을 활용한 선언적 프로그래밍을 구현한다

<기존 프로젝트 구조 분석>

- 현재 프로젝트의 구성

Spring Boot 프로젝트는 일반적으로 컨트롤러, 서비스, 필터, 인터셉터로 구성된다. 현재 프로젝트는 다음과 같은 패키지 구조를 가진다.

src/main/java/com/kh/spring/
├── controller/
│   ├── MemberController.java
│   ├── BoardController.java
│   └── HomeController.java
├── service/
│   ├── BoardServiceImpl.java
│   └── MemberServiceImpl.java
├── filter/
│   └── RequestTimeFilter.java
├── interceptor/
│   └── LoginCheckInterceptor.java
└── config/
    ├── WebConfig.java
    └── FilterConfig.java

컨트롤러는 HTTP 요청을 처리하고, 서비스는 비즈니스 로직을 담당한다. 필터는 서블릿 레벨에서 요청을 가로채고, 인터셉터는 스프링 컨텍스트 내에서 컨트롤러 호출 전후로 동작한다.


<기존 코드의 문제점>

- RequestTimeFilter의 중복 로직

필터를 사용한 요청 시간 측정 코드는 다음과 같다.

@Slf4j
@Component
public class RequestTimeFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        long startTime = System.currentTimeMillis();

        String method = request.getMethod();
        String url = request.getRequestURI();
        String queryString = request.getQueryString();
        String fullUrl = request.getRequestURL() + (queryString != null ? "?" + queryString : "");

        try{
            filterChain.doFilter(servletRequest, servletResponse);
        }finally {
            long endTime = System.currentTimeMillis();
            long duration = endTime - startTime;

            int status = response.getStatus();
            log.info("[{}] {} - Status: {} - duration: {}ms", method, fullUrl, status, duration);
        }
    }
}

이 코드는 모든 HTTP 요청에 대해 동일한 로깅 로직을 반복한다. 필터는 DispatcherServlet 호출 전에 실행되므로 컨트롤러 메서드 레벨의 세밀한 제어가 불가능하다. 특정 메서드에만 로깅을 적용하거나 제외하려면 복잡한 URL 패턴 매칭이 필요하다.

- LoginCheckInterceptor의 인증 로직

인터셉터를 활용한 로그인 검사 코드는 다음과 같다.

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession(false);
        Member loginMember = (Member) session.getAttribute("loginMember");

        if (session == null || session.getAttribute("loginMember") == null) {
            log.warn("미인증 사용자 요청 : {}", request.getRequestURI());
            response.sendRedirect(request.getContextPath());
        }

        log.info("인증된 사용자 요청 - URL : {}", request.getRequestURI());
        return true;
    }
}

인터셉터는 필터보다 세밀한 제어가 가능하지만 여전히 URL 패턴 기반으로 동작한다. 특정 메서드에만 인증을 적용하기 어렵고, 권한별 세분화된 접근 제어가 불가능하다. 인터셉터 설정에서 제외할 URL을 일일이 나열해야 하는 번거로움이 있다.

- 컨트롤러의 로깅 불일치

회원 컨트롤러의 로그인 처리 코드는 다음과 같다.

@PostMapping("login.me")
public ModelAndView login(String memberId, String memberPwd, HttpSession httpSession, ModelAndView mv) {
    Member loginMember = memberService.getMemberById(memberId);
    System.out.println(loginMember);

    if(loginMember==null){
        mv.addObject("errorMSg", "아이디를 찾을 수 없습니다.");
        mv.setViewName("common/error");
    } else if(!bCryptPasswordEncoder.matches(memberPwd, loginMember.getMemberPwd())){
        mv.addObject("errorMSg", "비밀번호를 확인해주세요.");
        mv.setViewName("common/error");
    } else{
        httpSession.setAttribute("loginMember", loginMember);
        mv.setViewName("redirect:/");
    }
    return mv;
}

로깅이 System.out.println()으로 처리되어 로그 레벨 제어가 불가능하다. 프로덕션 환경에서는 콘솔 출력이 파일로 저장되지 않아 추적이 어렵다. 예외 처리 로직이 각 메서드마다 다르게 작성되어 일관성이 부족하고, 성능 측정 코드가 없어 병목 지점을 파악하기 어렵다.

- 트랜잭션 관리의 가시성 부족

서비스 레이어의 트랜잭션 처리 코드는 다음과 같다.

@Override
@Transactional
public int insertBoard(Board b, Attachment at) {
    int result = boardMapper.insertBoard(b);

    if(at != null) {
        result *= boardMapper.insertAttachment(at);
    }

    return result;
}

@Transactional 어노테이션만으로는 트랜잭션 처리 과정을 모니터링하기 어렵다. 트랜잭션이 시작되고 커밋되는 시점을 로그로 확인할 수 없으며, 롤백 발생 시 원인을 파악하기 위해 디버깅이 필요하다.


<AOP의 개념>

- AOP의 정의

AOP(Aspect-Oriented Programming)는 관점 지향 프로그래밍으로, 횡단 관심사(Cross-cutting Concerns)를 모듈화하는 프로그래밍 패러다임이다. 횡단 관심사란 로깅, 보안, 트랜잭션 관리처럼 여러 모듈에 걸쳐 반복적으로 나타나는 공통 기능을 의미한다.

AOP는 이러한 공통 기능을 핵심 비즈니스 로직에서 분리하여 별도의 모듈로 관리한다. 이를 통해 코드 중복을 제거하고 관심사의 분리를 달성한다.

- AOP의 핵심 용어

AOP를 이해하기 위해서는 몇 가지 핵심 용어를 알아야 한다.

Aspect는 횡단 관심사를 모듈화한 클래스이다. 로깅 Aspect, 보안 Aspect처럼 특정 관심사를 담당한다. Join Point는 Aspect가 적용될 수 있는 지점으로, 메서드 호출, 객체 생성, 필드 접근 등이 해당한다. Spring AOP에서는 메서드 실행 시점만 지원한다.

Advice는 Join Point에서 실행될 코드로, @Before, @After, @Around 등의 어노테이션으로 지정한다. Pointcut은 Advice가 적용될 Join Point를 선택하는 표현식이다. execution(* com.kh.spring.service.*.*(..))처럼 패키지와 메서드를 지정한다.

- AOP의 동작 원리

Spring AOP는 프록시 패턴을 사용하여 동작한다. 스프링은 빈 생성 시 AOP가 적용된 메서드를 가진 클래스에 대해 프록시 객체를 생성한다. 클라이언트가 메서드를 호출하면 실제 객체가 아닌 프록시 객체의 메서드가 먼저 호출된다.

프록시 객체는 Advice에 정의된 부가 기능을 실행한 후 실제 객체의 메서드를 호출한다. 메서드 실행이 완료되면 다시 프록시로 제어가 돌아와 후처리 Advice를 실행한다. 이러한 방식으로 비즈니스 로직을 수정하지 않고 부가 기능을 추가할 수 있다.


<AOP로 로깅 개선>

- 로깅 Aspect 구현

기존 RequestTimeFilter를 AOP로 대체한 로깅 Aspect는 다음과 같다.

@Slf4j
@Aspect
@Component
public class LoggingAspect {

    @Before("execution(* com.kh.spring.service.*.*(..))")
    public void logBefore(JoinPoint joinPoint) {
        String className = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();

        log.info("[AOP-LOG] {}.{} 시작 - 파라미터: {}", className, methodName, args);
    }

    @Around("execution(* com.kh.spring.service.*.*(..))")
    public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();

        String className = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();

        try {
            Object result = joinPoint.proceed();

            long endTime = System.currentTimeMillis();
            long duration = endTime - startTime;

            log.info("[AOP-PERFORMANCE] {}.{} 실행시간: {}ms", className, methodName, duration);

            return result;
        } catch (Exception e) {
            long endTime = System.currentTimeMillis();
            long duration = endTime - startTime;

            log.error("[AOP-PERFORMANCE] {}.{} 실행시간: {}ms (예외 발생)", className, methodName, duration);
            throw e;
        }
    }

    @AfterThrowing(pointcut = "execution(* com.kh.spring.service.*.*(..))", throwing = "exception")
    public void logException(JoinPoint joinPoint, Exception exception) {
        String className = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();

        log.error("[AOP-ERROR] {}.{}에서 예외 발생: {}", className, methodName, exception.getMessage());
    }
}

- 로깅 Aspect의 동작 방식

@Aspect 어노테이션은 해당 클래스가 Aspect임을 나타낸다. @Component를 함께 사용하여 스프링 빈으로 등록한다.

@Before Advice는 메서드 실행 전에 동작한다. JoinPoint 객체를 통해 대상 메서드의 정보를 조회할 수 있다. 클래스명, 메서드명, 파라미터를 추출하여 로그로 남긴다.

@Around Advice는 메서드 실행 전후를 모두 제어한다. ProceedingJoinPoint.proceed()로 실제 메서드를 호출하며, 이 전후에 부가 기능을 수행한다. 실행 시간을 측정하기 위해 시작 시간과 종료 시간을 기록한다. 예외 발생 시에도 실행 시간을 기록한 후 예외를 다시 던진다.

@AfterThrowing Advice는 메서드 실행 중 예외가 발생했을 때 동작한다. throwing 속성으로 예외 객체를 받아 로그에 기록한다.


<AOP로 보안 개선>

- 보안 Aspect 구현

기존 LoginCheckInterceptor를 AOP로 대체한 보안 Aspect는 다음과 같다.

@Slf4j
@Aspect
@Component
public class SecurityAspect {

    @Before("@annotation(com.kh.spring.annotation.RequireLogin)")
    public void checkLogin(JoinPoint joinPoint) {
        HttpServletRequest request = getCurrentRequest();
        HttpSession session = request.getSession(false);

        if (session == null || session.getAttribute("loginMember") == null) {
            String methodName = joinPoint.getSignature().getName();
            String className = joinPoint.getTarget().getClass().getSimpleName();

            log.warn("[AOP-SECURITY] 미인증 사용자 요청 - {}.{}", className, methodName);
            throw new SecurityException("로그인이 필요합니다.");
        }

        Member loginMember = (Member) session.getAttribute("loginMember");
        log.info("[AOP-SECURITY] 인증된 사용자 요청 - 사용자: {}, 메서드: {}", 
                loginMember.getMemberId(), joinPoint.getSignature().getName());
    }

    private HttpServletRequest getCurrentRequest() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            throw new IllegalStateException("HTTP 요청 컨텍스트를 찾을 수 없습니다.");
        }
        return attributes.getRequest();
    }
}

- 어노테이션 기반 보안 제어

Pointcut 표현식에서 @annotation을 사용하면 특정 어노테이션이 붙은 메서드에만 Aspect를 적용할 수 있다. URL 패턴이 아닌 메서드 단위로 보안을 제어하므로 훨씬 세밀하고 명시적이다.

RequestContextHolder를 사용하여 현재 HTTP 요청 객체를 조회한다. 이는 스프링이 Thread Local에 저장한 요청 정보를 가져오는 방식이다. 세션에서 로그인 정보를 확인하고, 없으면 SecurityException을 던져 인증을 거부한다.

로그인이 확인되면 사용자 정보와 메서드 정보를 로그로 남긴다. 이를 통해 어떤 사용자가 어떤 기능을 사용했는지 추적할 수 있다.

- 커스텀 어노테이션 정의

보안 제어를 위한 커스텀 어노테이션은 다음과 같이 정의한다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireLogin {
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireAdmin {
}

@Target(ElementType.METHOD)는 이 어노테이션을 메서드에만 사용할 수 있음을 의미한다. @Retention(RetentionPolicy.RUNTIME)은 런타임에도 어노테이션 정보가 유지되어 리플렉션으로 접근 가능함을 의미한다.

@RequireLogin은 일반 사용자 인증에, @RequireAdmin은 관리자 권한 검사에 사용한다. 각 어노테이션에 대응하는 Aspect를 별도로 만들어 권한별 세분화된 제어가 가능하다.


<AOP로 트랜잭션 모니터링>

- 트랜잭션 Aspect 구현

트랜잭션 처리 과정을 가시화하는 Aspect는 다음과 같다.

@Slf4j
@Aspect
@Component
public class TransactionAspect {

    @Around("@annotation(org.springframework.transaction.annotation.Transactional)")
    public Object handleTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        String className = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();

        log.info("[AOP-TRANSACTION] 트랜잭션 시작 - {}.{}", className, methodName);

        try {
            Object result = joinPoint.proceed();

            log.info("[AOP-TRANSACTION] 트랜잭션 커밋 - {}.{}", className, methodName);
            return result;

        } catch (Exception e) {
            log.error("[AOP-TRANSACTION] 트랜잭션 롤백 - {}.{}, 예외: {}", 
                     className, methodName, e.getMessage());
            throw e;
        }
    }
}

- 트랜잭션 처리 과정의 가시화

@Transactional 어노테이션이 붙은 메서드에만 Aspect가 적용된다. 메서드 실행 전 트랜잭션 시작을 로그로 남긴다. 스프링의 트랜잭션 관리자가 실제 트랜잭션을 시작하기 전에 이 로그가 출력된다.

메서드가 정상적으로 완료되면 트랜잭션 커밋을 로그로 남긴다. 예외가 발생하면 롤백 사실과 예외 메시지를 로그에 기록한다. 이를 통해 어떤 메서드에서 트랜잭션이 롤백되었는지 즉시 파악할 수 있다.

실제 트랜잭션 관리는 스프링의 @Transactional이 담당하고, 이 Aspect는 모니터링만 수행한다. 트랜잭션의 전파 레벨이나 격리 수준은 @Transactional의 속성으로 제어한다.


<개선된 컨트롤러 구현>

- AOP가 적용된 컨트롤러

AOP를 활용하여 깔끔해진 컨트롤러 코드는 다음과 같다.

@Slf4j
@Controller
public class MemberControllerAOP {

    private final MemberService memberService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public MemberControllerAOP(MemberService memberService, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.memberService = memberService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    @PostMapping("login.me")
    public ModelAndView login(String memberId, String memberPwd, HttpSession httpSession, ModelAndView mv) {
        Member loginMember = memberService.getMemberById(memberId);

        if(loginMember==null){
            mv.addObject("errorMSg", "아이디를 찾을 수 없습니다.");
            mv.setViewName("common/error");
        } else if(!bCryptPasswordEncoder.matches(memberPwd, loginMember.getMemberPwd())){
            mv.addObject("errorMSg", "비밀번호를 확인해주세요.");
            mv.setViewName("common/error");
        } else{
            httpSession.setAttribute("loginMember", loginMember);
            mv.setViewName("redirect:/");
        }
        return mv;
    }

    @RequireLogin
    @GetMapping("myPage.me")
    public String myPage(HttpSession httpSession, Model model){
        Member loginMember = (Member) httpSession.getAttribute("loginMember");
        model.addAttribute("loginMember", loginMember);
        return "member/myPage";
    }

    @RequireLogin
    @PostMapping("update.me")
    public String updateMember(Member member, HttpSession httpSession, Model model){
        int result = memberService.updateMember(member);
        if(result > 0){
            httpSession.setAttribute("alertMsg", "회원정보 수정에 성공하였습니다");
            httpSession.setAttribute("loginMember", member);
            return "redirect:/myPage.me";
        } else{
            model.addAttribute("errorMsg", "회원정보 수정에 실패하였습니다");
            return "common/error";
        }
    }
}

- 비즈니스 로직의 순수성

로그인 메서드에서는 더 이상 로깅 코드가 보이지 않는다. 서비스 메서드 호출 시 LoggingAspect가 자동으로 로그를 남긴다. 개발자는 비즈니스 로직에만 집중할 수 있다.

마이페이지와 회원정보 수정 메서드에는 @RequireLogin 어노테이션만 추가하면 된다. 인증 로직은 SecurityAspect가 처리하므로 컨트롤러에서는 인증 여부를 확인할 필요가 없다. 메서드 선언부만 보고도 이 기능이 로그인을 요구함을 알 수 있다.


<AOP 설정>

- AOP 활성화

AOP 기능을 사용하려면 설정 클래스에 다음과 같이 어노테이션을 추가한다.

@Configuration
@EnableAspectJAutoProxy
public class AOPConfig {
}

@EnableAspectJAutoProxy는 스프링에게 AOP 프록시를 자동으로 생성하라고 지시한다. 이 어노테이션이 없으면 @Aspect가 붙은 클래스가 있어도 동작하지 않는다.

스프링 부트를 사용하는 경우 spring-boot-starter-aop 의존성을 추가하면 자동 설정이 활성화되어 별도 설정 없이 AOP를 사용할 수 있다.


<AOP 적용 전후 비교>

- 설정 코드의 간소화

기존 방식에서는 인터셉터와 필터를 각각 설정해야 했다.

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LoginCheckInterceptor())
            .addPathPatterns("/**")
            .excludePathPatterns("/", "/login.me", "/enrollForm.me");
}

@Bean
public FilterRegistrationBean<RequestTimeFilter> filterRegistrationBean(RequestTimeFilter filter){
    FilterRegistrationBean<RequestTimeFilter> registration = new FilterRegistrationBean<>();
    registration.setFilter(filter);
    registration.addUrlPatterns("/*");
    registration.setOrder(1);
    return registration;
}

제외할 URL을 일일이 나열해야 하고, 필터 순서를 직접 지정해야 한다. 새로운 경로가 추가될 때마다 설정을 수정해야 한다.

AOP 방식에서는 설정이 훨씬 간단하다.

@Configuration
@EnableAspectJAutoProxy
public class AOPConfig {
}

URL 패턴이 아닌 어노테이션으로 제어하므로 설정 파일을 수정할 필요가 없다. 메서드에 어노테이션만 추가하면 즉시 적용된다.

- 코드 가독성의 향상

기존 컨트롤러에서는 로깅이 비즈니스 로직과 섞여 있었다.

@PostMapping("login.me")
public ModelAndView login(String memberId, String memberPwd, HttpSession httpSession, ModelAndView mv) {
    Member loginMember = memberService.getMemberById(memberId);
    System.out.println(loginMember);
    // 비즈니스 로직...
}

AOP를 적용한 컨트롤러는 비즈니스 로직만 남는다.

@RequireLogin
@PostMapping("update.me")
public String updateMember(Member member, HttpSession httpSession, Model model){
    int result = memberService.updateMember(member);
    if(result > 0){
        httpSession.setAttribute("alertMsg", "회원정보 수정에 성공하였습니다");
        httpSession.setAttribute("loginMember", member);
        return "redirect:/myPage.me";
    } else{
        model.addAttribute("errorMsg", "회원정보 수정에 실패하였습니다");
        return "common/error";
    }
}

로깅, 인증, 성능 측정 등 모든 부가 기능은 AOP가 자동으로 처리한다. 코드를 읽는 사람은 핵심 로직에만 집중할 수 있다.


<실행 결과 분석>

- AOP 로그 출력 예시

AOP가 적용된 애플리케이션의 실행 로그는 다음과 같다.

[AOP-LOG] MemberServiceImpl.getMemberById 시작 - 파라미터: [user01]
[AOP-SECURITY] 인증된 사용자 요청 - 사용자: user01, 메서드: updateMember
[AOP-TRANSACTION] 트랜잭션 시작 - MemberServiceImpl.updateMember
[AOP-PERFORMANCE] MemberServiceImpl.updateMember 실행시간: 45ms
[AOP-TRANSACTION] 트랜잭션 커밋 - MemberServiceImpl.updateMember
[AOP-LOG] MemberServiceImpl.updateMember 완료 - 반환값: 1

로그를 통해 메서드 호출 순서를 명확히 파악할 수 있다. 먼저 회원 조회가 실행되고, 보안 검사를 통과한 후 트랜잭션이 시작된다. 메서드 실행 시간이 45밀리초였고 트랜잭션이 성공적으로 커밋되었음을 확인할 수 있다.

예외가 발생했을 때는 다음과 같은 로그가 출력된다.

[AOP-LOG] BoardServiceImpl.insertBoard 시작 - 파라미터: [Board@a1b2c3]
[AOP-TRANSACTION] 트랜잭션 시작 - BoardServiceImpl.insertBoard
[AOP-ERROR] BoardServiceImpl.insertBoard에서 예외 발생: Duplicate entry
[AOP-PERFORMANCE] BoardServiceImpl.insertBoard 실행시간: 23ms (예외 발생)
[AOP-TRANSACTION] 트랜잭션 롤백 - BoardServiceImpl.insertBoard, 예외: Duplicate entry

예외 발생 시점과 원인, 실행 시간, 롤백 여부를 모두 확인할 수 있다.


<AOP의 실무 이점>

- 코드 중복 제거

기존 방식에서는 필터, 인터셉터, 각 메서드마다 유사한 로깅 코드가 반복되었다. 로깅 방식을 변경하려면 모든 파일을 수정해야 했다. AOP를 사용하면 로깅 로직을 한 곳에 집중하여 관리한다. 로깅 포맷을 변경할 때 LoggingAspect만 수정하면 된다.

- 관심사의 분리

기존 방식에서는 비즈니스 로직과 부가 기능이 하나의 메서드에 섞여 있었다. 코드를 이해하려면 핵심 로직과 부가 로직을 구분해야 했다. AOP를 사용하면 비즈니스 로직은 컨트롤러와 서비스에, 부가 기능은 Aspect에 명확히 분리된다. 각 관심사가 독립적으로 관리되어 이해하기 쉽다.

- 유지보수성 향상

로깅 방식을 변경할 때 기존에는 모든 컨트롤러와 서비스를 수정해야 했다. AOP를 사용하면 LoggingAspect 하나만 수정하면 전체 애플리케이션에 즉시 반영된다. 새로운 메서드를 추가할 때도 Aspect가 자동으로 적용되어 추가 작업이 필요 없다.

- 일관성 확보

기존 코드에서는 System.out.println()log.info()가 혼재되어 로그 레벨 제어가 불가능했다. AOP를 사용하면 모든 로그가 일관된 포맷과 레벨로 출력된다. 로그 메시지의 접두사([AOP-LOG], [AOP-SECURITY])를 통해 로그 종류를 쉽게 구분할 수 있다.

- 세밀한 제어

필터와 인터셉터는 URL 패턴으로만 적용 범위를 지정할 수 있었다. 같은 경로라도 특정 메서드만 제외하기 어려웠다. AOP는 어노테이션 기반으로 메서드 단위 제어가 가능하다. 패키지, 클래스, 메서드명, 어노테이션 등 다양한 기준으로 Pointcut을 지정할 수 있다.


<AOP 사용 시 주의사항>

- 프록시의 한계

Spring AOP는 프록시 기반으로 동작하므로 몇 가지 제약이 있다. 같은 클래스 내부에서 메서드를 호출하면 AOP가 적용되지 않는다. 프록시를 거치지 않고 직접 호출하기 때문이다.

public class MemberService {
    public void method1() {
        method2(); // AOP 적용 안 됨
    }

    @RequireLogin
    public void method2() {
        // 로직
    }
}

이 경우 method1()에서 method2()를 호출하면 보안 검사가 수행되지 않는다. 외부에서 method2()를 직접 호출해야 AOP가 동작한다.

- 성능 오버헤드

AOP는 프록시 객체를 통한 간접 호출이므로 약간의 성능 오버헤드가 발생한다. 대부분의 경우 무시할 수 있는 수준이지만, 극도로 빈번하게 호출되는 메서드에는 신중하게 적용해야 한다. 성능 측정 후 필요 시 Pointcut을 조정하여 적용 범위를 제한한다.

- 디버깅의 어려움

AOP를 과도하게 사용하면 코드 흐름을 추적하기 어려워진다. 메서드 호출 전후에 어떤 Advice가 실행되는지 코드만 보고는 파악하기 어렵다. 적절한 로그 메시지와 명확한 Aspect 명명으로 이해를 돕는다. IDE의 AOP 시각화 도구를 활용하면 Aspect 적용 현황을 확인할 수 있다.


정리 및 요약

Spring Boot에서 Filter와 Interceptor를 사용한 공통 기능 처리는 코드 중복과 관심사 혼재 문제를 야기한다. AOP를 활용하면 이러한 문제를 효과적으로 해결할 수 있다.

AOP는 횡단 관심사를 별도의 Aspect로 분리하여 비즈니스 로직과 부가 기능을 명확히 구분한다. 로깅, 보안, 트랜잭션 모니터링 같은 공통 기능을 한 곳에서 관리하므로 유지보수성이 크게 향상된다.

커스텀 어노테이션을 활용하면 메서드 단위의 세밀한 제어가 가능하다. URL 패턴이 아닌 어노테이션으로 기능을 적용하므로 선언적이고 명시적이다. 개발자는 어노테이션만 보고도 해당 메서드의 부가 기능을 즉시 파악할 수 있다.

AOP의 핵심 가치는 관심사의 분리와 코드 재사용성이다. 비즈니스 로직에 집중하면서도 강력한 부가 기능을 제공할 수 있다. 실무 프로젝트에서 로깅, 보안, 성능 측정 등 반복되는 코드가 있다면 AOP 적용을 고려해야 한다.

단, AOP는 프록시 기반으로 동작하므로 내부 메서드 호출 시 적용되지 않는 제약이 있다. 과도한 사용은 디버깅을 어렵게 만들 수 있으므로 적절한 수준에서 활용해야 한다. 명확한 Pointcut 표현식과 일관된 네이밍으로 코드의 이해도를 높이는 것이 중요하다.