Spring

[Spring] AOP 예제

오잎 클로버 2022. 3. 14. 14:50
728x90

※ 본 글은 AOP에 대해 어느 정도 이해를 하고 있다는 가정 하에 작성된 글입니다. AOP에 대해 자세히 모르시는 분들은 이 글을 참고해주세요. ※

 

[Spring] Spring AOP/PSA 설명

이전 포스트에서 POJO를 간단하게나마 설명하였습니다. 그중에서 IOC와 DI는 짧게라도 설명한 적이 있으나, AOP와 PSA는 없었기에 이번 기회에 포스트하고자 합니다. AOP란? Aspect Oriented Programming의 약

workshop-6349.tistory.com

일단, 간단한 REST API를 만들어 해당 API가 소요되는 시간, 그리고 리소스 요청 횟수를 제한,
이 2가지를 간단하게 하는 포스트를 하고자 합니다.

가상의 시나리오

클로버 도서관은 현재 코로나가 더 심해진 결과, 온라인에서 책 기부, 조회,
대출 가능 상태, 파손 여부 등등을 알 수 있도록 API를 개발을 요청하였습니다.
클로버 도서관은 책을 새로 구매하거나, 기부를 받아 책들을 마련합니다.
이때, 책을 기부받았을 경우, 기부자 명단에 올라갑니다.
그리고 사용자는 책 조회와 대출, 반납, 그리고 기부만 할 수 있게 해주세요.
현재, 관리자 카드를 통해 책 추가 및 파손을 처리하고 있습니다.
관리자 카드는 클로버 도서관에서만 사용하고 있고, 각각의 일련번호를 지니고 있습니다.


위 시나리오와 같이 개발을 해야 하는 경우, 크게 3가지 정도의 문제가 떠오릅니다.

  1. 클로버 도서관은 공공시설이기에 시간이 최대한 적게 발생해야 합니다.
  2. 관리자 번호로 계속 관리자 정보를 사용하게 되면, 탈취의 가능성이 있습니다.
  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

 

GitHub - iqpizza6349/AOP_Study

Contribute to iqpizza6349/AOP_Study development by creating an account on GitHub.

github.com

여러 가지 이슈 해결

@Around를 사용하였을 때, 해당 method가 void일 경우, 아무런 response가 존재하지 않았음

→ void를 Object으로 바꿈 proceedingJoinPoint#proceed(); 이후, return proceedingJoinPoint;

참고: https://stackoverflow.com/questions/68122232/when-use-around-aspect-then-restcontroller-return-empty-response

 

When use @Around aspect then RestController return empty response

I have simple rest controller : @RestController @RequestMapping(value = "/api/admin/operations") public class OperationController { @GetMapping("/get")

stackoverflow.com

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