서론
지난 여름, 저는 부서로부터 기묘한 업무지시를 받았습니다. 간단한 CRUD 서버를 하나 만들라는 것입니다. 익히 아시는 것 처럼 CRUD는 Create, Read, Update, Delete의 약자로 뭔가를 저장하고 읽고 수정하고 지우는 작업을 의미합니다. 저희가 일을 하면서 만드는 정보시스템의 가장 기본이 되는 동작이기 때문에 보통 CRUD = 가장 기초적이고 쉬운 동작 이라는 의미로 쓰일 때가 많습니다. “일단 인턴들 CRUD 하나씩 개발해보게 하시죠” “그 기능이요? 기껏해야 CRUD 정도 난이도 입니다.” “일단 CRUD 하나 짜보면서 견적을 내보시죠” 이런 식으로요.
그래서 보통 시스템을 개발할 때는 CRUD와 CRUD가 아닌 것들을 구분하는 경우가 종종 있습니다. 일반적으로 같은 크기의 프로젝트라면 CRUD의 비중이 높을 수록 쉬운 프로젝트가 되고 CRUD가 아닌 기능의 비중이 높을 수록 어려운 프로젝트로 생각하는 경우가 많은데, 그래서 보통 프로젝트에서는 연차가 낮은 개발자들에게 CRUD 개발을 위주로 배정하고 연차가 높은 개발자들에게 CRUD가 아닌 기능들의 개발을 지시하는 경향이 살짝 있습니다. 저는 9년차 개발자이니까 일반적인 프로젝트 팀에서는 낮지도 높지도 않은 애매한 연차에 해당했지만, 전반적으로 연령대가 높은 저희 아키텍처 팀에서는 막내라인에 속하는 젊은 개발자였기 때문에 매우 간단한 CRUD 서버를 개발하라는 업무지시도 겸허히 받아들일 수 있었습니다.
주어진 개발기간은 1주일 정도로 통상적으로는 짧게 느껴지는 기간이었지만 CRUD 하나 개발하기에는 지나치게 길었기에 뭔가 쌔한 기분이 들어서 시작하기 전에 숨겨진 요구사항이 없는지 파악했었는데 딱히 엄청난 요구사항이 있지는 않았습니다. 만들고자 하는 서버의 기능 요구사항을 정리하자면 대충 다음과 같았습니다.
- 클라이언트가 전달한 문자열을 받아서 저장할 것
- 클라이언트가 해당 문자열이 실제로 있는지 확인을 요청하면 확인해줄 것
- 클라이언트가 문자열을 전달하지 않은 경우 서버에서 생성해서 저장한 뒤 전달해줄 것
- 해당 문자열은 중복이 있으면 안 됨
- 문자열은 지정한 기간이 지나면 자동으로 삭제 혹은 비활성화 될 수 있어야 함
그냥 일반적인 CRUD 기능을 하는 서버에서 심지어 U와 D도 빠진 형태에, 중복 없는 문자열을 생성하는 기능과 시간을 기준으로 데이터가 삭제되는 기능을 더한 심플한 요구사항이었습니다. 비기능 요구사항은 딱히 많지 않았는데 대충 다음과 같았습니다.
- 서버가 독립적으로 스케일 아웃 가능한 구조를 가질 것
- 이중화 구성이 가능할 것
- 컨테이너를 포함한 클라우드 환경에서 사용 가능할 것
- 가급적 높은 성능을 위해 RDB 보다는 IMDG를 사용할 것
사실 비기능 요구사항 중에서도 빡빡한 요구사항은 없었습니다. 독립적으로 스케일 아웃 가능한 구조, 높은 가용성 확보를 위한 이중화 구조, 컨테이너로 기동하거나 클라우드 환경에 적합하게 만드는 것들은 모두 최근의 백엔드 프로그래밍 트랜드에서는 자연스러운 요구사항에 속했습니다. 데이터의 저장 및 상태유지를 위해 사용되는 데이터베이스로 MariaDB, PostgreSQL 등으로 대표되는 관계형 데이터베이스나 Cassandra, MongoDB등의 대표적인 NoSQL 데이터베이스가 아닌 Redis, Hazelcast, Ignite 등으로 대표되는 IMDG(In-Memory Data Grid)를 쓰자는 요구사항은 약간 특이한 요구사항에 해당했지만 기능 요구사항을 고려해보면 딱히 문제될 것은 없었습니다.
모든 요구사항을 다 수용해도 1주일 내에 개발하는 데 별 문제가 없겠다고 판단한 저는 몇 번정도 다시 ‘혹시 다 만들고 나면 다른 기능 추가하라고 하시는 거 아니죠?’ ‘숨겨둔 요구사항 있으면 지금 말씀하세요’ ‘진짜 이거만 개발하면 되는거 맞죠?’ 등등의 질문으로 신뢰관계를 공고하게 쌓은 뒤 기간 내에 개발 가능하다는 답변을 드렸습니다.
이어서 쓰고자 하는 내용은 이러한 매우 간단한 CRUD 애플리케이션을 만들 때 어떤 점들을 고려했는지에 대해서 간단하게 정리한 것입니다.
중복없는 ID 생성하기
개발을 시작하기 전에 제가 가장 먼저 고민한 것은 자바의 UUID 클래스 였습니다. 자바의 UUID 클래스는 자바 1.5버전에서부터 제공되기 시작한 클래스로 무언가를 유일하게 식별하고자 할 때 사용되는 128비트 ID를 표현하는 불변(Immutable) 클래스 입니다. UUID를 문자열로 표현하면 일반적으로 다음과 같이 표현됩니다.
7a82a1d5-aacb-4a95-a035-61e69e4c3dae
14250b19-b1e5-4df9-be63-58592aaba4eb
f2d9bd8f-518e-41d2-ba35-fde761f0db7a
fad741e5-5ce9-46cb-acdd-8d49a0bbf0fa
0f1f0050-a2b4-44b9-b41b-80d1d901f7f9
ac72048d-401d-47a1-b9e9-82dac3dbe428
bf1bf223-b646-4eb1-a41d-e8dc2748e3c0
aa4e5b19-7654-4a91-a04e-b680ac761054
ced0b714-7a00-4d01-9656-f6c2d0591694
위 UUID 샘플에서 보실 수 있듯이 문자열은 16진수 숫자의 조합으로 이루어져있으므로 하나의 문자는 4비트를 표현할 수 있습니다. 문자열에는 총 32개의 문자가 있으므로 (대시 기호는 그냥 읽기 좋으라고 추가해준 것으로 별 의미 없습니다.) 128비트의 정보를 담고 있는 것을 확인 할 수 있습니다. 실제로는 6비트는 버전 등을 표현하기 위한 헤더 역할로 사용되기 때문에 UUID 클래스는 122비트의 정보로 구분되는 ID라고 볼 수 있습니다.
UUID 클래스의 RFC를 보면 다음과 같이 기술되어 있는 것을 확인할 수 있습니다.
A UUID is 128 bits long, and can guarantee uniqueness across space and time
UUID는 128비트의 길이로, 어떤 시간이나 공간에서도 유일함을 보장할 수 있다.
바꿔서 말하면 UUID를 무작위로 생성한다고 했을 때, 충분히 무작위로 생성할 수 있다면 UUID가 중복되는 경우는 없다는 의미가 됩니다. 그래서 자바의 UUID 클래스는 무작위로 UUID를 생성해주는 randomUUID() 라는 정적 메소드를 하나 가지고 있는데, 해당 메소드의 코드를 보면 다음과 같이 되어 있습니다.
public static UUID randomUUID() {
SecureRandom ng = Holder.numberGenerator;
byte[] randomBytes = new byte[16];
ng.nextBytes(randomBytes);
randomBytes[6] &= 0x0f; /* clear version */
randomBytes[6] |= 0x40; /* set to version 4 */
randomBytes[8] &= 0x3f; /* clear variant */
randomBytes[8] |= 0x80; /* set to IETF variant */
return new UUID(randomBytes);
}
SecureRandom을 사용하고 있으므로 의도치 않게 시드(Seed)가 꼬이거나 해서 생성되는 난수값이 겹치거나 할 일은 거의 없다고 생각할 수 있습니다. 그리고 실제로 UUID의 생성은 Random 클래스의 nextBytes 메소드를 이용해서 무작위 바이트를 생성하고 일부 비트에만 버전 정보등을 집어넣는 형태이므로 생성되는 UUID는 122비트 난수라고 봐도 무방합니다.
여기에서 한 가지 의문이 들 수 있습니다. 저 코드가 생성되는 UUID가 유일할 것임을 어떻게 보장해 줄 수 있을까요? 저희는 실제로 유일한 값을 생성하기 위해서 데이터베이스를 한 번 체크하거나, 시퀀스를 사용하는 등 기존의 상태(State)를 참조하는 방식을 많이 사용합니다. 하지만 UUID.randomUUID() 메소드는 기존에 생성된 값들을 참조하지 않는 단순한 난수 발생 메소드이기 때문에 생성되는 UUID가 실제로 유일함을 보장해주지는 않는 것 처럼 보입니다.
그리고 실제로 해당 클래스는 기존에 생성된 UUID와 중복되지 않는 값을 생성해줌을 보장해주지는 않습니다. 다만 중복될 확률이 지극히 낮을 뿐입니다. 해당 확률을 계산해보면, 임의의 2개의 랜덤 UUID를 생성했을 때 두 UUID가 같을 확률은 2의 122제곱 분의 1에 해당합니다. 만약 기존에 생성된 UUID가 1억개라면 2의 122제곱 분의 1억이 되겠죠. 2의 122제곱 분의 1 확률은 확실히 낮을 것 같은데, 2의 122제곱 분의 1억은 살짝 일어날 수도 있는 확률같이 보일 수도 있습니다. 하지만 2의 122제곱은 5316911983139663491615228241121378304 인데 일반적인 상식으로는 읽기도 어려운 숫자입니다. 이 숫자가 지나치게 크기 때문에 사실 기존에 생성된 UUID가 100개이거나 1억개이거나 1조개여도 확률에는 큰 변동이 없습니다.
이 CRUD 서버가 완성되어서 잘 쓰인다고 했을 때 저장해서 관리할 데이터의 건수를 예상해보면 아주 넉넉하게 잡은 뒤 10을 곱해도 100,000개 정도였습니다. 때문에 실제로 UUID.randomUUID() 메소드를 이용해서 UUID를 무작위 생성한 뒤 다른 사람들에게는 ‘DB에서 중복 있는지 없는지 검사한 다음 유일한 ID를 생성합니다’라고 양심을 속이며 거짓말을 해도 운이 너무 없어서 그 거짓말이 들통날 확률은 0.00000000000000000000000000000001% 정도입니다. 이 정도면 트랜잭션을 걸고, 쿼리를 몇 번 날리고, 중복 체크를 하는 등의 귀찮은 작업을 db.insert(UUID.randomUUID())
코드 한 줄로 치환해도 괜찮을 것 같이 보입니다. 이렇게 코드가 단순해지면 성능도 훨씬 좋아지고 버그가 발생할 가능성도 유의미하게 낮아집니다. 때문에 0.00000000000000000000000000000001%의 중복 가능성을 인정하고 코드를 단순하게 만드느냐, 아니면 저 가능성도 허용하지 않고 엔지니어의 자존심을 지키느냐의 선택이 필요한 순간이 되었는데 이 때 제가 고려한 내용은 다음과 같았습니다.
- 실제로 저장해서 유지해야 되는 데이터는 평균적으로 몇 건인가?
- 그랬을 때 UUID가 중복될 확률은 얼마인가?
- 신규 UUID의 생성 요청은 어느 정도 빈도로 들어오는가?
- UUID 생성에 트랜잭션을 걸고 중복 체크를 포함했을 때 발생하는 성능의 손실은 어느정도인가?
- 낮은 확률로 중복이 발생했을 때 생기는 문제점은 무엇이고 얼마나 치명적인가?
- 중복이 발생하는 사고가 발생했을 때 나는 얼마나 혼나는가?
가만히 생각해봤을 때, 중복이 발생할 확률은 위에서 계산했던 것 처럼 매우 낮은 수치였고, 새로운 UUID를 생성하는 요청도 초당 수천번 이상 들어오는 요청은 아니었기 때문에 트랜잭션을 걸어서 처리해도 유의미한 성능 손실이 발생하지는 않을 것으로 예상되었습니다. 다만 정말 운 없게 중복이 발생했을 때 예상되는 문제점은 다소 미묘했는데, 권한이 없는 사용자가 다른 사람의 권한을 가져갈 수 있는 일종의 보안 문제가 발생할 가능성이 있었습니다. 아주 낮은 확률이기는 하지만 잠재적인 보안문제를 남겨두는 것을 감수하기 어렵다는 판단을 내리고는 생성된 UUID에 대해서 중복 체크를 수행하는 로직을 추가하였습니다.
이 서버는 상태 유지를 위한 저장소, 즉 퍼시스턴트 레이어(Persistence Layer)로 레디스(Redis)를 사용하기로 하였기 때문에 실제 UUID의 중복 체크는 저장시점에 Redis의 SETNX 명령(Redis Template에서는 setIfAbsent)으로 저장하도록 구현하였습니다. SETNX 명령어는 Set if Not eXist의 약자로 해당 값이 레디스에 이미 있을 경우 저장하지 않고 false를 반환해줍니다. (일반적인 SET 명령어는 기존 값이 있을 경우 그냥 덮어씌웁니다.) 레디스는 데이터와 관련된 명령어 처리를 단일 스레드에서 처리하기 때문에 단일 명령어에 대해서는 클라이언트에서 별도로 트랜잭션을 처리할 필요가 없습니다. 때문에 실제로는 중복이 발생하지 않는 경우 성능 손실 없이 (네트워크 I/O를 한 번만 사용하면서) 데이터를 저장할 수 있으므로 크게 잃는 것 없이 중복 가능성을 제거 할 수 있었습니다.
레디스 연결하기
위에서 잠깐 스포일러가 나왔지만 이 CRUD 서버를 개발할 때는 데이터를 레디스에 저장하기로 결정했습니다. 여기에는 큰 고민이 있었던 것은 아니고 메모리 기반의 데이터 저장소 중 레디스의 포지션이 압도적이었고 주어진 요구사항에 매우 잘 맞았기 때문입니다.
레디스는 메모리에 데이터를 저장하기 때문에 처리속도가 매우 빠르다는 장점이 있습니다. 사양이나 상황에 따라 다르지만 일반적인 사양의 서버에서 redis-benchmark 프로그램을 돌려보면 GET / SET을 초당 20만번 정도 처리하는 것을 확인할 수 있습니다. 이는 높은 TPS를 확보하고자 할 때 매우 유리하게 작용하는데 비즈니스 로직의 처리에 레디스만 사용하도록 구성하면 크게 신경쓰지 않아도 1만 TPS 내외의 높은 성능을 쉽게 확보할 수 있습니다.
다만 아무렇게나 만들어도 무조건 ‘레디스는 빠르니까’라고 생각하기에는 생각보다 고려해야 할 내용이 많은데 예를 들면 다음과 같은 내용이 있습니다.
- 비즈니스 로직을 처리할 때 사용하는 레디스 명령어의 시간복잡도
- 특정 동작이 레디스 키 영역(Key space)의 전체 스캔을 유발하지는 않는지?
- 레디스에 저장되는 데이터의 양과 사전에 확보할 수 있는 메모리의 양
- 메모리가 가득찼을 때 오래된 데이터를 자동 삭제해도 되는가?
- 여러 명령어를 전달할 때 트랜잭션 처리가 되어야 하는가?
- 일반적인 키 - 밸류 형태의 자료구조가 아닌 List, Set, Hash, SortedSet 등의 구조가 필요한가? 이 때 각 자료구조 내부의 데이터에도 별도의 만료시간이 설정되어야 하는가?
시간복잡도의 경우 레디스 공식 홈페이지의 명령어 설명에 각 명령어마다 모두 기재되어 있기 때문에 참고해서 서버 로직을 구성하면 큰 문제는일으키지 않을 수 있습니다. 레디스의 전체 스캔(Full Scan)의 경우 대표적으로 KEYS명령어에서 발생하는데 해당 명령을 사용하는 케이스는 보통 레디스에서 뭔가를 검색하고자 하는 경우 (예를 들어서 특정한 Prefix를 가진 키를 모두 찾고자 하는 경우)이기 때문에 요구사항에서 검색에 대한 요건을 미리 제거해두어야 곤란한 상황을 면할 수 있습니다. 만약 어쩔 수 없이 검색을 해야 되는 상황이라면 SCAN명령어를 대신 사용해야 하지만 이 경우에도 트랜잭션 처리등을 위해 서버 코드가 매우 복잡해지는 문제가 발생합니다.
메모리양의 경우 예상되는 데이터 크기를 예상해야 하는데, 예를 들어서 128바이트짜리 문자열을 100,000건 저장할 경우 12,800,000바이트 = 12메가바이트 정도의 메모리를 사용한다고 생각할 수 있습니다. 별다른 설정(설정파일의 maxmemory 항목)이 없을 경우 레디스는 32비트 배포본인 경우 3기가바이트 까지, 64비트 배포본인 경우 서버가 허용하는 메모리까지 사용하기 때문에 하나의 서버에 이것저것 엄청 깔아둬서 가용메모리가 매우 부족한 경우가 아니라면 일반적으로는 괜찮다고 볼 수 있습니다.
하지만 서버에 메모리가 부족한 경우 가상메모리를 사용하여 스왑(swap)이 발생하여 성능이 극심이 저하되는 경우가 있을 수 있으므로, 레디스 설정에서 일정 메모리 공간을 미리 할당해서 최대 메모리를 제한하는 경우가 충분히 있을 수 있는데 이럴 때는 메모리가 가득찼을 경우 어떤 정책을 취할지에 대해서 사전에 고려해야 할 필요가 있습니다. 레디스를 데이터베이스로 생각하고 쓰는 경우 정말 간과하기 쉬운 사실인데, 레디스는 캐시용도로 사용되는 경우도 있기 때문에 정책에 따라서 메모리가 가득찰 경우 LRU 등의 알고리즘으로 잘 사용되지 않는 데이터를 삭제할 수도 있습니다. 데이터가 임의로 삭제될 수 있음을 가정하면 애플리케이션을 짤 때 코드가 매우 달라질 수 있으므로 주의해야 합니다. 물론 데이터가 삭제되는 것을 막고 대신 메모리가 가득 찼을 경우 데이터 추가 명령어에서 오류가 발생하도록 설정할 수도 있습니다.
예를 들어서 위에서 저는 중복 방지를 위해서 SETNX 명령어를 주어서 생성된 UUID를 레디스에 저장하고, 만약 저장 결과가 false일 경우 새로운 랜덤 UUID를 생성해서 다시 SETNX 를 수행하는 과정을 반복하도록 구성하였다는 말을 했었는데, SETNX 명령어는 이미 해당 데이터가 있을 때 false를 리턴하지만 위 설정에 의하여 메모리가 가득찬 경우에도 false를 리턴하므로 무한정 새로운 UUID를 생성해서 계속 SET을 시도할 경우 서버 애플리케이션과 레디스에 모두 예상치 못한 부하를 줄 위험이 있습니다. 때문에 저 같은 경우에는 새로운 UUID를 생성해서 다시 시도하는 로직을 10번 정도로만 제한하고 그래도 안 들어가면 그냥 클라이언트에 오류를 반환하도록 코드를 작성하였습니다. 실제로는 2번 정도만 시도해도 정말 기가막힌 우연에 의해 랜덤하게 생성된 데이터가 중복때문에 안 들어갔을 확률은 0.00000000000000000000000000000001 * 0.00000000000000000000000000000001 이므로 10번을 시도했는데도 데이터가 추가 되지 않았다면 10번 모두 중복이 발생했다고 생각하기 보다는 레디스의 메모리가 가득찼다고 판단하는게 합리적인 추론일 것입니다.
레디스의 경우 별도로 트랜잭션을 지원하지 않고 다만 들어온 명령어를 별도의 스레드 분리 없이 순차적으로 처리하는 구조를 가지고 있습니다. 트랜잭션 처리의 경우 여러 명령어를 묶어서 보내놓고 한 번에 실행하는 멀티 커맨드 기능은 지원하지만 중간에 오류가 발생했을 때 기존에 처리된 명령어를 롤백 시키는 등의 트랜잭션 처리는 지원하지 않기 때문에 - 공식 문서에서도 롤백 기능 지원하다 느려지느니 그냥 빠르게 처리하는 것에 집중하겠다고 적혀있습니다 - 기존 관계형 데이터베이스와 같은 트랜잭션 처리가 가능하겠거니 생각하고 접근하는 것은 다소 위험합니다. 이번 개발에서는 멀티 커맨드 기능을 일부 활용한 적이 있었는데 이에 대해서는 아래에 다시 설명하도록 하겠습니다.
마지막으로 레디스는 전체적으로 커다란 하나의 사전형 자료구조(Dictionary ADT) 형태를 가지고 있어서 개발하는 입장에서는 레디스 서버 자체를 어떤 해시맵(Hash Map) 처럼 취급해도 괜찮지만, 개발자의 편의를 위해서 리스트나 집합등의 자료구조도 값 중 하나로 취급할 수 있도록 구성되어 있습니다. 예를 들어서 MyData - Test123의 경우 키가 MyData, 값이 Test123으로 문자열 형태의 데이터를 저장하지만 MyList - [Test1, Test2, Test3]과 같은 형태로 키가 MyList이고 값이 리스트 형태인 저장도 가능하다는 말입니다.
이러한 구조는 값을 체계적으로 묶어서 관리할 때 매우 편하게 사용됩니다. 이번 개발에서는 테넌트를 구분해야 된다는 요구사항이 없었고, 실제로 개발을 시작하기 전에 제가 몇 번이나 '테넌트 구분 진짜 없어도 되나요?'라고 물어본 적이 있었는데 이는 레디스를 이용해서 개발을 할 때 매우 중요하게 고려해야 하는 포인트가 되기 때문입니다.
예를 들어서 테넌트 구분이 없는 단순한 CRUD 서버의 경우 문자열을 저장할 때는 그냥 다음과 같이 키에 저장하고자 하는 문자열을, 값에는 아무 값이나 필요한 값을 넣으면 됩니다. 저장하고자 하는 문자열을 키에 저장하는 이유는 해당 문자열의 중복체크를 해야 하기 때문입니다. (레디스는 값을 기준으로 EXIST나 SETNX등의 체크를 수행할 수 없습니다.)
Key : 7a82a1d5-aacb-4a95-a035-61e69e4c3dae / Value : 201809162118
Key : 14250b19-b1e5-4df9-be63-58592aaba4eb / Value : 201809162119
Key : f2d9bd8f-518e-41d2-ba35-fde761f0db7a / Value : 201809162120
만약 테넌트를 구분해야 한다면 크게 두 가지 선택을 할 수 있습니다. 하나는 Key를 정할 때 테넌트명을 붙여서 저장하는 것입니다.
Key : my_tenant:7a82a1d5-aacb-4a95-a035-61e69e4c3dae / Value : 201809162118
Key : second_tenant:14250b19-b1e5-4df9-be63-58592aaba4eb / Value : 201809162119
Key : my_tenant:f2d9bd8f-518e-41d2-ba35-fde761f0db7a / Value : 201809162120
서버 애플리케이션에서는 데이터에 접근할 때 요청에 포함된 테넌트명을 기준으로 키를 조합할 수 있기 때문에 GET과 SET 모두 동일하게 O(1)의 복잡도로 수행할 수 있습니다.
다른 하나는 테넌트 명을 가진 집합(SET) 자료구조를 생성하고 거기에 문자열들을 저장하는 방식입니다.
Key : my_tenant / Value : {7a82a1d5-aacb-4a95-a035-61e69e4c3dae, f2d9bd8f-518e-41d2-ba35-fde761f0db7a}
Key : second_tenant / Value : {14250b19-b1e5-4df9-be63-58592aaba4eb}
특정 집합에 데이터를 저장하거나 가져오는 것, 데이터가 존재하는지 확인하는 동작등은 모두 상수시간에 수행되는 명령이기 때문에 이러한 구조에서도 성능이 저하되지는 않습니다. 이러한 방식의 장점은 특정 테넌트에 속한 모든 데이터를 한 번에 처리할 수 있다는 점입니다. 예를 들어서 my_tenant 테넌트를 삭제해야 한다고 가정하면 집합 형태로 저장한 경우 DEL my_tenant명령어 하나로 모든 데이터를 삭제할 수 있지만, 첫번째 방법의 경우 my_tenant: 으로 시작하는 키를 검색 해야 하기 때문에 매우 골치아픈 동작이 됩니다. 위에서 말했듯이 레디스에서 검색은 굉장히 까다로운 로직을 필요로 합니다. 삭제가 아니라 테넌트 내에 키가 몇 개인지 조회하는 사이즈(Size) 명령등도 똑같이 두번째 방식에서는 매우 쉽지만 첫번째 방식에서는 매우 어려운 명령이 됩니다.
하지만 두번째 방식에도 문제가 있는데, 레디스는 자료구조 내부에 위치한 데이터들에 대해서는 개별적으로 만료시간(EXPIRE 명령)을 줄 수 없습니다. 레디스에서 특정 시간이 지난뒤 키를 자동으로 삭제해주는 기능은 오직 최상위 키 영역에만 한정되기 때문에 기능 요구사항 중 특정 데이터들은 시간이 지나면 자동으로 삭제되어야 한다는 요구사항은 별도의 자료구조를 사용하면 매우 구현하기 어려워지는 문제가 생깁니다.
이 문제는 레디스의 근본적인 디자인과 요구사항이 충돌하면서 생기는 문제로, 사실 답이 안 나오는 문제이기도 합니다. 그리고 사실 두 가지 방법 모두 문제를 해결할 방법은 있습니다. 예를 들어서 첫번째 방식의 경우 테넌트가 삭제될 때 해당 테넌트명으로 시작하는 키를 SCAN 명령을 통해 일정 단위로 삭제하도록 만들 수 있습니다. 이는 관계형 데이터베이스에서 페이징(Paging) 기능을 쓰는 것과 유사한 동작입니다. 두번째 방식의 경우 일반적인 집합이 아닌 정렬된 집합(Sorted Set - ZSET)을 사용하여 자료구조를 구성한 뒤 순위를 결정하는 값으로 만료시점의 타임스탬프를 사용하고, 집합 내부의 데이터를 취급할 때 현재 타임스탬프 이후의 값들만 취급하는 형태로 구성할 수 있습니다. 이 경우 만료된 데이터가 메모리를 지속적으로 점유하는 것을 막기 위하여 별도의 스케줄러나 프로세스를 통해 오래된 데이터를 스캔하여 삭제하는 로직도 같이 구현하여야 하며, 이 때 삭제해야 될 데이터가 매우 많을 경우 또 해당 동작이 레디스의 스레드를 오래 점유하지 않도록 나누어서 삭제하도록 하는 고려도 같이 해야 합니다.
저는 다른 애플리케이션을 개발할 때 두 가지 방법을 모두 사용했던 경험이 있었기 때문에 어떤 형태로 데이터를 저장해야 할 지에 대해서 대략적인 기준을 이미 가지고 있던 상태였는데, 특정 테넌트와 같은 기준 집합 내의 데이터의 카운트가 중요한 경우 후자와 같이 별도의 자료구조를 사용하는 방법을 사용하는 것이 좋고, 데이터 카운트가 딱히 중요하지 않은 경우 전자가 조금 더 합리적인 선택이 됩니다. 이는 전자가 아무래도 코드가 훨씬 간단한 편이지만 카운트 명령을 처리 하려면 또 SCAN을 해야 하기 떄문에 효율성이 매우 떨어지는 문제를 가지고 있기 때문입니다. 이번 CRUD 개발에서는 딱히 키의 숫자를 세어야 된다는 요구사항은 없었기 때문에 전자를 선택했습니다. 물론 다 만들고 나면 '통계화면 하나쯤은 있어야 하지 않겠어?'라는 요구사항이 들어올 수 있으므로 이걸 잘 막아야 되는 부담감은 있었습니다.
번외로 아예 다른 방법도 있는데, 테넌트별로 아예 다른 네임스페이스를 사용하는 방법입니다. 관계형 데이터베이스가 Database를 기준으로 완전히 다른 스키마를 가질 수 있는 것 처럼, 레디스도 번호로 된 네임스페이스를 구분 하는 기능을 가지고 있는데 각각의 테넌트가 완전히 다른 네임스페이스를 가지도록 하면 훨씬 더 간단하게 구현할 수도 있습니다. 다만 레디스쪽의 문제가 아닌 자바쪽 클라이언트에서 각각 다른 네임스페이스는 서로 다른 레디스 커넥션을 필요로 하기 때문에 해당 처리를 동적으로 처리하는 것은 프레임워크 레벨에서 약간 테크니컬한 처리를 필요로 합니다.
위에서 말했듯이 요구사항에는 테넌트 구분이 없었는데 굳이 복잡한 코드를 추가해서 테넌트 구분을 미리 넣어둔 이유는 위에서 알 수 있듯이 나중에 테넌트 구분을 추가하는 것이 거의 불가능할 정도로 변경영향이 큰 기능이었기 때문에 조금 고생스럽더라도 처음 만들때 미리 고민하는 것이 유리할 것이라 판단했기 때문이었습니다.
부록 : 어떤 레디스 클라이언트를 사용할 것인가
자바 애플리케이션에서 레디스 서버에 명령을 날리기 위해서 사용하는 라이브러리는 크게 3가지가 있습니다. (실제로는 더 많지만 많이 쓰이는 것은 3가지 정도 입니다.) Jedis, Lettuce, Redisson이 그것인데 모두 장단점이 있습니다.
Jedis
장점 : 매우 오랫동안 문제없이 쓰임. Spring Data Redis에서 공식 지원. 매우 많은 레퍼런스
단점 : 이제 거의 업데이트 안 됨. 최신 프로그래밍 트랜드 반영 안 됨. 버그 픽스도 느림. 스프링도 슬슬 Jedis를 버리기 시작함.
Lettuce
장점 : 스프링에서 지원하는 차세대 클라이언트. 매우 충실한 반응형 프로그래밍 지원. Redis Template을 그대로 사용 가능
단점 : 상대적으로 복잡한 API. Redisson에 비해 적은 기능.
Redisson
장점 : 분산 객체, 분산 서버 동기화, 반응형 프로그래밍 지원, 레디스를 이용한 RMI 등 매우 많은 기능. 개발자 친화적인 API.
단점 : 스프링에서 공식 지원하지 않음. 상대적으로 적은 레퍼런스.
저는 주로 Redisson을 클라이언트로 선호하였는데, 원격 객체를 사용한 데이터의 저장과 관리가 매우 직관적이고 레디스를 이용한 크리티컬 섹션의 설정이나 세마포어의 사용 등 분산 환경에 매우 적합한 API를 다수 지원하고 있기 때문이었습니다. Jedis의 경우 기존에 Jedis를 이용해서 개발한 프로그램이 있다면 굳이 다른 라이브러리로 바꿀 필요가 없을 정도로 잘 돌아간다는 것 외에는 새로 개발할 때 선택할 이유가 별로 없었고, Lettuce의 경우 스프링5가 출시된 시점부터 굉장히 힘을 많이 받았다는 장점이 있었습니다.
저는 이번 개발에서 처음으로 Lettuce를 사용하였는데 사실 별로 깊은 고민은 없었고, Redisson만 너무 많이 써봐서 다른 라이브러리도 사용해보고 싶었던 점도 있었고 혹시라도 Spring MVC를 WebFlux등을 기반으로 한 비동기 컨트롤러로 변경할 때 Lettuce 처럼 Reactive API를 다수 지원하는 라이브러리가 훨씬 유리할 것이라고 판단했기 때문입니다. Redisson도 반응형 프로그래밍을 충실하게 지원하고는 있지만 스프링 프레임워크와의 연계가 잘 되는 쪽이 더 좋은 선택이라고 생각했습니다.
토큰을 이용한 권한 관리
보통 뭔가 급하게 개발하라는 지시를 받을 때는 보안에 대해서 크게 신경쓰지말라고 하는 경우가 많습니다. 아무래도 기능이 단순하다고 해도 보안등에 들어가는 공수가 크게 줄어드는 것은 아니고, 보안 요소를 사전에 다 고려하면서 개발하면 빠르게 개발해서 API를 전달하기 쉽지 않기 때문입니다. 하지만 보안 요소도 마찬가지로 나중에 쉽게 추가할 수 있는 성질의 것은 아니기 때문에 개발을 시작할 때 최소한 보안과 관련된 처리가 들어갈 자리는 만들어두고 시작할 필요가 있었습니다.
제가 이 애플리케이션을 만들 때 고민했던 것은 API와 기능 레벨에서의 보안 요소 였는데, 로그인이나 권한 구분이 없는 현재의 요구사항에서는 모든 데이터에 대한 Rest API가 모든 클라이언트에 개방되기 때문에 아무나 UUID만 알고 있다면 값을 조회하거나 수정하거나 삭제할 수 있는 문제가 생길 수 있습니다.
이럴 때 가장 쉽게 처리하는 방법은 역시나 Spring Security 등의 권한 처리를 위한 라이브러리를 추가한 뒤, 테넌트등을 구분하여 사용자 권한을 처리하는 방식입니다. 저는 이 애플리케이션의 Rest API를 디자인할 때 큰 고민을 하지는 않았기 때문에 데이터의 경우 api/v1/테넌트명/데이터키 형태로 접근이 가능했는데 이렇게 테넌트 단위로 경로가 구분되면 Spring Security를 이용한 권한 처리시에도 경로를 기준으로 처리할 수 있기 때문에 거의 코딩 없이 권한 처리를 수행할 수 있는 장점이 있습니다.
하지만 보안에 대한 고려와 마찬가지로 테넌트 구분도 요구사항에는 포함되어 있지 않았기 때문에 저는 api/v1/데이터키 형태의 기본 테넌트를 쓰는 API도 추가해둔 상태이고 이 경우 모든 권한이 하나로 통합되어버리기 떄문에 테넌트 단위의 권한 구분은 별 의미가 없어집니다. 여전히 하나의 데이터에 대한 접근 권한을 가진 클라이언트는 다른 모든 데이터에 대해서도 권한을 가지게 됩니다.
때문에 고민한 것은 개별 데이터 단위로 접근 권한을 제어하는 방식이었습니다. 기본적인 발상은 필요한 경우 데이터를 접근하기 위한 토큰과 수정하기 위한 토큰을 발행하여 데이터와 같이 저장하고, 추후 데이터에 접근 혹은 수정을 시도할 때 토큰이 있는 경우 해당 토큰을 같이 전달하여 요청하지 않으면 요청을 거부하는 형태였습니다.
사실 이런식으로 토큰을 활용하여 자원에 대한 접근을 제어 하는 방식은 Rest API에서 매우 일상적을 사용되는 방식이기 때문에 딱히 그 설계에 대해서 많이 고민할 필요는 없었습니다. 다만 이런 방식이 의미있기 위해서는 토큰의 발급 및 검증이 들어가더라도 전체적인 성능에 큰 지장이 없도록 구현할 필요가 있었고, 토큰을 제거하거나 노출된 경우 재발행 할 수 있는 방법도 같이 제공하는 것이 좋을 것이라 생각했습니다.
위에서 레디스에 데이터를 저장할 때 문자열 데이터는 실제로 레디스의 키로 사용되고 해당 키에 연결되는 값(Value)은 별로 중요하지 않다고 이야기 한 적이 있었는데, 토큰 개념을 고려하면 값에도 슬슬 유의미한 정보가 추가되어야 합니다.
Key : my-key-001
Value : my-key-001:null:ced0b714-7a00-4d01-9656-f6c2d0591694
예를 들어서 위와 같이 저장된 데이터는 my-key-001이라는 키에 값을 키:검증토큰:수정토큰 형태로 저장한 예제입니다. 별도의 검증토큰을 발행되지 않았기 때문에 null로 처리하였습니다. 만약 해당 키에 대한 수정 요청이 Rest API로 들어온 경우 서버는 요청의 헤더 등에 포함된 토큰 정보를 수령한 뒤, 레디스에서 꺼내온 수정 토큰인 ced0b714-7a00-4d01-9656-f6c2d0591694와 비교하여 해당 요청이 수정 권한을 적절하게 가지고 있는지 판단할 수 있습니다. 이 경우에도 레디스에서 값을 가져오는 GET명령은 O(1)의 복잡도로 한 번만 수행되기 때문에 자바에서 문자열 비교하는 정도의 로직만 추가되어서 전체 성능에는 거의 영향이 없을 것으로 예상할 수 있습니다.
저는 실제로 Value에는 키와 토큰 및 키+토큰을 해시한 해시 문자열을 같이 집어넣어서 사용했는데, 해당 해시는 혹시라도 있을 레디스 내부의 데이터 조작을 방지하기 위해서 사용하였습니다. 예를 들어서 누군가가 위의 데이터를 SET my-key-001 "my-key-001:null:null과 같은 명령어로 변경해버린 경우
Key : my-key-001
Value : my-key-001:null:null
수정 토큰이 사라지면서 수정 권한이 풀려버릴 수 있는데, 만약 Value를 my-key-001:null:ced0b714-7a00-4d01-9656-f6c2d0591694:해시값 형태로 저장한다면 해시값을 생성하는 로직을 같이 알아내서 해시를 다시 만들어내지 않는 이상은 데이터를 쉽게 변경할 수 없을 것입니다. Value에 키가 한 번 더 들어간 이유도 같은 맥락에서 생각할 수 있습니다. 만약 Value에 키가 없었다면 레디스에서 검증/수정토큰이 없는 다른 데이터에서 해시값을 획득해서 데이터를 수정해버리면 해시 생성 로직 없이도 데이터를 변경해버릴 수 있는 이슈가 여전히 생기는데, 데이터의 키를 값에 한 번 더 포함시켜서 이러한 공격을 방어할 수 있습니다.
위 경우에서는 데이터 구조가 키:검증토큰:수정토큰:해시 형태로 단순했기 때문에 그냥 문자열로 값을 저장하고, 추후 검증할 때 문자열을 잘라내서 검증하는 방식으로 처리하였는데 나중에 토큰을 삭제하거나 수정하는 기능이 추가되면서 이러한 방식도 다소 복잡하게 처리되는 문제가 생겼습니다. 이 때는 두 가지 정도의 방법으로 문제를 해결할 수 있는데, 한 가지는 저장되는 데이터를 JSON 형태로 직렬화 하여 처리하는 방식입니다.
Key : my-key-001
Value : {"key":"my-key-001", "vToken":null, "mToken": "ced0b714-7a00-4d01-9656-f6c2d0591694"}
이는 비즈니스 로직을 구현할 때 자바 객체 형태로 데이터의 값에 접근하고 수정한 뒤 저장할 수 있게 만들어주므로 문자열을 자르고 붙이는 것에 비해서 훨씬 개발이 쉬워집니다. 다른 방법은 레디스에서 제공하는 Hash 자료구조로 데이터를 저장하는 방식입니다. 레디스의 Hash는 Value를 또 하나의 맵으로 사용할 수 있도록 해주는 자료구조로 다음과 같은 형태의 저장이 가능합니다.
127.0.0.1:6379> HSET my-key-001 key my-key-001
(integer) 1
127.0.0.1:6379> HSET my-key-001 mToken ced0b714-7a00-4d01-9656-f6c2d0591694
(integer) 1
127.0.0.1:6379> HGET my-key-001 mToken
"ced0b714-7a00-4d01-9656-f6c2d0591694"
이 애플리케이션에서는 비즈니스 로직상 키 내부의 데이터 (토큰 및 해시 등)를 모두 한 번에 저장했다가 한 번에 가져오는 동작만 있었기 때문에 데이터를 JSON 형태로 저장해서 사용하는 방식을 선택했습니다. 이 경우 코드가 훨씬 간단해지는 장점이 있습니다. 다만 레디스의 Hash도 데이터를 한 번에 저장하거나 가져오는 HMSET / HMGET 명령어를 지원하기 때문에 Hash를 쓰고자 선택했어도 큰 문제는 없었을 것입니다.
레디스의 Hash를 쓸 경우 예를 들어서 mToken을 삭제하는 로직을 처리하고자 할 때, HDEL my-key-001 mToken 처럼 단순한 명령어로 구성요소를 삭제할 수 있습니다. JSON으로 처리할 경우 어쨌든 모든 데이터 값을 가져온 다음 자바 객체로 역직렬화 시킨 뒤 값을 삭제하고 다시 JSON으로 변환해서 레디스에 반영하는 과정이 포함되므로 약간의 오버헤드가 존재한다고 볼 수도 있습니다.
별도의 식별자를 이용한 데이터 접근
개발을 진행하던 중간에 데이터에 대한 접근이 키가 아닌 또 다른 식별자로도 이루어졌으면 좋겠다는 의견을 받았습니다. 어떤 말이냐면 만약 데이터에 대한 생성요청이 다음과 같은 경우
{"key":"my-key-001", "identifier":"my-id-1234"}
해당 데이터에 대한 접근은 keys/my-key-001혹은 identifiers/my-id-1234양 쪽으로 이루어질 수 있어야 한다는 요구였습니다. 사실 관계형 데이터베이스를 기반으로 하는 애플리케이션이었다면 스키마와 쿼리를 조금 수정해서 처리할 수 있는 요구사항이었지만 레디스를 쓰는 애플리케이션의 경우 약간 더 고민이 필요하기는 했습니다. 하지만 대충 생각했을 때 어떤 검색이 필요한 것이 아닌 고정적인 식별자를 추가하는 것은 레디스로 구현할 때 크게 문제가 되지는 않을 것이라 유추했기 때문에 해당 기능을 추가해드리기로 결정하고 조금 더 상세히 고민해봤습니다.
사실 구현 자체는 매우 간단하게 할 수 있습니다. 식별자 역시 유일하게 데이터를 식별하는 값이기 때문입니다. 예를 들어서 다음과 같이 레디스에 데이터를 저장할 수 있습니다.
Key : keys:my-key-001
Value : my-key-001:null:ced0b714-7a00-4d01-9656-f6c2d0591694
Key : identifiers:my-id-1234
Value : my-key-001
우선 키에 해당 데이터가 키 기반의 데이터를 표현하고 있음을 나타내는 keys: 이라는 Prefix를 추가하였습니다. 그리고 식별자의 경우 identifiers:이라는 Prefix를 추가한 뒤 식별자를 기재하였고, 그 값으로 해당 식별자가 가리키는 키를 넣었습니다.
레디스의 키는 실제로는 바이트로 저장되지만 거의 모든 개발자들이 문자열 형태로 직렬화해서 사용하는데, 이 때 키에 계층구조를 표현하기 위해서는 콜론을 사용하여 구분하는 것이 일반적인 컨벤션 입니다. 설명을 위해서 여태 표기를 안했지만 실제로 각 키는 my_tenant:identifiers:my-id-1234 와 같은 형태로 테넌트를 포함하여 정의했습니다.
클라이언트가 서버에 뭔가 검증을 요청할 때는 테넌트명과 식별자명 혹은 데이터 키가 전달되기 때문에 비즈니스 로직에서 실제 검증을 시도할 때는 필요한 키를 완전하게 구성할 수 있습니다. 예를 들어서 검증 요청이 다음과 같을 경우
{"tenant":"my_tenant", "identifier":"my-id-1234"}
서비스 레이어에서는 레디스에서 데이터를 가져오기 위해서 주어진 정보만으로 my_tenant:identifier:my-id-1234 라는 완전한 키를 조합할 수 있는데, 이렇게 되면 레디스에서 바로 상수시간에 my-key-001이라는 실제 데이터의 키를 가져오거나 혹은 해당 식별자가 존재하지 않는다는 결과를 받을 수 있습니다. 실제 데이터의 키를 가져온 경우 마찬가지로 서비스 레이어는 my_tenant:keys:my-key-001 이라는 데이터 키를 조합할 수 있고, 이 키를 이용해서 상수시간에 실제 데이터의 값을 꺼내올 수 있습니다. 이 과정에서 서버는 레디스에 2번 네트워크 I/O를 발생시켰으며 두 개의 명령은 모두 레디스에서 O(1)의 복잡도로 실행되었기 때문에 성능은 여전히 매우 높은 상태로 유지됨을 알 수 있습니다. 만약 클라이언트에서 들어온 요청으로 완전한 키를 구성하지 못해서 (요청에 와일드카드를 지원해야 한다거나 해서) SCAN 명령등이 추가되어야 했거나, 뭔가 목록을 가져와서 거기서 다시 원하는 값을 찾거나 해야했다면 프로그램의 구조와 코드, 성능 모두 상당히 안 좋아지는 결과가 나왔을 겁니다.
이러한 디자인은 무난하게 구성할 수 있었는데, 사실 문제가 되는 것은 유효기간의 존재였습니다. 가만히 생각해보면 실제 데이터에 EXPIRE 시간이 설정된 경우 식별자에도 동일한 만료시간이 설정되어야 함을 알 수 있는데, 이를 서비스 레이어에서는 다음과 같은 형태로 수행하게 됩니다.
SET Key
EXPIRE Key 60
SET Identifier
EXPIRE Identifier 100
레디스에는 SET과 EXPIRE를 한번에 처리 할 수 있는 SETEX명령도 있고, SET 명령에도 EXPIRE와 NX 처리를 동시에 할 수 있도록 파라메터를 전달할 수 있지만 아쉽게도 스프링의 Redis Template은 setIfAbsent 메소드에 유효시간을 전달하도록 지원하고 있지 않기 때문에 유효기간을 설정해야 하는 경우 부득이하게 4번의 네트워크 I/O가 발생하게 됩니다. 이 자체는 성능에 큰 영향을 주는 부분은 아니지만 문제가 될 수 있는 것은 4번의 동작이 하나의 트랜잭션으로 묶이지 않는 다는 점입니다. 만약 다수의 서버에서 요청이 들어가는 경우 EXPIRE Key 60 명령어와 SET Identifier 명령 사이에 다른 서버의 요청이 들어갈 수도 있는데, 만약 그 요청이 운 없게도 KEYS 명령어등 시간이 오래걸리는 명령인 경우 레디스는 순차적으로 SET - EXPIRE - KEYS - SET – EXPIRE 명령을 처리하기 때문에 키의 만료시간과 식별자의 만료시간에 차이가 발생할 가능성이 존재합니다.
물론 실제로 애플리케이션 서버는 KEYS 명령어를 쓰지 않도록 하였기 때문에 이런 일은 생기기 어렵지만 어쨌든 키와 식별자를 한 몸으로 만들기 위해서 트랜잭션으로 묶는 것이 훨씬 안전해보이는 것은 사실입니다. 다만 위에서 언급했듯이 레디스의 트랜잭션 지원은 다소 애매한 구석이 있어서 롤백을 지원하지 않기 때문에 트랜잭션을 어떤 식으로 묶을 것인지도 다소 고민이 필요한 부분이었습니다.
사실 트랜잭션 처리를 하는 가장 큰 이유중 하나는 SET Key - EXPIRE Key - SET Identifier 순서로 명령을 처리할 때 Identifier가 중복되어 SET이 불가능한 경우 서버는 Identifier가 중복되었다는 오류 메시지를 반환하고, 이미 저장된 Key를 다시 제거하는 형태의 롤백 동작일텐데 레디스에서는 이런 동작을 위해서는 개발자가 하나씩 손으로 다 만들어줘야 했습니다.
때문에 식별자가 추가된 이후로 데이터를 저장하는 방식은 다소 복잡해졌는데, 우선 SET과 EXPIRE 동작은 스프링 레벨에서 모두 멀티 커맨드로 묶였습니다. SET - EXPIRE - SET - EXPIRE 와 같은 형태로 묶은 명령어는 실제로 레디스에는 각각 별개의 네트워크 I/O로 전송되기 때문에 여전히 4번의 네트워크 I/O로 전달되지만 멀티 커맨드로 전달된 경우 레디스는 해당 명령을 받는 즉시 실행하지 않고 일단 저장만 해둡니다.
이후 명령어를 실제로 실행하는 EXEC 명령이 전달되면 그 때 레디스는 저장해뒀던 명령어들을 실행하는데, 이 때 4개의 명령어는 중간에 다른 명령어가 끼어들지 않고 한 번에 실행될 것이 보장됩니다. 하지만 중간에 EXISTS identifier와 같은 형태로 뭔가를 체크하고 분기를 태우는 등의 동작은 불가능합니다. 만약 트랜잭션 중간에 로직을 추가하고자 하는 경우 Redis Script를 이용하여 레디스에 스크립트를 작성한 뒤 해당 스크립트를 실행하는 형태로 만들어야 하는데, 레디스는 스크립트 언어로 Lua를 사용하기 때문에 러닝 커브가 조금 있습니다.
때문에 롤백 기능을 지원하기 위해서는 별 수 없이 멀티 커맨드의 실행 결과를 받아서 수작업으로 처리할 수 밖에 없는데, 4개의 커맨드의 실행 결과가 true - true - true - true로 전달되면 모두 성공한 것이니 롤백이 필요없지만 true - true - false - false로 전달되면 식별자 저장에 실패한 것이므로 결과를 받아서 다시 Key에 대한 DELETE명령을 전달해서 키를 삭제할 필요가 있었습니다. 물론 삭제 명령이 실패한 경우도 있을 수 있으므로 레디스에서 지원하지 않는 롤백 기능을 애플리케이션 레벨에서 추가하는 것은 기본적으로 손이 많이 가는 일입니다.
또 다른 고민으로는 키를 지웠을 때 식별자도 같이 지워야 한다는 것이었는데, 이는 키의 값에 다음과 같이 식별자 정보를 추가해주는 것으로 해결했습니다.
Key : my-key-001
Value : {"key":"my-key-001", "identifier":"my-id-1234", "vToken":null, "mToken": "ced0b714-7a00-4d01-9656-f6c2d0591694"}
만약 키에 대한 삭제요청이 들어온 경우 identifier 값을 가져와서 해당 식별자에 대한 삭제 동작을 수행한 뒤 데이터를 완전히 삭제해주면 되므로 크게 어려운 문제는 아니었습니다. 다만 데이터를 이렇게 저장할 경우 식별자와 키가 1:1로만 대응되는 제한이 생겨버리게 되므로, 만약 하나의 키에 여러 개의 식별자가 붙을 수 있는 경우에는 identifier를 배열등의 형태로 저장할 필요가 있었습니다. 만약 값을 JSON 형태로 저장하지 않았다면 다소 어려운 표현이 되었을 겁니다.
결론
이러한 고민과 개발을 통해 아주 간단한 CRUD 애플리케이션을 구현하고 배포할 수 있었습니다.