개요
이전에 진행했던 협업 문서 관리 프로젝트 Octodocs에서 문서 데이터를 영속화하기 위한 아키텍처에 대해 작성해보려고 합니다.
우선 Octodocs 프로젝트는 두 가지 핵심 기능을 제공합니다.
문서 간 관계 시각화 기능과 동시 편집 기능입니다.


핵심적인 부분은 사용자들이 동시에 협업 문서를 관리하고 문서를 편집할 수 있는 부분입니다.
이러한 동시 편집 알고리즘에는 대표적으로 OT 알고리즘과 CRDT 알고리즘이 존재합니다.
YJS는 CRDT 알고리즘을 구현한 구현체이기 때문에 CRDT 알고리즘에 대해 알아봅시다.
CRDT는 Conflict-free Replicated Data Type의 악여로 충돌 없는 복제된 데이터타입을 의미합니다.
CRDT 알고리즘을 사용하면 어떠한 순서로 변경 요청이 들어와도 항상 동일한 결과를 보장할 수 있습니다.
CRDT는 각 단어마다 고유한 값을 부여하고 이 값을 기반으로 순서를 조절합니다.
예를 들어 "HELO"라는 단어가 있고 두 명의 사용자가 동시에 단어를 삽입했다고 합시다.
한 사용자는 "HELLO"로, 다른 사용자는 "HELO!" 수정을 했다면 CRDT 알고리즘이 충돌을 해결하는 과정은 아래 그림과 같습니다.

이러한 CRDT 알고리즘의 장점은 바로 클라이언트 간 통신만으로도 동시성 문제를 해결할 수 있다는 점입니다.
서버에서 모든 데이터를 받아 통합한 뒤 클라이언트로 내려줄 필요가 없습니다.
이 CRDT 알고리즘을 구현한 라이브러리 중 하나가 바로 YJS입니다.
이제 본격적으로 CRDT 알고리즘을 사용하여 문서 동시 편집기를 개발할 때 데이터를 영속화 하기 위해 설계했던 아키텍처와 구현 과정에 대해 말씀드리겠습니다.
DB 영속화 기능 변화 과정
먼저 YJS를 사용하여 동시 편집 기술을 구현하는 방법을 알아봅시다.

우선 YJS 라이브러리를 통해 Socket Server를 만들어야 합니다.
그리고 client 측에서는 provider를 통해 Socket Server와 통신하고 데이터를 개별적인 YDoc에 저장합니다.
다른 client가 문서를 변경했다면 Socket Server를 통해 변경 사항을 받게 되고 충돌 문제를 해결합니다.
즉, Socket Server는 단순히 데이터를 전달하는 통로에 불과합니다.
그런데 이러한 구조는 Server가 YDoc의 전체 문서 내용을 알지 못 하기 때문에 영속화를 할 때 어려움이 있습니다.
물론 Socket Server에 들어오는 데이터를 분석해서 저장할 수도 있지만 CRDT 알고리즘에 적합한 형태로 변경 사항 데이터가 유입되기 때문에 이를 분석하는 것은 너무 많은 리소스가 필요합니다.
YJS에서 영속화 기능을 제공하긴 하지만 아쉽게도 저희가 사용하는 postgres DB는 제공하지 않기 때문에 이러한 기능을 직접 구현하기로 결정했습니다.
제가 선택한 방식은 단순합니다. 그냥 Server에서도 똑같이 Client를 생성하는 방식입니다.
서버에서도 똑같이 provider와 YDoc을 만들면 YDoc을 통해 전체 문서 내용을 조회할 수 있습니다.

첫 번째 시도 - 변경 사항이 발생할 때마다 postgres DB 갱신
제가 영속화 기능을 구현하기 위해 첫 번째로 시도한 방식은 YDoc에 변경 사항이 발생할 때마다 postgres DB를 갱신하는 방식입니다.
하지만 동시 편집 특성 상 여러 명이 같은 문서를 동시에 편집하게 될 경우 초당 수십 번 변경 사항이 발생합니다.

