좋아요 기능의 동시성 문제를 낙관적 락(Optimistic Lock)과 Retry를 통해 해결하고 개선하는 과정을 정리해 보았다.

 

현재 진행 중인 프로젝트에는 방명록(Post)에 좋아요를 누를 수 있고 Post 목록 조회 시 좋아요 개수를 같이 보여주고 있다.

Post 목록 조회 시마다 방명록 좋아요 테이블을 join해서 좋아요 개수를 계산하는 것은 비효율적이라고 판단해서 반정규화로 Post에 별도의 likeCount 칼럼을 추가했다.

 

하지만 여러 사용자가 동시에 좋아요를 누를 경우 likeCount를 update 하면서 lost update 문제가 발생할 수 있다. 예를 들어 방명록의 좋아요가 5개에서 두 유저가 좋아요를 누르면 7개가 되어야 한다. 그런데 두 유저가 동시에 좋아요를 누르고 각각 조회 시점에 좋아요 개수가 5개로 보이면 둘 다 6으로 업데이트를 하게 된다. (업데이트를 어떻게 하냐에 따라 다르지만..)

 

이러한 문제를 해결하기 위해 낙관적 락(Optimistic Lock), 비관적 락(Pessimistic Lock)을 사용해 볼 수 있다.

 

낙관적 락은 충돌이 많이 발생하지 않는다고 가정하고 애플리케이션 레벨에서 락을 거는 방식이다.

비관적 락은 충돌이 많이 발생한다고 가정하고 DB에서 락을 거는 방식이다.

 

DB에서 락을 거는 방식은 레코드를 SELECT로 읽으면서 FOR SHARE, FOR UPDATE를 사용하면 직접 잠금을 걸 수 있다.

(select .. from .. where .. for share)

  • FOR SHARE는 읽기 잠금(공유 잠금, Shared lock)으로 다른 세션에서 해당 레코드를 읽는 것은 가능하고 변경할 수 없게 한다.
  • FOR UPDATE는 쓰기 잠금(배타 잠금, Exclusive lock)으로 다른 트랜잭션에서 그 레코드를 변경하는 것뿐만 아니라 읽기(FOR SHARE 절을 사용하는 SELECT 쿼리)도 사용할 수 없다.

참고로 InnoDB 스토리지 엔진의 경우 MVCC(Multi Version Concurrency Control)를 통해 잠금 없는 읽기(Non Locking Consistent Read)를 지원하기 때문에 단순 SELECT문은 아무런 대기 없이 실행된다.

 

비관적 락은 높은 데이터 무결성을 보장하고 충돌이 많이 발생하는 경우 낙관적 락보다 효율적일 수 있지만 일반적으로 DB에 락을 걸기 때문에 동시 처리 성능이 떨어지고 데드락 위험이 있다. 좋아요 기능에 비관적 락을 거는 것은 성능면에서도 적절하지 않은 것 같고 현재 상황에서 충돌이 거의 발생하지 않는다고 판단해서 낙관적 락을 사용하기로 했다.

 

낙관적 락은 애플리케이션에서 락을 거는 방식으로 JPA는 @Version 애노테이션으로 version 칼럼을 통해 버전을 관리할 수 있다.

version 자료형으로는 Integer(int), Long(long) , Short(short) , timestamp가 있다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long likeCount;

    @Version
    private Integer version;
}

 

방명록 좋아요 로직은 아래와 같이 작성했다. 방명록의 likeCount는 JPA의 변경 감지(dirty checking)으로 update 한다.

 

@Transactional
public void likePost(Long postId, Long userId) {
    final User user = findLoginUser(userId);
    final Post post = findPost(postId);
    if (postQueryRepository.existsPostLike(post, user)) {
        throw new ApiException(PostErrorCode.ALREADY_LIKE);
    }
    final PostLike postLike = post.likeFrom(user);
    postLikeRepository.save(postLike);
}

 

