임시저장 기능 고도화 해보기 (1)
들어가며
최근, 설문조사용 폼을 생성하고 관리하는 서비스에 임시저장 기능을 개발하게 되었습니다.
저희 서비스는 구글 폼, 네이버 폼, Tally Form과 유사한 사용자 경험을 제공하며 다양한 데이터를 저장하고 관리할 수 있도록 설계되어 있습니다. 특히, 많은 데이터를 한꺼번에 저장하는 과정에서 발생하는 효율성 문제를 해결하기 위해 고민도 많이 해보았습니다.
임시저장 기능이 필요한 이유는 데이터를 입력하는 동안 사용자가 예기치 못한 상황(네트워크 오류, 브라우저 종료 등)에서 데이터 유실을 방지하려는 것입니다. 그러나 이 과정에서 데이터의 양이 많아지면 서버에 가해지는 부하가 증가하고, 속도와 안정성에 영향을 미친다는 문제점이 존재합니다.
현재 저희 서비스에서는 이러한 데이터를 mongoDB(NoSQL)에 nested 형태로 하나의 객체로 관리하였는데요, 하지만 이러한 NoSQL 기반 대량의 JSON 데이터를 관리하는 기존 방식은 임시저장과 같은 고속·고빈도 데이터 송수신 요구사항을 충족하기에 적합하지 않았습니다.
이 글에서는 이를 해결하기 위해 고민한 설계 과정과 배경, 그리고 서비스의 작동 방식을 정리해보았습니다.

draft save is important
아키텍쳐
우선 아키텍쳐를 그려본 후 고민하고 구축해보려고 합니다.

