Total 28 posts
300만 건의 데이터에서 대량 DELETE를 지양해야 하는 이유
들어가며 최근 팀장님과 데이터베이스 최적화에 대해 이야기를 나누었다. 당시 대화의 주제는 “300만 건이 넘는 대규모 로그 테이블에서 불필요한 과거 데이터를 어떻게 정리할 것인가?” 였다. 나는 이 질문을 듣고 바로 조건문을 달아 DELETE 쿼리를 날리면 된다고 생각했지만, 깊게 생각해보지 않은 1차원적인 답변이었다. 내 답변을 듣고 난 후의 팀장님의 대답은 “대용량 환경에서는 DELETE 쿼리를 사용하는 것이 능사가 아니라는 것” 이다. 이 내용을 주제로 해결책과 근본적인 아키텍처까지 정리해 보고자 한다. 300만 건의 로그 정리 방법 팀장님이 내게 다음과 같은 질문을 던지셨다. ...
In-Memory DB 구현 10편 - Decoder와 Checksum으로 영속화 된 데이터 불러오기
들어가며 이전 포스팅에서 구현 했던 Encoder와 SAVE 명령으로 데이터가 바이너리 파일로 저장되어 생성되긴 하지만 아직 복원 기능이 없어서 서버를 재시작하면 저장된 바이너리 파일이 있어도 해당 바이너리 파일을 역직렬화하고 로드하는 기능은 구현하지 않았기때문에 데이터는 전부 사라진다. 이번 포스팅에서는 바이너리 파일을 읽어 메모리 자료구조로 복원하는 Decoder를 구현하고 CRC32 Checksum으로 파일이 손상되지 않았는지 검증하는 메커니즘을 추가해보자. 서버 시작 시 자동으로 RDB 파일을 로드하여 영속성 사이클을 완성하는 것이 최종 목표다. 데이터 무결성 데이터 무결성은 데이터가 저장, 전송, 처리되는 전 과정에서 의도하지 않은 변경이 발생하지 않았음을 보장하는 성질이다. 데이터베이스 이론에서는 ACID의 Consistency와 연결되지만 여기서 다루는 것은 더 낮은 레벨의 문제인 파일 자체가 물리적으로 온전한가에 대한 것이다. ...
In-Memory DB 구현 9편 - 데이터 영속화하기
들어가며 현재까지 구축한 데이터베이스로는 서버를 종료하거나 장애가 발생하면 저장된 데이터는 전부 사라진다. 이번 포스팅에서는 메모리의 현재 상태를 바이너리 파일로 기록하는 SAVE 명령어를 구현한다. 이 과정에서 바이너리 인코딩, 직렬화 포맷 설계, 파일 I/O의 핵심 개념을 다뤄보자. 영속성과 스냅샷 데이터베이스에서 영속성(Persistence)이란, 데이터가 프로세스의 생명주기를 넘어서 유지되는 성질이다. 인메모리 데이터베이스는 기본적으로 영속성이 없다. 메모리는 휘발성 저장장치이기 때문이다. 레디스는 두 가지 방식으로 영속성을 제공한다. RDB (Redis Database) - 특정 시점의 메모리 상태를 통째로 파일에 덤프하는 스냅샷 방식이다. 파일이 작고 로딩이 빠르지만 마지막 스냅샷 이후의 데이터는 유실될 수 있다. AOF (Append Only File) - 모든 쓰기 명령을 로그 파일에 순서대로 기록하는 저널링 방식이다. 데이터 유실이 거의 없지만 파일 크기가 크고 로딩이 느리다. 필자는 RDB 방식을 구현할 생각이다. SAVE 명령을 실행하면 메모리의 모든 데이터를 바이너리 파일에 기록한다. ...
In-Memory DB 구현 8편 - TTL과 만료 전략, 최소힙으로 데이터 수명 관리하기
들어가며 지금까지 구현한 데이터베이스에서 저장된 모든 데이터는 명시적으로 삭제하지 않는 한 영구적으로 존재한다. 하지만 세션 토큰, 캐시 데이터, 인증 코드 같은 것들은 일정 시간이 지나면 자동으로 사라져야 한다. 이번 포스팅에서는 키에 만료 시간(TTL)을 설정하고 만료된 키를 자동으로 삭제하는 두 가지 전략을 구현한다. 그 과정에서 최소 힙 자료구조를 직접 구현하고 백그라운드 고루틴의 라이프사이클도 관리해보는 과정을 다뤄보겠다. TTL TTL(Time To Live)은 데이터가 유효한 시간을 의미한다. 네트워크 패킷에서는 홉 수를 제한하고 DNS 캐시에서는 레코드의 유효 기간을 정하고, CDN에서는 캐시 갱신 주기를 결정한다. 컴퓨터 시스템 전반에서 “이 데이터가 얼마나 오래 살아야 하는가"를 제어하는 수단으로 널리 쓰인다. ...
In-Memory DB 구현 7편 - 이중 연결 리스트로 List 명령어 구현하기
들어가며 이번 포스팅에서는 이중 연결 리스트를 직접 구현하고, 저장소를 다중 타입으로 확장하여 LPUSH, RPUSH, LPOP, RPOP, LRANGE 명령어를 추가한다. 배열과 연결 리스트의 근본적인 차이부터 시작해서 왜 이중 연결 리스트가 이 상황에 적합한지, 그리고 포인터로 노드 간 연결을 어떻게 구현하는지를 다룬다. 배열과 연결 리스트 데이터를 순차적으로 저장하는 가장 기본적인 두 자료구조가 배열과 연결 리스트이다. 이 둘의 핵심 차이는 메모리 배치에 있다. 배열은 연속된 메모리 공간에 요소를 나란히 저장한다. 컴파일러가 시작 주소 + (인덱스 × 요소 크기)로 바로 계산할 수 있기 때문에 인덱스 접근이 O(1)이다. 하지만 앞쪽에 요소를 삽입하거나 삭제하면 나머지 요소를 전부 한 칸씩 밀거나 당겨야 하므로 O(n)이 된다. go의 슬라이스([]string)가 내부적으로 배열 기반이다. ...
In-Memory DB 구현 6편 - Race Condition과 뮤텍스로 동시성 안전한 저장소 만들기
들어가며 이전 포스팅에서 go 키워드 하나로 서버를 동시성 서버로 바꿨다. 여러 클라이언트가 동시에 접속해서 PING을 보내면 모두 PONG을 정상적으로 받는다. 다수의 클라이언트가 동시에 연결하는 문제는 해결되었다. 하지만 PING은 저장소에 접근하지 않고 서버에서 특정 응답만 한다. 클라이언트들이 서버에 연결하는 순간 서로에게 공유되는 자원인 map 저장소에 동시에 SET과 GET을 수행하는 순간 현재 구조에서는 경쟁 상태가 발생한다. 이번 포스팅에서는 이 문제의 본질인 Race Condition을 이론적으로 파고들고, 세마포어부터 뮤텍스까지 동기화 기법의 계보를 살펴본 뒤 RWMutex로 저장소를 보호하는 과정을 알아보자. ...
In-Memory DB 구현 5편 - 고루틴으로 동시성 서버 만들기(프로세스/스레드/컨텍스트스위칭/GMP)
들어가며 지금까지 만든 서버는 RESP 프로토콜을 파싱하고, SET/GET 명령어로 데이터를 저장하고 조회할 수 있다. 기능적으로는 꽤 그럴듯하지만 치명적인 한계가 하나 있다. 현재의 코드 구조로는 클라이언트 A가 연결 중이면, 클라이언트 B는 A의 처리가 끝날 때까지 기다려야 한다. 1편에서 살펴본 Accept Queue에 연결이 쌓이기만 할 뿐 서버가 꺼내가지 못하는 상황이다. 실제 데이터베이스에서 이런 구조는 쓸 수 없다. 이번 포스팅에서는 이 문제를 해결하기 위해 동시성의 핵심 개념을 살펴보고 go의 고루틴이 왜 경량 스레드라 불리는지 GMP 스케줄러 모델까지 파고든 뒤 코드 한 줄로 서버를 동시성 서버로 탈바꿈시키는 과정을 다루겠다. ...
In-Memory DB 구현 4편 - 저장소 구현 (해시 테이블/해시충돌/리팩터/리해싱)
들어가며 이전 포스팅에서 RESP 프로토콜을 구현해 클라이언트와 서버가 구조화된 형식으로 통신할 수 있게 되었다. 이번 포스팅에서는 Key-Value 저장소를 구현하고 SET/GET 명령어를 추가해보자. go의 map을 감싸는 단순한 구조체를 만드는 것이 전부지만, 그 뒤에 숨어있는 해시 테이블의 원리를 깊이 있게 다뤄보려 한다. 해시 함수가 갖춰야 할 조건, 충돌을 해결하는 전략, 로드 팩터와 리해싱까지 살펴보면 map[string]string 한 줄이 왜 O(1)인지 납득할 수 있을 것이다. Key-Value 저장 모델 가장 단순한 데이터 모델 Key-Value 모델은 데이터를 저장하는 가장 단순한 방식이다. key로 값을 저장하고 같은 키로 값을 조회한다. ...
In-Memory DB 구현 3편 - RESP 프로토콜
들어가며 이전 포스팅에서 TCP 스트림을 파싱해 PING/ECHO 명령어를 처리하는 서버를 만들었다. 하지만 PING\r\n 같은 단순한 텍스트 형식에는 몇 가지 한계가 있다. 데이터 안에 \r\n이 포함되면 구분자로 오인한다 문자열인지, 숫자인지, 에러인지 타입을 알 수 없다 악의적인 클라이언트가 무한히 긴 데이터를 보낼 수 있다 Redis는 이런 문제를 해결하기 위해 Redis Serialization Protocol 이라는 자체 프로토콜을 사용한다. 이번 포스팅에서는 RESP 프로토콜의 구조를 이해하고 직접 파서를 구현해본다. 단순히 어떻게 파싱하는지를 넘어서 프로토콜이 왜 이런 형태를 갖게 되었는지, 직렬화 포맷 사이의 트레이드오프는 무엇이고 바이너리 세이프라는 개념이 실제로 어떤 문제를 해결하는지까지 깊이 들여다본다. ...
In-Memory DB 구현 2편 - TCP 스트림 파싱하기 (TCP 스트림/시스템콜/버퍼링)
들어가며 이전 포스팅에서 TCP 서버를 만들어 클라이언트 연결을 받고 +PONG\r\n을 응답하는 데까지 성공했다. 하지만 실제 데이터베이스라면 클라이언트가 보낸 명령어를 읽고 해석해야 한다. PING이면 PONG을, GET key면 해당 값을 응답해주듯 말이다. 문제는 클라이언트가 PING\r\n을 보냈다고 해서 서버가 정확히 PING\r\n을 한 번에 받는다는 보장이 없다. 이번 포스팅에서는 이 문제의 본질을 TCP 프로토콜의 구조까지 내려가서 이해하고 커널과 유저 공간 사이의 데이터 흐름을 살펴본 뒤, go의 bufio.Reader와 io.Reader 인터페이스를 활용해 스트림 파싱을 구현하는 과정을 다룬다. ...