락 모드는 LockModeType로 설정할 수 있는데 version이 있는 Entity는 락 옵션을 적용하지 않아도 기본적으로 낙관적 락을 사용한다.

  • NONE
    • 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경(삭제)되지 않아야 한다. 엔티티를 수정할 때 version을 체크하면서 version을 증가시킨다. 만약 version 값이 다르면 예외가 발생한다.
    • update에 성공한 thread(pool-4-thread-2)은 추가 쿼리가 발생하지 않았다. 반면 update에 실패한 thread(pool-4-thread-1)은 select 쿼리를 한번 더 날리는 것으로 봐서 updated row가 없으면 select 쿼리로 조회해보고 예외를 던지는 것 같다.

  • OPTIMISTIC
    • 엔티티를 조회만 해도 버전을 체크해서 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
    • 트랜잭션을 커밋할 때 update와 상관없이 version을 조회해서 현재 엔티티의 버전과 같은지 체크한다.
    • 만약 version이 바뀌어 update에 실패하면 NONE인 경우랑 마찬가지로 version 조회 대신 엔티티를 조회해 보고 예외를 던진다.

 

  • OPTIMISTIC_FORCE_INCREMENT
    • 엔티티를 수정하지 않아도 version을 증가시키고, 엔티티를 수정하면 version이 2번 나타날 수 있다.

 

Optimistic Lock 예외 상황을 테스트해보았다. CountDownLatch를 사용해서 첫 번째 Thread를 작업 중간에 wait 시키고, 두번째 Thread가 version을 update 시킨다. 그러면 첫번째 Thread는 바뀐 version으로 인해 update에 실패하고 예외가 발생한다.

 

var waitingLatch = new CountDownLatch(1);
// 낙관적 락 version이 업데이트가 될때까지 대기
var waitWorker = CompletableFuture.runAsync(() -> waitPostLikeAndNoRetry(post, likeFailUser, waitingLatch));
// 낙관적 락 version을 업데이트하고 countDown
CompletableFuture.runAsync(() -> postService.likePost(post.getId(), likeSuccessUser.getId()))
    .thenRun(waitingLatch::countDown);
// 두번째 작업의 완료로 인해 version이 변경 되어 OptimisticLockingFailureException 예외 발생
assertThatThrownBy(waitWorker::join)
    .extracting("cause")
    .isInstanceOf(OptimisticLockingFailureException.class);

 

Hibernate에서 발생한 StaleObjectStateException 예외를 스프링에서 OptimisticLockingFailureException으로 감싸서 던진다.

 

 

Optimistic Lock 예외를 클라이언트한테 에러로 응답해 주면 클라이언트는 매우 불편할 것이다. 그래서 Optimistic 예외 발생 시 재시도를 위해 스프링 AOP로 Retry를 추가해 줬다.

 

AOP란?

  • 애플리케이션 로직은 크게 핵심 기능과 부가 기능으로 나눌 수 있는데 부가 기능은 핵심 기능을 보조하기 위해 제공되는 기능이다.
  • 여기서 핵심 기능은 좋아요 기능이고 부가 기능은 재시도 기능이 되겠다. 좋아요를 처리하는 로직 외에 예외를 try-catch 해서 재시도하는 로직이 들어가면 가독성도 떨어지고 다른 기능에서도 재시도가 필요한 경우 거의 동일한 로직을 사용한다.
  • AOP(Aspect-Oriented Programming)은 관점 지향 프로그래밍으로, Aspect는 부가 기능과 해당 부과 기능을 어디에 적용할지 선택하는 기능을 하나의 모듈로 만든 것이며 핵심 기능에서 부가 기능을 분리하기 위해 나온 것이다.
  • 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단 관심사(cross-cutting-concerns) 관점으로 보면 여러 모듈에서 공통적으로 사용되는 부가 기능들이 있을 것이다.

 

스프링 AOP는 프록시를 통해 AOP를 적용하는데 메서드에만 적용할 수 있는 등의 한계와 주의점들이 있지만 비교적 간단하고 유연하게 AOP를 적용할 수 있다.

 

