TIL
[TIL] 230614 <Spring> Spring AOP
- -
[My 셀렉샵 Top5 회원 찾기 설계 및 구현]
>> 회원별 'My 셀렉샵' 사용시간 조회 가능
- 일반 회원은 조회 불가능
- '관리자' 만 조회 가능
- '나만의 셀렉샵' 서버 사용시간으로 하기로 함
- 서버 사용시간: 'My 셀렉샵' 모든 API 수행시간의 총합
- 상품 조회 API ("GET /api/search") 수행시간
- 관심상품 등록 API ("POST /api/products") 수행시간
- 폴더 저장 API ("POST /api/folders") 수행시간
- ...
- 예) 회원 A 의 "서버 사용시간"
- 상품 조회 API: 6시간
- 관심상품 등록 API: 3시간
- 폴더 저장 API: 1시간
- ⇒ 총합: 10시
- 서버 사용시간: 'My 셀렉샵' 모든 API 수행시간의 총합
- API 사용시간
- = Controller 에 요청이 들어온 시간 ~ 응답이 나간 시간
- 예)
- Controller 에 요청이 들어온 시간: 9시 10분 30초
- Controller 에 응답이 나간 시간: 9시 10분 33초
- API 사용시간?
- Controller 에 응답이 나간 시간 - Controller 에 요청이 들어온 시간
- 3초
수행시간 측정 코드 구현
Intellij 메뉴에서 File > New > Scratch File → Java 선택
class Scratch { public static void main(String[] args) { // 측정 시작 시간 long startTime = System.currentTimeMillis(); // 함수 수행 long output = sumFromOneTo(1_000_000_000); // 측정 종료 시간 long endTime = System.currentTimeMillis(); long runTime = endTime - startTime; System.out.println("소요시간: " + runTime); } private static long sumFromOneTo(long input) { long output = 0; for (int i = 1; i < input; ++i) { output = output + i; } return output; } }
회원별 사용시간 누적 저장
entity > ApiUseTime
@Entity @Getter @Setter @NoArgsConstructor @Table(name = "api_use_time") public class ApiUseTime { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToOne @JoinColumn(name = "user_id", nullable = false) private User user; @Column(nullable = false) private Long totalTime; public ApiUseTime(User user, Long totalTime) { this.user = user; this.totalTime = totalTime; } public void addUseTime(long useTime) { this.totalTime += useTime; } }
repository > ApiUseTimeRepository
public interface ApiUseTimeRepository extends JpaRepository<ApiUseTime, Long> { Optional<ApiUseTime> findByUser(User user); }
controller>ProductController>createProduct()
private final ApiUseTimeRepository apiUseTimeRepository; // 관심 상품 등록하기 @PostMapping("/products") public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) { // 측정 시작 시간 long startTime = System.currentTimeMillis(); try { // 응답 보내기 return productService.createProduct(requestDto, userDetails.getUser()); } finally { // 무조건 수행됨 // 측정 종료 시간 long endTime = System.currentTimeMillis(); // 수행시간 = 종료 시간 - 시작 시간 long runTime = endTime - startTime; // 로그인 회원 정보 User loginUser = userDetails.getUser(); // API 사용시간 및 DB 에 기록 ApiUseTime apiUseTime = apiUseTimeRepository.findByUser(loginUser) .orElse(null); if (apiUseTime == null) { // 로그인 회원의 기록이 없으면 apiUseTime = new ApiUseTime(loginUser, runTime); } else { // 로그인 회원의 기록이 이미 있으면 apiUseTime.addUseTime(runTime); } System.out.println("[API Use Time] Username: " + loginUser.getUsername() + ", Total Time: " + apiUseTime.getTotalTime() + " ms"); apiUseTimeRepository.save(apiUseTime); } }
부가기능 모듈화
- '핵심기능': 각 API 별 수행해야 할 비즈니스 로직
ex) 상품 키워드 검색, 관심상품 등록, 회원 가입, 관심상품에 폴더 추가, .... - '부가기능': 핵심기능을 보조하는 기능
ex) 회원 패턴 분석을 위한 로그 기록, API 수행시간 저장

