Theory
중급 2-3시간 이론 + 실습

Spring 07: Custom Annotation 활용

커스텀 어노테이션으로 공통 모듈 구현과 코드 최적화

Custom AnnotationAOPValidationArgumentResolver분산 락
📚 목차

1. 커스텀 어노테이션 기초

2. AOP 기반 커스텀 어노테이션

3. 분산 락과 동시성 제어

4. Validation 커스텀 어노테이션

5. Controller 파라미터 처리

6. 보안 및 권한 어노테이션

7. 정리 및 베스트 프랙티스

🎯 학습 목표
  • • 커스텀 어노테이션의 정의와 메타 어노테이션을 이해한다
  • • AOP와 결합하여 로깅, 재시도, 분산 락을 구현한다
  • • ConstraintValidator로 커스텀 검증 어노테이션을 만든다
  • • ArgumentResolver로 Controller 파라미터를 자동 주입한다

커스텀 어노테이션 기초

어노테이션이란?

어노테이션은 코드에 메타데이터를 추가하는 방법입니다. Spring에서는 어노테이션을 통해 설정, 검증, AOP 적용 등 다양한 기능을 선언적으로 사용할 수 있습니다. 커스텀 어노테이션을 만들면 반복되는 코드를 줄이고, 비즈니스 의도를 명확히 표현할 수 있습니다.

어노테이션 정의 기본

// 기본 어노테이션 정의
@Target(ElementType.METHOD)           // 적용 대상
@Retention(RetentionPolicy.RUNTIME)   // 유지 정책
@Documented                           // Javadoc에 포함
public @interface LogExecutionTime {
    String value() default "";        // 속성 정의
}

// 사용
@LogExecutionTime("주문 생성")
public Order createOrder(OrderRequest request) {
    // ...
}

@Target - 적용 대상

ElementType적용 대상예시
TYPE클래스, 인터페이스, enum@Service, @Entity
METHOD메서드@GetMapping, @Transactional
FIELD필드@Autowired, @Column
PARAMETER메서드 파라미터@RequestParam, @PathVariable
CONSTRUCTOR생성자@Autowired
ANNOTATION_TYPE다른 어노테이션@Target, @Retention
// 여러 대상에 적용 가능
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Loggable {}

// 모든 대상에 적용 (Target 생략 시)
public @interface Universal {}

@Retention - 유지 정책

SOURCE

컴파일 시 제거

예: @Override, @SuppressWarnings

CLASS

.class 파일까지 유지

런타임에 접근 불가 (기본값)

RUNTIME ⭐

런타임까지 유지

리플렉션으로 접근 가능

Spring에서는 거의 항상 RUNTIME 사용!런타임에 어노테이션 정보를 읽어서 처리해야 하기 때문입니다.

어노테이션 속성

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    
    // 기본 속성 (value)
    String value() default "";
    
    // 명시적 속성
    int requests() default 100;
    int seconds() default 60;
    
    // 배열 속성
    String[] excludePaths() default {};
    
    // enum 속성
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    
    // 다른 어노테이션 속성
    Retry retry() default @Retry(maxAttempts = 3);
    
    // Class 속성
    Class<? extends RateLimitKeyGenerator> keyGenerator() 
        default DefaultKeyGenerator.class;
}

// 사용 예시
@RateLimit(requests = 10, seconds = 60)
public void limitedMethod() {}

@RateLimit(value = "api-limit", requests = 5, 
           excludePaths = {"/health", "/info"})
public void apiMethod() {}

// value만 사용할 때는 이름 생략 가능
@RateLimit("simple-limit")
public void simpleMethod() {}

메타 어노테이션 (합성 어노테이션)

// 여러 어노테이션을 하나로 합성
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Service                    // 메타 어노테이션
@Transactional             // 메타 어노테이션
@Slf4j                     // 메타 어노테이션
public @interface BusinessService {
    @AliasFor(annotation = Service.class)
    String value() default "";
}

// 사용 - 3개 어노테이션 효과
@BusinessService("orderService")
public class OrderService {
    // @Service, @Transactional, @Slf4j 모두 적용됨
}

// Spring의 합성 어노테이션 예시
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Controller
@ResponseBody
public @interface RestController {
    @AliasFor(annotation = Controller.class)
    String value() default "";
}

// @GetMapping도 합성 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
    @AliasFor(annotation = RequestMapping.class)
    String[] value() default {};
}

AOP 기반 커스텀 어노테이션

커스텀 어노테이션과 AOP를 결합하면 선언적으로 횡단 관심사를 적용할 수 있습니다. 어노테이션으로 의도를 표현하고, Aspect에서 실제 로직을 구현합니다.

1. @LogExecutionTime - 실행 시간 측정

// 1. 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogExecutionTime {
    String value() default "";           // 로그 메시지
    boolean logArgs() default false;     // 파라미터 로깅 여부
    boolean logResult() default false;   // 결과 로깅 여부
    long warnThreshold() default 1000;   // 경고 임계값 (ms)
}

// 2. Aspect 구현
@Aspect
@Component
@Slf4j
public class ExecutionTimeAspect {
    
    @Around("@annotation(logExecutionTime)")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint, 
                                    LogExecutionTime logExecutionTime) throws Throwable {
        String methodName = joinPoint.getSignature().toShortString();
        String message = logExecutionTime.value().isEmpty() 
            ? methodName : logExecutionTime.value();
        
        // 파라미터 로깅
        if (logExecutionTime.logArgs()) {
            log.info("[{}] 파라미터: {}", message, Arrays.toString(joinPoint.getArgs()));
        }
        
        long startTime = System.currentTimeMillis();
        
        try {
            Object result = joinPoint.proceed();
            
            long executionTime = System.currentTimeMillis() - startTime;
            
            // 결과 로깅
            if (logExecutionTime.logResult()) {
                log.info("[{}] 완료 - {}ms, 결과: {}", message, executionTime, result);
            } else {
                log.info("[{}] 완료 - {}ms", message, executionTime);
            }
            
            // 임계값 초과 경고
            if (executionTime > logExecutionTime.warnThreshold()) {
                log.warn("[{}] 실행 시간 임계값 초과: {}ms > {}ms", 
                    message, executionTime, logExecutionTime.warnThreshold());
            }
            
            return result;
            
        } catch (Exception e) {
            long executionTime = System.currentTimeMillis() - startTime;
            log.error("[{}] 실패 - {}ms, 예외: {}", message, executionTime, e.getMessage());
            throw e;
        }
    }
}

// 3. 사용
@Service
public class OrderService {
    
    @LogExecutionTime("주문 생성")
    public Order createOrder(OrderRequest request) {
        return orderRepository.save(new Order(request));
    }
    
    @LogExecutionTime(value = "주문 조회", logArgs = true, logResult = true)
    public Order getOrder(Long orderId) {
        return orderRepository.findById(orderId).orElseThrow();
    }
    
    @LogExecutionTime(value = "대량 주문 처리", warnThreshold = 5000)
    public List<Order> processBulkOrders(List<OrderRequest> requests) {
        // 오래 걸리는 작업
    }
}

2. @Retry - 재시도 로직

