※ 본 글은 AOP에 대해 어느 정도 이해를 하고 있다는 가정 하에 작성된 글입니다. AOP에 대해 자세히 모르시는 분들은 이 글을 참고해주세요. ※
일단, 간단한 REST API를 만들어 해당 API가 소요되는 시간, 그리고 리소스 요청 횟수를 제한,
이 2가지를 간단하게 하는 포스트를 하고자 합니다.
가상의 시나리오
클로버 도서관은 현재 코로나가 더 심해진 결과, 온라인에서 책 기부, 조회,
대출 가능 상태, 파손 여부 등등을 알 수 있도록 API를 개발을 요청하였습니다.
클로버 도서관은 책을 새로 구매하거나, 기부를 받아 책들을 마련합니다.
이때, 책을 기부받았을 경우, 기부자 명단에 올라갑니다.
그리고 사용자는 책 조회와 대출, 반납, 그리고 기부만 할 수 있게 해주세요.
현재, 관리자 카드를 통해 책 추가 및 파손을 처리하고 있습니다.
관리자 카드는 클로버 도서관에서만 사용하고 있고, 각각의 일련번호를 지니고 있습니다.
위 시나리오와 같이 개발을 해야 하는 경우, 크게 3가지 정도의 문제가 떠오릅니다.
- 클로버 도서관은 공공시설이기에 시간이 최대한 적게 발생해야 합니다.
- 관리자 번호로 계속 관리자 정보를 사용하게 되면, 탈취의 가능성이 있습니다.
- 너무 많은 이용자가 계속 요청을 할 경우, API에 무리가 발생합니다.
우선 첫 번째 문제를 해결하는 방법으로는 DB 튜닝 및 조회 제한 등을 적용하여 시간을 최대한 적게 발생시켜
해결할 수 있고, 2번 문제는 관리자 일련번호를 활용한 토큰 발급으로 해결할 수 있습니다.
그리고 마지막 문제인 3번은 정해진 시간 내에 너무 많은 요청이 들어왔을 경우, 429 에러를 발생시키는 것으로
어느정도 해결할 수 있습니다. (근본적인 해결책은 아니지만..)
엔티티 설계
엔티티 설계는 Book(도서), 그리고 Patron(후원자). 2가지뿐입니다.
@Getter
@AllArgsConstructor @NoArgsConstructor
@Builder
@Entity
public class Book {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
@Column(nullable = false)
private String publisher;
private boolean loaned = false;
private boolean damaged = false;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn
private Patron patron;
public void updateLoaned(boolean loaned) {
this.loaned = loaned;
}
public void updateDamaged() {
this.damaged = true;
}
public void setPatron(Patron patron) {
this.patron = patron;
}
}
@Getter
@AllArgsConstructor @NoArgsConstructor
@Builder
@Entity
public class Patron {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique=true, nullable=false)
private String name;
}
Book과 Patron의 연관관계는 일대다 단방향으로 하였습니다.
(후원자들이 굳이 어떤 도서들을 기부하였는지 알 필요는 없었기에)
그리고 fetch은 EAGER(즉시) 보다는 LAZY(지연)로 하였습니다.
(속도 개선 및 DB 쿼리 조회에 미미하게 영향을 미침)
리포지토리 설계
먼저 후원자 리포지토리는 후원자 이름을 사용한 간단한 쿼리만 사용합니다.
public interface PatronRepository extends JpaRepository<Patron, Long> {
boolean existsByName(String name);
Optional<Patron> findByName(String name);
}
그리고 도서 리포지토리는 조금이라도 효율성을 높이기 위해서 쿼리를 직접 구현했습니다.
public interface BookRepository extends JpaRepository<Book, Long> {
boolean existsByTitleAndAuthor(String title, String author);
@Query("select b from Book b where b.title like concat(?1, '%')")
List<Book> findAllByTitleContains(String title, PageRequest pageRequest);
@Query("select b from Book b where b.author like concat(?1, '%')")
List<Book> findAllByAuthorContains(String author, PageRequest pageRequest);
@Query("select b from Book b where b.patron.name like concat(?1, '%')")
List<Book> findAllByPatronName(String patron_name, PageRequest pageRequest);
@Query("select b from Book b where b.title like concat(?1, '%') and b.author like concat(?2, '%')")
List<Book> findAllByTitleAndAuthor(String title, String author, PageRequest pageRequest);
@Query("select b from Book b where b.title like concat(?1, '%') and b.patron.name like concat(?2, '%')")
List<Book> findAllByTitleAndPatron(String title, String patron_name, PageRequest pageRequest);
@Query("select b from Book b where b.author like concat(?1, '%') and b.patron.name like concat(?2, '%')")
List<Book> findAllByAuthorAndPatron(String title, String author, PageRequest pageRequest);
@Query("select b from Book b where b.title like concat(?1, '%') and b.author like concat(?2, '%') and b.patron.name like concat(?3, '%')")
List<Book> findAllByTitleAndAuthorAndPatron(String title, String author, String patron_name, PageRequest pageRequest);
지저분하고 굉장히 별로인 쿼리임을 알지만 제가 아는 선에서 쿼리 속도를 높였습니다.
서비스 설계
public interface BookService {
BookResponseDto registerBook(String token, BookRequestDto bookRequestDto);
List<BookResponseDto> findAllByTitle(String keyword, PageRequest pageRequest);
List<BookResponseDto> findAllByAuthor(String authorName, PageRequest pageRequest);
List<BookResponseDto> findAllByPatron(String patronName, PageRequest pageRequest);
List<BookResponseDto> findAllByTitleAndAuthor(String keyword, String authorName, PageRequest pageRequest);
List<BookResponseDto> findAllByTitleAndPatron(String keyword, String patronName, PageRequest pageRequest);
List<BookResponseDto> findAllByAuthorAndPatron(String authorName, String patronName, PageRequest pageRequest);
List<BookResponseDto> findAllByAll(String keyword, String authorName, String patronName, PageRequest pageRequest);
BookResponseDto loanBook(Long id);
BookResponseDto returnBook(Long id);
PageRequest setPageRequest(String request, String type);
void deleteBook(Long id);
}
public interface ManagerService {
TokenDto issueToken(String managerId);
boolean checkToken(String token);
}
public interface PatronService {
Patron findByName(String name);
PatronDto registerPatron(String name);
void deletePatron(String name);
}
위 서비스 interface를 정의하고 (interface)Impl.class를 구현하여 서비스를 구현하였습니다.
Response 설계
@Service
public class ResponseService {
public <T> ResponseEntity<T> getResponse(T data) {
return getResponse(data, HttpStatus.OK);
}
public <T> ResponseEntity<T> getResponse(T data, HttpStatus httpStatus) {
return new ResponseEntity<>(data, httpStatus);
}
public ResponseEntity<?> getCommonResponse(HttpStatus httpStatus) {
return new ResponseEntity<>(httpStatus);
}
public ResponseEntity<String> getResponseWithHeader(String data, String headerKey, String headerValue, HttpStatus httpStatus) {
HttpHeaders headers = new HttpHeaders();
headers.set(headerKey, headerValue);
return new ResponseEntity<>(data, headers, httpStatus);
}
}
@Getter
@Builder
public class ExceptionResponse {
private final Date timeStamp;
private final String message;
}
@Builder
@Getter
public class RetryAfterResponse {
@JsonProperty(value = "Retry-After")
private String RetryAfter;
}
해더 역시 값을 따로 넣을 수 있도록 하였지만, 이번 예제에서는 사용되지 않았습니다.
AOP 설계
어노테이션을 따로 구현하여 AOP를 설계 및 구현하였습니다.
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExecTimer {
}
@Slf4j
@Aspect
@Component
public class ExecutionTimer {
private final RateLimiter rateLimiter = RateLimiter.create(4.0);
// joinPoint 를 Annotation 으로 설정
@Pointcut("@annotation(com.example.aop_study.global.aop.annotation.ExecTimer)")
private void timer() {}
// method 실행 전, 후로 시간을 공유해야하기 때문
@Around("timer()")
public Object AssumeExecutionTimer(ProceedingJoinPoint joinPoint)
throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Object result = joinPoint.proceed();
stopWatch.stop();
long totalTimeMillis = stopWatch.getTotalTimeMillis();
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
String methodName = methodSignature.getMethod().getName();
log.info("실행 메소드: {}, 실행 시간: {}ms", methodName, totalTimeMillis);
return result;
}
@Before("timer()")
public void beforeRequestLimit(JoinPoint joinPoint) {
if (!rateLimiter.tryAcquire()) {
throw new HttpClientErrorException(HttpStatus.TOO_MANY_REQUESTS, "60");
}
}
}
Around를 사용하여 method 전, 후 둘 다 적용되도록 하였습니다.
그리고 Before를 사용하여 모든 timer()가 적용되는 method는 초당 최대 요청 횟수를 정해 사용하도록 합니다.
RateLimiter는 따로 dependency를 추가하면 사용할 수 있습니다.
implementation 'com.google.guava:guava:31.0.1-jre'
컨트롤러 설계
@RequestMapping("/api/books")
@RequiredArgsConstructor
@RestController
public class BookController {
private final BookServiceImpl bookService;
private final ResponseService responseService;
@ExecTimer
@PostMapping
public ResponseEntity<BookResponseDto> donateBook(@RequestBody BookRequestDto bookRequestDto) {
return responseService.getResponse(bookService.registerBook(null, bookRequestDto), HttpStatus.CREATED);
}
@ExecTimer
@GetMapping
public ResponseEntity<List<BookResponseDto>> searchByKeyword(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String author,
@RequestParam(required = false) String patron,
@RequestParam(defaultValue = "acs") String page
) {
PageRequest pageRequest = bookService.setPageRequest(page, "title");
if (keyword != null && author != null && patron != null) {
return responseService.getResponse(bookService.findAllByAll(keyword, author, patron, pageRequest));
}
else if (keyword != null && author != null) {
return responseService.getResponse(bookService.findAllByTitleAndAuthor(keyword, author, pageRequest));
}
else if (keyword != null && patron != null) {
return responseService.getResponse(bookService.findAllByTitleAndPatron(keyword, patron, pageRequest));
}
else if (author != null && patron != null) {
return responseService.getResponse(bookService.findAllByAuthorAndPatron(author, patron, pageRequest));
}
else if (keyword != null) {
return responseService.getResponse(bookService.findAllByTitle(keyword, pageRequest));
}
else if (author != null) {
return responseService.getResponse(bookService.findAllByAuthor(author, pageRequest));
}
else if (patron != null) {
return responseService.getResponse(bookService.findAllByPatron(patron, pageRequest));
}
else {
throw new HttpClientErrorException(HttpStatus.BAD_REQUEST, "keyword, author, patron 중 한 가지는 충족되어야합니다.");
}
}
// 대출
@ExecTimer
@PatchMapping("loan")
public ResponseEntity<BookResponseDto> loanByTitle(@RequestBody BookIdDto bookIdDto) {
if (bookIdDto.getId() == null) {
throw new HttpClientErrorException(HttpStatus.BAD_REQUEST, "`id`(body) must be Number");
}
return responseService.getResponse(bookService.loanBook(bookIdDto.getId()), HttpStatus.CREATED);
}
// 반납
@ExecTimer
@PatchMapping("return")
public ResponseEntity<BookResponseDto> returnByTitle(@RequestBody BookIdDto bookIdDto) {
if (bookIdDto.getId() == null) {
throw new HttpClientErrorException(HttpStatus.BAD_REQUEST, "`id`(body) must be Number");
}
return responseService.getResponse(bookService.returnBook(bookIdDto.getId()), HttpStatus.CREATED);
}
}
하나의 URI에서 파라미터를 최소 1개, 최대 3개를 사용할 수 있도록 구현하고 싶었지만, 마땅히 어떻게 해야 효율적으로 구현할 수 있을 지 몰라서, 일일이 하나하나 구현하였습니다.
@RequestMapping("/api/managers")
@RequiredArgsConstructor
@RestController
public class ManagerController {
private final ManagerServiceImpl managerService;
private final BookServiceImpl bookService;
private final PatronServiceImpl patronService;
private final ResponseService responseService;
// 책 추가
@PostMapping("/books")
public ResponseEntity<BookResponseDto> registerBook(
@RequestHeader(name = "X-AUTH-TOKEN") String token,
@RequestBody BookRequestDto bookRequestDto) {
if (!managerService.checkToken(token)) {
throw new HttpClientErrorException(HttpStatus.CONFLICT, "관리자 정보가 없는 토큰입니다.");
}
BookResponseDto bookResponseDto = bookService.registerBook(token, bookRequestDto);
return responseService.getResponse(bookResponseDto, HttpStatus.CREATED);
}
// 책 폐기
@DeleteMapping("/books")
public ResponseEntity<?> deleteBook(
@RequestHeader(name = "X-AUTH-TOKEN") String token,
@RequestBody BookIdDto bookIdDto) {
if (!managerService.checkToken(token)) {
throw new HttpClientErrorException(HttpStatus.CONFLICT, "관리자 정보가 없는 토큰입니다.");
}
bookService.deleteBook(bookIdDto.getId());
return responseService.getCommonResponse(HttpStatus.NO_CONTENT);
}
// 후원자 추가
@PostMapping("/patron")
public ResponseEntity<PatronDto> registerPatron(
@RequestHeader(name = "X-AUTH-TOKEN") String token,
@RequestBody PatronDto patronDto) {
if (!managerService.checkToken(token)) {
throw new HttpClientErrorException(HttpStatus.CONFLICT, "관리자 정보가 없는 토큰입니다.");
}
return responseService.getResponse(patronService.registerPatron(patronDto.getName()), HttpStatus.CREATED);
}
// 후원자 삭제
@DeleteMapping("/patron")
public ResponseEntity<PatronDto> deletePatron(
@RequestHeader(name = "X-AUTH-TOKEN") String token,
@RequestBody PatronDto patronDto) {
if (!managerService.checkToken(token)) {
throw new HttpClientErrorException(HttpStatus.CONFLICT, "관리자 정보가 없는 토큰입니다.");
}
return responseService.getResponse(patronService.registerPatron(patronDto.getName()), HttpStatus.CREATED);
}
}
@RequestMapping("/api/token")
@RequiredArgsConstructor
@RestController
public class TokenController {
private final ManagerServiceImpl managerService;
private final ResponseService responseService;
// 토큰 발행
@PostMapping
public ResponseEntity<TokenDto> issueToken(@RequestBody TokenRequestDto tokenRequestDto) {
TokenDto tokenDto = managerService.issueToken(tokenRequestDto.getManagerId());
return responseService.getResponse(tokenDto, HttpStatus.CREATED);
}
}
토큰 발행을 하며, 리프레시 토큰은 따로 구현하지 않았습니다.
테스트 케이스 설계
- 도서 기부 성공
- 도서 기부 실패 (이미 등록된 도서)
- 도서 기부 실패 (존재하지않는 후원자)
- 도서 기부 실패 (후원자 없이 후원)
- 도서 조회 성공
- 도서 10권 조회 [시간 소요 확인]
- 도서 대출 성공
- 도서 대출 실패 (이미 대출된 도서)
- 도서 대출 실패 (파손된 도서)
- 도서 반납 성공
- 도서 반납 실패 (대출되지않은 도서)
- 관리자 권한 확인 - 도서 추가
- 도서 폐기 성공
- 도서 폐기 실패 (대출상태인 도서)
- 도서 폐기 실패 (이상 없는 도서)
- 후원자 추가 성공
- 후원자 추가 실패 (이미 존재하는 후원자)
- 후원자 삭제 성공
- 후원자 삭제 실패 (존재하지않는 후원자)
전체 코드
https://github.com/iqpizza6349/AOP_Study
여러 가지 이슈 해결
@Around를 사용하였을 때, 해당 method가 void일 경우, 아무런 response가 존재하지 않았음
→ void를 Object으로 바꿈 proceedingJoinPoint#proceed(); 이후, return proceedingJoinPoint;
Consider defining a bean of type 'com.example.aop_study.global.response.service.ResponseService' in your configuration.
@BootstrapWith(SpringBootTestContextBootstrapper.class) 가 적용되어있는 어노테이션을 2개이상 사용하게 될 경우 발생하는 것으로 보입니다.
이상입니다.
'Spring' 카테고리의 다른 글
스프링 4 경험하기 (0) | 2022.04.30 |
---|---|
[Spring] JPA 공부 #1 (1) | 2022.03.25 |
[Spring] DTO vs VO (0) | 2022.02.28 |
[Spring] Spring Transaction 세부 설정 설명 (0) | 2022.02.21 |
[Spring] Spring Transaction 특징 및 핵심기술 설명 (0) | 2022.02.20 |