문제점
- 모든 '핵심기능'의 Controller 에 '부가기능' 코드를 추가했을 때..
- '핵심기능' 이 100개라면??
- 100개의 '핵심기능' 모두에 동일한 내용의 코드 추가 필요
- '핵심기능' 이 나중에 추가된다면?
- 항상 '부가기능' 추가를 신경써야함
- '부가기능' 추가를 깜박한다면?
- 일부 API 수행시간이 추가되지 않음 → Top5 회원의 신뢰성 이슈가 발생
- '핵심기능' 이 100개라면??
- '핵심기능' 수정 시
- 같은 함수 내에 '핵심기능'과 '부가기능'이 섞여 있음
- '핵심기능' 이해를 위해 '부가기능'까지 이해 필요
- '부가기능'의 변경이 필요하다면??
- '핵심기능'의 개수만큼 '부가기능'도 수정 필요
🔻 🔻 🔻 🔻 🔻
AOP (Aspect Oriented Programming)를 통해 부가기능을 모듈화!
- '부가기능'은 '핵심기능'과는 관점(Aspect), 관심이 다름
- 따라서 '핵심기능'과 분리해서 '부가기능' 중심으로 설계, 구현 가능

[Spring AOP]

Spring의 AOP 애너테이션
1. @Aspect
- Spring 빈(Bean) 클래스에만 적용 가능합니다.
2. 어드바이스 종류 (언제)
- @Around: '핵심기능' 수행 전과 후 (@Before + @After)
- @Before: '핵심기능' 호출 전 (ex. Client 의 입력값 Validation 수행)
- @After: '핵심기능' 수행 성공/실패 여부와 상관없이 언제나 동작 (try, catch 의 finally() 처럼 동작)
- @AfterReturning: '핵심기능' 호출 성공 시 (함수의 Return 값 사용 가능)
- @AfterThrowing: '핵심기능' 호출 실패 시. 즉, 예외 (Exception) 가 발생한 경우만 동작
(ex. 예외가 발생했을 때 개발자에게 email 이나 SMS 보냄
3. 포인트 컷 (어느 지점에서)
<포인트컷 Expression Language>
- 포인트컷 Expression 형태
execution(modifiers-pattern? return-type-pattern declaring-type-pattern? method-name-pattern(param-pattern) throws-pattern?)
ㄴ ? 는 생략 가능
ㄴ 포인트컷 Expression 예제
@Around("execution(public * com.sparta.myselectshop.controller..*(..))") public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { ... }
- modifiers-pattern
- public, private, *
- return-type-pattern
- void, String, List<String>, *****
- declaring-type-pattern
- 클래스명 (패키지명 필요)
- com.sparta.myselectshop.controller.* - controller 패키지의 모든 클래스에 적용
- com.sparta.myselectshop.controller.. - controller 패키지 및 하위 패키지의 모든 클래스에 적용
- method-name-pattern(param-pattern)
- 함수명
- addFolders : addFolders() 함수에만 적용
- add* : add 로 시작하는 모든 함수에 적용
- 파라미터 패턴 (param-pattern)
- (com.sparta.myselectshop.dto.FolderRequestDto) - FolderRequestDto 인수 (arguments) 만 적용
- () - 인수 없음
- (*) - 인수 1개 (타입 상관없음)
- (..) - 인수 0~N개 (타입 상관없음)
- 함수명
- @Pointcut
- 포인트컷 재사용 가능
- 포인트컷 결합 (combine) 가능
@Component @Aspect public class Aspect { @Pointcut("execution(* com.sparta.myselectshop.controller.*.*(..))") private void forAllController() {} @Pointcut("execution(String com.sparta.myselectshop.controller.*.*())") private void forAllViewController() {} @Around("forAllContorller() && !forAllViewController()") public void saveRestApiLog() { ... } @Around("forAllContorller()") public void saveAllApiLog() { ... } }
controller>ProductController
@PostMapping("/products") public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) { return productService.createProduct(requestDto, userDetails.getUser()); }
aop>UseTimeAop (AOP 사용해 FolderController, ProductController, NaverApiController에 부가기능 추가)
@Slf4j(topic = "UseTimeAop") @Aspect //클래스쪽에 달아야하며 Bean클래스에만 적용가능 @Component public class UseTimeAop { private final ApiUseTimeRepository apiUseTimeRepository; public UseTimeAop(ApiUseTimeRepository apiUseTimeRepository) { this.apiUseTimeRepository = apiUseTimeRepository; } @Pointcut("execution(* com.sparta.myselectshop.controller.ProductController.*(..))") private void product() {} @Pointcut("execution(* com.sparta.myselectshop.controller.FolderController.*(..))") private void folder() {} @Pointcut("execution(* com.sparta.myselectshop.naver.controller.NaverApiController.*(..))") private void naver() {} //수행되기 전 시작시간, 후 종료시간 @Around("product() || folder() || naver()") //Pointcut 찍어줌 (하나라도 성립하면 수행) public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { // 측정 시작 시간 long startTime = System.currentTimeMillis(); try { // 핵심기능 수행 Object output = joinPoint.proceed(); //해당하는 요청 컨트롤러의 메서드가 수행됨 return output; } finally { // 측정 종료 시간 long endTime = System.currentTimeMillis(); // 수행시간 = 종료 시간 - 시작 시간 long runTime = endTime - startTime; // 로그인 회원이 없는 경우, 수행시간 기록하지 않음 Authentication auth = SecurityContextHolder.getContext().getAuthentication(); //인증객체 가져옴 if (auth != null && auth.getPrincipal().getClass() == UserDetailsImpl.class) { // 로그인 회원 정보 UserDetailsImpl userDetails = (UserDetailsImpl) auth.getPrincipal(); User loginUser = userDetails.getUser(); // API 사용시간 및 DB 에 기록 ApiUseTime apiUseTime = apiUseTimeRepository.findByUser(loginUser).orElse(null); if (apiUseTime == null) { // 로그인 회원의 기록이 없으면 apiUseTime = new ApiUseTime(loginUser, runTime); } else { // 로그인 회원의 기록이 이미 있으면 apiUseTime.addUseTime(runTime); } log.info("[API Use Time] Username: " + loginUser.getUsername() + ", Total Time: " + apiUseTime.getTotalTime() + " ms"); apiUseTimeRepository.save(apiUseTime); } } } }
Spring의 AOP 동작