변경 사항이 발생할 때마다 postgres DB에 갱신 쿼리를 날렸을 때 DB에 엄청난 부하가 발생했고 성능 저하로 이어져습니다.

두 번째 시도 - 주기적으로 YDoc의 내용 DB로 반영
DB에 대한 부하를 방지하기 위해 두 번째로 주기적으로 YDoc의 내용을 DB로 반영하는 구조를 설계했습니다.

주기적으로 YDoc의 내용을 DB에 반영했기 때문에 DB에 가해지는 부하를 줄일 수 있었습니다.
그런데 YDoc의 경우 서버 메모리에 보관된 값이기 때문에 만약 소켓 서버가 다운될 경우 데이터가 모두 날아갈 수 있다는 단점이 있었습니다.
그래서 이 YDoc의 값을 실시간으로 저장할 중간 계층을 두기로 결정했습니다.
세 번째 시도 - 캐시 layer 도입
그렇다면 저희는 YDoc에 발생하는 변경 사항을 실시간으로 저장하면서 저장소의 부하로 인한 성능 문제를 해결해야 했습니다.
그래서 저희가 선택한 방식은 cache layer를 도입하는 것이었습니다.

YDoc에 변경 사항이 발생하면 가장 먼저 cache에 데이터를 저장합니다.
그리고 주기적으로 캐시로부터 DB에 변경 사항을 반영합니다.
cache 서버로는 in memory 기반으로 빠른 속도를 가지고 있는 redis로 선택했습니다.
이렇게 변경 사항을 실시간으로 저장해서 소켓 서버에 다운에 대비할 수 있고 부하로 인한 성능 문제를 해결할 수 있었습니다.
캐싱 전략
다음은 캐싱 전략입니다.
일반적으로 캐싱의 주된 목적은 조회 성능 향상입니다.
하지만 저희 프로젝트의 캐싱 목적은 조회 성능 향상보다는 쓰기 성능 향상에 있었습니다.
우선 YJS 라이브러리 특성 상 YDoc에 넣을 데이터를 조회하는 연산 자체가 거의 발생하지 않았습니다.
YJS가 document 데이터를 조회할 때는 오직 "처음 Socket Server가 실행되고 첫 사용자가 document에 접속했을 때"뿐입니다.
이후에 사용자가 접속했을 때는 이미 정보가 memory에 로드되어 있기 때문에 추가적으로 DB에 조회 쿼리를 날리지 않습니다.
그래서 사실상 조회 성능을 향상시키는 목적이 아닌 쓰기 성능을 향상시키기 위해 캐시 layer를 도입했습니다..
쓰기 전략으로는 Write Back 전략을 선택했습니다.
위에서 언급했듯이 데이터는 DB에 바로 저장하지 않고 무조건 캐시에 저장하고 주기적으로 캐시에 있는 값을 DB에 반영하는 전략을 선택했습니다.
최종 아키텍처
최종 아키텍처는 아래와 같습니다.

위 아키텍처댈 구현을 했다면 독립적인 서버를 여러 개 띄워야 합니다.
하지만 비용 문제로 위 아키텍처대로 물리적인 서버로 분할하진 못 했고 하나의 물리적인 서버 안에서 여러 개의 docker container로 서비스를 배포하게 되었습니다.
캐시 값 제거
일반적으로 write back 전략을 사용하면 캐시에서 조회를 하기 위해 주기적으로 DB에 반영할 때 캐시 값을 삭제하지 않습니다.
추후 조회할 때 캐시에 있는 값을 조회해서 조회 성능을 높여야 하기 때문입니다.
하지만 앞서 언급했던 것처럼 저희는 Document에 대한 조회 자체가 많이 발생하지 않았기 때문에 캐싱을 조회 성능 향상 목적으로 사용하지 않았습니다.
즉, 캐시에 계속 데이터를 저장할 필요가 없었습니다.
그런데 캐시에 계속 데이터를 저장하게 될 경우 불필요한 메모리를 차지하게 되고 데이터를 영속화 할 때도 마지막 반영 이후 변경된 값만 반영하는 로직을 추가로 작성해야 합니다.
그래서 저희는 캐시에 있는 값을 DB에 반영하고 난 뒤, 캐시에 있는 값을 지우기로 결정했습니다.
변경 사항 갱신 흐름
앞에서 말씀드렸던 것처럼 변경 사항 갱신 흐름은 다음과 같습니다.
- redis에 저장된 변경 사항 조회
- 변경 사항 postgres에 반영
- 메모리 절약을 위해 redis 변경 사항 삭제

