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);
}
}
'TIL' 카테고리의 다른 글
[TIL] 230618 <Spring> Jakarta Validation API (0) | 2024.06.18 |
---|---|
[TIL] 230617 <Spring> @Transactional, @ResponseStatus, HTTP 상태 코드 (0) | 2024.06.17 |
[TIL] 230613 <Spring> 테스트 (3) | 2024.06.13 |
[TIL] 230612 <Spring> 카카오 로그인 (1) | 2024.06.12 |
[TIL] 230611 <Spring> 팀프로젝트 14조 - E거 I4아이가 KPT 회고 (2) | 2024.06.11 |
Contents
소중한 공감 감사합니다