AOP 간단 용어

  • 조인 포인트(Join point)
    • 추상적인 개념으로 AOP를 적용할 수 있는 지점
    • 스프링 AOP는 프록시 방식의 한계로 조인 포인트가 메소드 지점으로 제한된다.

 

 

  • 포인트컷(Pointcut)
    • 어디에 부가 기능을 적용할지, 적용하지 않을지 판단하는 필터링 로직이다.
    • 주로 AspectJ 표현식, 애노테이션으로 지정
  • 어드바이스(Advice)
    • 프록시가 호출하는 부가 기능, 즉 프록시 로직
  • 애스펙트(Aspect)
    • Advice + Pointcut를 모듈화 한 것
    • 여러 Advice와 Pointcut이 함께 존재
  • 어드바이저(Advisor)
    • 하나의 Advice와 Pointcut으로 구성
    • 스프링 AOP에서만 사용되는 용어

스프링 AOP 라이브러리를 추가하면 AnnotationAwareAspectJAutoProxyCreator 라는 빈 후처리기(BeanPostProcessor)가 스프링 빈에 자동으로 등록된다. (BeanPostProcessor는 스프링이 객체를 생성하여 Bean 저장소에 등록하기 전에 조작하기 위해 사용한다.)

AnnotationAwareAspectJAutoProxyCreator는 스프링 빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용, @AspectJ 와 관련된 AOP 기능도 자동으로 찾아서 처리한다.

스프링이 bean 대상이 되는 객체를 생성해서 AnnotationAwareAspectJAutoProxyCreator에 전달하면 스프링 컨테이너에서 모든 Advisor를 조회해서 Pointcut을 통해 프록시 적용 대상인지를 판단하고 프록시 적용 대상이라면 프록시를 생성하고 반환해서 프록시를 스프링 빈으로 등록한다. @Aspect 애노테이션을 찾아서 Advisor로 변환해서 저장하는 기능도 한다.

 

BeanFactoryAspectJAdvisorBuilder는 내부 저장소에 @Aspect 정보를 기반으로 생성된 Advisor를 캐시 한다. 캐시에 어드바이저가 이미 만들어져 있는 경우 캐시에 저장된 어드바이저 반환한다.

 

Advice 종류

  • @Around : 메서드 호출 전후 수행, JoinPoint 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능
  • @Before: JoinPoint 실행 이전에 실행
  • @AfterReturning : JoinPoint 정상 완료 후 실행
  • @AfterThrowing : 메서드가 예외를 던지는 경우 실행
  • @After : JoinPoint가 정상 또는 예외에 관계없이 실행

Retry 로직은 다음과 같이 추가했다. Advisor는 기본적으로 순서를 보장하지 않아서 필요시 Order 애노테이션으로 순서를 적용해줘야 한다. (클래스 단위로 적용)

@Transactional은 @EnableTransactionManagement를 보면 Order가 가장 낮은 순위로 되어있다.

 

 

Order를 지정해주지 않으면 Transactional이 Retry보다 먼저 실행되는 경우 변경 감지가 커밋 시점에 발생하기 때문에 변경 감지 update 쿼리가 나가기 전에 retry 로직이 성공으로 return 되고OptimisticLockingFaulureExceptuon을 catch 할 수 없다.

 

Retry를 통해 정상적으로 좋아요가 처리되는 테스트를 해보았다.

 

//given
final ExecutorService executorService = Executors.newFixedThreadPool(postLikeCount);
final CountDownLatch completeLatch = new CountDownLatch(postLikeCount);
//when
users.forEach(user -> executorService.submit(() -> {
    postService.likePost(post.getId(), user.getId());
    completeLatch.countDown();
}));
completeLatch.await();
//then
assertThat(postRepository.findById(post.getId()))
    .isPresent().get()
    .satisfies(updatedPost -> assertThat(updatedPost.getLikeCount()).isEqualTo(postLikeCount));

 

추가로 낙관적 락을 사용할 때 외래키가 있으면 데드락(DeadLock)으로 인해 CannotAcquireLockException 예외가 발생할 수 있다.

