BindProject

AOP 도입하기

dding-shark 2025. 8. 1. 14:37
728x90

1. AOP, 대체 왜 필요한가? - 프로그래밍의 '관점'을 바꾸다

본격적인 이야기에 앞서, AOP가 무엇인지, 그리고 왜 현대적인 소프트웨어 개발에서 필수적인 패러다임으로 자리 잡았는지부터 짚고 넘어가겠습니다.

두 종류의 '관심사'

소프트웨어를 개발할 때 우리가 작성하는 코드는 크게 두 가지 '관심사(Concern)'로 나눌 수 있습니다.

  1. 핵심 관심사 (Core Concern): 애플리케이션의 본질적인 기능, 즉 비즈니스 로직 그 자체를 의미합니다. 예를 들어, '쿠폰을 발급한다', '사용자 프로필을 조회한다', '예약 스케줄을 확인한다'와 같은 기능들이 여기에 해당합니다. 이들은 애플리케이션이 존재하는 이유 그 자체입니다.
  2. 횡단 관심사 (Cross-Cutting Concern): 비즈니스 로직과 직접적인 관련은 없지만, 애플리케이션 전반에 걸쳐 필요한 부가 기능들을 말합니다. 핵심 로직의 여러 모듈에 공통적으로 나타나기 때문에 '횡단(Cross-Cutting)'이라는 이름이 붙었습니다. 대표적인 예시는 다음과 같습니다.
    • 로그 추적: 어떤 요청이 어떤 메소드를 거쳐 실행되는지 흐름을 추적합니다.
    • 성능 측정: 각 로직의 실행 시간을 측정하여 병목 지점을 찾습니다.
    • 보안: 인증된 사용자인지, 특정 리소스에 접근할 권한이 있는지 확인합니다.
    • 트랜잭션 관리: 데이터베이스 작업을 하나의 원자적인 단위로 묶어 일관성을 보장합니다.
    • 캐싱: 반복적으로 조회되는 데이터의 결과를 임시 저장하여 성능을 향상시킵니다.

문제는 전통적인 OOP(객체 지향 프로그래밍) 방식만으로는 이 횡단 관심사를 깔끔하게 분리하기 어렵다는 것입니다. 그 결과, 횡단 관심사 코드는 비즈니스 로직 코드 사이사이에 흩어져 중복되고, 이는 곧 유지보수 비용의 급증으로 이어집니다.

AOP: 흩어진 횡단 관심사를 'Aspect'로 모으다

AOP는 바로 이 문제를 해결하기 위해 등장했습니다. 핵심 관심사와 횡단 관심사를 완전히 분리하고, 횡단 관심사를 'Aspect'라는 독립적인 모듈로 만들어 관리하는 프로그래밍 패러다임입니다.

스프링 프레임워크에서 AOP는 프록시(Proxy) 패턴을 기반으로 동작합니다. 클라이언트가 비즈니스 로직을 수행하는 실제 객체(Target)를 직접 호출하는 것이 아니라, 실제 객체인 척하는 대리인 객체(Proxy)를 대신 호출하게 만드는 것이죠. 이 프록시 객체가 횡단 관심사(로그, 보안 등)에 해당하는 부가 로직을 먼저 처리한 뒤, 실제 객체의 비즈니스 로직을 호출해 줍니다.

이러한 AOP의 마법을 이해하기 위한 몇 가지 핵심 용어가 있습니다.

  • Aspect (관점): 횡단 관심사를 모듈화한 단위입니다. @Aspect 어노테이션을 사용하여 정의하며, 여러 개의 Advice와 Pointcut을 포함합니다. (예: LoggingAspect, SecurityAspect)
  • Join Point (조인 포인트): Advice(부가 기능)가 적용될 수 있는 애플리케이션 실행의 '모든 시점'을 의미합니다. 스프링 AOP에서는 메소드 실행 시점이 유일한 조인 포인트입니다.
  • Advice (어드바이스): '무엇을(What)' 할 것인가에 해당하는 부가 기능의 실제 구현체입니다. @Around, @Before, @After 등의 어노테이션으로 정의하며, 특정 조인 포인트에 삽입되어 동작합니다.
  • Pointcut (포인트컷): '어디에(Where)' Advice를 적용할지 결정하는 표현식입니다. 수많은 조인 포인트 중에서 특정 조건(패키지 구조, 메소드 이름 등)에 맞는 것들만 선별하는 필터 역할을 합니다.
  • Target (타겟): Advice가 적용될 실제 비즈니스 로직을 담고 있는 원본 객체입니다. AOP 프록시에 의해 감싸여집니다.
  • Proxy (프록시): AOP의 핵심. Advice가 적용된 후 생성되는 대리인 객체입니다. 클라이언트의 요청을 가로채 Advice를 먼저 수행하고, 이후에 Target 객체의 메소드를 호출합니다.

