지도 기반 프로젝트를 진행하면서 RestClient를 사용해서 카카오맵 API를 통해 리버스 지오코딩을 하고 있다.
지오코딩(geocoding)은 주소를 좌표로 변환하는 작업, 리버스 지오코딩은 반대로 좌표를 주소로 변환하는 것을 말한다.
카카오맵 API는 기본적으로 Json 형식으로 응답을 하는데 응답받은 데이터를 사용하기 위해 역직렬화하여 객체로 변환하는 작업이 필요하며 이를 위해 JsonDeserializer를 사용해보았다.
먼저 스프링에서 HTTP RequestBody, ResponseBody의 데이터를 직렬화, 역직렬화하는 기능은 메시지 컨버터가 처리한다.
아래 공식 문서 내용을 보면 RestClient를 builder()로 생성할 수 있고, 메시지 컨버터를 포함한 여러 설정을 할 수 있다.
스프링에서 제공하는 메시지 컨버터 인터페이스 HttpMessageConverter는 클라이언트 사이드(ex. RestClient)와 서버 사이드(ex. RestController)에서 HTTP 요청, 응답의 body를 읽고 쓰는 역할을 하며 다양한 구현체들이 있다.
RestClient는 기본적으로 등록되어있는 메시지 컨버터를 사용하고 추가로 확장할 수도 있다. 등록된 메시지 컨버터 중에 canRead()를 통해 변환이 가능한 컨버터를 찾아서 처리를 한다.
MappingJackson2MessageConverter는 기본적으로 내장된 컨버터로 json 데이터를 변환하는 작업을 해주며 내부적으로 ObjectMapper(Jackson 라이브러리)를 사용한다. ObjectMapper도 canDeserialize()를 통해 deserialize가 가능한지를 체크한다.
카카오맵 API의 Json 응답 예시를 보면 SnakeCase 표기법으로 되어 있는데 기본적으로는 CamelCase 표기법으로 읽기 때문에ObjectMapper에 별도로 setPropertyNamingStrategy()로 설정을 해줘야 한다.
Jackson 라이브러리는 이러한 설정을간편하게할 수 있도록 @JsonNaming과 같은 애노테이션을 지원해준다.
ObjectMapepr는 직렬화시 Getter가 있어야 하고 역직렬화 시 기본 생성자와 Setter가 있어야 하는 등의 조건이 있는데(필수는 아니지만)Jackson 2.12 버전부터 Record 타입을 지원해 주면서 편하게 불변으로 사용할 수 있다.
이런 식으로 key-value에 맞춰서 클래스를 생성해 주면 되는데 depth가 깊어지면 복잡해지기 때문에 JsonDeserializer를 구현해서 직접 역직렬화를 해줄 수 있다. JsonDeserializer를 extends하고 deserialize()을 Override해준다.
jsonParser.getCode()을 통해 ObjectCodec(ObjectMapper 부모 클래스)의 readTree()를 호출하면 트리 구조의 JsonNode를 받을 수 있다. (해당 노드는 Json 데이터를 파싱한 것이다)
_children은 Json 각 객체({}) 내부 key-value를 LinkedHashMap으로 관리하기 때문에 순서를 보장해 주며 같은 depth에 같은 key가 여러 개면 마지막 key만 담긴다.
findValue()는 특정 key의 value를 찾아서 가장 먼저 나오는 결과를 반환해 주는 메서드로 결과가 없으면 null을 반환한다.
하위 _children의 entry를 반복문으로 도는데 각각의 entry를 재귀 호출하기 때문에 depth 우선이 아닌 객체({}) 기준으로 가장 먼저 나오는 key의 value를 반환한다.
내 경우에는 가장 상단에 있는 값이 필요해서 findValue()를 사용했다.
마지막으로 위에서 구현한 JsonDeserializer를 필요한 클래스에 애노테이션으로 선언해 주면 된다.
이렇게 @JsonDeserialize 애노테이션을 선언해주면 MappingJackson2HttpMessageConverter에서 내부적으로 @JsonDeserialize 애노테이션을 찾아서 적용을 해준다.
좋아요 기능의 동시성 문제를 낙관적 락(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의 변경 감지 기능에 너무 의존하다 보니 이런 부분을 놓친 것 같다. 그래도 낙관적 락 사용하고 테스트해보면서 배운 게 많아서 나름 만족스럽다.
환경 변수를 서버내에 하드 코딩하지 않고 외부에서 관리하면서 안전하게 사용할 수 있는 방법을 찾다가 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 버전대에서는 설정이 바뀐 부분이 좀 있는데 공식 문서에 잘 나와있고 블로그에도 정리해주신 분이 있어서 편하게 설정할 수 있었다.
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 인스턴스의 키를 사용한다.
외부 API 테스트를 위해 RestClientTest 사용시 일반적으로 MockRestServiceServer를 @Autowired로 주입 받아서 사용하는데 RestTemplate 또는 RestClient가 2개 이상인 경우 에러가 발생한다.
찾아보니 관련 이슈가 있었다.
If you have twoRestTemplatebeans or twoRestClientbeans in your application, you can't use an auto-wiredMockRestServiceServerin your test
고칠 예정이라고 하는데 일단 질문자의 샘플에 대한 예시는 이런식으로 사용하라고 나와있다.
@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.