좋아요 기능의 동시성 문제를 낙관적 락(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

 

 

 

환경 변수를 서버내에 하드 코딩하지 않고 외부에서 관리하면서 안전하게 사용할 수 있는 방법을 찾다가 AWS에서 key, value 기반으로 관리할 수 있는 SSM Parameter Store를 찾았다. 현재 프로젝트 규모나 관리할 환경 변수가 많지는 않아서 적절하다고 생각되었다. 비용도 standard는 무료로 사용할 수 있다.

 

 

먼저 AWS 웹 콘솔 Parameter Store에서 key, value를 추가하면 되는데 민감한 데이터의 경우 KMS로 암호화해서 보관할 수도 있다.

계층 구조로 프로필, 용도에 따라 prefix(ex. "/config/photosot_dev")를 추가하고 뒤에 환경 변수를 추가해주면 된다.

 

 

 

 

이제 Spring Boot 설정을 보면 먼저 지속적으로 버전 관리가 되고 있는 awspring.io를 사용했고 spring boot 3 버전대에서는 설정이 바뀐 부분이 좀 있는데 공식 문서에 잘 나와있고 블로그에도 정리해주신 분이 있어서 편하게 설정할 수 있었다.

 

 

Spring Cloud AWS

Secrets Manager helps to protect secrets needed to access your applications, services, and IT resources. The service enables you to easily rotate, manage, and retrieve database credentials, API keys, and other secrets throughout their lifecycle. Spring Clo

docs.awspring.io

 

Gradle에서 Spring Cloud AWS BOM(Bill of Materials)을 사용해서 의존성 관리를 간편하게 할 수 있다. platform() dependencies를 추가하고 하위 의존성은 버전 없이 spring-cloud-aws-starter-xx 만 입력해주면 된다. (-s3, -parameter-store 등)

 

 

application.properties, application.yml 설정 파일에 Parameter Store의 key prefix를 아래와 같이 spring.config.import에 맞춰서 추가해준다.

 

prefix를 추가해서 자동으로 바인딩 시킬 수도 있는데 오히려 헷갈리는 것 같아서 prefix 부분은 직접 명시해줬다.

 

 

 

마지막으로 EC2에서 Parameter Store에 값을 가져올 수 있도록 EC2 인스턴스에 IAM Role로 AmazonSSMReadOnlyAccess 를 추가해준다. (Systems Manager의 기능이므로 SSM Access를 추가)

 

EC2 터미널에서 aws configure list를 입력하면 IAM-Role로 발급 받은 access, secret key를 볼 수 있다. 스프링 부트를 실행하면 해당 key를 이용해서 Parameter Store에서 값을 가져와서 환경 변수를 주입해준다.

 

 

 

나의 경우 로컬 default 프로필에서 S3 업로드를 할때 IAM User를 생성해서 access, secret key를 환경 변수로 사용하고 있었는데 default 프로필이다보니 dev 프로필에서도 적용이 되어서 문제가 생겼다.

 

그래서 dev 프로필에서는 instance-profile을 true로 설정하고 access, secret key를 빈 값으로 덮어씌웠다. instance-profile은 EC2 인스턴스의 key를 사용하겠다는 것이다.

 

CredentialsProperties를 보면 access,secret key가 nullable하고 instanceProfile 기본값은 false로 되어있다. access, secret key가 있으면 해당 키를 사용하고 없는 경우 ec2 인스턴스의 키를 사용한다.

 

 

 

 

[참고]

 

 

[Spring] - Spring Boot 2.4 이상 환경에서 AWS Parameter Store 적용하기

회사 소스코드 application.yml 설정파일을 보면 데이터베이스 접속정보, api키값 등 보안에 민감한 데이터가 있어서 이러한 데이터를 외부에서 직접 주입 할 수 있도록 구성하려고 이것 저것 알아보

kim-jong-hyun.tistory.com

 

spring boot 3.2에서 aws parameter store 적용하기

최근에 사이드 프로젝트를 하나 진행하고 있다.배포도 생각중이라 DB 정보를 어떻게 관리해야 하나 고민을 하던 와중에 우연치 않게 aws parameter store에 대해서 듣게 되었다.가격도 공짜에 사이드

velog.io

 

SpringBoot & AWS S3 연동하기

안녕하세요? 이번 시간엔 SpringBoot & AWS S3 연동하기 예제를 진행해보려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github와

jojoldu.tistory.com

 

AWS Instance Profile doesn't work with Spring Cloud AWS

I have a small Spring Boot app, using Spring Cloud AWS (1.0.0.RELEASE) to access SQS queue. It is beeing deployed on an EC2 instance with Instance Profile set. It appears that AWS side of things is

stackoverflow.com

 

외부 API 테스트를 위해 RestClientTest 사용시 일반적으로 MockRestServiceServer를 @Autowired로 주입 받아서 사용하는데 RestTemplate 또는 RestClient가 2개 이상인 경우 에러가 발생한다.

 

 

찾아보니 관련 이슈가 있었다.

 

If you have two RestTemplate beans or two RestClient beans in your application, you can't use an auto-wired MockRestServiceServer in your test

 

 

MockRestServiceServerAutoConfiguration does not support RestTemplate and RestClient together · Issue #38820 · spring-projects

It should be possible to have together RestTemplate and RestClient in application and test them independently. Unfortunately, currently if RestTemplate is used, RestClient cannot be tested: org.spr...

github.com

 

고칠 예정이라고 하는데 일단 질문자의 샘플에 대한 예시는 이런식으로 사용하라고 나와있다. 

    @Test
    void test(@Autowired RestClient restClient, @Autowired MockServerRestClientCustomizer customizer) {

        // given
        customizer.getServer().expect(requestTo("/")).andRespond(withSuccess());

        // when then
        restClient.get()
                .uri("/")
                .retrieve();
    }

 

 

@RestClientTest의 메타 애노테이션중 @AutoConfigureMockRestServiceServer를 보면 여러 Clients를 쓰는 경우 MockServerRestXXXCustomizer를 주입 받아서 getServer()를 사용하거나 MockRestServiceServer에 직접 bind 하라고 나와있다.

 

 

나는 HttpInterface를 쓰고 있어서 아래와 같이 직접 bind()해주는 방식으로 수정했다. MockRestServiceServer에 먼저 bindTo()를 해준 뒤에 HttpClient 프록시를 Bean으로 등록하는 부분에 restClientBuilder를 넣어줘야한다. 

 

Build the MockRestServiceServer and set up the underlying RestTemplate with a ClientHttpRequestFactory that creates mock requests. 

 

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

 

multipart/form-data를 받을 경우 @RequestParam 애노테이션을 사용하는데 애노테이션의 주석을 보면 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을 명시해주면 해결이 된다.

+ Recent posts