트렌비에서는 대부분의 서버가 분산된 EKS 환경에서 운영되고 있습니다. 하지만, 새로운 프로젝트를 만들 때에 분산 환경을 위한 분산 락 설정을 매 번 추가해주어야 하는 번거로움과 레거시 시스템에서는 분산 환경을 충분히 고려하지 못한 경우가 있어 동시성 문제가 발생하는 경우가 종종 있었습니다. 이를 해결하기 위해, 사내 공통 분산 락 라이브러리를 개발하자는 아이디어를 팀장님에게 제안하였고 자유롭게 만들어 볼 수 있는 기회를 주어서 제작해보게 되었습니다.
분산 락
Java와 Kotlin에서는 Monitor 인터페이스로 ReentrantLock, synchronized 키워드 등을 통해 락을 관리할 수 있습니다. 하지만 이러한 방식은 인메모리에서만 작동하여 분산 환경에서는 적용하기 어렵습니다. 예를 들어, 클라이언트 요청이 로드 밸런서를 통해 두 개의 서버에 분산된 상황을 생각해보겠습니다.
서버 A의 로직에 lock이 걸려있더라도 서버 B는 서버 A에 해당 메서드가 lock이 걸려있는지를 알 수가 없습니다.
위와 같은 상황을 해결하기 위해서는 두 가지 방법이 떠오르는데요.
외부의 인프라를 사용하여 두 개의 서버의 진입점은 한 대의 외부 서버에서 관리하도록 락을 관리 하는 방법과 게이트웨이 자체에 해당 요청에 대한 진입점에 lock을 건다면 해당 요청에 대해서 한 번에 하나의 API 만 수행되도록 할 수 있습니다.
하지만, 저희는 AWS에서 제공해주는 로드 밸런서를 이용하고 있기 때문에 이는 제외하였고 추가적으로 별도의 게이트웨이를 거쳐서 들어오도록 만드는 것은 다소 과한 리소스 투자라고 생각하였습니다. 동시성 문제는 데이터의 정합성 문제로 야기 읽기 쓰기의 차이에서 발생하며 대부분의 경우 보관 가능성이 있는 데이터는 인메모리가 아닌 DB에서 보관을 하기 때문에 DB의 읽기 쓰기 과정에서 발생하는 동시성 문제를 해결해야 하는 상황에서 선택지는 두 가지가 있었습니다.
트렌비는 Database는 MySQL과 Postgresql를 혼용하여 사용하고 있는 상황입니다. 따라서, MySQL의 NamedLock PostgreSQL을 사용한 락을 이용하기에는 라이브러리의 복잡도가 올라가기 때문에 각 도메인 별로 이미 사용중인 Redis를 사용한 분산 락 라이브러리를 제작하고자 하였습니다.
스핀 락 VS Pub-Sub
Redis를 이용하여 Lock을 시도하는 대표적인 두 가지 방법이 있습니다.
SpinLock과 Pub-Sub 방식의 Lock 입니다.
SpinLock은 폴링 방식으로서 Lock을 획득하고 나면 나머지 진입 된 요청들이 while() 문을 통해 lock의 key가 해제 되었는지 지속적으로 호출하여 확인하면서 해제되었다면 진입하는 방식입니다.
폴링 방식은 서버에 좋지 않은 부하를 일으키게 되어 좀 더 안정적이게 운영 할 수 있는 Pub-Sub 방식을 선택하였습니다.
Pub-Sub 방식은 락을 획득했을 때 들어온 많은 요청들이 해당 락의 해제가 이루어졌음을 알려달라고 구독을 합니다. 락이 해제되었을 때 해당 락이 해제되었음을 알림 받기 위한 요청들에게 구독이 해제되었음을 알리고 락을 획득합니다. 이제 별도의 배경 지식을 가지고 라이브러리 제작 이야기로 들어가보겠습니다.
라이브러리 만들기
먼저, 해당 라이브러리 제작에 있어서 https://helloworld.kurly.com/blog/distributed-redisson-lock/ 에서 많은 부분 도움을 받았습니다.
자바의 Redis 클라이언트인 Redisson에는 이미 Pub-Sub 방식의 Lock을 라이브러리로 제공해주고 있고, 이를 적절히 이용하고자 하였습니다. 락의 실행과 해제에 대한 상황은 다음과 같이 이루어진다고 볼 수 있는데요.
이를 위한 해결 방법으로 두 가지가 떠올랐습니다. 먼저 첫 번째는 템플릿 메서드 패턴을 이용하는 것 입니다.
템플렛 메서드로 구현하는 락
하지만, 템플릿 메서드를 사용한다면 매번 해당 DistributedLock에 대한 logic()을 구현해주어야 하고 분산락을 적용하기 위한 클래스가 양산되게 됩니다.
AOP로 구현하는 락
템플릿 메서드를 사용함으로써 생기는 단점보다 좀 더 유연하고 동적으로 사용 할 수 있는 AOP를 이용하게 되었습니다.
먼저, AOP에서 애노테이션을 이용하여 편리하게 사용 할 수 있게 애노테이션을 추가하였습니다.
애노테이션 코드
이어서, Aspect 구현체입니다. 여기서 눈 여겨볼 점은 SpringBoot에서 제공해주는 @ConditionalOnBean을 통해 생성을 제어 할 수 있도록 하였습니다. RedissonProperties 클래스는 라이브러리 설정을 위하여 별도로 추가한 Property 클래스입니다. AOP 클래스 이후에 소개하겠습니다. 또한, 향후에 별도로 디버깅을 할 수 있도록 로그도 추가하였습니다.
AOP 코드
RedissonProperties 코드
해당 속성 값은 SpringBoot data.redis에서 제공하는 RedisProperty를 사용하여 필요한 속성들만 사용하도록 하였습니다.
현재 트렌비는 Redis를 클러스터링 하고 있는 환경 또한 존재하기에 프로퍼티 설정에 cluster를 확인하는 로직 또한 별도로 만들어 두었습니다. org.redisson:redisson-spring-boot-starter의 라이브러리에는 spring-boot-starter-data-redis를 의존하고 있기에 사용 할 수 있고 해당 Property는 해당 스프링부트 서버가 실행 될 때 생성 됩니다. 실제로 적절히 주입되는지 테스트 코드를 통해 살펴보면 아래와 같이 적절히 빈이 주입되는 것을 볼 수 있습니다.
RedissonClient 코드
RedissonConfiguration은 이전에 생성되는 RedissonProperties를 이용하여 클러스터가 존재한다면 설정에 node 주소가 추가 된 클라이언트를 없다면 싱글 서버로 돌아가는 클라이언트가 생성되도록 하였습니다. 분산 환경을 위한 분산락을 만들 수 있는 환경은 마련되었습니다.
라이브러리를 라이브 서버에 도입하기 이전에 정상적으로 적용되는지 확인해보고자 먼저 테스트가 필요했고, jar파일을 파일 기반으로 먼저 의존성을 추가하여 테스트 하고자 하였습니다. 그 이전에 한 가지 고려사항이 생겼는데요. 바로 JAR로 만들것인지 FAT-JAR로 만들 것 인지였습니다.
JAR vs FAT-JAR
결론적으로는 jar 파일을 생성하는 경우에 모든 의존성을 전부 가지고 오는 fat-jar를 선택하였습니다. 라이브러리를 사용하는 누군가는 어떤 의존성을 추가해주어야 하는지 몰라도 해당 라이브러리를 사용하는데에 있어서 지장이 없어야 합니다. jar 파일의 사이즈를 줄이기 위해서 jar로 만들어서 별도의 의존성을 추가해달라고 했을 때 저라면 먼저 이런 생각이 들었을 것 같았습니다.
라이브러리를 사용하는데 다른 라이브러리를 또 추가로 검색해서 주입해주어야 해? 조금 번거롭네..
이러한 이유를 근거로 FAT-JAR로 만들어 질 수 있도록 하였습니다. 해당 gradle 소스는 다음과 같습니다.
jar 파일은 프로젝트 루트 경로의 gradlew가 있는 곳에서 ./gradlew build or ./gradlew clean build를 통해 만들어낼 수 있습니다. 항상 새로이 build 되길 원했기 때문에 ./gradlew clean build를 통해서 만들었습니다. 생성된 jar 파일은 project-root/build/libs/ 에서 확인 할 수 있습니다. 이제, 별도의 프로젝트에서 해당 라이브러리가 온전히 동작하는지 확인을 해보도록 하겠습니다. 다음과 같이 별도의 프로젝트를 생성 한 뒤 libs 폴더를 생성하여 jar 파일을 옮겨주었습니다.
테스트를 위해 먼저 아래와 같은 설정으로 별도의 프로젝트 환경을 구성해주었습니다.
코어 라이브러리 의존성을 Gradle Kotlin DSL 에 맞게 작성해주었습니다.
라이브러리 테스트
테스트를 위해서 몇 가지 코드를 추가하여 테스트를 진행하였습니다.
본래라면 DB에서 돌아가도록 해야하지만 편의성을 위해 인메모리 저장소를 사용하도록 하여 작성하였습니다. 동시성 문제는 재고 시스템에서 가장 손쉽게 확인 할 수 있기에 재고 시스템에서 확인해보도록 하겠습니다.
도메인 코드
저장소 코드
마치 MySQL에서 AutoIncrement가 동작하는 것처럼 아이디 생성을 하는데에 있어서 Thread-Safe 하도록 Atomic Integer를 사용하여, 별도의 스레드에서도 동작 할 수 있도록 하였습니다.
서비스 로직
이어서 Lock을 이용한 Logic과 Lock을 이용하지 않는 로직을 나누어 테스트를 진행하였습니다.
Lock이 적용되는 서비스 로직
Lock이 적용되지 않는 서비스 로직
테스트 코드
실제 테스트 결과는 아래와 같이 정상적으로 수행되는 것을 볼 수 있고, log level을 debug로 해두었을 경우 로그도 정상적으로 나오는 것을 확인 할 수 있었습니다.
분산락이 적용되지 않은 경우에 대한 남은 재고 수는 다음과 같이 나타납니다.
(때때로, 실패하는 경우도 있지만 멀티 쓰레드 환경에서 일부러 동시성 문제가 발생하도록 항상 성공가능한 테스트는 어떻게 짜야할지.. 떠오르지가 않았습니다.)
이제, 정상적으로 라이브러리가 돌아가는 것을 보았으니 라이브러리를 올려야 합니다.
라이브러리를 올리는 과정은 원활한 흐름을 위하여 2편에서 제공하고자 합니다.
해당 예제의 코드는
https://github.com/bombo-dev/Learning-code/tree/main/create-library
https://github.com/bombo-dev/Learning-code/tree/main/SpEL/SpEL
에서 확인이 가능합니다.
참고 자료
- https://helloworld.kurly.com/blog/distributed-redisson-lock/
- https://docs.spring.io/spring-framework/reference/core/expressions.html
'Backend > Spring' 카테고리의 다른 글
Spring Security 5 -> Spring Security 6 에서의 Session 변경점 (0) | 2023.08.20 |
---|---|
Spring Security OAuth2 주요 용어와 인증 방식 (0) | 2023.08.14 |
Invalid character found in method name 에러 (2) | 2023.05.07 |
Spring AOP (0) | 2023.04.26 |
스프링의 빈 후처리기 (0) | 2023.04.24 |