회사 제품에서 현재 DynamoDB와 Redis를 사용하고 있다.
온보딩 과정에서 이를 사용해 간단한 서비스를 구축하는 과제가 있었다.
단순히 새로운 기술을 적용해보는 것이 재미있었던 것도 있었지만,
MSA 구조에서 발생할 수 있는 문제들을 해결하는 과정이 특히 재미있었다.
DynamoDB는 AWS에서 제공하는 완전 관리형 NoSQL 데이터베이스 서비스이다.
(완전 관리형이란 프로비저닝, 백업, 스케일링 등을 AWS에 담당한다는 의미이다.)
DynamoDB의 핵심 구성 요소는 테이블(Table), 아이템(item), 속성(attribute)이다.
테이블은 아이템의 집합이며, 각각의 아이템은 속성의 집합이다.
DynamoDB는 테이블에서 아이템을 식별하기 위해 프라이머리 키(Primary Key)를 사용한다.
서로 다른 마이크로 서비스에서 동시에 데이터베이스 쓰기 작업을 시도하는 경우,
데이터의 무결성이 깨지고 의도하지 않은 결과가 반환되는 동시성 문제가 발생할 수 있다.
이러한 동시성 문제를 해결하는 방법으로 Optimistic & Pessimistic Locking이 있다.
Pessimistic Locking은 기본적으로 트랜잭션 충돌이 발생할 것이라 가정한다.
따라서 하나의 트랜잭션이 데이터에 접근 시 Lock(Shared Lock이나 Exclusive Lock)을 걸고,
다른 트랜잭션이 해당 데이터에 접근하지 못하도록 한다.
Shared Lock의 경우, 다른 트랜잭션에서 읽기(Read)만 가능하며,
Exclusive Lock의 경우, 다른 트랜잭션에서 읽기(Read), 쓰기(Write) 모두 불가하다.
Pessimistic Locking은 데이터 무결성을 보장하는 수준이 매우 높지만,
데이터 자체에 Lock을 걸기 때문에 동시성이 떨어져 성능 손해가 많다.
Optimistic Locking은 기본적으로 트랜잭션 충돌이 발생하지 않을 것이라 가정한다.
Lock을 사용하지 않으며, 데이터를 수정하고자 할 때에는 데이터가 변경되었는지 검사한다.
데이터가 변경되었는지 검사하고 적용하는 과정을 CAS(Compare ans Set or Swap)라고 한다.
동시성이 높아 성능은 좋을 수 있지만, 롤백 처리에 대한 추가 구현이 필요하며,
잦은 충돌이 일어나는 경우 롤백 처리에 대한 비용이 많이 들어 오히려 성능 손해를 볼 수 있다.
DynamoDB에서는 버전(Version) 번호를 이용한 Optimistic Locking을 지원한다.
간단히 설명하자면 우선, 테이블 내 아이템에 버전이라는 속성을 부여한다.
테이블에서 해당 아이템을 조회하는 경우, 해당 버전을 기록하도록 한다.
아이템 수정은 버전 번호가 변경되지 않은 경우에만 가능하며, 수정 후에는 버전을 변경한다.
즉, 버전이 다르다는 것은 이미 수정이 발생했음을 의미하며 해당 경우 수정이 실패한다.
이렇게 DynamoDB에서는 버전을 이용한 CAS 과정을 진행하며,
버전이 일치하지 않을 경우 자체적으로 예외(Exception)를 발생시킨다.
그래서 예외 처리를 위한 추가적인 작업이 필요한데, 과제 역시 이와 관련한 것이었다.
아래는 내가 과제 구현 시 사용한 예외 처리 사항이다.
예외 발생 시 특정 기준 안에서 retry를 시도하는 방식으로 해결하였다.
public async Task<T> CasAsync<T>(Func<Task<T>> action)
{
int retry = 0;
while (true)
{
try
{
return await action();
}
catch (OptimisticLockingFailedException)
{
if (retry >= _retryPolicy.Retry)
{
throw;
}
}
IBackoffPolicy backoffPolicy = _backoffPolicy;
int num = retry + 1;
retry = num;
await Task.Delay(backoffPolicy.GetBackOffDelay(num));
}
}
Redis는 데이터베이스, 캐시, 메시지 브로커, 스트리밍 엔진 등으로 사용되는
Key-Value 형식의 인메모리 데이터 구조 저장소(Data Structure Store)이다.
일반적인 인메모리 데이터 구조 저장소에서 지원하는 Key-Value 데이터 타입은 문자열이다.
그러나 Redis에서는 문자열 뿐만 아니라 다양한 데이터 타입을 지원한다.
(ex : strings, hashes, lists, sets, sorted sets, bitmaps, streams 등)
또한, Redis는 영속성 관련하여 사용자 편의를 위해 다양한 옵션을 제공한다.
(ex : RDB, AOF, RDB + AOF, No Persistence 등)
이외에도 메모리 크기 조정과 다양한 키 방출 정책 등을 지원한다.
회사에서는 Redis를 다양한 방식으로 사용하고 있지만,
이번 온보딩 과제에서는 Redis를 캐시로 사용하는 것을 다루었다.
해당 과정에서 캐시 히트와 캐시 미스 상황을 고려해야 했는데, 구현한 내용은 아래와 같다.
public async Task<Result> LoadInfoAsync(User user, Information info, CancellationToken cancellationToken)
{
var user = GetUser(user);
var isInfoCached = await IsInfoCachedAsync(user, info, cancellationToken);
if (isInfoCached)
{
... 기타 구현
return ...
}
var isInfoInDb = await IsInfoInDbAsync(user, info, cancellationToken);
if (isInfoInDb)
{
await MarkInfoInCacheAsync(user, info, cancellationToken);
... 기타 구현
return ...
}
}
우선, IsInfoCachedAsync( ) 메소드를 통해 특정 정보가 캐시되어 있는지 확인한다.
만약 캐시되어 있다면 캐시 히트인 상황으로 해당 정보를 전달하면 된다.
private async Task<bool> IsInfoCachedAsync(User user, Information info, CancellationToken cancellationToken)
{
var key = NameOf.InformationCache(user.AccountId, info.Id);
var value = await _infoCache.GetAsync<string>(key);
return !(value is null);
}
캐시되어 있지 않다면 캐시 미스인 상황으로 IsInfoInDbAsync( ) 메소드를 호출한다.
이는 특정 정보가 데이터베이스에 존재하는 지 확인하는 메소드로,
존재한다면 MarkInfoInCacheAsync( ) 메소드를 통해 해당 정보를 캐시한다.
private async Task<bool> IsInfoInDbAsync(User user, Information info, CancellationToken cancellationToken)
{
var key = InformationDocument.KeyOf(user.AccountId, info.Id);
var doc = await _Repository.Get(key);
return !(doc is null);
}
private async Task MarkInfoInCacheAsync(User user, Information info, CancellationToken cancellationToken)
{
var key = NameOf.InformationCache(user.AccountId, info.Id);
await _Cache.SetAsync(key, "", info);
}
DynamoDB, Redis 등 새로운 기술을 사용해볼 수 있어 흥미로웠던 시간이었다.
또한 동시성 문제나 캐싱과 관련한 것은 백엔드 개발을 하다보면 자주 접할 문제라고 생각한다.
이렇게 온보딩 과정을 통해 약간이나마 맛을 볼 수 있었던 것 같다.
참고 자료 1) - Amzazon DynamoDB
참고 자료 2) - Introduction to Redis