티스토리 뷰
안녕하세요 저는 VI Engineering 팀 김윤제입니다.
Gmarket Mobile Web Vip(View Item Page = 상품 상세)를 담당하고 있는 Backend Engineer 입니다.
이번 블로그에서는 개인적으로 상품 상세 페이지에 넣고 싶었던
현재 이 상품 몇 명이 보고 있어요 기능을 혼자 공부하며 개발해보는데 있어서
어떻게 설계를 해야 최적의 성능을 낼 수 있을지 고민하였고 그 과정을 설명드리려고 합니다.
자세한 내용은 아래에서 살펴보도록 하겠습니다.
동작 과정
요구사항은 다음과 같았습니다.
상품 별로 중복되지 않은 사용자가 몇 명이 보고 있는지 실시간으로 집계하여 보여준다.
현재 이 상품 몇 명이 보고 있어요 기능의 동작 과정은 다음과 같습니다.
- 사용자가 웹 또는 앱을 통하여 상품 상세 페이지에 접속한다.
- 상품 상세 서버에서는 상품 번호와 사용자 인식 정보를 데이터베이스에 저장한다.
- 상품 상세 서버에서는 데이터베이스에서 해당 상품번호에 몇 명의 사용자가 있는지 검색한다.
- 데이터베이스에서 검색한 사용자 수를 현재 사용자에게 반환한다.
- 접속 중인 사용자 (웹 또는 앱)는 주기적으로 상품 상세 서버에 현재 몇명이 이 상품을 보고 있는지 요청한다.
- 사용자가 이탈 (상품을 벗어남)하면 데이터베이스에서 해당 상품번호에 저장된 해당 사용자 인식 정보를 제거합니다.
5번에서 주기적으로 상품 상세 서버에 요청을 해야 하는데 웹 / 앱이 동일한 방법을 써야 관리하기가 쉬울 것 같다는 점에서
웹소켓 / 소켓이 아닌 API 요청을 받는 게 좋다고 생각했습니다.
실시간으로 쏟아지는 트래픽 속에서 집계를 해야 하는 상황이라 RDB로는 성능이 안 나올 것 같아 NoSql 데이터베이스를 선택했습니다.
이제 Nosql 데이터베이스 중 Redis와 MongoDb 사이에서 선택해야 했는데요.
아래에서 각 데이터베이스 별로 장단점을 알아보도록 하겠습니다.
Redis
Redis란?
Redis는 Remote Dictionary Server의 약자로 키(Key) - 값(Value) 쌍의 해시 맵과 같은 구조를 가진
비관계형(NoSQL) 데이터베이스 관리 시스템(DBMS)입니다.
Redis는 오픈 소스 기반으로인-메모리(In-memory) 데이터 구조 저장소로 메모리에 데이터를 저장합니다.
따라서 별도의 쿼리문이 필요로 하지 않고, 인-메모리에 저장되기 때문에
상당히 빠른 속도로 처리할 수 있습니다.
Redis의 특징 및 장단점
1. 성능
모든 Redis 데이터는 메모리에 저장되어 대기 시간을 낮추고 처리량을 높입니다.
평균적으로 읽기 및 쓰기의 작업 속도가 1ms로 디스크 기반 데이터베이스보다 빠릅니다.
2. 유연한 데이터 구조
Redis의 데이터는 String, List, Set, Hash, Sorted Set, Bitmap, JSON 등
다양한 데이터 타입을 지원합니다.
따라서, 애플리케이션의 요구 사항에 알맞은 다양한 데이터 타입을 활용할 수 있습니다.
3. 개발 용이성
Redis는 쿼리문이 필요로 하지 않으며, 단순한 명령 구조로 데이터의 저장, 조회 등이 가능합니다.
또한, Java, Python, C, C++, C#, JavaScript, PHP, Node.js, Ruby 등을 비롯한
다수의 언어를 지원합니다.
4. 영속성
Redis는 영속성을 보장하기 위해 데이터를 디스크에 저장할 수 있다.
서버에 치명적인 문제가 발생하더라도 디스크에 저장된 데이터를 통해 복구가 가능합니다.
5. 싱글 스레드 방식
Redis는 싱글 스레드 방식을 사용하여 한 번에 하나의 명령어만을 처리합니다.
따라서 연산을 원자적으로 처리하여 Race Condition(경쟁 상태)가 거의 발생하지 않습니다.
하지만, 멀티 스레드를 지원하지 않기 때문에
시간 복잡도가 O(n)인 명령어의 사용은 주의해서 사용해야 합니다.
Item View Count (지금 이 상품 몇 명이 보고 있어요) 기능은 실시간 집계가 필요하며 성능적으로도 빨라야 해서
고속의 인 메모리 기반의 데이터베이스 레디스를 생각하게 되었습니다.
하지만 레디스로 구현을 해당 기능을 올바르게 구현하기 위해서는 자료구조를 어떤 것을 선택하는지가 중요합니다.
아래에서 레디스에서 자료구조 선택하는 데 있어서 했던 고민들을 살펴보도록 하겠습니다.
자료 구조 선택의 과정
Set (1)
가정:
제일 먼저 생각했던 방법입니다. (굉장히 간단할 것이라고 생각했습니다.)
Set 자료구조에 Key로 상품번호를 넣고, Value에 사용자 인식 정보를 넣는다면
ex (key = "123456789", value="rlanwl")
사용자는 중복되지 않을 것이고, Insert, Select 시간 복잡도 O(1) 성능 이슈 X
앱은 라이프 사이클에 따라 웹은 브라우저 종료 감지로 사용자 이탈 시 제거하면 럭키 비키합니다.
현실:
파이어 폭스 같은 브라우저는 자바스크립트의 종료 감지 이벤트가 안될 수 있습니다.
네트워크 이슈로 종료 감지를 못하면?
Set에 저장된 데이터는 영영 삭제되지 않는 문제가 있습니다.
유감.
Set (2)
위와 같은 방식으로 저장하되 각 키에 ExpireTime (60초)를 설정
그럴 경우 만약 1번 상품에 대하여 60초 동안 아무도 안 본다면 1번 상품을 레디스에서 삭제.
레디스에서 삭제가 되려면 60초 동안 각 상품을 안 봐야 하는데... 새벽에나 가능하지 않을까
또한 최대한 사용자 수를 동기화를 해야 하는데 너무 오차가 크다는 문제가 있습니다.
유감.
Set X Hash
가정:
Set 자료구조에 Key로 상품번호를 넣고, Value에 사용자 인식 정보를 넣고..
Hash에는 모든 상품과 사용자 정보, 현재 시간을 넣어서 별도의 Batch을 통해 이탈 감지 실패한 케이스들을 삭제합니다.
Insert, Select 시간복잡도 O(1)
현실:
Hash에 저장된 데이터를 Batch 을 통해 지울 경우 모든 상품을 검사하며
저장된 모든 유저들의 저장시간을 비교해야 하는데
해시에 저장된 모든 키 조회로 인한 레디스 부하 -> 시간 복잡도: O(N) (Hash의 필드 수)
키 리스트를 순회하며 유저 조회 후 날짜 비교 어플리케이션 부하
Sorted Set
가정:
Sorted Set에는 Key, Value, Score을 저장할 수 있으니
Key에는 상품번호, Value에는 사용자 인식 정보, Score에는 현재 시간을 저장합니다. -> 시간 복잡도 O(log n)
중복된 Value를 허용하지 않으며 동일한 데이터 입력 시 Score를 업데이트할 수 있습니다.
현재 상품을 몇 명이 보고 있는지는 해당 Key에 저장된 길이를 구하면 됩니다. -> 시간 복잡도 O(1)
또한 Sorted Set에는 ZREMRANGEBYSCORE라는 명령어가 있는데 이 것을 활용하면
일정 시간 동안 Score가 업데이트되지 않은 사용자 정보를 제거 할 수 있습니다.
현실:
가장 이상적인 자료구조 조합이지만
Batch를 돌려서 일정 시간 동안 Score가 업데이트 되지 않은 사용자 정보를 제거해야 하는데
SortedSet에 저장된 모든 키를 다 찾아야 합니다. => ZRANGE 시간 복잡도 O(log n + m)
그 키 별로 ZREMRANGEBYSCORE 명령어를 실행해야 하기 때문에 => 시간 복잡도 O(log n + m)
총 시간 복잡도는 O(log n + m) + 상품 수 * O(log n + m)으로 레디스의 부하가 예상되나
SortedSet에 저장된 상품별 사용자 정보에서 얼마나 많은 수의 이탈 감지 실패가 발생했는지가 관건
Batch도 언제 돌릴지가 관건이었습니다.
(하지만 이탈 감지 실패는 그렇게 많지 않을 것으로 보이기 때문에 이 자료구조를 선택하였습니다.)
위 Set X Hash 구조와 성능적으로는 크게 차이가 나지 않지만 확실히 SortedSet 자료구조가 가져다주는 편리한 이점이 있어서 해당 기능을 사용하였습니다.
MongoDB
이번에는 몽고 DB에서는 어떨지 살펴보았습니다.
MongoDB는 고성능, 고가용성 및 쉬운 확장성을 제공하는 NoSQL, Document 지향 데이터베이스입니다.
데이터를 배열 및 중첩 Document와 같은 복잡한 데이터 유형을 효율적으로 저장할 수 있는
유연한 JSON과 유사한 형식인 BSON(Binary JSON)으로 저장합니다.
몽고DB의 주요 특징으로는 스키마리스 구조, 고성능, 고가용성, 확장성 등이 있습니다.
이러한 특징들은 개발자가 더 빠르고 유연하게 애플리케이션을 개발할 수 있게 돕습니다.
왜냐하면 몽고DB는 동적 스키마를 지원하기 때문에, 애플리케이션의 데이터 구조가 변경되어도
데이터베이스를 수정할 필요가 없습니다.
이는 개발 시간을 단축시키는 큰 이점입니다.
또한, 몽고DB는 내장된 샤딩 기능을 통해 데이터베이스의 수평 확장이 용이하며,
이는 대규모 데이터베이스 관리에 필수적인 기능입니다.
고가용성을 위한 복제 세트와 자동 장애 복구 기능은 몽고DB를 더욱 신뢰할 수 있는
데이터 저장소로 만듭니다.
왜냐하면 이러한 기능들은 데이터의 안정성과 서비스의 지속 가능성을 보장하기 때문입니다.
몽고 디비에서는 아래와 같이 2가지 설계를 생각해 보았습니다.
첫 번째, 다음과 같이 컬렉션 구조를 가져갔습니다.
- userProductView
{
itemNo: "1",
loginId: "abcd",
timestamp: "2024-09-17T04:06:19.474+00:00"
}
Flow는 다음과 같습니다.
- userProductView -> 상품 별로 사용자 인식 정보와 현재 시간을 upsert한다. -> 시간 복잡도 O(log n)
- 위 userProductView에 저장된 상품 번호 별 document 수를 구한다. -> 시간 복잡도 O(n)
- 위 2번에서 구한 상품 별 ViewCount를 반환한다.
이 구조는 생각보다 레디스보다 간단해 보이지만 숨은 내용이 있었습니다.
- 검색 속도 개선을 위한 인덱스 설정
몽고 디비를 사용할 경우 userProductView 컬렉션에 itemNo(상품 번호) 필드에 index를 걸어야 합니다. - 사용자 수 동기화를 위한 timestamp에 expire를 설정
몽고 디비를 사용할 경우 각 도큐먼트 별로 expire를 설정할 수 있기 때문에
레디스처럼 별도의 Job을 구성하여 이탈 감지 실패 케이스들에 대하여 검사를 할 필요가 없습니다.
하지만 정확한 만료 시간 보장이 어렵다는 단점이 있으며, 성능 저하가 있을 수 있습니다.
두 번째, 다음과 같이 하나의 컬렉션 구조를 가져갑니다.
{
itemNo: "1",
"users": [
{
loginId: "abcd",
timestamp: "2024-09-17T04:06:19.474+00:00"
}
]
}
동작 Flow는 다음과 같습니다.
- 상품 조회 => 인덱스가 있는 경우 O(log n) 또는 인덱스가 없는 경우 O(n)
사용자가 상품을 조회할 때, 해당 itemNo에 대한 Document를 찾아야 합니다.
사용자 정보를 users 배열에 추가하여 timestamp를 갱신합니다.
해당 itemNo에 expire 설정 - 상품에 속한 users 배열을 순회하며 특정 시간 기준으로 활동하지 않은 user라 판단하는 비교 로직을 수행해야 하므로, 배열의 크기에 따라 O(n)시간이 걸립니다.
- 배열에 사용자 추가 => O(1)
하지만 이 방식에도 문제가 있었습니다.
- 상품 번호에 expire가 설정되어 있기 때문에 실제로 자주 보는 상품이라면 삭제가 되지 않을 수 있어서 데이터의 양이 엄청나게 커질 수 있습니다. 그럴 경우 상품 전체 조회 O(n) + user 배열 순회 O(n) + 배열 삭제 및 업데이트 O(n)
- 이에 따라 또다시 Batch Job을 구성해 삭제해야 하는...
어떤 게 더 좋은 거지?
레디스와 몽고 DB를 전체 Flow에 시간복잡도를 비교해 보았을 때 테이블로 보면 다음과 같습니다.
해당 기능을 구현하기에 있어서 실시간이라는 점, 여러 도메인에 조회 기능을 제공해야 한다는 점에서 보면 성능적으로 레디스가 나을 듯합니다.
하지만 레디스는 가격이 비싸다는 가장 큰 문제가 있습니다.
복잡성과 유지보수면에서는 Batch Job이 없는 몽고 DB가 더 나아 보이지만,
몽고 DB는 Index 설정, Expire 설정 등 따져야 할 게 몇 가지 있습니다.
각 데이터베이스별로 장단점이 있어서 어떤 것을 사용하는 게 좋을지는 면밀히 검토해봐야 할 듯합니다.
끝으로
처음에는 간단할 것이라 판단하였지만 생각보다 따져볼 것이 많아 복잡했습니다.
만들고 싶었던 기능을 이것저것 따져가며 여러 데이터베이스와 비교를 해보는 것도 꽤 재미있는 경험이 되었습니다.
이제는 주니어보단 시니어 개발자에 가까운 연차가 되어가네요.
꾸준히 노력하여 부끄럽지 않은 개발자가 되도록 하겠습니다.
감사합니다.
'Infra' 카테고리의 다른 글
Redis Stream 적용기 (5) | 2024.07.11 |
---|---|
쿠버네티스 오퍼레이터를 Java로 개발해보기 (0) | 2024.07.01 |
신규 서비스 "꿀템"을 만들기 위한 여정(네? 다음달까지요?) -2편 (10) | 2024.06.30 |
경력 입사자의 스크럼 스프린트 적응기 (with Jira software) (33) | 2024.06.28 |
Jenkins 성능 개선 part1 - 캐싱 적용 (0) | 2023.07.27 |