쉽게 말해, 개발자는 횡단 관심사를 Aspect로 정의해두기만 하면, 스프링이 Pointcut을 기준으로 Target 객체에 대한 Proxy를 만들어, 적절한 Join Point에서 Advice를 실행시켜 주는 것입니다.

2. AOP 도입 전, 우리 프로젝트의 현실 (feat. 기술 부채)

이론은 충분히 알았으니, 이제 AOP가 왜 절실했는지 저희 프로젝트의 '비포(Before)' 상태를 함께 보시죠.

문제점 1: 모든 컨트롤러를 점령한 log.info()와 성능 측정 코드

좋은 로깅은 선택이 아닌 필수입니다. 하지만 AOP가 없던 시절, 저희는 모든 API의 요청/응답을 기록하기 위해 각 컨트롤러 메소드의 시작과 끝에 log.info()를 수동으로 넣고 있었습니다.

// CouponController.java (Before AOP)
@PostMapping("/{couponId}/issue")
public Mono<ResponseEntity<BaseResponse<?>>> issueCouponToUser(Authentication authentication, @PathVariable Long couponId) {
    long startTime = System.currentTimeMillis(); // 성능 측정 시작
    Long userId = Long.parseLong(authentication.getName());

    log.info("POST /api/bff/coupons/v1/{}/issue called for user ID: {}", couponId, userId); // 로깅

    Mono<ResponseEntity<BaseResponse<?>>> result = couponClient.issueCouponToUser(couponId, userId);

    long endTime = System.currentTimeMillis(); // 성능 측정 종료
    log.info("Execution time: {}ms", (endTime - startTime)); // 로깅
    return result;
}

// OperateHourController.java (Before AOP)
@GetMapping("/daily")
public Mono<ResponseEntity<BaseResponse<?>>> getDailySchedules(@RequestParam Long studioId, @RequestParam LocalDate date) {
    StopWatch stopWatch = new StopWatch(); // 이번엔 StopWatch? 일관성 없는 코드
    stopWatch.start();

    log.info("GET /api/bff/operation-hours/v1/daily called with studioId: {}, date: {}", studioId, date); // 로깅

    Mono<ResponseEntity<BaseResponse<?>>> result = operateHourClient.getDailySchedules(studioId, date);

    stopWatch.stop();
    log.info("Daily schedule retrieval took {}ms", stopWatch.getTotalTimeMillis()); // 로깅
    return result;
}

위 코드의 문제점은 명확합니다.

  • 보일러플레이트 코드: 핵심 로직은 couponClient.issueCouponToUser 한 줄인데, 부가적인 코드가 훨씬 더 많습니다.
  • 일관성 부재: 어떤 개발자는 System.currentTimeMillis()를, 다른 개발자는 StopWatch를 사용합니다. 로그 포맷도 제각각입니다.
  • 유지보수의 어려움: "모든 API 실행 시간에 클래스명과 메소드명도 같이 로깅해주세요." 라는 요구사항이 추가되면, 프로젝트의 모든 컨트롤러를 찾아다니며 수정해야 하는 끔찍한 일이 벌어집니다.

문제점 2: 복사-붙여넣기 된 위험한 보안 코드

보안은 아무리 강조해도 지나치지 않습니다. 하지만 '본인만 리소스를 수정/삭제할 수 있다'는 간단한 권한 검증 로직조차 여러 컨트롤러에 중복되어 있었습니다.