// 1. 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
    int maxAttempts() default 3;
    long delay() default 1000;                    // ms
    double multiplier() default 1.5;              // 지수 백오프
    Class<? extends Exception>[] retryOn() 
        default {RuntimeException.class};
    Class<? extends Exception>[] noRetryOn() 
        default {};
}

// 2. Aspect 구현
@Aspect
@Component
@Slf4j
public class RetryAspect {
    
    @Around("@annotation(retry)")
    public Object retry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
        String methodName = joinPoint.getSignature().toShortString();
        int maxAttempts = retry.maxAttempts();
        long delay = retry.delay();
        double multiplier = retry.multiplier();
        
        Exception lastException = null;
        
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                if (attempt > 1) {
                    log.info("[{}] 재시도 {}/{}", methodName, attempt, maxAttempts);
                }
                return joinPoint.proceed();
                
            } catch (Exception e) {
                lastException = e;
                
                // 재시도 대상 예외인지 확인
                if (!shouldRetry(e, retry)) {
                    log.warn("[{}] 재시도 불가 예외: {}", methodName, e.getClass().getName());
                    throw e;
                }
                
                if (attempt < maxAttempts) {
                    log.warn("[{}] 실패 (시도 {}/{}): {} - {}ms 후 재시도", 
                        methodName, attempt, maxAttempts, e.getMessage(), delay);
                    
                    Thread.sleep(delay);
                    delay = (long) (delay * multiplier);  // 지수 백오프
                }
            }
        }
        
        log.error("[{}] 최대 재시도 횟수 초과", methodName);
        throw lastException;
    }
    
    private boolean shouldRetry(Exception e, Retry retry) {
        // noRetryOn에 포함되면 재시도 안함
        for (Class<? extends Exception> noRetry : retry.noRetryOn()) {
            if (noRetry.isInstance(e)) return false;
        }
        
        // retryOn에 포함되면 재시도
        for (Class<? extends Exception> retryOn : retry.retryOn()) {
            if (retryOn.isInstance(e)) return true;
        }
        
        return false;
    }
}

// 3. 사용
@Service
public class ExternalApiService {
    
    @Retry(maxAttempts = 3, delay = 1000, multiplier = 2.0)
    public ApiResponse callExternalApi(String endpoint) {
        // 외부 API 호출 - 실패 시 자동 재시도
        return restTemplate.getForObject(endpoint, ApiResponse.class);
    }
    
    @Retry(
        maxAttempts = 5, 
        delay = 500,
        retryOn = {ConnectException.class, SocketTimeoutException.class},
        noRetryOn = {IllegalArgumentException.class}
    )
    public void sendNotification(Notification notification) {
        // 네트워크 오류만 재시도, 잘못된 인자는 재시도 안함
    }
}

3. @Cacheable (커스텀 구현)

// 1. 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomCache {
    String name();                        // 캐시 이름
    String key() default "";              // 캐시 키 (SpEL)
    long ttl() default 3600;              // TTL (초)
    boolean condition() default true;     // 캐시 조건
}

// 2. Aspect 구현
@Aspect
@Component
@Slf4j
public class CacheAspect {
    
    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    private final SpelExpressionParser parser = new SpelExpressionParser();
    
    @Around("@annotation(customCache)")
    public Object cache(ProceedingJoinPoint joinPoint, CustomCache customCache) 
            throws Throwable {
        
        String cacheName = customCache.name();
        String cacheKey = buildCacheKey(customCache, joinPoint);
        String fullKey = cacheName + ":" + cacheKey;
        
        // 캐시 조회
        String cached = redisTemplate.opsForValue().get(fullKey);
        if (cached != null) {
            log.debug("캐시 히트: {}", fullKey);
            return deserialize(cached, getReturnType(joinPoint));
        }
        
        // 캐시 미스 - 실제 메서드 실행
        log.debug("캐시 미스: {}", fullKey);
        Object result = joinPoint.proceed();
        
        // 결과 캐싱
        if (result != null) {
            String serialized = objectMapper.writeValueAsString(result);
            redisTemplate.opsForValue().set(fullKey, serialized, 
                customCache.ttl(), TimeUnit.SECONDS);
            log.debug("캐시 저장: {} (TTL: {}s)", fullKey, customCache.ttl());
        }
        
        return result;
    }
    
    private String buildCacheKey(CustomCache cache, ProceedingJoinPoint joinPoint) {
        if (cache.key().isEmpty()) {
            // 기본: 메서드명 + 파라미터 해시
            return joinPoint.getSignature().getName() + ":" + 
                   Arrays.hashCode(joinPoint.getArgs());
        }
        
        // SpEL 파싱
        return parseSpelKey(cache.key(), joinPoint);
    }
}

// 3. 사용
@Service
public class ProductService {
    
    @CustomCache(name = "product", key = "#id", ttl = 1800)
    public Product getProduct(Long id) {
        return productRepository.findById(id).orElseThrow();
    }
    
    @CustomCache(name = "products", key = "#category + ':' + #page", ttl = 300)
    public Page<Product> getProductsByCategory(String category, int page) {
        return productRepository.findByCategory(category, PageRequest.of(page, 20));
    }
}

4. @Audit - 감사 로깅

// 1. 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audit {
    String action();                      // 수행 작업
    String resourceType();                // 리소스 타입
    String resourceId() default "";       // 리소스 ID (SpEL)
    AuditLevel level() default AuditLevel.INFO;
}

public enum AuditLevel {
    DEBUG, INFO, WARN, CRITICAL
}

// 2. Aspect 구현
@Aspect
@Component
@Slf4j
public class AuditAspect {
    
    private final AuditLogRepository auditLogRepository;
    
    @AfterReturning(pointcut = "@annotation(audit)", returning = "result")
    public void auditSuccess(JoinPoint joinPoint, Audit audit, Object result) {
        saveAuditLog(joinPoint, audit, result, null);
    }
    
    @AfterThrowing(pointcut = "@annotation(audit)", throwing = "ex")
    public void auditFailure(JoinPoint joinPoint, Audit audit, Exception ex) {
        saveAuditLog(joinPoint, audit, null, ex);
    }
    
    private void saveAuditLog(JoinPoint joinPoint, Audit audit, 
                               Object result, Exception ex) {
        String username = getCurrentUsername();
        String resourceId = parseResourceId(audit.resourceId(), joinPoint, result);
        
        AuditLog log = AuditLog.builder()
            .username(username)
            .action(audit.action())
            .resourceType(audit.resourceType())
            .resourceId(resourceId)
            .level(audit.level())
            .success(ex == null)
            .errorMessage(ex != null ? ex.getMessage() : null)
            .ipAddress(getClientIp())
            .timestamp(LocalDateTime.now())
            .build();
        
        auditLogRepository.save(log);
    }
}

// 3. 사용
@Service
public class UserService {
    
    @Audit(action = "CREATE", resourceType = "USER", resourceId = "#result.id")
    public User createUser(UserRequest request) {
        return userRepository.save(new User(request));
    }
    
    @Audit(action = "DELETE", resourceType = "USER", resourceId = "#userId", 
           level = AuditLevel.CRITICAL)
    public void deleteUser(Long userId) {
        userRepository.deleteById(userId);
    }
    