show engine innodb status; 로 데드락 체크를 해보면 아래와 같다.(최대한 생략시킨 결과)

 

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 1068786, ACTIVE 0 sec starting index read

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 36000 page no 4 n bits 72
index PRIMARY of table `photospot`.`post`
trx id 1068786 lock mode S locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 36000 page no 4 n bits 72
index PRIMARY of table `photospot`.`post` trx id 1068786
lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 1068787, ACTIVE 0 sec starting index read

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 36000 page no 4 n bits 72
index PRIMARY of table `photospot`.`post`
trx id 1068787 lock mode S locks rec but not gap

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 36000 page no 4 n bits 72
index PRIMARY of table `photospot`.`post` trx id 1068787
lock_mode X locks rec but not gap waiting

*** WE ROLL BACK TRANSACTION (2)

 

(참고로 InnoDB는 인덱스의 레코드에 대해 락을 건다.)

트랜잭션 1, 2가 post 테이블의 primary index에 대한 S-Lock을 점유하고 있는 상태에서 X-lock을 대기하면서 결국 하나의 트랜잭션이 롤백되었다.

PostLike 테이블은 Post의 PK를 FK로 가지고 있다. 그래서 좋아요를 insert 할 때 FK 제약 조건을 체크하기 위해 S-Lock을 건다. (S-Lock은 여러 트랜잭션에서 걸 수 있다.)

그다음 Post의 좋아요 개수를 Update 할 때 X-Lock을 획득하는데 트랜잭션 1,2가 각각 S-Lock을 점유하고 있어서 서로 X-Lock을 획득하지 못하고 대기한다. (X-Lock은 읽기 잠금도 제한하기 때문에 이미 읽기 잠금이 걸려있으면 대기한다.)

 

PostLike의 FK를 제거하니 CannotAcquireLockException 예외가 발생하지 않았다.

 

사실 update는 한 번에 하나의 트랜잭션만 레코드를 변경하기 때문에 Atomic 하게 동작한다. 그래서 이런 단순 count를 증가시키는 쿼리는 직접 likeCount = likeCount + 1로 update 하면 동시성 문제를 해결할 수 있다.

 

UPDATE post SET like_count = like_count + 1 where id = 1;

 

JPA의 변경 감지 기능에 너무 의존하다 보니 이런 부분을 놓친 것 같다. 그래도 낙관적 락 사용하고 테스트해보면서 배운 게 많아서 나름 만족스럽다.

 

 

[참고]

 

트랜잭션 동시성 문제 해결- Optimistic Lock과 AOP활용

0. 배경 서로 다른 API요청이 거의 동시에 들어올 경우 동시성 문제가 발생할 수 있는 기능이 있어서 이를 해결하기 위한 부분을 고민하게 되었습니다. 0-1. 기능 설명 해당 프로젝트는 사람들을

velog.io

 

좋아요 개수 조회 최적화하기