그런데 2번과 3번 연산을 수행 도중 변경 사항이 발생할 경우 아래 그림처럼 변경 사항이 postgres에 반영되지 않을 수 있습니다.

즉 1, 2, 3번 연산을 수행하는 동안 redis에 대한 접근을 막아야 합니다.
이를 위해 저희는 redis 분산 lock을 적용했습니다.

변경 사항을 갱신하는 3개의 연산을 수행하기 전에 lock을 획득하고 수행이 끝난 뒤 lock을 해제합니다.
그리고 lock을 획득해야만 redis에 값을 갱신하는 작업을 수행할 수 있도록 합니다.
즉 스케줄러가 migrate 작업을 진행하는 중에는 해당 key에 대한 연산을 막아버리는 것입니다.
문제 상황
저희는 redis 분산 lock을 통해 동시성 문제를 해결했지만 프로젝트 특성을 고려했을 때 오버 엔지니어링이라고 판단했습니다.
첫 번째, 두 프로세스가 redis에 동시 접근할 가능성이 낮습니다.
websocket 서버와 backend 서버는 별도의 프로세스로 실행되어 같은 redis를 사용하기 때문에 동시성 문제가 발생할 가능성이 있으나 실제로 두 프로세스에서 동시에 요청을 보낼 가능성은 낮습니다.
websocket 서버는 redis에 자주 접근하지만 backend 서버의 경우 오직 스케줄러가 동작할 때 redis에 접근합니다.
스케줄러가 하루에 한 번 동작한다고 하면 하루에 딱 한 번 redis에 동시 접근할 가능성이 존재하는 것입니다.
두 번째, 하나의 page를 migrate하는 시간이 굉장히 짧습니다.
migrate하는 연산은 redis에 저장된 값을 가져오고 postgres에 update 쿼리 하나를 날린 다음 redis에 저장된 값을 삭제하기 때문에 굉장히 짧은 시간에 이루어집니다.
하나의 연산이 수행되는 시간을 측정해보았습니다.
backend:dev: [Nest] 142 - 01/16/2025, 9:03:40 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 142 - 01/16/2025, 9:03:40 AM LOG [TasksService] 총 개수 : 1개
backend:dev: [Nest] 142 - 01/16/2025, 9:03:40 AM LOG [TasksService] 성공 개수 : 1개
backend:dev: [Nest] 142 - 01/16/2025, 9:03:40 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 142 - 01/16/2025, 9:03:40 AM LOG [TasksService] 실행 시간 : 0.028518430000003717초
28ms의 시간이 걸렸습니다.
하루에 한 번, 28ms라는 시간 동안 변경 사항이 발생할 확률은 굉장히 낮습니다.
세 번째, 변경 사항이 반영되지 않고 삭제되더라도 큰 문제가 발생하지 않습니다.
28ms라는 짧은 시간 동안 발생한 변경 사항은 많지 않다고 판단하였고 이 정도 변경 사항은 유실되더라도 사용자가 크게 불편하지 않을 것이라고 판단했습니다.
위와 같은 이유 때문에 저희는 redis 분산 lock을 사용하지 않기로 결정했고 비교적 가볍게 동시성 문제를 해결하기 위해 redis 낙관적 락을 도입했습니다.
낙관적 락이란?
redis에서는 트랜잭션을 시작하기 전에 watch 명령어를 통해 특정 key의 변경 사항을 계속 모니터링 할 수 있습니다.
만약 transaction을 적용하기 전에 해당 key가 변경되었다면 watch 명령어가 이를 감지하고 transaction을 취소합니다.
즉 migrate 함수에 이 낙관적 lock을 적용하면 redis의 값을 삭제하기 전에 변경 사항이 발생했을 때 redis의 값을 삭제하지 않고 그대로 두게 됩니다.
즉 변경 사항이 사라지지 않고 다음 스케줄러를 반영할 때 적용이 되겠죠?
해결 방안
낙관적 lock을 적용하는 방법은 굉장히 간단합니다.
redis 트랜잭션을 시작하기 전에 watch를 통해 작업을 진행하는 key를 모니터링 하면 됩니다.
async migratePage(key: string) {
// 낙관적 락 적용
await this.redisClient.watch(key);
const data = await this.redisClient.hgetall(key);
. . .
}
redis lock을 적용한 결과 여러가지 장점을 얻게 되었습니다.
첫 번째, 공유 자원에 접근할 때 락을 획득할 필요가 없습니다.
저희 프로젝트의 공유 자원은 redis입니다.
아래는 redis 값을 저장하는 메소드의 일부입니다.
연산 시작 전에 매번 lock을 획득하는 모습을 볼 수 있습니다.
async setFields(key: string, map: Record<string, string>) {
// 락 획득할 수 있을 때만 set
const release = await this.acquireLock(this.redisClient, key);
// fieldValueArr 배열을 평탄화하여 [field, value, field, value, ...] 형태로 변환
const flattenedFields = Object.entries(map).flatMap(([field, value]) => [
field,
value,
]);
// 락 해제
await release();
// hset을 통해 한 번에 여러 필드를 설정
return await this.redisClient.hset(key, ...flattenedFields);
}
낙관적 lock 적용 이후 공유 자원에 접근하는 메소드는 lock을 획득할 필요가 없습니다.
async set(key: string, value: object) {
await this.redisClient.hset(key, Object.entries(value));
}
async setFields(key: string, map: Record<string, string>) {
// fieldValueArr 배열을 평탄화하여 [field, value, field, value, ...] 형태로 변환
const flattenedFields = Object.entries(map).flatMap(([field, value]) => [
field,
value,
]);
// hset을 통해 한 번에 여러 필드를 설정
return await this.redisClient.hset(key, ...flattenedFields);
}
공유 자원에 접근할 때마다 lock을 획득해야 하는 번거로움이 없어졌고 개발자의 실수도 줄어들 수 있다는 장점이 있습니다.
두 번째, migrate 함수의 시간이 줄어들었습니다.
분산 lock을 사용했을 때는 lock을 획득한 뒤 연산을 마치고 lock을 제거하는 연산이 필요했습니다.
하지만 redis가 제공해주는 낙관적 lock의 경우 lock을 제거하지 않아도 됩니다.
redis transaction이 EXEC 된다면 자연스럽게 watch를 사용한 모니터링이 중단되기 때문입니다.
적은 데이터에서는 유의미한 성능 향상이 발생하지 않아서 1000개의 데이터를 기준으로 테스트를 진행해보았습니다.
redis에 1000개 page를 넣고 스케줄러가 migrate를 진행했을 때의 속도입니다.
먼저 redis 분산 lock을 사용했을 때입니다.
backend:dev: [Nest] 172 - 01/16/2025, 3:39:50 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172 - 01/16/2025, 3:39:50 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:39:50 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:39:50 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172 - 01/16/2025, 3:39:50 AM LOG [TasksService] 실행 시간 : 0.7971999099999957초
backend:dev: [Nest] 172 - 01/16/2025, 3:40:00 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172 - 01/16/2025, 3:40:01 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:01 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:01 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:01 AM LOG [TasksService] 실행 시간 : 1.0430806220000013초
backend:dev: [Nest] 172 - 01/16/2025, 3:40:10 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172 - 01/16/2025, 3:40:10 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:10 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:10 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:10 AM LOG [TasksService] 실행 시간 : 0.7559004180000047초
backend:dev: [Nest] 172 - 01/16/2025, 3:40:20 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172 - 01/16/2025, 3:40:20 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:20 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:20 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:20 AM LOG [TasksService] 실행 시간 : 0.6894285239999881초
backend:dev: [Nest] 172 - 01/16/2025, 3:40:30 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172 - 01/16/2025, 3:40:31 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:31 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:31 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:31 AM LOG [TasksService] 실행 시간 : 1.0472734700000002초
backend:dev: [Nest] 172 - 01/16/2025, 3:40:40 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172 - 01/16/2025, 3:40:41 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:41 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:41 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172 - 01/16/2025, 3:40:41 AM LOG [TasksService] 실행 시간 : 1.0676237879999972초
backend:dev: [Nest] 172 - 01/16/2025, 3:41:20 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172 - 01/16/2025, 3:41:21 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:41:21 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:41:21 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172 - 01/16/2025, 3:41:21 AM LOG [TasksService] 실행 시간 : 1.3261687099999981초
backend:dev: [Nest] 172 - 01/16/2025, 3:42:10 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172 - 01/16/2025, 3:42:10 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:42:10 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172 - 01/16/2025, 3:42:10 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172 - 01/16/2025, 3:42:10 AM LOG [TasksService] 실행 시간 : 0.9570084919999936초
평균 시간 : 9537ms
다음은 낙관적 lock을 사용했을 때입니다.
backend:dev: [Nest] 260 - 01/16/2025, 3:57:00 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260 - 01/16/2025, 3:57:00 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:00 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:00 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:00 AM LOG [TasksService] 실행 시간 : 0.6592435100000003초
backend:dev: [Nest] 260 - 01/16/2025, 3:57:10 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260 - 01/16/2025, 3:57:10 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:10 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:10 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:10 AM LOG [TasksService] 실행 시간 : 0.9697250730000087초
backend:dev: [Nest] 260 - 01/16/2025, 3:57:20 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260 - 01/16/2025, 3:57:20 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:20 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:20 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:20 AM LOG [TasksService] 실행 시간 : 0.7115303379999823초
backend:dev: [Nest] 260 - 01/16/2025, 3:57:30 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260 - 01/16/2025, 3:57:30 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:30 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:30 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:30 AM LOG [TasksService] 실행 시간 : 0.742726290999999초
backend:dev: [Nest] 260 - 01/16/2025, 3:57:40 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260 - 01/16/2025, 3:57:41 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:41 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:41 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:41 AM LOG [TasksService] 실행 시간 : 1.0181726379999891초
backend:dev: [Nest] 260 - 01/16/2025, 3:57:50 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260 - 01/16/2025, 3:57:50 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:50 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:50 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260 - 01/16/2025, 3:57:50 AM LOG [TasksService] 실행 시간 : 0.6968618019999704초
backend:dev: [Nest] 260 - 01/16/2025, 3:58:00 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260 - 01/16/2025, 3:58:00 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:58:00 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:58:00 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260 - 01/16/2025, 3:58:00 AM LOG [TasksService] 실행 시간 : 0.6486756690000184초
backend:dev: [Nest] 260 - 01/16/2025, 3:58:10 AM LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260 - 01/16/2025, 3:58:10 AM LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:58:10 AM LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260 - 01/16/2025, 3:58:10 AM LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260 - 01/16/2025, 3:58:10 AM LOG [TasksService] 실행 시간 : 0.6107508379999781초
평균 시간 : 7512ms
9537ms에서 7512ms로 줄어든 모습을 확인할 수 있습니다.