    @Audit(action = "UPDATE_PASSWORD", resourceType = "USER", resourceId = "#userId",
           level = AuditLevel.WARN)
    public void changePassword(Long userId, String newPassword) {
        // 비밀번호 변경
    }
}

분산 락과 동시성 제어

@DistributedLock - Redis 기반 분산 락

// 1. 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();                         // 락 키 (SpEL 지원)
    long waitTime() default 5000;         // 락 대기 시간 (ms)
    long leaseTime() default 10000;       // 락 유지 시간 (ms)
    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
    String prefix() default "lock:";      // 키 접두사
}

// 2. Aspect 구현
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class DistributedLockAspect {
    
    private final RedissonClient redissonClient;
    private final SpelExpressionParser parser = new SpelExpressionParser();
    
    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) 
            throws Throwable {
        
        String lockKey = distributedLock.prefix() + 
                         parseKey(distributedLock.key(), joinPoint);
        RLock lock = redissonClient.getLock(lockKey);
        
        String methodName = joinPoint.getSignature().toShortString();
        boolean acquired = false;
        
        try {
            // 락 획득 시도
            acquired = lock.tryLock(
                distributedLock.waitTime(),
                distributedLock.leaseTime(),
                distributedLock.timeUnit()
            );
            
            if (!acquired) {
                log.warn("[{}] 락 획득 실패: {}", methodName, lockKey);
                throw new LockAcquisitionException(
                    "락을 획득할 수 없습니다: " + lockKey);
            }
            
            log.debug("[{}] 락 획득: {}", methodName, lockKey);
            return joinPoint.proceed();
            
        } finally {
            if (acquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
                log.debug("[{}] 락 해제: {}", methodName, lockKey);
            }
        }
    }
    
    private String parseKey(String keyExpression, ProceedingJoinPoint joinPoint) {
        if (!keyExpression.contains("#")) {
            return keyExpression;
        }
        
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        String[] paramNames = signature.getParameterNames();
        Object[] args = joinPoint.getArgs();
        
        StandardEvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i < paramNames.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }
        
        Expression expression = parser.parseExpression(keyExpression);
        return String.valueOf(expression.getValue(context));
    }
}

// 3. 사용
@Service
public class StockService {
    
    // 상품별 재고 락
    @DistributedLock(key = "'stock:' + #productId", waitTime = 3000, leaseTime = 5000)
    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
        
        if (product.getStock() < quantity) {
            throw new InsufficientStockException(productId, product.getStock(), quantity);
        }
        
        product.decreaseStock(quantity);
    }
    
    // 주문별 결제 락 (중복 결제 방지)
    @DistributedLock(key = "'payment:' + #orderId", waitTime = 1000, leaseTime = 30000)
    @Transactional
    public PaymentResult processPayment(Long orderId, PaymentRequest request) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        
        if (order.isPaid()) {
            throw new AlreadyPaidException(orderId);
        }
        
        PaymentResult result = paymentGateway.process(request);
        order.markAsPaid(result.getTransactionId());
        
        return result;
    }
    
    // 사용자별 포인트 락
    @DistributedLock(key = "'point:' + #userId")
    @Transactional
    public void usePoints(Long userId, int points) {
        User user = userRepository.findById(userId).orElseThrow();
        user.usePoints(points);
    }
}

@RateLimit - API 호출 제한

// 1. 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int requests() default 100;           // 허용 요청 수
    int seconds() default 60;             // 시간 윈도우
    String key() default "";              // 제한 키 (SpEL, 기본: IP)
    String message() default "요청 한도를 초과했습니다";
    RateLimitType type() default RateLimitType.SLIDING_WINDOW;
}

public enum RateLimitType {
    FIXED_WINDOW,      // 고정 윈도우
    SLIDING_WINDOW,    // 슬라이딩 윈도우
    TOKEN_BUCKET       // 토큰 버킷
}

// 2. Aspect 구현
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class RateLimitAspect {
    
    private final StringRedisTemplate redisTemplate;
    
    @Around("@annotation(rateLimit)")
    public Object rateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) 
            throws Throwable {
        
        String key = buildKey(rateLimit, joinPoint);
        int maxRequests = rateLimit.requests();
        int windowSeconds = rateLimit.seconds();
        
        boolean allowed = switch (rateLimit.type()) {
            case FIXED_WINDOW -> checkFixedWindow(key, maxRequests, windowSeconds);
            case SLIDING_WINDOW -> checkSlidingWindow(key, maxRequests, windowSeconds);
            case TOKEN_BUCKET -> checkTokenBucket(key, maxRequests, windowSeconds);
        };
        
        if (!allowed) {
            log.warn("Rate limit 초과: key={}", key);
            throw new RateLimitExceededException(rateLimit.message());
        }
        
        return joinPoint.proceed();
    }
    
    private boolean checkSlidingWindow(String key, int maxRequests, int windowSeconds) {
        String redisKey = "rate_limit:sliding:" + key;
        long now = System.currentTimeMillis();
        long windowStart = now - (windowSeconds * 1000L);
        
        // Sorted Set으로 슬라이딩 윈도우 구현
        redisTemplate.opsForZSet().removeRangeByScore(redisKey, 0, windowStart);
        
        Long count = redisTemplate.opsForZSet().zCard(redisKey);
        if (count != null && count >= maxRequests) {
            return false;
        }
        
        redisTemplate.opsForZSet().add(redisKey, String.valueOf(now), now);
        redisTemplate.expire(redisKey, windowSeconds, TimeUnit.SECONDS);
        
        return true;
    }
    
    private boolean checkTokenBucket(String key, int maxTokens, int refillSeconds) {
        String redisKey = "rate_limit:bucket:" + key;
        String script = """
            local tokens = redis.call('get', KEYS[1])
            local lastRefill = redis.call('get', KEYS[2])
            local now = tonumber(ARGV[1])
            local maxTokens = tonumber(ARGV[2])
            local refillRate = tonumber(ARGV[3])
            
            if tokens == false then
                tokens = maxTokens
                lastRefill = now
            else
                tokens = tonumber(tokens)
                lastRefill = tonumber(lastRefill)
                local elapsed = now - lastRefill
                local refill = math.floor(elapsed / 1000 * refillRate)
                tokens = math.min(maxTokens, tokens + refill)
                lastRefill = now
            end
            
            if tokens > 0 then
                redis.call('set', KEYS[1], tokens - 1)
                redis.call('set', KEYS[2], lastRefill)
                return 1
            else
                return 0
            end
            """;
        
        // Lua 스크립트 실행
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Arrays.asList(redisKey + ":tokens", redisKey + ":lastRefill"),
            String.valueOf(System.currentTimeMillis()),
            String.valueOf(maxTokens),
            String.valueOf((double) maxTokens / refillSeconds)
        );
        
        return result != null && result == 1;
    }
}

// 3. 사용
@RestController
public class ApiController {
    
    // IP 기반 제한 (분당 100회)
    @RateLimit(requests = 100, seconds = 60)
    @GetMapping("/api/products")
    public List<Product> getProducts() {
        return productService.findAll();
    }
    