속닥속닥 프로젝트(https://github.com/woowacourse-teams/2022-sokdak…

tecoble.techcourse.co.kr

 

Optimistic Lock in JPA

<br /><br />

junhyunny.github.io

 

MySQL 낙관적 락과 데드락(dead lock) With JPA Hibernate

프로젝트에서 모임 가입 기능을 구현하면서, 동시성 문제와 데드락까지 경험한 내용 그리고 어떻게 해결하였는지 고민과정과 해결방법을 정리하려고 합니다. 프로젝트 버전 SpringBoot 2.7.8 MySQL 8.

0soo.tistory.com

 

 

스프링 핵심 원리 - 고급편 강의 - 인프런

스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기 📢 수강

www.inflearn.com

 

자바 ORM 표준 JPA 프로그래밍 | 김영한 - 교보문고

자바 ORM 표준 JPA 프로그래밍 | 자바 ORM 표준 JPA는 SQL 작성 없이 객체를 데이터베이스에 직접 저장할 수 있게 도와주고, 객체와 관계형 데이터베이스의 차이도 중간에서 해결해준다. 이 책은 JPA

product.kyobobook.co.kr

RealMySQL8.0

 

스프링에서 이미지 업로드를 할때 Json 데이터를 Multipart로 같이 보내는 경우 Content-Type을 application/json으로 명시해줘주지 않으면 application/octet-stream not supported 예외가 발생한다.

 

multipart/form-data를 받을 경우 @RequestPart 애노테이션을 사용하는데 애노테이션의 주석을 보면 method argument가 String or MultipartFile이 아닌 경우 HttpMessageConverter에서 각 request part의 content-type을 체크한다고 나와있다.

 

@RequestPart 애노테이션이 붙은 파라미터를 처리하는 RequestPartMethodArgumentResolver는 AbstracMessageConverterMethodArgumentResolver를 extends 하고 있다. 그래서 resolveMultipartArgument()로 Multipart를 처리하고 Multipart가 아닌 경우에는 AbstracMessageConverterMethodArgumentResolver의 readWithMessageConverters()에서 messageConvert로 revolve 한다.

 

 

이때 Content-Type이 없는 경우 default로 application/octet-stream가 설정이 된다.

 

application/octet-stream는 보통 unknown binary file에 대해 default로 사용이 된다.

 

 

이제 등록된 MessageConverter를 돌면서 convert가 가능한지 체크를 한다.

 

 ByteArrayHttpMessageConverter가 application/octet-stream을 처리하지만 Controller에서는 byte[]가 아닌 json을 mapping하려고 하기 때문에 supports()에서 false를 반환한다.

 

그래서 json을 mapping해주기 위해 AbstractJackson2HttpMessageConverter를 extends해서 application/octet-stream 타입을 받아서 변환하도록 해줄 수도 있지만 request 전송시 content-type을 명시해주면 해결이 된다.

 

OAuth(Open Authorization)란 표준 인증 프로토콜로 사용자들이 특정 플랫폼에 있는 자신의 정보에 대해 접근 권한을 부여하는 수단으로 사용이 된다.

 

사용자 인증(Authentication) 처리를 카카오, 네이버, 구글과 같이 OAuth 로그인 API를 제공하는 대형 서비스에게 맡기고 필요한 권한(Authorization)을 사용자 동의를 통해 받아서 필요한 Resource를 받아오는 것이다.

 

 

SecurityConfig

 

 

이전에는 스프링 시큐리티 설정을 WebSecurityConfigurerAdapter의 configure()로 했었지만 현재는 deprecated 되어서 SecurityFilterChain을 Bean으로 등록해야 한다.

 

oauth2Login() 밑에 부분을 보면 userService()에 CustomOAuth2UserService를 등록하는데 이는 함수형 인터페이스인 OAuth2UserService의 loadUser() 메서드를 구현한 것으로 로그인 성공 이후 Access Token을 이용해서 유저 정보(resource owner)를 받아서 OAuth2User(AuthenticatedPrincipal)를 반환한다.

 

 

CustomOAuth2UserService

 

 

사용자가 성공적으로 인증이 되면 OAuth2User에는 GrantedAutority Set이 포함되어 있는데 이 GrantedAuthority 컬렉션은 반환할 OAuth2AuthenticationToken에 매핑하는데 사용 될 수 있다.

 

매핑 옵션으로는 GrantedAuthoritiesMapper 또는 OAuth2UserService를 사용한 Delegation-based 전략이 있는데 후자의 경우 OAuth2UserRequest 및 OAuth2User에 대한 액세스를 제공하기 때문에 더 유연하고 향상된 방법이라고 한다. 나는 DefaultOAuth2UserService(표준 OAuth 2.0 공급자를 지원하는 OAuth2UserService의 구현)를 사용하였다.

 

DefaultOAuth2UserService 생성자를 통해 userRequest로 OAuth2User를 가져오고 Attributes 정보로 User Entity를 조회(기존 회원)하여 가져오거나 저장(새로운 회원)한다.

 

UserNameAttributeName()은 키 값으로 카카오의 경우 id가 된다

 

 

마지막으로 OAuth2User(AuthenticatedPrincipal)를 생성하여 반환한다.

 

 

아래는 전체적인 순서도와 내부적으로 어떻게 처리가 되는지 정리를 해봤다.

 

순서도

 

 

먼저 로그인을 누르면 프론트에서 "/oauth2/authorization/kakao"로 요청을 보내고 OAuth2AuthorizationRequestRedirectFilter는 기본 경로로 받아서 yml 파일에 정의해둔 authorization url로 요청을 보낸다. 

이때 앱의 Rest API 키 값인 client_id랑 Authorization Code를 받을 redirect_url, response_type을 같이 보내는데 이 정보는 yml 파일에 저장을 해놓는다.

 

참고로 스프링 시큐리티가 yml 파일을 읽어서 OAuth2ClientProperties를 생성하고 ClientRegistration 객체(kakao, google, naver ..)를 생성해서 InMemoryClientRegistrationRepository에 저장한다. 

 

 

위에서 말한 OAuth2AuthorizationRequestRedirectFilter는 OAuth2AUthorizationRequestResolver의 resolve()를 호출하는데 로그인 요청 URL에서 registrationId를 확인하고 InMemoryClientRegistrationRepository에서 ClientRegistration 객체를 꺼내 OAuth2AuthorizagtionRequest 객체를 만든다.

 

 

그리고 OAuth2AuthorizationRequest를 받아서 redirect를 보내면 카카오 로그인 창이 뜨게 되는 것이다.

 

 

 

사용자가 카카오 로그인 인증을 성공하면 카카오 서버에서는 위에서 요청시 보낸 redirect_url로 인가 코드(Authorization Code)를 보내기 때문에 OAuth2LoginAuthenticationFilter의 기본 경로에 맞춰서 redirect_url을 yml 파일에 정의해두었다.

 

 

 

OAuth2LoginAuthenticationFilter도 OAuth2AuthorizationRequestRedirectFilter처럼 registrationId를 통해 ClientRegistation을 가져오고 Authorization Code를 사용해서 Access Token을 가져온다.

 

OAuth2LoginAuthenticationProvider는 authenticate() 메서드를 실행하여 Access Token을 받고 loadUser 메서드를 통해 유저 정보를 가져오는 처리까지 하는데 이때 loadUser()를 실행하는 UserService가 처음에 SecuriryConfig에서 userService()에 주입해준  CustomUserService가 된다.

 

 

다음에는 JWT를 발급하는 부분을 처리해봐야겠다.

 

 

[참고]

 

OAuth2 :: Spring Security

Spring Security provides comprehensive OAuth 2 support. This section discusses how to integrate OAuth 2 into your servlet based application.

docs.spring.io

 

[OAuth + Spring Boot + JWT] 2. 스프링 시큐리티로 OAuth 구현해보기

이번에는 \[OAuth + Spring Boot + JWT] 1. OAuth란? 프론트엔드와 백엔드의 역할 마지막에 설명한 구조를 스프링 시큐리티 없이 구현하기 앞서 스프링 시큐리티를 사용해서 oauth를 구현해보려고 한다.👀

velog.io

 

[Spring Boot] OAuth2 + JWT + React 적용해보리기

오늘 팀원이랑 이야기를 해보다가 우려했던 일이 벌어졌다.. 우려했던 일이란?우려했던 일(현재 문제점)개선 방안OAuth2란?With Spring Boot구현현재까지 구현되었던 프로젝트의 로그인과정을 살펴보

velog.io

 

 이전에 Swagger로 API 문서를 만들어봤는데 UI도 예쁘게 만들어주고 API 테스트도 해볼 수 있어서 편리하게 사용했었다.

 

 

 

[SpringFox] Swagger3.0 사용하기 1 (Spring Security, JWT)

Swagger란 REST API를 문서화하여 보기 편하게 시각화하고, 테스트할 수 있는 라이브러리로 서버 가동시 @RestController를 읽어서 API 분석을 해서 HTML로 문서화를 하기 때문에 API 문서를 직접 만들어서

treecode.tistory.com

 

 

하지만 컨트롤러에 설정 코드가 많이 들어가고 동기화가 되지 않는 단점이 있어서 이번에는 Spring Rest Docs를 공부해서 적용을 해보았다.

 

Asciidoctor로 만든 문서와 Spring MVC Test로 생성된 AsciiDoc 스니펫을 결합하여 API 문서(HTML)를 만든다고 나와있는데 원하는 경우 Markdown을 사용하도록 Spring REST Docs를 구성할 수도 있다고 한다.

 

1. 설정


 

Asciidoctor Plugin

*.adoc으로 생성된 asciidoc 파일을 html로 변환해주는 플러그인, 처음에 "org.asciidoctor.convert" 를 사용했다가 오류가 떠서 확인해보니 Gradle 7 부터는 "org.asciidoctor.jvm.convert"를 사용한다고 한다.

그리고 asciidoctorExtensions 설정도 추가해준다.

 

 

snippetsDir에 테스트 빌드로 생성되는(outputs) 스니펫들이 저장될 위치를 지정해준다.

 

dependsOn은 해당 작업에 의존한다는 의미로 bootJar를 실행하면 asciidoctor가 먼저 실행이 된다. 그리고 finalizedBy는 후행 작업을 명시하는 것으로 마지막에 build/docs/asciidoc/ 에 생성된 html 파일을 static 경로에 복사한다.

 

블로그 보면서 해보다가 설정 안 맞아서 고생했는데 블로그 글을 쓰면서 공식 문서를 살펴보니 다시 한번 공식 문서의 중요성을 느끼게 된다.

 

 

 

 

2. 테스트 코드


 

@AutoConfigureRestDocs 애노테이션으로 Rest Docs 여러 설정을 간편하게 할 수 있다.

 

우선 순위에 따라 @AutoConfigureRestDocs(), OperationRequestPreprocessor로 url 설정을 할 수 있다.

 

MockMvc, Rest Assured 등으로 테스트를 작성하면 되는데 API 명세를 ResourceSnippetParameters builder()로 만든다,

 

 

3. 결과


 

퀄리티는 만들기 나름이라고 하는데.. Swagger를 썼을 때의 UI에 비하면 아주 심심한 화면이다. 그래서 더 괜찮은 방법이 없나 찾아보다가 Rest Docs랑 Swagger UI를 결합하는 방식을 소개하는 글을 발견해서 결국 해당 방식으로 변경을 했다!

이거에 대한 정리는 다음에 이어서 해야겠다.

 

 

 

[참고]

 

 

나의 첫 SpringRestDocs 적용기 part 1

※ 모든 코드는 저의 Github 에서 확인하실 수 있습니다. 0. 코드로 말합니다. 개발업무에 있어 형상관리업무만큼은 지극히 최소한으로 유지하는 게 좋다고 생각합니다. 과거엔 코드를 유지보수할

ahndy84.tistory.com

 

API 문서 자동화 - Spring REST Docs 팔아보겠습니다

프로덕션 코드와 분리하여 문서 자동화를 하고 싶다고요? 신뢰도 높은 API 문서를 만들고 싶다고요? 테스트가 성공해야 문서를 만들 수 있다!! Spring REST Docs가 있습니다. API 문서를 자동화 도구로

tecoble.techcourse.co.kr

 

betterfuture4 (헌치) - velog

🌱 함께 자라는 중입니다 🚀 rerub0831@gmail.com

velog.io

 

Spring Rest Docs 적용 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요? 우아한형제들에서 정산시스템을 개발하고 있는 이호진입니다. 지금부터 정산시스템 API 문서를 wiki 에서 Spring Rest Docs 로 전환한 이야기를 해보려고 합니다. 1. 전환하는

techblog.woowahan.com

 

Spring REST Docs 적용 (Gradle 7)

Spring REST Docs를 통해 API를 문서화한다.

xlffm3.github.io

 

+ Recent posts