전체적인 아키텍쳐는 이와 같다.
저희 서비스는 기본적으로 Kubernetes(K8s) 환경에서 운영되고 있으며, 두 개의 Pod가 띄워진 상태에서 시작됩니다.
이 분산 환경에서는 Pod가 상황에 따라 장애로 인해 재시작될 수도 있고, 유동적으로 확장될 수도 있습니다. 이러한 특성을 고려하여 아래와 같은 아키텍처를 설계하였습니다.
1. 사용자 설문조사 편집 페이지 접속
사용자가 설문조사를 편집하기 위해 편집 페이지에 접속합니다. 이때, 현재 동시 편집 기능은 제공하지 않으므로, 해당 설문조사에 편집 잠금(Lock)이 걸려 있는지 확인합니다.
Lock 설정되어 있다면, 설문조사가 다른 사용자에 의해 편집 중이기 때문에 접근할 수 없습니다. Lock이 없어야 사용자는 편집을 시작할 수 있습니다.
2. SQLite 데이터 확인 및 초기화
사용자가 처음 설문조사 편집 페이지에 접속하였을 때, 임시저장을 위한 소켓을 연결하게 되는데요, 서버 내부의 SQLite 데이터베이스에 해당 설문조사 데이터가 없을 수도 있습니다.
이는 Pod가 종료되면서 인메모리 기반 SQLite 데이터가 삭제된 경우 혹은 사용자가 이전에 접속했던 Pod와 다른 Pod에 접속한 경우에 발생하게 됩니다.
SQLite에 데이터가 없는 경우
영속성 데이터베이스인 MongoDB에서 해당 설문조사의 원본 데이터를 가져옵니다. 가져온 데이터를 SQLite에 초기화하여 세팅한 후, 사용자가 편집할 수 있도록 준비합니다.
SQLite에 데이터가 있는 경우
SQLite에 이미 해당 설문조사 데이터가 존재한다면, 기존 데이터를 그대로 사용합니다. 데이터 초기화 과정이 생략되므로, 불필요한 네트워크 호출을 방지할 수 있습니다.
3. 실시간 수정 반영
사용자가 설문조사의 내용을 편집(예: 문항 수정, 단계 추가, 옵션 변경 등)하면, 수정된 내용은 디바운스(debounce)를 통해 서버에 반영됩니다.
수정된 부분만 SQLite 데이터베이스에 저장되는데요, 예를 들어 문항을 수정했다면 문항 테이블의 해당 열 데이터만 갱신됩니다.
4. 주기적인 데이터 백업 (5분 간격)
설문조사를 편집하는 동안에도 임시저장 데이터가 날아가지 않도록, 5분마다 실행되는 크론잡(Cron Job)을 등록하여 데이터를 주기적으로 영속성 스토리지에 백업합니다.
해당 크론잡은 SQLite에 저장된 임시 데이터를 가져와 MongoDB에 동기화합니다. 이를 통해 사용자가 편집 중 예상치 못한 문제가 발생하더라도, 최근 데이터가 MongoDB에 저장되어 데이터 유실을 방지합니다.
5. 소켓이 끊어졌을 때
heartBeat로 확인하였을 때, ping을 보냈는데도 불구하고 pong이 클라이언트에서 오지 않았을 경우에 서버는 소켓이 끊어졌다고 판단합니다.
이때 작성하던 모든 내역을 sqlite에서 영속성 스토리지인 mongoDB로 백업하게 됩니다.
6. Pod 종료 시 데이터 백업
서비스를 운영하다보면, Pod가 종료되는 상황에 직면할 수 있습니다. 이는 메모리 사용량 초과, CPU 사용량 초과, 시스템 장애 등을 통해 발생할 수 있는데요, Pod가 종료될 때는 graceful shutdown 과정을 통해 데이터를 안전하게 백업하는 프로세스를 수행합니다.
서버가 종료 시그널(SIGTERM)을 받으면, NestJS의 onModuleDestroy 및 onApplicationShutdown 이벤트를 통해 종료 이벤트를 처리합니다.
기본값으로는 30초정도의 처리 시간이 주어지고, pod나 deploy 설정을 통해 더 늘일 수 있습니다.
우선 SQLite에 저장된 모든 임시 데이터는 MongoDB에 최종 백업하며, 추가로 SQLite 데이터를 파일 형태로 변환하여 S3에 업로드합니다.
이를 통해 Pod 장애 상황에서도 데이터를 안전하게 복구할 수 있는 이중 백업 시스템을 구현합니다.
SQL과 NoSQL의 혼용?
왜 SQL을 추가 도입했을까요? 저희 회사는 MERN 스택(MongoDB, Express.js, React, Node.js)을 기반으로 서비스를 개발하고 있습니다. 이는 기본적으로 NoSQL인 MongoDB를 데이터베이스로 활용하는 구조인데요, MongoDB는 설문조사 데이터와 같이 복잡한 계층 구조를 가진 데이터를 효율적으로 관리할 수 있는 장점을 가지고 있습니다.
그러나 이번 임시저장 기능을 설계하면서, 기존 NoSQL 기반 접근 방식만으로는 반복적이고 잦은 데이터 수정 작업에서 성능과 효율성 측면에서 한계를 확인하게 되었습니다.
결론적으로, 저희는 임시저장 데이터를 SQL로 관리하고, 원본 데이터는 기존 NoSQL에 저장하는 방식으로 설계하였습니다. 이와 같은 결정은 두 데이터베이스의 장단점을 상황에 맞게 최적화하려는 목적에서 비롯된 것입니다.
조회 영역에서 NoSQL이 유리했다고 판단한 이유
설문조사 서비스에서 데이터 조회 및 간소화된 관리는 NoSQL인 MongoDB의 강점이 돋보이는 영역입니다.
설문조사 폼에는 1단계, 2단계, 여러 문항, 문항별 질문·답변지, 옵션, 제한 사항 등 복잡하게 연계된 데이터들이 포함됩니다. 이 모든 데이터를 nested된 하나의 객체로 관리하면 단일 조회로 손쉽게 전달할 수 있기 때문에 사용자에게 빠르고 간편한 경험을 제공합니다.
예를 들어, 설문조사 내용을 클라이언트에 렌더링할 때, MongoDB에 저장된 하나의 대규모 문서를 호출해 바로 화면에 뿌릴 수 있습니다. 따라서 유저가 설문조사를 채우는 과정에서는 NoSQL의 구조가 매우 용이합니다.