    // 사용자 기반 제한 (시간당 10회)
    @RateLimit(requests = 10, seconds = 3600, key = "'user:' + #userId",
               message = "주문은 시간당 10회까지만 가능합니다")
    @PostMapping("/api/orders")
    public Order createOrder(@RequestParam Long userId, @RequestBody OrderRequest request) {
        return orderService.createOrder(userId, request);
    }
    
    // 토큰 버킷 방식 (초당 10회, 버스트 허용)
    @RateLimit(requests = 10, seconds = 1, type = RateLimitType.TOKEN_BUCKET)
    @GetMapping("/api/search")
    public List<Product> search(@RequestParam String keyword) {
        return productService.search(keyword);
    }
}

@Idempotent - 멱등성 보장

// 1. 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    String key();                         // 멱등성 키 (SpEL)
    long ttl() default 86400;             // 키 유지 시간 (초, 기본 24시간)
    String message() default "이미 처리된 요청입니다";
}

// 2. Aspect 구현
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class IdempotentAspect {
    
    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    
    @Around("@annotation(idempotent)")
    public Object checkIdempotent(ProceedingJoinPoint joinPoint, Idempotent idempotent) 
            throws Throwable {
        
        String idempotentKey = "idempotent:" + parseKey(idempotent.key(), joinPoint);
        
        // 이미 처리된 요청인지 확인
        String cachedResult = redisTemplate.opsForValue().get(idempotentKey);
        if (cachedResult != null) {
            log.info("멱등성 키 존재, 캐시된 결과 반환: {}", idempotentKey);
            
            // 캐시된 결과 반환
            Class<?> returnType = ((MethodSignature) joinPoint.getSignature())
                .getReturnType();
            return objectMapper.readValue(cachedResult, returnType);
        }
        
        // 처리 중 표시 (다른 요청 차단)
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(idempotentKey + ":processing", "1", 30, TimeUnit.SECONDS);
        
        if (Boolean.FALSE.equals(acquired)) {
            throw new IdempotentProcessingException("요청 처리 중입니다");
        }
        
        try {
            // 실제 메서드 실행
            Object result = joinPoint.proceed();
            
            // 결과 캐싱
            String serialized = objectMapper.writeValueAsString(result);
            redisTemplate.opsForValue().set(idempotentKey, serialized, 
                idempotent.ttl(), TimeUnit.SECONDS);
            
            return result;
            
        } finally {
            // 처리 중 표시 제거
            redisTemplate.delete(idempotentKey + ":processing");
        }
    }
}

// 3. 사용
@Service
public class PaymentService {
    
    // 결제 요청 멱등성 보장 (같은 주문에 대해 중복 결제 방지)
    @Idempotent(key = "'payment:' + #request.orderId + ':' + #request.idempotencyKey")
    @Transactional
    public PaymentResult processPayment(PaymentRequest request) {
        // 결제 처리 로직
        return paymentGateway.charge(request);
    }
    
    // 환불 요청 멱등성 보장
    @Idempotent(key = "'refund:' + #transactionId", ttl = 604800)  // 7일
    @Transactional
    public RefundResult processRefund(String transactionId, RefundRequest request) {
        return paymentGateway.refund(transactionId, request);
    }
}

Validation 커스텀 어노테이션

Bean Validation(JSR-380)을 확장하여 비즈니스 규칙에 맞는 커스텀 검증 어노테이션을 만들 수 있습니다. ConstraintValidator를 구현하여 검증 로직을 정의합니다.

1. @PhoneNumber - 전화번호 검증

// 1. 어노테이션 정의
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
@Documented
public @interface PhoneNumber {
    String message() default "올바른 전화번호 형식이 아닙니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    PhoneType type() default PhoneType.ANY;
}

public enum PhoneType {
    ANY,           // 모든 형식
    MOBILE,        // 휴대폰 (010, 011, 016, 017, 018, 019)
    LANDLINE,      // 유선전화 (02, 031, 032, ...)
    TOLL_FREE      // 수신자부담 (080, 1588, 1577, ...)
}

// 2. Validator 구현
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
    
    private PhoneType type;
    
    private static final Pattern MOBILE_PATTERN = 
        Pattern.compile("^01[016789]-?\d{3,4}-?\d{4}$");
    private static final Pattern LANDLINE_PATTERN = 
        Pattern.compile("^0[2-6][1-5]?-?\d{3,4}-?\d{4}$");
    private static final Pattern TOLL_FREE_PATTERN = 
        Pattern.compile("^(080|1[56]\d{2})-?\d{3,4}-?\d{4}$");
    
    @Override
    public void initialize(PhoneNumber constraintAnnotation) {
        this.type = constraintAnnotation.type();
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            return true;  // @NotBlank와 조합해서 사용
        }
        
        String normalized = value.replaceAll("[\s-]", "");
        
        return switch (type) {
            case MOBILE -> MOBILE_PATTERN.matcher(normalized).matches();
            case LANDLINE -> LANDLINE_PATTERN.matcher(normalized).matches();
            case TOLL_FREE -> TOLL_FREE_PATTERN.matcher(normalized).matches();
            case ANY -> MOBILE_PATTERN.matcher(normalized).matches() ||
                       LANDLINE_PATTERN.matcher(normalized).matches() ||
                       TOLL_FREE_PATTERN.matcher(normalized).matches();
        };
    }
}

// 3. 사용
public class UserRequest {
    
    @NotBlank(message = "이름은 필수입니다")
    private String name;
    
    @NotBlank
    @PhoneNumber(type = PhoneType.MOBILE, message = "휴대폰 번호 형식이 올바르지 않습니다")
    private String mobile;
    
    @PhoneNumber(type = PhoneType.LANDLINE)
    private String telephone;  // 선택 필드
}

2. @BusinessNumber - 사업자등록번호 검증

// 1. 어노테이션 정의
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = BusinessNumberValidator.class)
public @interface BusinessNumber {
    String message() default "올바른 사업자등록번호가 아닙니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    boolean checkDigit() default true;  // 검증 자릿수 체크 여부
}

// 2. Validator 구현 (사업자등록번호 검증 알고리즘)
public class BusinessNumberValidator 
        implements ConstraintValidator<BusinessNumber, String> {
    
    private boolean checkDigit;
    private static final int[] WEIGHTS = {1, 3, 7, 1, 3, 7, 1, 3, 5};
    
    @Override
    public void initialize(BusinessNumber constraintAnnotation) {
        this.checkDigit = constraintAnnotation.checkDigit();
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            return true;
        }
        
        // 숫자만 추출
        String digits = value.replaceAll("[^0-9]", "");
        
        if (digits.length() != 10) {
            return false;
        }
        
        if (!checkDigit) {
            return true;  // 형식만 체크
        }
        
        // 검증 자릿수 계산
        int sum = 0;
        for (int i = 0; i < 9; i++) {
            sum += (digits.charAt(i) - '0') * WEIGHTS[i];
        }
        sum += ((digits.charAt(8) - '0') * 5) / 10;
        
        int checkDigit = (10 - (sum % 10)) % 10;
        
        return checkDigit == (digits.charAt(9) - '0');
    }
}

// 3. 사용
public class CompanyRequest {
    
