[실전 서버리스 09] D1은 단일 스레드다, 대량 작업을 배치로 쪼개기

D1을 쓰다 대량의 데이터를 한 번에 바꾸려다 실패한 적이 있다. 수십만 행을 한 쿼리로 업데이트하려 했더니 실행 한도에 걸려 멈춘 것이다. 원인을 파보니 D1의 근본 특성, 단일 스레드 때문이었다. 이번 편은 이 특성이 무엇이고, 대량 작업을 어떻게 배치로 쪼개 안전하게 처리하는지를 다룬다.


이 특성을 모르면 대량 작업에서 반드시 벽에 부딪힌다. 앞 편의 rows read와 함께, D1을 제대로 쓰려면 꼭 알아야 하는 두 번째 핵심이다.

1. 단일 스레드란 무엇인가

D1은 단일 스레드로 동작한다. 즉 한 데이터베이스가 쿼리를 한 번에 하나씩 처리한다는 뜻이다. 여러 쿼리가 동시에 오면 줄을 서서 차례로 실행된다. 여러 요청을 병렬로 동시에 처리하는 방식과는 다르다.


이게 성능에 주는 의미가 있다. 한 데이터베이스의 최대 처리량이 쿼리 하나하나의 실행 시간에 직접 묶인다는 것이다. 쿼리가 빠르면 그만큼 많이 처리하고, 느린 쿼리 하나가 오래 붙잡고 있으면 뒤의 쿼리들이 다 기다린다. 그래서 각 쿼리를 빠르게 유지하는 게 전체 처리량에 중요하다.


앞 편에서 인덱스로 rows read를 줄이라고 한 게 여기서도 이어진다. 인덱스로 쿼리가 빨라지면 단일 스레드가 그만큼 빨리 다음 쿼리로 넘어간다. 느린 쿼리는 자기 비용만 늘리는 게 아니라 뒤의 모든 쿼리를 지연시킨다. 단일 스레드 환경에서 쿼리 최적화가 더욱 중요한 이유다.


단일 스레드라는 말이 낯설 수 있는데, 은행 창구 하나에 비유하면 쉽다. 창구가 하나라 손님이 줄을 서서 한 명씩 처리되는 것이다. 손님 응대가 빠르면 줄이 빨리 줄고, 한 손님이 오래 붙잡으면 뒤가 다 기다린다. D1도 이 창구 하나짜리 은행처럼 쿼리를 하나씩 처리한다.


한 가지 더, 단일 스레드는 동시성 문제가 적다는 숨은 장점도 있다. 여러 작업이 동시에 같은 데이터를 건드려 생기는 복잡한 충돌이, 쿼리가 하나씩 처리되니 잘 안 생긴다. 순서가 명확해 예측하기 쉽다. 개인 개발자에게 이 예측 가능성은 오히려 다루기 편한 면이 있다. 제약이자 단순함인 것이다.

2. 대량 작업이 왜 문제인가

단일 스레드의 특성상, 한 쿼리가 너무 많은 일을 하려 하면 문제가 된다. 예를 들어 수십만 행을 한 번에 업데이트하거나 삭제하려는 쿼리는 실행 시간이 매우 길어진다. 그런데 D1에는 한 쿼리의 실행 한도가 있어서, 이 한도를 넘으면 쿼리가 중간에 끊긴다.


내가 겪은 게 정확히 이거였다. 특정 조건의 데이터를 대량으로 정리하려고 한 쿼리로 수십만 행을 건드렸더니, 실행 한도에 걸려 실패했다. 게다가 이런 대량 작업이 도는 동안엔 단일 스레드가 거기 묶여 있어서, 다른 정상 요청들도 느려진다. 대량 작업 하나가 사이트 전체에 영향을 주는 것이다.


그래서 대량 데이터 작업은 D1에서 특별히 조심해야 한다. 평소의 작은 쿼리와 달리, 대량 작업은 한도와 성능 양쪽에서 위험하다. 무심코 큰 업데이트나 삭제를 날렸다가 사이트가 느려지거나 작업이 실패하는 사고가 흔하다.


대량 작업이 실행 한도에 걸린다는 게 처음엔 당황스럽다. 그냥 한 번에 다 하면 될 것 같은데 안 되기 때문이다. 하지만 이건 플랫폼이 한 작업이 자원을 독점하지 못하게 막는 안전장치다. 이 한도 덕에 무거운 작업 하나가 사이트 전체를 마비시키는 걸 막는다. 제약이자 보호인 셈이다.


조금 더 짚으면, 단일 스레드는 앞 편의 인덱스 최적화와 시너지가 크다. 인덱스로 각 쿼리가 빨라지면 단일 스레드가 그만큼 빨리 다음 쿼리로 넘어가, 전체 처리량이 올라간다. 반대로 느린 쿼리 하나는 자기만이 아니라 뒤의 모든 쿼리를 지연시킨다. 그래서 단일 스레드 환경일수록 쿼리 최적화의 값어치가 배가된다.

3. 배치로 쪼개기

해결책은 대량 작업을 작은 배치로 쪼개는 것이다. 수십만 행을 한 번에 하는 대신, 예를 들어 천 행씩 나눠 여러 번에 걸쳐 처리한다. 각 배치는 작아서 실행 한도 안에 들고, 단일 스레드를 오래 붙잡지도 않는다.


배치로 쪼개면 각 조각 사이에 다른 요청이 처리될 틈도 생긴다. 천 행 처리하고 잠깐 쉬고 다음 천 행을 처리하는 사이에, 정상적인 사용자 요청이 끼어들어 처리된다. 대량 작업이 사이트를 독점하지 않고, 다른 요청과 공존하며 진행된다. 사용자 경험을 해치지 않으면서 큰 작업을 처리하는 방법이다.