설문을 조회할 때 강력한 성능을 가진다.
SQL을 고려한 이유
그러나 데이터를 저장한다는 관점에서 보았을 때, 특히 임시저장처럼 설문조사를 작성하며 여러 단계를 수정하는 상황에서는 NoSQL의 한계가 뚜렷하게 드러나게 됩니다.
예를 들어, 수정의 비효율성 관점에서 보면 NoSQL 구조에서 하나의 설문 객체 안에 3개의 단계를 포함하고 있다고 가정할 때 2단계의 내용만 수정해야 하는 상황에서도 전체 객체를 다시 수정해서 모두 저장해야 합니다. 잦은 수정이 요구되는 임시저장 작업에서는 이 방식이 비효율적이고, 성능과 관리상의 문제를 야기합니다.
반면, 정규화된 SQL에서는 다음과 같은 장점을 활용할 수 있습니다.
- 첫번째, 부분 업데이트가 가능하며, 테이블의 특정 행(row)이나 열(column)만 수정하면 필요한 변경 작업을 완료할 수 있음
- 두번째, 정확하고 효율적인 관리가 가능하며, 각 도메인이 분리된 테이블로 관리되기 때문에 데이터 간 의존성을 최소화하며 작업이 가능
- 세번째, 빈번한 요청 처리에 유리하며, 임시저장처럼 데이터가 자주 갱신되더라도 네트워크 비용을 최소화하고 성능을 유지할 수 있음
SQL을 임시저장에 도입한 이유
설문조사 작성 과정에서는 매우 잦은 데이터 수정이 이루어집니다. 한 문항의 질문을 수정하거나, 새로운 옵션을 추가하거나, 또는 특정 단계를 이동시키면서 설문조사 데이터의 일부분이 바뀌는 일이 빈번합니다.
다시 말해, 임시저장은 '설문조사를 완성해 나가는 중간 과정'에 해당하며, 많은 빈도의 데이터 변경을 요구합니다.
예를 들어, 하나의 객체 안에 3단계가 있다고 가정했을 때, 2단계만 수정하려 하더라도 NoSQL에서는 전체 객체를 통째로 저장해야 하는 부담이 있습니다.
SQL을 사용하면, 단순히 특정 테이블에서 관련된 행 또는 컬럼만 수정하면 되므로 작업이 훨씬 단순하고 효율적입니다. 이와 같은 특성에 따라, 네트워크 부하를 줄이고 데이터 관리의 효율성을 강화하기 위해 임시저장 데이터에 한해 SQL을 선택하게 되었습니다.
SQLite를 선택한 이유 중 가장 큰 이유는 임시 저장은 휘발성 데이터여도 된다는 부분입니다.
또한 비용 문제도 무시하지 못했는데요, SQL 도입과 함께 직면한 문제는 인프라 리소스와 비용이었습니다. 엔터프라이즈 레벨의 완성형 데이터베이스를 구축하거나 관리하기는 쉽지 않았으며, 별도의 비용 발생도 피하고자 했습니다.
이에 따라 가볍고 간편한 인메모리 데이터베이스인 SQLite를 선택했습니다.
SQLite는 다음과 같은 장점을 제공한다고 생각했습니다.
- 간단한 배포, 별도 서버나 복잡한 설정 없이 앱단에서 바로 사용 가능
- 로컬 데이터 관리, Pod별로 독립적으로 데이터를 관리할 수 있으면서, 소규모 저장 시 매우 효율적
- MERN 스택과의 호환성, 경량화된 SQL 환경을 제공하며, MongoDB 기반 아키텍처와도 충돌 없이 사용 가능
- 임시저장 데이터는 빠르게 갱신되고, 최종 데이터로 MongoDB에 마이그레이션 되기 때문에 SQLite는 이러한 가벼운 작업에 충분한 성능과 안정성을 제공했습니다