    @NotBlank
    private String companyName;
    
    @NotBlank
    @BusinessNumber
    private String businessNumber;  // 123-45-67890
}

3. @UniqueEmail - DB 중복 체크

// 1. 어노테이션 정의
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
    String message() default "이미 사용 중인 이메일입니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    // 수정 시 자기 자신 제외를 위한 필드
    String idField() default "";
}

// 2. Validator 구현
@Component
@RequiredArgsConstructor
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
    
    private final UserRepository userRepository;
    private String idField;
    
    @Override
    public void initialize(UniqueEmail constraintAnnotation) {
        this.idField = constraintAnnotation.idField();
    }
    
    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        if (email == null || email.isBlank()) {
            return true;
        }
        
        // 수정 시 자기 자신 제외
        if (!idField.isEmpty()) {
            // HibernateConstraintValidatorContext를 통해 객체 접근
            // 또는 ThreadLocal 등으로 현재 사용자 ID 전달
        }
        
        return !userRepository.existsByEmail(email);
    }
}

// 3. 사용
public class SignUpRequest {
    
    @NotBlank
    @Email
    @UniqueEmail
    private String email;
    
    @NotBlank
    @Size(min = 8, max = 20)
    private String password;
}

4. @PasswordMatch - 비밀번호 확인 (클래스 레벨)

// 1. 어노테이션 정의 (클래스 레벨)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
    String message() default "비밀번호가 일치하지 않습니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    String passwordField() default "password";
    String confirmField() default "confirmPassword";
}

// 2. Validator 구현
public class PasswordMatchValidator 
        implements ConstraintValidator<PasswordMatch, Object> {
    
    private String passwordField;
    private String confirmField;
    
    @Override
    public void initialize(PasswordMatch constraintAnnotation) {
        this.passwordField = constraintAnnotation.passwordField();
        this.confirmField = constraintAnnotation.confirmField();
    }
    
    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        try {
            Object password = getFieldValue(value, passwordField);
            Object confirmPassword = getFieldValue(value, confirmField);
            
            boolean valid = password != null && password.equals(confirmPassword);
            
            if (!valid) {
                // 에러 메시지를 confirmPassword 필드에 연결
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                    .addPropertyNode(confirmField)
                    .addConstraintViolation();
            }
            
            return valid;
            
        } catch (Exception e) {
            return false;
        }
    }
    
    private Object getFieldValue(Object object, String fieldName) throws Exception {
        Field field = object.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        return field.get(object);
    }
}

// 3. 사용
@PasswordMatch
public class ChangePasswordRequest {
    
    @NotBlank
    private String currentPassword;
    
    @NotBlank
    @Size(min = 8, max = 20)
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]+$",
             message = "비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다")
    private String password;
    
    @NotBlank
    private String confirmPassword;
}

5. @DateRange - 날짜 범위 검증

// 1. 어노테이션 정의
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface DateRange {
    String message() default "시작일은 종료일보다 이전이어야 합니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    String startDate();
    String endDate();
    boolean allowEqual() default true;  // 같은 날짜 허용 여부
}

// 2. Validator 구현
public class DateRangeValidator implements ConstraintValidator<DateRange, Object> {
    
    private String startDateField;
    private String endDateField;
    private boolean allowEqual;
    
    @Override
    public void initialize(DateRange constraintAnnotation) {
        this.startDateField = constraintAnnotation.startDate();
        this.endDateField = constraintAnnotation.endDate();
        this.allowEqual = constraintAnnotation.allowEqual();
    }
    
    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        try {
            LocalDate startDate = (LocalDate) getFieldValue(value, startDateField);
            LocalDate endDate = (LocalDate) getFieldValue(value, endDateField);
            
            if (startDate == null || endDate == null) {
                return true;  // null 체크는 @NotNull로
            }
            
            if (allowEqual) {
                return !startDate.isAfter(endDate);
            } else {
                return startDate.isBefore(endDate);
            }
            
        } catch (Exception e) {
            return false;
        }
    }
}

// 3. 사용
@DateRange(startDate = "startDate", endDate = "endDate")
public class SearchRequest {
    
    @NotNull
    private LocalDate startDate;
    
    @NotNull
    private LocalDate endDate;
    
    private String keyword;
}

// 여러 날짜 범위 검증
@DateRange.List({
    @DateRange(startDate = "reservationStart", endDate = "reservationEnd"),
    @DateRange(startDate = "checkIn", endDate = "checkOut", allowEqual = false)
})
public class BookingRequest {
    private LocalDate reservationStart;
    private LocalDate reservationEnd;
    private LocalDate checkIn;
    private LocalDate checkOut;
}

Controller 파라미터 처리

HandlerMethodArgumentResolver를 구현하면 Controller 메서드의 파라미터를 커스텀 어노테이션으로 자동 주입할 수 있습니다.

1. @CurrentUser - 현재 로그인 사용자

// 1. 어노테이션 정의
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
    boolean required() default true;
}

// 2. ArgumentResolver 구현
@Component
@RequiredArgsConstructor
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
    
    private final UserRepository userRepository;
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class) &&
               User.class.isAssignableFrom(parameter.getParameterType());
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                   ModelAndViewContainer mavContainer,
                                   NativeWebRequest webRequest,
                                   WebDataBinderFactory binderFactory) {
        
        Authentication authentication = SecurityContextHolder.getContext()
            .getAuthentication();
        
        if (authentication == null || !authentication.isAuthenticated() ||
            authentication.getPrincipal().equals("anonymousUser")) {
            
            CurrentUser annotation = parameter.getParameterAnnotation(CurrentUser.class);
            if (annotation != null && annotation.required()) {
                throw new UnauthorizedException("로그인이 필요합니다");
            }
            return null;
        }
        
        // Principal에서 사용자 ID 추출
        String username = authentication.getName();
        return userRepository.findByUsername(username)
            .orElseThrow(() -> new UserNotFoundException(username));
    }
}

// 3. WebMvcConfigurer에 등록
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    
    private final CurrentUserArgumentResolver currentUserArgumentResolver;
    
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(currentUserArgumentResolver);
    }
}

// 4. 사용
@RestController
@RequestMapping("/api")
public class UserController {
    
    @GetMapping("/me")
    public UserResponse getMyInfo(@CurrentUser User user) {
        return UserResponse.from(user);
    }
    
    @GetMapping("/orders")
    public List<Order> getMyOrders(@CurrentUser User user) {
        return orderService.findByUser(user);
    }
    
    @PostMapping("/posts")
    public Post createPost(@CurrentUser User user, @RequestBody PostRequest request) {
        return postService.create(user, request);
    }
    
    // 비로그인 허용
    @GetMapping("/profile/{username}")
    public ProfileResponse getProfile(
            @PathVariable String username,
            @CurrentUser(required = false) User currentUser) {
        
        Profile profile = profileService.findByUsername(username);
        boolean isOwner = currentUser != null && 
                          currentUser.getUsername().equals(username);
        return ProfileResponse.of(profile, isOwner);
    }
}

2. @ClientIp - 클라이언트 IP 주소

// 1. 어노테이션 정의
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface ClientIp {
}

