새소식

TIL

[TIL] 230614 <Spring> Spring AOP

  • -

 

>> 회원별 'My 셀렉샵' 사용시간 조회 가능

  • 일반 회원은 조회 불가능
  • '관리자' 만 조회 가능

  • '나만의 셀렉샵' 서버 사용시간으로 하기로 함
    • 서버 사용시간: 'My 셀렉샵' 모든 API 수행시간의 총합
      1. 상품 조회 API ("GET /api/search") 수행시간
      2. 관심상품 등록 API ("POST /api/products") 수행시간
      3. 폴더 저장 API ("POST /api/folders") 수행시간
      4. ...
    • 예) 회원 A 의 "서버 사용시간"
      • 상품 조회 API: 6시간
      • 관심상품 등록 API: 3시간
      • 폴더 저장 API: 1시간
      • ⇒ 총합: 10시
  • 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 회원의 신뢰성 이슈가 발생
  • '핵심기능' 수정 시
    • 같은 함수 내에 '핵심기능'과 '부가기능'이 섞여 있음
    • '핵심기능' 이해를 위해 '부가기능'까지 이해 필요
  • '부가기능'의 변경이 필요하다면??
    • '핵심기능'의 개수만큼 '부가기능'도 수정 필요

 

🔻 🔻 🔻 🔻 🔻

AOP (Aspect Oriented Programming)를 통해 부가기능을 모듈화!

  • '부가기능'은 '핵심기능'과는 관점(Aspect), 관심이 다름
  • 따라서 '핵심기능'과 분리해서 '부가기능' 중심으로 설계, 구현 가능

 

 


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 동작

개념적 이해
스프링 실제 동작

 

AOP 적용 전
AOP 적용 후

  • Spring이 프록시(가짜 혹은 대리) 객체를 중간에 삽입해줌
  • DispatcherServlet과 ProductController 입장에서는 변화가 전혀 없음!
    • 호출되는 함수의 input, output 이 완전 동일
    • "joinPoint.proceed()" 에 의해서 원래 호출하려고 했던 함수, 인수(argument)가 전달됨 → createProduct(requestDto);

 

 


 

  • Client의 Error 처리에 대한 요구사항
    1. 폴더 중복 발생 시 상태코드는 400
      • statusCode : 400
    2. errorMessage : 중복된 폴더명을 제거해 주세요! 폴더명: {중복 폴더명}

 

@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;
}

 

 


@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
		);
	}
}

 

 


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);
	}
}
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.