데이터베이스를 상황에 맞게 사용하는 전략
이번 설계에서 확인했듯이, 데이터베이스는 그 특성과 사용 목적에 따라 최적의 전략으로 선택해야 한다고 생각합니다.
MongoDB는 구조화되지 않은 대규모 데이터를 한 번에 조회하거나 사용자 화면에 렌더링해야 하는 작업에는 최적화된 선택입니다.
반면, SQLite는 잦은 변경이 필요한 작업에서 효율적이며, 비용 부담 없이 앱단에서 빠르고 쉽게 사용할 수 있습니다.
결과적으로, NoSQL과 SQL을 병행하여 사용함으로써, 각 데이터베이스의 강점을 활용하며 서비스의 효율성을 극대화할 수 있었습니다.
Redis는?
임시저장 데이터를 관리하기 위해 SQLite가 아닌 Redis를 고려해보기도 했습니다. Redis는 높은 성능과 빠른 응답 속도로 잘 알려져 있지만, 저희 서비스에서는 SQLite가 더 적합하다 판단했으며 근거는 아래와 같습니다.
-
첫번째, 관계형 데이터 지원
- SQLite는 완전한 관계형 데이터베이스(RDBMS)로, 테이블 간의 복잡한 관계를 효과적으로 관리할 수 있습니다. 반면, Redis는 Key-Value 구조를 기반으로 하며, 관계형 데이터 모델을 직접적으로 지원하지 않습니다. 저희 서비스의 설문조사 폼 데이터는 Submission(제출 데이터) → Step(단계) → Question(문항) 등으로 복잡한 도메인 간의 관계를 가지고 있습니다. 이런 데이터 구조는 관계형 데이터베이스에서 효율적으로 처리할 수 있기 때문에 SQLite가 적합했습니다.
-
두번째, ACID 트랜잭션 지원
- SQLite는 ACID(Atomicity, Consistency, Isolation, Durability) 트랜잭션을 완벽히 지원해 데이터의 무결성과 안정성을 보장합니다. 반면 Redis에서는 트랜잭션 지원이 제한적이며, 복잡한 데이터 변경이나 충돌 방지에는 부족한 점이 있습니다. 임시저장 기능에서는 여러 사용자가 동시에 데이터를 수정하거나 저장할 수 있기 때문에, 정확한 데이터 쓰기와 안전한 수정이 무결성을 보장하는 핵심 요소였습니다. 이를 위해 SQLite를 선택하게 되었습니다.
-
세번째, 복합 쿼리 처리 가능
- SQLite는 SQL 쿼리를 지원하여 JOIN, 집계 연산, 다중 조건 검색 등의 복잡한 쿼리를 손쉽게 처리할 수 있습니다. Redis는 단순한 Key-Value 데이터를 처리하기에 적합하며, 복잡한 데이터 처리에는 한계를 가지는데요, 설문조사 데이터는 여러 단계와 문항 간의 관계가 얽혀있어 다중 테이블 간 JOIN 연산이나, 특정 조건에 맞는 데이터를 검색하기 위한 복합 쿼리가 빈번하게 필요했습니다. 따라서 이런 요구 사항은 SQLite와 잘 맞아떨어졌습니다.
-
마지막으로, 메모리 및 저장 효율성 관점
- SQLite는 메모리와 디스크를 동시에 활용해 저장 공간의 효율성을 극대화합니다. 반면, Redis는 데이터를 메모리에 실시간으로 유지하는 구조이기 때문에, 데이터가 많아질수록 메모리 사용량이 크게 증가하고 비용 효율성이 떨어질 수 있습니다. 저희 서비스는 대량의 대규모 설문조사 데이터를 처리하며, 메모리 리소스를 효율적으로 관리할 필요성이 컸습니다. 따라서 SQLite처럼 디스크 기반 데이터 저장을 지원하는 DB가 적합했습니다.
SQLite3 vs Better-SQLite3: 장점
SQLite를 도입할 때, 성능 최적화를 위해 기본 라이브러리인 sqlite3 대신 better-sqlite3를 도입했습니다.
better-sqlite3는 속도와 효율성 측면에서 뛰어나며, 간결한 코드 작성 경험까지 제공했습니다.
-
첫번째, Better-sqlite3는 동기적 처리 방식을 지원하므로, 기존 sqlite3의 비동기 콜백 방식에서 오는 콜백 지옥 문제를 해결했습니다. Promise로 코드를 래핑하지 않아도 즉시 처리 결과를 얻을 수 있어 로직을 간단하게 작성할 수 있었습니다.
-
두번째로 인메모리 기반 최적화가 가능했습니다. 데이터를 메모리로 로드하는 방식을 통해 디스크 I/O를 최소화하였으며, 필요한 경우 메모리 맵핑을 통해 데이터를 효율적으로 접근할 수 있었습니다. 캐싱 최적화로 인해 자주 액세스되는 데이터를 메모리에 저장해 읽기 성능을 크게 향상시켰습니다.
-
마지막으로 WAL(Write-Ahead Logging) 모드 활용하여 읽기-쓰기 성능을 최적화가 가능했는데요, 동시에 여러 사용자가 요청을 처리할 수 있도록 동시성을 지원하여 읽기 성능 향상하였으며, 임시저장 데이터를 손실 없이 안전하게 저장하여 데이터 무결성 보장하였습니다.
SQLite3 vs Better-SQLite3: 성능비교
SQLite3, Better-SQLite3 두 라이브러리의 성능을 비교해본 결과 아래와 같았습니다.
[단건 데이터 삽입(INSERT) 작업]
- sqlite3 평균 약 1.2ms
- better-sqlite3 0.08ms (약 93% 향상)
[대량 삽입(배치 INSERT) 작업]
- sqlite3 평균 145ms
- better-sqlite3 12ms (약 91% 향상)
[단일 조회(SELECT) 작업]
- sqlite3 평균 0.08ms
- better-sqlite3 0.03ms (약 96% 향상)
[UPDATE(데이터 수정) 작업]
- 약 94%의 개선
- 트랜잭션 처리 속도 약 93% 개선
[복잡한 쿼리(JOIN 또는 집계 연산을 포함한 SELECT) 작업]
- sqlite3 15ms
- better-sqlite3 2.1ms (약 86% 향상)
이어가며
우선 도입 배경과 고민 과정, 그리고 아키텍처에 대해 기술해보았습니다.
이후 글에서는 ORM 설정, 소켓 설정, 초기화 및 수정 전략, 백업 전략, 그리고 전체적인 아키텍처가 요구 사항을 어떻게 충족하는지에 대한 내용을 이어서 다룰 예정입니다.