// 2. ArgumentResolver 구현
@Component
public class ClientIpArgumentResolver implements HandlerMethodArgumentResolver {
    
    private static final String[] IP_HEADERS = {
        "X-Forwarded-For",
        "Proxy-Client-IP",
        "WL-Proxy-Client-IP",
        "HTTP_X_FORWARDED_FOR",
        "HTTP_X_FORWARDED",
        "HTTP_X_CLUSTER_CLIENT_IP",
        "HTTP_CLIENT_IP",
        "HTTP_FORWARDED_FOR",
        "HTTP_FORWARDED",
        "HTTP_VIA",
        "REMOTE_ADDR"
    };
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(ClientIp.class) &&
               String.class.equals(parameter.getParameterType());
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                   ModelAndViewContainer mavContainer,
                                   NativeWebRequest webRequest,
                                   WebDataBinderFactory binderFactory) {
        
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        if (request == null) {
            return null;
        }
        
        for (String header : IP_HEADERS) {
            String ip = request.getHeader(header);
            if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
                // X-Forwarded-For는 콤마로 구분된 IP 목록일 수 있음
                return ip.split(",")[0].trim();
            }
        }
        
        return request.getRemoteAddr();
    }
}

// 3. 사용
@RestController
public class AuditController {
    
    @PostMapping("/api/login")
    public LoginResponse login(
            @RequestBody LoginRequest request,
            @ClientIp String clientIp) {
        
        log.info("로그인 시도: {} from {}", request.getUsername(), clientIp);
        return authService.login(request, clientIp);
    }
    
    @PostMapping("/api/orders")
    public Order createOrder(
            @CurrentUser User user,
            @ClientIp String clientIp,
            @RequestBody OrderRequest request) {
        
        return orderService.create(user, request, clientIp);
    }
}

3. @RequestContext - 요청 컨텍스트 정보

// 1. 컨텍스트 DTO
@Getter
@Builder
public class RequestContext {
    private final String requestId;
    private final String clientIp;
    private final String userAgent;
    private final String referer;
    private final Locale locale;
    private final ZoneId timezone;
    private final Instant requestTime;
}

// 2. 어노테이션 정의
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestContextParam {
}

// 3. ArgumentResolver 구현
@Component
public class RequestContextArgumentResolver implements HandlerMethodArgumentResolver {
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(RequestContextParam.class) &&
               RequestContext.class.equals(parameter.getParameterType());
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                   ModelAndViewContainer mavContainer,
                                   NativeWebRequest webRequest,
                                   WebDataBinderFactory binderFactory) {
        
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        
        return RequestContext.builder()
            .requestId(getOrGenerateRequestId(request))
            .clientIp(extractClientIp(request))
            .userAgent(request.getHeader("User-Agent"))
            .referer(request.getHeader("Referer"))
            .locale(request.getLocale())
            .timezone(extractTimezone(request))
            .requestTime(Instant.now())
            .build();
    }
    
    private String getOrGenerateRequestId(HttpServletRequest request) {
        String requestId = request.getHeader("X-Request-ID");
        if (requestId == null || requestId.isEmpty()) {
            requestId = UUID.randomUUID().toString();
        }
        MDC.put("requestId", requestId);
        return requestId;
    }
    
    private ZoneId extractTimezone(HttpServletRequest request) {
        String tz = request.getHeader("X-Timezone");
        if (tz != null) {
            try {
                return ZoneId.of(tz);
            } catch (Exception ignored) {}
        }
        return ZoneId.of("Asia/Seoul");
    }
}

// 4. 사용
@RestController
public class ApiController {
    
    @PostMapping("/api/events")
    public Event trackEvent(
            @RequestContextParam RequestContext context,
            @RequestBody EventRequest request) {
        
        return eventService.track(Event.builder()
            .type(request.getType())
            .data(request.getData())
            .requestId(context.getRequestId())
            .clientIp(context.getClientIp())
            .userAgent(context.getUserAgent())
            .timestamp(context.getRequestTime())
            .build());
    }
}

4. @DecryptedBody - 암호화된 요청 복호화

// 1. 어노테이션 정의
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptedBody {
    String algorithm() default "AES";
}

// 2. ArgumentResolver 구현
@Component
@RequiredArgsConstructor
public class DecryptedBodyArgumentResolver implements HandlerMethodArgumentResolver {
    
    private final ObjectMapper objectMapper;
    private final EncryptionService encryptionService;
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(DecryptedBody.class);
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                   ModelAndViewContainer mavContainer,
                                   NativeWebRequest webRequest,
                                   WebDataBinderFactory binderFactory) throws Exception {
        
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        
        // 암호화된 요청 본문 읽기
        String encryptedBody = StreamUtils.copyToString(
            request.getInputStream(), StandardCharsets.UTF_8);
        
        // 복호화
        DecryptedBody annotation = parameter.getParameterAnnotation(DecryptedBody.class);
        String decryptedJson = encryptionService.decrypt(
            encryptedBody, annotation.algorithm());
        
        // JSON → 객체 변환
        Class<?> targetType = parameter.getParameterType();
        return objectMapper.readValue(decryptedJson, targetType);
    }
}

// 3. 사용
@RestController
public class SecureController {
    
    @PostMapping("/api/secure/payment")
    public PaymentResponse processPayment(@DecryptedBody PaymentRequest request) {
        // request는 이미 복호화된 상태
        return paymentService.process(request);
    }
    
    @PostMapping("/api/secure/transfer")
    public TransferResponse transfer(
            @DecryptedBody(algorithm = "RSA") TransferRequest request) {
        return transferService.execute(request);
    }
}

보안 및 권한 어노테이션

1. @RequireRole - 역할 기반 접근 제어

// 1. 어노테이션 정의
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    String[] value();                     // 필요한 역할
    LogicalOp op() default LogicalOp.OR;  // 역할 간 논리 연산
}

public enum LogicalOp {
    OR,   // 하나라도 있으면 허용
    AND   // 모두 있어야 허용
}

// 2. Aspect 구현
@Aspect
@Component
@Order(1)  // 가장 먼저 실행
@Slf4j
public class RoleCheckAspect {
    
    @Before("@annotation(requireRole) || @within(requireRole)")
    public void checkRole(JoinPoint joinPoint, RequireRole requireRole) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        
        if (auth == null || !auth.isAuthenticated()) {
            throw new UnauthorizedException("로그인이 필요합니다");
        }
        
        Set<String> userRoles = auth.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .map(role -> role.replace("ROLE_", ""))
            .collect(Collectors.toSet());
        
        String[] requiredRoles = requireRole.value();
        boolean hasAccess;
        
        if (requireRole.op() == LogicalOp.AND) {
            hasAccess = Arrays.stream(requiredRoles).allMatch(userRoles::contains);
        } else {
            hasAccess = Arrays.stream(requiredRoles).anyMatch(userRoles::contains);
        }
        
        if (!hasAccess) {
            log.warn("권한 부족: 필요={}, 보유={}", 
                Arrays.toString(requiredRoles), userRoles);
            throw new ForbiddenException("접근 권한이 없습니다");
        }
    }
}