- Spring이 프록시(가짜 혹은 대리) 객체를 중간에 삽입해줌
- DispatcherServlet과 ProductController 입장에서는 변화가 전혀 없음!
- 호출되는 함수의 input, output 이 완전 동일
- "joinPoint.proceed()" 에 의해서 원래 호출하려고 했던 함수, 인수(argument)가 전달됨 → createProduct(requestDto);
[API 예외처리]
- Client의 Error 처리에 대한 요구사항
- 폴더 중복 발생 시 상태코드는 400
- statusCode : 400
- errorMessage : 중복된 폴더명을 제거해 주세요! 폴더명: {중복 폴더명}
- 폴더 중복 발생 시 상태코드는 400
@ExceptionHandler 사용
- FolderController 의 모든 메서드에 예외처리 적용 (AOP) : @ExceptionHandler
- @ExceptionHandler 는 Spring에서 예외처리를 위한 애너테이션
- 특정 Controller에서 발생한 예외를 처리하기 위해 사용
- @ExceptionHandler가 붙어있는 메서드는 Controller에서 예외가 발생했을 때 호출되며, 해당 예외를 처리하는 로직을 담고 있음
- AOP를 이용한 예외처리 방식이기때문에, 메서드마다 try catch할 필요없이 깔끔한 예외처리가 가능
controller>FolderController
//AOP를 이용한 예외처리 방식 @ExceptionHandler({IllegalArgumentException.class}) //Controller 내부에서 IllegalArgumentException 발생 시 처리 public ResponseEntity<RestApiException> handleException(IllegalArgumentException ex) { System.out.println("FolderController.handleException"); RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value()); return new ResponseEntity<>( // HTTP body restApiException, // HTTP status code HttpStatus.BAD_REQUEST ); }
exception>RestApiException
@Getter @AllArgsConstructor public class RestApiException { private String errorMessage; private int statusCode; }
[Spring의 Global 예외처리]