// ImageController.java (Before AOP)
@DeleteMapping(path = "/{imageId}")
public Mono<ResponseEntity<BaseResponse<?>>> deleteImage(Authentication authentication, @RequestParam Long userId, @PathVariable Long imageId) {
    // ---- 보안 코드 시작 ----
    String userIdFromToken = authentication.getName();
    if (!userIdFromToken.equals(userId.toString())) {
        throw new BffException(BffErrorCode.NOT_MATCHED_TOKEN); // 권한 없음 예외
    }
    // ---- 보안 코드 끝 ----
    log.info("이미지 삭제 요청...");
    return imageClient.deleteImage(imageId);
}

// UserProfileController.java (Before AOP)
@PutMapping(path = "/update")
public Mono<ResponseEntity<BaseResponse<?>>> userProfileUpdate(Authentication authentication, @RequestBody UserProfileUpdateRequestFromClient req) {
    // ---- 보안 코드 시작 (조금 다름) ----
    String userIdFromToken = authentication.getName();
    if (!userIdFromToken.equals(req.getUserId().toString())) {
        throw new BffException(BffErrorCode.NOT_MATCHED_TOKEN);
    }
    // ---- 보안 코드 끝 ----
    log.info("프로필 업데이트 요청...");
    return userProfileClient.updateProfile(req);
}

이 방식의 가장 큰 문제는 실수의 가능성입니다. 새로운 API를 만들 때 개발자가 권한 검증 로직을 깜빡하고 누락한다면, 이는 곧바로 심각한 보안 취약점으로 이어집니다. 또한, 권한 정책이 변경되면 이 코드를 사용하는 모든 곳을 찾아 수정해야 하는 부담도 있습니다.

3. AOP 도입: 우리 프로젝트는 어떻게 바뀌었나?

이제 AOP라는 강력한 무기를 장착한 후, 저희 프로젝트가 어떻게 탈바꿈했는지 그 '애프터(After)' 모습을 보여드리겠습니다.

가. 해결책 1: @TraceLog 어노테이션으로 로그 추적과 성능 측정을 한번에!

가장 먼저, 흩어져 있던 로깅과 성능 측정 코드를 처리할 LoggingAspect를 만들었습니다. 그리고 @TraceLog라는 커스텀 어노테이션을 정의하여, 이 어노테이션이 붙은 메소드의 실행 흐름과 소요 시간을 자동으로 측정하고 기록하도록 했습니다.

[TraceLogAspect.java - AOP의 핵심 구현체]

@Slf4j
@Aspect // 이 클래스가 Aspect임을 선언
@Component
public class LoggingAspect {

    // Pointcut: bff 패키지 하위의 모든 controller 패키지 내의 모든 메소드를 대상으로 함
    @Pointcut("execution(* bff.controller..*.*(..))")
    private void controllerLayer() {}

    // Advice: Pointcut으로 지정된 메소드 실행 전/후/예외 발생 시점에 개입
    @Around("controllerLayer()")
    public Object logExecutionTrace(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        String methodName = joinPoint.getSignature().toShortString();
        log.info("[Trace] ---> {} | args={}", methodName, joinPoint.getArgs());

        try {
            // 실제 타겟 메소드 실행
            Object result = joinPoint.proceed();
            return result;
        } finally {
            long endTime = System.currentTimeMillis();
            long executionTime = endTime - startTime;
            log.info("[Trace] <--- {} | executed in {}ms", methodName, executionTime);
        }
    }
}

이 Aspect 하나를 만들어 둠으로써, 이제 컨트롤러 코드는 놀랍도록 간결해집니다.

[CouponController.java - After AOP]

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/bff/coupons/v1")
public class CouponController {

    private final CouponClient couponClient;

    @TraceLog // AOP야, 이 메소드의 로그와 성능 측정을 부탁해!
    @PostMapping("/{couponId}/issue")
    public Mono<ResponseEntity<BaseResponse<?>>> issueCouponToUser(Authentication authentication, @PathVariable Long couponId) {
        Long userId = Long.parseLong(authentication.getName());
        // ✨ 오직 핵심 비즈니스 로직만 남았습니다.
        return couponClient.issueCouponToUser(couponId, userId);
    }
}

이제 개발자는 @TraceLog 어노테이션 하나만 붙이면 됩니다. 로그 포맷을 바꾸고 싶다면 LoggingAspect 클래스 하나만 수정하면 프로젝트 전체에 일괄 적용됩니다.

