Spring 07: Custom Annotation 활용
커스텀 어노테이션으로 공통 모듈 구현과 코드 최적화
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 주소 |
| 요청 전처리 | Interceptor | API 키 검증, 권한 체크 |
| 응답 후처리 | ResponseBodyAdvice | 데이터 마스킹, 암호화 |
| 여러 어노테이션 조합 | 메타 어노테이션 | @RestController, @GetMapping |
✅ 베스트 프랙티스
어노테이션 이름만으로 기능을 알 수 있게 작성
// Good
@LogExecutionTime, @RequireRole, @RateLimit
// Bad
@Log, @Check, @Limit대부분의 경우에 맞는 기본값으로 사용 편의성 향상
@Retry(maxAttempts = 3, delay = 1000) // 기본값 제공
public @interface Retry {
int maxAttempts() default 3;
long delay() default 1000;
}동적 값이 필요한 속성은 SpEL 표현식 지원
@DistributedLock(key = "'order:' + #orderId")
@RateLimit(key = "'user:' + #userId")@Documented 추가, Javadoc으로 사용법 설명
어노테이션 처리 로직에 대한 단위/통합 테스트 필수
⚠️ 주의사항
- @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