// 3. 사용
@RestController
@RequestMapping("/api/admin")
@RequireRole("ADMIN")  // 클래스 레벨 - 모든 메서드에 적용
public class AdminController {
    
    @GetMapping("/users")
    public List<User> getUsers() {
        return userService.findAll();
    }
    
    @DeleteMapping("/users/{id}")
    @RequireRole(value = {"ADMIN", "SUPER_ADMIN"}, op = LogicalOp.AND)
    public void deleteUser(@PathVariable Long id) {
        userService.delete(id);
    }
}

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @GetMapping
    @RequireRole({"USER", "ADMIN"})  // USER 또는 ADMIN
    public List<Order> getOrders(@CurrentUser User user) {
        return orderService.findByUser(user);
    }
    
    @PostMapping("/{id}/refund")
    @RequireRole("ADMIN")
    public void refundOrder(@PathVariable Long id) {
        orderService.refund(id);
    }
}

2. @OwnerOnly - 리소스 소유자 확인

// 1. 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OwnerOnly {
    String resourceType();                // 리소스 타입
    String resourceIdParam();             // 리소스 ID 파라미터명
    boolean allowAdmin() default true;    // 관리자 허용 여부
}

// 2. Aspect 구현
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class OwnerOnlyAspect {
    
    private final Map<String, OwnershipChecker> checkers;
    
    @Before("@annotation(ownerOnly)")
    public void checkOwnership(JoinPoint joinPoint, OwnerOnly ownerOnly) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        String currentUserId = auth.getName();
        
        // 관리자 체크
        if (ownerOnly.allowAdmin() && hasRole(auth, "ADMIN")) {
            return;
        }
        
        // 리소스 ID 추출
        Object resourceId = extractParameter(joinPoint, ownerOnly.resourceIdParam());
        if (resourceId == null) {
            throw new IllegalArgumentException("리소스 ID를 찾을 수 없습니다");
        }
        
        // 소유권 확인
        OwnershipChecker checker = checkers.get(ownerOnly.resourceType());
        if (checker == null) {
            throw new IllegalStateException(
                "OwnershipChecker not found: " + ownerOnly.resourceType());
        }
        
        if (!checker.isOwner(currentUserId, resourceId)) {
            log.warn("소유권 없음: user={}, resource={}:{}", 
                currentUserId, ownerOnly.resourceType(), resourceId);
            throw new ForbiddenException("해당 리소스에 대한 권한이 없습니다");
        }
    }
}

// 3. OwnershipChecker 인터페이스
public interface OwnershipChecker {
    boolean isOwner(String userId, Object resourceId);
}

// 4. 구현체 등록
@Configuration
public class OwnershipConfig {
    
    @Bean
    public Map<String, OwnershipChecker> ownershipCheckers(
            OrderRepository orderRepository,
            PostRepository postRepository) {
        
        return Map.of(
            "ORDER", (userId, resourceId) -> {
                Order order = orderRepository.findById((Long) resourceId).orElse(null);
                return order != null && order.getUserId().toString().equals(userId);
            },
            "POST", (userId, resourceId) -> {
                Post post = postRepository.findById((Long) resourceId).orElse(null);
                return post != null && post.getAuthorId().toString().equals(userId);
            }
        );
    }
}

// 5. 사용
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @GetMapping("/{orderId}")
    @OwnerOnly(resourceType = "ORDER", resourceIdParam = "orderId")
    public Order getOrder(@PathVariable Long orderId) {
        return orderService.findById(orderId);
    }
    
    @DeleteMapping("/{orderId}")
    @OwnerOnly(resourceType = "ORDER", resourceIdParam = "orderId", allowAdmin = false)
    public void cancelOrder(@PathVariable Long orderId) {
        orderService.cancel(orderId);
    }
}

@RestController
@RequestMapping("/api/posts")
public class PostController {
    
    @PutMapping("/{postId}")
    @OwnerOnly(resourceType = "POST", resourceIdParam = "postId")
    public Post updatePost(@PathVariable Long postId, @RequestBody PostRequest request) {
        return postService.update(postId, request);
    }
}

3. @Sensitive - 민감 데이터 마스킹

// 1. 어노테이션 정의
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Sensitive {
    SensitiveType type() default SensitiveType.DEFAULT;
    int showFirst() default 0;    // 앞에서 보여줄 글자 수
    int showLast() default 0;     // 뒤에서 보여줄 글자 수
}

public enum SensitiveType {
    DEFAULT,      // 전체 마스킹
    EMAIL,        // 이메일 (앞 3자 + @ 뒤 도메인)
    PHONE,        // 전화번호 (가운데 마스킹)
    CARD_NUMBER,  // 카드번호 (앞 4자리, 뒤 4자리)
    NAME,         // 이름 (첫 글자만)
    ADDRESS       // 주소 (상세주소 마스킹)
}

// 2. 마스킹 유틸리티
@Component
public class SensitiveDataMasker {
    
    public String mask(String value, Sensitive annotation) {
        if (value == null || value.isEmpty()) {
            return value;
        }
        
        return switch (annotation.type()) {
            case EMAIL -> maskEmail(value);
            case PHONE -> maskPhone(value);
            case CARD_NUMBER -> maskCardNumber(value);
            case NAME -> maskName(value);
            case ADDRESS -> maskAddress(value);
            default -> maskDefault(value, annotation.showFirst(), annotation.showLast());
        };
    }
    
    private String maskEmail(String email) {
        int atIndex = email.indexOf('@');
        if (atIndex <= 3) {
            return "***" + email.substring(atIndex);
        }
        return email.substring(0, 3) + "***" + email.substring(atIndex);
    }
    
    private String maskPhone(String phone) {
        String digits = phone.replaceAll("[^0-9]", "");
        if (digits.length() < 10) return "***";
        return digits.substring(0, 3) + "-****-" + digits.substring(digits.length() - 4);
    }
    
    private String maskCardNumber(String cardNumber) {
        String digits = cardNumber.replaceAll("[^0-9]", "");
        if (digits.length() < 16) return "****-****-****-****";
        return digits.substring(0, 4) + "-****-****-" + digits.substring(12);
    }
}

// 3. ResponseBodyAdvice로 자동 마스킹
@RestControllerAdvice
@RequiredArgsConstructor
public class SensitiveDataMaskingAdvice implements ResponseBodyAdvice<Object> {
    
    private final SensitiveDataMasker masker;
    
    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }
    
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                   MediaType selectedContentType,
                                   Class selectedConverterType,
                                   ServerHttpRequest request,
                                   ServerHttpResponse response) {
        if (body == null) return null;
        
        maskSensitiveFields(body);
        return body;
    }
    
    private void maskSensitiveFields(Object obj) {
        if (obj == null) return;
        
        if (obj instanceof Collection<?> collection) {
            collection.forEach(this::maskSensitiveFields);
            return;
        }
        
        for (Field field : obj.getClass().getDeclaredFields()) {
            Sensitive sensitive = field.getAnnotation(Sensitive.class);
            if (sensitive != null && field.getType() == String.class) {
                field.setAccessible(true);
                try {
                    String value = (String) field.get(obj);
                    String masked = masker.mask(value, sensitive);
                    field.set(obj, masked);
                } catch (IllegalAccessException ignored) {}
            }
        }
    }
}