@ControllerAdvice 사용
- @ControllerAdvice는 Spring에서 예외처리를 위한 클래스 레벨 애너테이션
- 모든 Controller에서 발생한 예외를 처리하기 위해 사용
- @ControllerAdvice가 붙은 클래스에서는 @ExceptionHandler메서드를 정의하여 예외를 처리하는 로직을 담을 수 있음
- @ControllerAdvice 를 사용하는 이유?
- 예외처리를 중앙 집중화하기 좋음
- 각각의 Controller에서 예외처리 로직을 반복하지 않아도 되므로 코드의 중복을 방지하고 유지보수성을 높일 수 있음
- 예외 처리 로직을 모듈화하여 관리하기 쉽기 때문에, 팀 내에서 공통된 예외 처리 로직을 공유하거나 다른 팀에서 예외 처리를 참고할 수 있어 개발 생산성을 향상 가능
- @RestControllerAdvice
- @ControllerAdvice + @ResponseBody
exception > GlobalExceptionHandler
@RestControllerAdvice //Controller에서 발생하는 모든 예외처리들을 공통적으로 처리해주기 위함 public class GlobalExceptionHandler { @ExceptionHandler({IllegalArgumentException.class}) public ResponseEntity<RestApiException> handleException(IllegalArgumentException ex) { RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value()); return new ResponseEntity<>( // HTTP body restApiException, // HTTP status code HttpStatus.BAD_REQUEST ); } }
[Error 메시지 관리하기]
Spring의 properties 파일을 이용한 에러 메시지 관리
- 에러 메시지는 properties 파일에서 key-value 형태로 작성되며, 작성된 값은 messageSource를 Bean으로 등록하여 사용가능
resources > messages.properties
below.min.my.price=최저 희망가는 최소 {0}원 이상으로 설정해 주세요. not.found.product=해당 상품이 존재하지 않습니다.
- Spring Boot에서는 messageSource가 자동으로 Bean으로 등록됨
- messageSource.getMessage() 메서드
- 첫번째 파라미터는 messages.properties 파일에서 가져올 메시지의 키 값을 전달
- 두번째 파라미터는 메시지 내에서 매개변수를 사용할 경우 전달하는 값
- 세번째 파라미터는 언어 설정을 전달
- Locale.getDefault()메서드는 기본 언어 설정을 가져오는 메서드
service>ProductService>updateProduct()
private final MessageSource messageSource; public static final int MIN_MY_PRICE = 100; @Transactional public ProductResponseDto updateProduct(Long id, ProductMypriceRequestDto requestDto) { int myprice = requestDto.getMyprice(); if (myprice < MIN_MY_PRICE) { throw new IllegalArgumentException( messageSource.getMessage( "below.min.my.price", new Integer[]{MIN_MY_PRICE}, "Wrong Price", Locale.getDefault() ) ); } Product product = productRepository.findById(id).orElseThrow(() -> new ProductNotFoundException(messageSource.getMessage( "not.found.product", null, "Not Found Product", Locale.getDefault() )) ); product.update(requestDto); return new ProductResponseDto(product); }
- Exception 클래스를 직접 구현하여 사용
exception>ProductNotFoundException
public class ProductNotFoundException extends RuntimeException { public ProductNotFoundException(String message) { super(message); } }
소중한 공감 감사합니다