나. 해결책 2: @CheckOwner로 선언하는 철통 보안

중복되던 권한 검증 로직 역시 SecurityAspect와 커스텀 어노테이션 @CheckOwner로 완벽하게 분리했습니다.

[ImageController.java - After AOP]

@RestController
@RequestMapping(path = "/api/image/v1")
public class ImageController {
    // ...
    @TraceLog
    @CheckOwner // AOP야, 이 메소드는 리소스 소유자만 접근 가능하게 해줘!
    @DeleteMapping(path = "/{imageId}")
    public Mono<ResponseEntity<BaseResponse<?>>> deleteImage(Authentication authentication, @RequestParam Long userId, @PathVariable Long imageId) {
        // ✨ 복잡한 if문이 사라지고 핵심 로직만 남았습니다.
        return imageClient.deleteImage(imageId);
    }
}

SecurityAspect@CheckOwner 어노테이션이 붙은 메소드가 실행되기 전에, Authentication 객체와 요청 파라미터의 userId를 비교하여 권한이 없으면 예외를 발생시키고 메소드 실행을 차단합니다. 개발자는 더 이상 보안 로직을 신경 쓸 필요 없이, 비즈니스 로직 개발에만 집중할 수 있게 되었습니다.

다. 해결책 3: @Cacheable로 응답 속도를 비약적으로 향상시키다

AOP의 진정한 위력은 성능 최적화에서 드러납니다. 자주 바뀌지 않는 데이터를 매번 DB에서 조회하는 것은 엄청난 낭비입니다. 저희는 OperateHourController의 일일 운영 스케줄 조회 기능에 캐싱을 적용하기로 했습니다.

[OperateHourController.java - After AOP]

@RestController
@RequestMapping("/api/bff/operation-hours/v1")
public class OperateHourController {
    // ...
    @TraceLog
    // Spring의 캐싱 추상화를 활용. 'dailySchedules' 캐시에 studioId와 date를 조합한 키로 결과를 저장
    @Cacheable(value = "dailySchedules", key = "#studioId + ':' + #date.toString()")
    @GetMapping("/daily")
    public Mono<ResponseEntity<BaseResponse<?>>> getDailySchedules(@RequestParam Long studioId, @RequestParam LocalDate date) {
        // ✨ 이 코드는 캐시에 데이터가 없을 때만 최초 1회 실행됩니다.
        // DB 조회에 150ms가 걸렸다면, 다음부터는 캐시에서 5ms 만에 응답합니다.
        return operateHourClient.getDailySchedules(studioId, date);
    }
}

스프링이 기본으로 제공하는 @Cacheable 어노테이션과 캐싱 Aspect를 활용하면, 핵심 로직 코드는 단 한 줄도 건드리지 않고 성능 최적화라는 새로운 부가 기능을 손쉽게 추가할 수 있습니다. 동일한 요청에 대해서는 DB를 조회하지 않고 캐시(Redis 등)에서 즉시 결과를 반환하므로, 시스템 부하를 줄이고 사용자 경험을 극적으로 향상시킬 수 있습니다.

4. 결론: AOP는 선택이 아닌 필수다

AOP를 도입한 후, 저희 프로젝트는 단순히 코드 중복이 줄어든 것을 넘어, 개발 문화 자체에 긍정적인 변화를 가져왔습니다.

  • 코드베이스의 변화: 핵심 로직과 부가 로직이 명확히 분리되어 코드의 가독성과 모듈성이 비약적으로 향상되었습니다. 더 이상 비즈니스 로직을 파악하기 위해 부가 기능 코드를 헤맬 필요가 없어졌습니다.
  • 개발자의 변화: 개발자들은 반복적인 작업에서 해방되어, 더 창의적이고 중요한 비즈니스 문제 해결에 집중할 수 있게 되었습니다. 또한, 어노테이션 기반의 선언적 프로그래밍을 통해 실수를 줄이고 생산성을 높일 수 있었습니다.
  • 시스템의 변화: 로그 추적을 통해 시스템의 관측 가능성(Observability)이 높아졌고, 장애 발생 시 원인 분석이 쉬워졌습니다. 캐싱을 통해 시스템의 전반적인 성능과 안정성이 향상되었습니다.
728x90