채팅 서비스를 구현하며 채팅 서비스는 동시에 여러 클라이언트로부터 요청이 들어오며 해당 내용에 서버 내에 저장되어야 함을 생각해 보았습니다.
먼저 동시에 여러 클라이언트로부터 요청이 들어오고 이를 최대한 빠르게 다른 사용자로 전파해야 합니다. 그리고 해당 채팅은 서버 내에 반드시 저장되어야 합니다.
두가지 조건을 만족시키기 위해 실시간 채팅 데이터를 DBMS에 데이터를 저장하는 것에 대해 고려해보았습니다. DBMS도 사용자 레벨에서 충분히 빠르겠지만 DBMS 내에 쌓인 데이터가 늘어나게 되면 필연적으로 느려지게 될 수밖에 없으며 DBMS는 보통 디스크에 데이터를 저장하게 되므로 쓰기 속도 때문에 느릴 수밖에 없습니다.
그리고 채팅 요청이 있을 때마다 DB에 저장 요청을 날리는 것도 다소 비효율적이라고 생각했습니다. 그래서 채팅을 서버에 저장하고, 이전 채팅을 불러오는 동작에 대해 캐시를 적용해 보기로 하였습니다.
캐시는 컴퓨터공학에서 데이터나 값을 미리 복사해 놓거나 저장해 놓는 장소를 의미합니다. 캐시를 사용하는 이유는 원본 데이터가 저장되는 공간에 접근하는 속도가 느린 경우 이를 다른 저장소에 해 놓을수 있는데 이 다른 저장소를 캐시라고 합니다.
캐시는 컴퓨터공학에서 폭넓게 쓰입니다. CPU 내에도 캐시 메모리가 존재하며 디스크 내에도 캐시 메모리가 존재합니다.
- Cache-Aside (Look-Asid Cache)
- 앱에서 캐시, DB 접근 모두 하는 모델
- 값을 읽어올 때는 데이터가 캐시에 존재하면 캐시에 있는 데이터를 꺼내오고 없으면 앱이 직접 DB에서 데이터를 꺼내오고 해당 값은 캐싱
- 값을 쓸때는 캐시와 DB에 동시에 씀
- Cache-Through (Inline Cache)
- 앱에서 캐시에 직접 접근하고 DB 접근은 캐시가 하는 모델이며 데이터 처리를 캐시가 함
- Read-Through (동기) : 앱이 데이터를 캐시에서 읽어옴. 만약 값이 캐시 내에 없다면 캐시가 직접 DB에 접근하여 값을 가져와서 앱에 전달하고 캐시가 해당 값을 캐싱
- Write-Through (동기) : 앱이 값을 쓸때 캐시에 쓰고 캐시가 알아서 DB에 값을 씀
- Write-Behind (비동기) : 앱이 값을 캐시에 쓸 때 값들을 캐싱해놓고 비동기적으로 DB에 값을 씀
- 앱에서 캐시에 직접 접근하고 DB 접근은 캐시가 하는 모델이며 데이터 처리를 캐시가 함
저희는 소켓통신에서 클라이언트가 서버에 접근하는 경우엔 영속성 계층에 최대한 접근하지 않도록 하였으므로 Inline Cache를 적용하기로 하였고, 쓰기 작업엔 채팅 요청이 있을 때마다 DB에 저장하는 방식을 사용하지 않을 것이므로 Async Write-Behind를 사용할 것입니다. 이전 채팅의 읽기 작업은 HTTP를 사용하므로 쓰기 작업에만 캐시를 구현합니다.
Spring Framework는 redis와 더불어 사용하는 위에 명시된 전략들을 프레임워크 단에서 구현되어 있는 것으로 보이지만 nestjs는 캐시 전략은 직접 구현해야 하는 것으로 보입니다.
nestjs의 캐시 매니저는 다음 기능을 지원합니다.
- 인-메모리 캐시 및 redis 연동 가능
- get()/set() 메소드를 이용한 키-값 접근
- 키에 대한 TTL (Time-To-Live, expiration time in seconds) 적용
위 작업들 정도밖에 지원해주지 않아 nestjs의 캐시 매니저를 사용하되 사용하고자 하는 전략은 직접 구현하기로 했습니다.
(다른 프레임워크를 상세하게 찾아보진 않았지만) nestjs는 Write-Behind를 직접 구현해야 합니다. 서비스 모듈에 다음 코드를 구현하였습니다.
구현해야 하는 부분은 다음과 같습니다.
- 캐시가 비었다면 초기화 후 데이터 삽입
- 읽기도 캐시에서 먼저 수행해야 하므로 DB에 저장되는것과 동일한 인덱스 넘버 적용하여 데이터에 인덱스 붙이기
의사 코드는 다음과 같습니다.
cacheChatWrite(채팅) {
let 채팅_인덱스 = 캐시_인스턴스.get("인덱스")
if 채팅_인덱스가 존재하지 않는다면
채팅_인덱스 = DB에서 마지막 인덱스를 가져옴
캐시_인스턴스.set("인덱스", 채팅_인덱스 + 1)
let 채팅_캐시 = 캐시_인스턴스.get("채팅")
if 채팅_캐시가 존재하지 않는다면
채팅_캐시 = 캐시_인스턴스.set("채팅", [])
채팅_캐시.push(채팅, 인덱스)
}
데이터를 매초 정각에 (1분에 한번씩 00초에) 캐시 내에 있는 데이터를 DB로 옮깁니다.
@Cron("0 * * * * *") // 1분에 한번 구동
writeBehind() {
만약 캐시 데이터가 존재하면
캐시 데이터를 DB에 저장
}
읽고자 하는 데이터를 캐시에서 먼저 찾아본 후에 캐시에 없다면 DB를 찾아봅니다.
채팅 데이터를 읽는 경우는 현재로는 이전 채팅을 정해진 개수만큼 가져오는 기능밖에 없으므로 채팅 인덱스를 이용해 특정 개수만큼 채팅을 가져옵니다.
cacheChatRead(채팅 메시지 고유번호, 가져올 메시지 개수) {
만약 캐시에 데이터가 존재하지 않는다면
DB에서 데이터를 가져옴
캐시에 데이터가 존재한다면
캐시에서 인자를 이용해 데이터를 가져옴
만약 캐시에 데이터가 충분히 없다면
DB에서 나머지 데이터를 가져옴
}