// 4. 사용
@Getter
public class UserResponse {
    private Long id;
    private String name;
    
    @Sensitive(type = SensitiveType.EMAIL)
    private String email;  // "john@example.com" → "joh***@example.com"
    
    @Sensitive(type = SensitiveType.PHONE)
    private String phone;  // "010-1234-5678" → "010-****-5678"
    
    @Sensitive(type = SensitiveType.CARD_NUMBER)
    private String cardNumber;  // "1234-5678-9012-3456" → "1234-****-****-3456"
}

4. @ApiKey - API 키 인증

// 1. 어노테이션 정의
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiKeyRequired {
    String header() default "X-API-Key";
    String[] scopes() default {};  // 필요한 권한 범위
}

// 2. Interceptor 구현
@Component
@RequiredArgsConstructor
public class ApiKeyInterceptor implements HandlerInterceptor {
    
    private final ApiKeyService apiKeyService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                              HttpServletResponse response,
                              Object handler) {
        
        if (!(handler instanceof HandlerMethod handlerMethod)) {
            return true;
        }
        
        ApiKeyRequired annotation = handlerMethod.getMethodAnnotation(ApiKeyRequired.class);
        if (annotation == null) {
            annotation = handlerMethod.getBeanType().getAnnotation(ApiKeyRequired.class);
        }
        
        if (annotation == null) {
            return true;
        }
        
        String apiKey = request.getHeader(annotation.header());
        if (apiKey == null || apiKey.isEmpty()) {
            throw new UnauthorizedException("API 키가 필요합니다");
        }
        
        ApiKeyInfo keyInfo = apiKeyService.validate(apiKey);
        if (keyInfo == null) {
            throw new UnauthorizedException("유효하지 않은 API 키입니다");
        }
        
        // 권한 범위 확인
        String[] requiredScopes = annotation.scopes();
        if (requiredScopes.length > 0) {
            Set<String> keyScopes = keyInfo.getScopes();
            boolean hasScope = Arrays.stream(requiredScopes)
                .anyMatch(keyScopes::contains);
            
            if (!hasScope) {
                throw new ForbiddenException("API 키에 필요한 권한이 없습니다");
            }
        }
        
        // 요청에 API 키 정보 저장
        request.setAttribute("apiKeyInfo", keyInfo);
        
        return true;
    }
}

// 3. 사용
@RestController
@RequestMapping("/api/v1/external")
@ApiKeyRequired  // 클래스 레벨
public class ExternalApiController {
    
    @GetMapping("/products")
    public List<Product> getProducts() {
        return productService.findAll();
    }
    
    @PostMapping("/orders")
    @ApiKeyRequired(scopes = {"orders:write"})
    public Order createOrder(@RequestBody OrderRequest request) {
        return orderService.create(request);
    }
    
    @DeleteMapping("/orders/{id}")
    @ApiKeyRequired(scopes = {"orders:write", "orders:delete"})
    public void deleteOrder(@PathVariable Long id) {
        orderService.delete(id);
    }
}

정리 및 베스트 프랙티스

커스텀 어노테이션 활용 요약

AOP 기반

  • • @LogExecutionTime - 실행 시간 측정
  • • @Retry - 재시도 로직
  • • @Audit - 감사 로깅
  • • @DistributedLock - 분산 락
  • • @RateLimit - API 호출 제한
  • • @Idempotent - 멱등성 보장

Validation 기반

  • • @PhoneNumber - 전화번호 검증
  • • @BusinessNumber - 사업자번호 검증
  • • @UniqueEmail - 중복 이메일 체크
  • • @PasswordMatch - 비밀번호 확인
  • • @DateRange - 날짜 범위 검증

ArgumentResolver 기반

  • • @CurrentUser - 현재 사용자 주입
  • • @ClientIp - 클라이언트 IP
  • • @RequestContext - 요청 컨텍스트
  • • @DecryptedBody - 복호화된 요청

보안 기반

  • • @RequireRole - 역할 기반 접근 제어
  • • @OwnerOnly - 리소스 소유자 확인
  • • @Sensitive - 민감 데이터 마스킹
  • • @ApiKeyRequired - API 키 인증

구현 방식 선택 가이드

요구사항구현 방식예시
메서드 실행 전후 로직AOP (@Around)로깅, 성능 측정, 트랜잭션
입력값 검증ConstraintValidator전화번호, 이메일 형식
Controller 파라미터 주입ArgumentResolver현재 사용자, IP 주소
요청 전처리InterceptorAPI 키 검증, 권한 체크
응답 후처리ResponseBodyAdvice데이터 마스킹, 암호화
여러 어노테이션 조합메타 어노테이션@RestController, @GetMapping

✅ 베스트 프랙티스

1. 명확한 네이밍

어노테이션 이름만으로 기능을 알 수 있게 작성

// Good
@LogExecutionTime, @RequireRole, @RateLimit

// Bad
@Log, @Check, @Limit
2. 적절한 기본값 제공

대부분의 경우에 맞는 기본값으로 사용 편의성 향상

@Retry(maxAttempts = 3, delay = 1000)  // 기본값 제공
public @interface Retry {
    int maxAttempts() default 3;
    long delay() default 1000;
}
3. SpEL 지원으로 유연성 확보

동적 값이 필요한 속성은 SpEL 표현식 지원

@DistributedLock(key = "'order:' + #orderId")
@RateLimit(key = "'user:' + #userId")
4. 문서화

@Documented 추가, Javadoc으로 사용법 설명

5. 테스트 작성

어노테이션 처리 로직에 대한 단위/통합 테스트 필수

⚠️ 주의사항

  • @Retention(RUNTIME) 필수:런타임에 어노테이션 정보를 읽으려면 RUNTIME 유지 정책 필요
  • AOP Self-Invocation:같은 클래스 내부 호출은 AOP 적용 안됨
  • 순서 고려:여러 Aspect 사용 시 @Order로 실행 순서 명시
  • 성능 영향:너무 많은 AOP 적용은 성능에 영향, 필요한 곳에만 적용
  • 예외 처리:Aspect에서 예외 발생 시 적절한 처리 필요

실무 적용 체크리스트

반복 코드 식별

여러 곳에서 반복되는 패턴을 어노테이션으로 추출

적절한 구현 방식 선택

AOP, Validator, ArgumentResolver 중 적합한 방식

테스트 코드 작성

정상/예외 케이스 모두 테스트

문서화

사용법, 속성 설명, 예제 코드 포함

팀 공유

공통 모듈로 분리하여 팀 전체에서 활용

권장 패키지 구조

com.example.common/
├── annotation/
│   ├── LogExecutionTime.java
│   ├── Retry.java
│   ├── DistributedLock.java
│   └── validation/
│       ├── PhoneNumber.java
│       └── BusinessNumber.java
├── aspect/
│   ├── LoggingAspect.java
│   ├── RetryAspect.java
│   └── DistributedLockAspect.java
├── resolver/
│   ├── CurrentUserArgumentResolver.java
│   └── ClientIpArgumentResolver.java
└── validator/
    ├── PhoneNumberValidator.java
    └── BusinessNumberValidator.java