구현할 때는 어디까지 처리했는지를 기억하며 이어가는 방식을 쓴다. 마지막으로 처리한 지점을 기록해두고, 다음 배치는 그 지점부터 이어서 처리한다. 이렇게 하면 중간에 멈춰도 그 지점부터 재개할 수 있어 안전하다. 대량 작업을 나눠서, 이어서, 안전하게 하는 것이다.


배치로 쪼갤 때 어디까지 했는지 기억하며 이어가는 방식이 특히 중요하다. 마지막 처리 지점을 기록해두면, 중간에 멈춰도 그 지점부터 재개할 수 있다. 대량 작업은 오래 걸려 중간에 끊길 수 있는데, 이 이어가기가 있으면 처음부터 다시 안 해도 된다. 안전하게 재개 가능한 배치가 정석이다.


또 하나, 배치 작업을 정기 작업과 결합하면 대량 처리를 자동화할 수 있다. 뒤에서 다룰 크론으로 배치를 조금씩 나눠 주기적으로 돌리면, 사람이 지켜보지 않아도 큰 작업이 서서히 처리된다. 오래된 데이터 정리 같은 걸 이렇게 자동 배치로 돌리면, 대량 작업의 부담이 시간에 분산되어 사라진다.

4. 마이그레이션에 적용

이 배치 원칙은 앞 편에서 다룬 마이그레이션에도 그대로 적용된다. 스키마를 바꾸면서 기존 데이터를 대량으로 변환해야 할 때가 있는데, 이것도 한 번에 하면 안 된다. 데이터가 많으면 실행 한도에 걸리기 때문이다. 마이그레이션 스크립트를 배치로 짜야 한다.


내 커뮤니티에서도 데이터 구조를 바꾸며 대량 변환이 필요했던 적이 있는데, 이때 배치 처리로 나눠서 했다. 전체를 작은 묶음으로 나눠 순차적으로 변환하니, 한도에 안 걸리고 사이트에 영향도 적었다. 대량 마이그레이션은 반드시 이렇게 쪼개서 한다는 게 원칙이 됐다.


이건 정기적으로 도는 정리 작업에도 적용된다. 오래된 데이터를 정리하거나 통계를 갱신하는 배치 작업을, 한 번에 몰아서 하지 않고 작은 단위로 나눠 처리한다. 뒤에서 다룰 정기 작업, 즉 크론과 결합하면 이런 대량 처리를 조금씩 나눠 자동으로 진행할 수 있다.


마이그레이션에 배치를 적용하는 건 실전에서 꼭 필요하다. 스키마를 바꾸며 기존 데이터를 대량 변환할 때, 한 번에 하면 한도에 걸린다. 그래서 마이그레이션 스크립트도 데이터를 작은 묶음으로 나눠 순차 처리하게 짠다. 대량 마이그레이션은 반드시 쪼갠다는 걸 원칙으로 삼아야 한다.


조금 더 실무적으로, 배치 크기를 정하는 것도 요령이 필요하다. 너무 크면 한도에 걸리고, 너무 작으면 배치 횟수가 많아져 비효율적이다. 데이터 크기와 쿼리 무게를 보고 적당한 배치 크기를 정한다. 나는 처음에 작게 시작해 안전을 확인하고 점차 키우며 최적점을 찾았다. 무리하지 않는 크기가 안전하다.

5. 단일 스레드를 전제로 설계한다

단일 스레드라는 특성을 알고 나면, 처음부터 그걸 전제로 설계하게 된다. 개별 쿼리는 빠르게 유지하고, 대량 작업은 배치로 나누고, 무거운 작업이 사용자 요청과 경쟁하지 않게 시간을 분산한다. 이런 설계가 D1을 안정적으로 쓰는 기반이 된다.


이 특성이 단점으로만 보일 수 있지만, 뒤집으면 단순함이기도 하다. 단일 스레드는 동시성 문제, 즉 여러 작업이 동시에 같은 데이터를 건드려 생기는 복잡한 문제가 적다. 쿼리가 하나씩 처리되니 순서가 명확하고, 예측하기 쉽다. 이 예측 가능성은 개인 개발자에게 오히려 다루기 편한 면이 있다.


정리하면 D1은 단일 스레드라 쿼리를 하나씩 처리하고, 그래서 대량 작업은 배치로 쪼개 실행 한도와 성능 문제를 피해야 하며, 이 특성을 전제로 설계해야 한다. 다음 편에서는 이 D1의 읽기 부담을 근본적으로 줄이는 방법, 캐시 계층을 쌓아 데이터베이스 읽기를 90퍼센트까지 줄인 실전 이야기를 다룬다.


단일 스레드를 전제로 설계하면, 무거운 작업을 사용자 요청과 겹치지 않게 배치하는 감각이 생긴다. 대량 정리는 트래픽이 적은 시간에, 작은 배치로 나눠 돌린다. 이렇게 하면 대량 작업이 사용자 경험을 해치지 않는다. 특성을 알고 그에 맞게 설계하는 것이 제약을 다루는 방법이다.


다음 편에서는 이 D1의 읽기 부담을 근본적으로 줄이는 방법, 캐시 계층을 쌓아 데이터베이스 읽기를 크게 줄인 실전을 다룬다. 단일 스레드의 부담을 덜어주는 가장 강력한 방법이 바로 이 캐시다.