← 프리뷰 목록으로

데이터베이스 내부구조: I/O 작업 이해하기

게시일: 2025년 11월 22일 | 원문 작성일: 2024년 11월 25일 | 저자: Pavel “Xemul” Emelyanov | 원문 보기

핵심 요약

ScyllaDB의 수석 엔지니어이자 전직 리눅스 커널 해커인 Pavel Emelyanov가 리눅스 I/O 접근 방식의 트레이드오프와 데이터베이스가 현대 SSD의 특성을 어떻게 활용할 수 있는지 설명합니다.

주요 내용:

  • 네 가지 I/O 방식: read/write, mmap, Direct I/O, Asynchronous I/O의 작동 원리와 각각의 장단점
  • 성능 트레이드오프: 캐시 히트율, I/O 스케줄링, 스레드 관리, 애플리케이션 복잡도 간의 균형
  • 파일시스템 vs Raw 디스크: 관리 비용과 성능의 실전 고려사항
  • SSD의 특성: IOPS, 처리량, 레이턴시 간의 관계와 IO 스케줄러의 역할
  • 실전 도구: Diskplorer로 실제 디스크 동작 측정하기

들어가며

이 글은 무료로 제공되는 Database Performance at Scale 책의 3장 일부를 발췌한 내용이에요. 이 책은 대규모 환경에서 데이터베이스 성능에 영향을 미치는 종종 간과되는 요인들을 다룹니다.

인메모리 데이터베이스가 아니라면 데이터를 외부 스토리지에 보관해야 해요. 로컬 디스크, 네트워크 스토리지, 분산 파일/객체 스토리지 시스템 등 다양한 옵션이 있죠. “I/O”라는 용어는 일반적으로 로컬 스토리지(디스크나 디스크 위의 파일시스템)에서 데이터를 읽고 쓰는 걸 의미해요. 리눅스 서버에서 파일에 접근하는 방법은 크게 네 가지가 있어요: read/write, mmap, Direct I/O (DIO) read/write, Asynchronous I/O (AIO/DIO). AIO는 캐시 모드에서 거의 사용하지 않기 때문에 보통 DIO와 함께 쓰여요.

전통적인 read/write

가장 전통적인 방법은 __CODE_UNDER_0__와 __CODE_UNDER_1__ 시스템 콜을 사용하는 거예요. 현대 구현에서 read 시스템 콜(또는 그 변형들인 __CODE_UNDER_2__, __CODE_UNDER_3__, __CODE_UNDER_4__ 등)은 커널에게 파일의 특정 섹션을 읽어서 호출 프로세스의 주소 공간으로 데이터를 복사하라고 요청해요.

요청한 데이터가 모두 페이지 캐시에 있으면 커널은 데이터를 복사하고 즉시 반환해요. 그렇지 않으면 디스크에서 요청한 데이터를 페이지 캐시로 읽어오도록 요청하고, 호출 스레드를 재우고, 데이터가 준비되면 스레드를 깨워서 데이터를 복사하죠.

반면 write는 보통 데이터를 페이지 캐시로 복사하기만 해요. 커널은 나중에 페이지 캐시를 디스크로 다시 써주죠.

mmap

더 현대적인 대안은 __CODE_UNDER_5__ 시스템 콜로 파일을 애플리케이션 주소 공간에 메모리 매핑하는 거예요. 이렇게 하면 주소 공간의 일부가 파일 데이터를 담고 있는 페이지 캐시 페이지를 직접 참조하게 돼요.

이렇게 매핑하면 애플리케이션은 프로세서의 메모리 읽기/쓰기 명령어로 파일 데이터에 접근할 수 있어요. 요청한 데이터가 캐시에 있으면 커널을 완전히 우회하고 메모리 속도로 읽기(또는 쓰기)가 이뤄져요. 캐시 미스가 발생하면 페이지 폴트가 일어나고 커널은 활성 스레드를 재우고 해당 페이지의 데이터를 읽으러 가요. 데이터가 준비되면 메모리 관리 유닛을 설정하여 새로 읽은 데이터에 스레드가 접근할 수 있게 되고, 그때 스레드가 깨어나죠.

Direct I/O (DIO)

전통적인 read/write와 mmap 둘 다 커널 페이지 캐시를 사용하고 I/O 스케줄링을 커널에 맡겨요. 애플리케이션이 직접 I/O를 스케줄링하고 싶을 때(이유는 나중에 설명할게요) Direct I/O를 사용할 수 있어요.

파일을 __CODE_UNDER_6__ 플래그로 열면 일반적인 read/write 계열의 시스템 콜을 사용하지만 동작이 달라져요. 캐시에 접근하는 대신 디스크에 직접 접근하기 때문에 호출 스레드는 무조건 재워져요. 게다가 디스크 컨트롤러가 데이터를 커널을 우회해서 사용자 공간으로 직접 복사하죠.

Asynchronous I/O (AIO/DIO)

비동기 Direct I/O는 Direct I/O의 개선 버전이에요. Direct I/O와 비슷하게 동작하지만 호출 스레드가 재워지는 걸 방지하죠. 대신 애플리케이션 스레드가 __CODE_UNDER_7__ 시스템 콜로 Direct I/O 작업을 스케줄링하지만 스레드는 재워지지 않아요. I/O 작업이 일반 스레드 실행과 병렬로 실행되죠.

별도의 시스템 콜인 __CODE_UNDER_8__로 완료된 I/O 작업의 결과를 기다리고 수집해요. DIO처럼 커널의 페이지 캐시를 우회하고, 디스크 컨트롤러가 데이터를 사용자 공간으로 직접 복사해요.

io_uring에 대한 참고사항

비동기 I/O를 수행하는 API는 리눅스에 오래전에 등장했고, 커뮤니티가 환영했어요. 하지만 실제로 사용해보니 많은 비효율이 드러났죠. 어떤 상황에서는 대기한다거나(이름과 달리), 커널을 너무 자주 호출해야 한다거나, 제출된 요청 취소를 제대로 지원하지 못한다거나요. 결국 업데이트된 요구사항이 기존 API와 호환되지 않는다는 게 명확해졌고, 새로운 API가 필요했어요.

이렇게 __CODE_UNDER_9__ API가 등장했어요. AIO와 같은 기능을 제공하지만 훨씬 더 편리하고 성능이 좋은 방식으로요(문서도 훨씬 나아졌죠). 구현 세부사항까지 파고들지 않고, 이게 존재한다는 것과 레거시 AIO보다 선호된다는 것만 알아두면 돼요.

트레이드오프 이해하기

다양한 액세스 방식은 일부 특성을 공유하고 다른 면에서 달라요. 아래 표에 이런 특성들이 요약되어 있어요.

특성 read/write mmap DIO AIO/DIO
커널 캐시 사용 아니오 아니오
데이터 복사 커널→사용자 공간 없음 (캐시 히트 시) 디스크→사용자 공간 디스크→사용자 공간
I/O 스케줄링 커널 커널 애플리케이션 애플리케이션
스레드 대기 I/O 대기 시 페이지 폴트 시 I/O 대기 시 대기 안 함
정렬 요구사항 커널이 처리 커널이 처리 애플리케이션이 처리 애플리케이션이 처리
애플리케이션 복잡도 낮음 중간 중간 높음

데이터 복사와 MMU 활동

mmap 방식의 장점 중 하나는 데이터가 캐시에 있으면 커널을 완전히 우회한다는 거예요. 데이터를 커널 공간에서 사용자 공간으로 복사할 필요가 없어서 프로세서 사이클이 적게 들어요. 이건 캐시 히트율이 높은 워크로드(예: 스토리지 대 RAM 비율이 1:1에 가까운 경우)에 유리해요.

하지만 mmap의 단점은 데이터가 캐시에 없을 때 나타나요. 이건 보통 스토리지 대 RAM 비율이 1:1보다 훨씬 높을 때 발생해요. 캐시로 들어오는 모든 페이지가 다른 페이지를 축출시키죠. 이 페이지들을 페이지 테이블에 삽입하고 제거해야 하고, 커널은 페이지 테이블을 스캔해서 비활성 페이지를 찾아 축출 후보로 만들어야 해요.

게다가 mmap은 페이지 테이블을 위한 메모리가 필요해요. x86 프로세서에서 매핑된 파일 크기의 0.2%가 필요하죠. 낮아 보이지만 애플리케이션이 스토리지 대 메모리 비율이 100:1이면 메모리의 20%(0.2% * 100)가 페이지 테이블에 할당돼요.

I/O 스케줄링

커널이 캐싱을 제어하는 방식(mmap과 read/write)의 문제 중 하나는 애플리케이션이 I/O 스케줄링 제어권을 잃는다는 거예요. 커널은 적절하다고 생각하는 데이터 블록을 골라서 쓰기나 읽기를 스케줄링하죠. 이게 다음과 같은 문제를 일으킬 수 있어요:

  • 쓰기 폭주: 커널이 대량의 쓰기를 스케줄링하면 디스크가 오랫동안 바빠서 읽기 레이턴시에 영향을 줘요
  • 중요도를 구별할 수 없음: 커널은 “중요한” I/O와 “덜 중요한” I/O를 구별할 수 없어요. 백그라운드 작업의 I/O가 포그라운드 작업을 압도해서 레이턴시에 영향을 줄 수 있죠

커널 페이지 캐시를 우회하면 애플리케이션이 I/O 스케줄링 부담을 떠안아요. 이게 문제를 해결한다는 의미는 아니지만, 충분한 주의와 노력을 들이면 해결할 수 있다는 의미죠.

Direct I/O를 사용할 때 각 스레드가 언제 I/O를 발행할지 제어하지만, 커널은 언제 스레드를 실행할지 제어하므로 I/O 발행 책임은 커널과 애플리케이션이 공유해요. AIO/DIO를 사용하면 애플리케이션이 언제 I/O를 발행할지 완전히 제어하죠.

스레드 스케줄링

mmap이나 read/write를 사용하는 I/O 집약적 애플리케이션은 캐시 히트율을 예측할 수 없어요. 그래서 코어 수보다 훨씬 많은 스레드를 실행해야 하죠. 너무 적은 스레드를 쓰면 모든 스레드가 디스크 I/O를 기다리게 되어 프로세서가 충분히 활용되지 않을 수 있어요.

각 스레드가 보통 최대 하나의 디스크 I/O를 처리하기 때문에, 디스크를 완전히 활용하려면 스토리지 서브시스템이 처리할 수 있는 동시 I/O 수의 몇 배 정도 스레드가 필요해요. 하지만 캐시 히트율이 충분히 높으면 이렇게 많은 스레드가 제한된 코어 수를 놓고 경쟁하게 돼요.

Direct I/O를 사용하면 이 문제가 어느 정도 완화돼요. 애플리케이션이 스레드가 정확히 언제 I/O에 재워지고 언제 실행될 수 있는지 알기 때문에 런타임 조건에 따라 실행 중인 스레드 수를 조정할 수 있어요.

AIO/DIO를 사용하면 애플리케이션이 실행 중인 스레드와 대기 중인 I/O 둘 다 완전히 제어할 수 있어요(둘은 완전히 분리돼 있죠). 그래서 인메모리 상황이든 디스크 바운드 상황이든 그 중간이든 쉽게 조정할 수 있어요.

I/O 정렬

스토리지 장치에는 블록 크기가 있어요. 모든 I/O는 이 블록 크기(보통 512 또는 4096바이트)의 배수로 수행되어야 하죠. read/write나 mmap을 사용하면 커널이 정렬을 자동으로 수행해요. 작은 읽기나 쓰기는 커널이 발행하기 전에 올바른 블록 경계로 확장되죠.

DIO를 사용하면 애플리케이션이 블록 정렬을 수행해야 해요. 이건 약간의 복잡성을 추가하지만 장점도 있어요. 커널은 보통 512바이트 경계로 충분할 때도 4096바이트 경계로 과도하게 정렬해요. 하지만 DIO를 사용하는 애플리케이션은 512바이트 정렬 읽기를 발행할 수 있어서 작은 항목에서 대역폭을 절약할 수 있죠.

애플리케이션 복잡도

지금까지 AIO/DIO가 I/O 집약적 애플리케이션에 유리해 보였지만, 이 방식은 상당한 비용이 따라와요. 바로 복잡도죠. 캐시 관리 책임을 애플리케이션에 두면 커널보다 더 나은 선택을 하고 오버헤드를 줄일 수 있어요. 하지만 그 알고리즘을 작성하고 테스트해야 하죠.

비동기 I/O를 사용하려면 애플리케이션을 콜백, 코루틴 또는 유사한 방법으로 작성해야 하고, 기존 라이브러리를 재사용하기 어려워져요.

파일시스템과 디스크 선택하기

I/O 방식을 선택하는 것 외에도 데이터베이스 설계는 어떤 매체에 I/O를 수행할지 고려해야 해요. 많은 경우 파일시스템 대 raw 블록 장치의 선택이고, 이는 다시 전통적인 회전 디스크 대 SSD 드라이브의 선택이 될 수 있어요. 클라우드 환경에서는 로컬 드라이브가 항상 일시적이기 때문에 세 번째 옵션이 있을 수 있는데, 이는 복제에 엄격한 요구사항을 부과해요.

파일시스템 vs Raw 디스크

이 결정은 관리 비용과 성능 두 가지 관점에서 접근할 수 있어요.

스토리지를 raw 블록 장치로 접근하면 블록 할당과 회수의 모든 어려움을 애플리케이션이 직접 처리해야 해요. 앞서 메모리 관리를 다룰 때 언급했듯이, 같은 과제가 RAM과 디스크 모두에 적용돼요.

또 다른 과제는 크래시 시 데이터 무결성을 제공하는 거예요. 완전한 인메모리 데이터베이스가 아니라면 재시작 후 데이터를 잃거나 디스크에서 쓰레기를 읽는 걸 피하는 방식으로 I/O가 수행되어야 해요. 하지만 현대 파일시스템은 둘 다 제공하고 할당 효율성과 데이터 무결성을 신뢰할 만큼 성숙해요. 안타깝게도 raw 블록 장치를 접근하면 이런 기능이 부족하므로 애플리케이션이 직접 같은 품질로 구현해야 하죠.

성능 관점에서 차이는 그렇게 극적이지 않아요. 한편으로 파일에 데이터를 쓰면 항상 관련 메타데이터 업데이트가 동반돼요. 이게 디스크 공간과 I/O 대역폭을 소비하죠. 하지만 일부 현대 파일시스템은 성능과 효율성의 훌륭한 균형을 제공해서 추가적인 I/O 레이턴시 오버헤드를 최소화해요. (가장 두드러진 예는 XFS예요. 또 다른 정말 좋고 성숙한 소프트웨어는 Ext4죠.)

파일시스템 진영의 강력한 무기는 __CODE_UNDER_10__ 시스템 콜이에요. 파일시스템이 디스크에 공간을 미리 할당하도록 만들죠. 이걸 사용하면 파일시스템도 extent 메커니즘을 완전히 활용할 기회가 생겨서 파일 사용의 QoS를 raw 블록 장치 사용과 같은 성능 수준으로 끌어올려요.

추가 쓰기

데이터베이스는 파일에 추가하는 데 크게 의존하거나 개별 파일 블록의 제자리 업데이트를 요구할 수 있어요. 두 접근법 모두 세심한 주의가 필요한데, 기본 시스템에 다른 속성을 요구하거든요.

한편으로 추가 쓰기는 파일시스템을 주의 깊게 다뤄야 해서 메타데이터 업데이트(특히 파일 크기)가 일반 I/O에 영향을 주지 않도록 해야 해요. 반면에 추가 쓰기는 캐시를 우회하는 방식이므로 디스크 덮어쓰기의 문제를 자연스럽게 처리해요. 이와 대조적으로 제자리 업데이트는 raw 블록 장치를 쓰더라도 디스크가 이런 워크로드를 견디지 못할 수 있기 때문에 무작위 오프셋과 크기로 일어날 수 없어요.

이제 스택을 더 깊이 파고들어 하드웨어 레벨로 내려가 볼게요.

현대 SSD의 작동 원리

다른 계산 자원처럼 디스크도 제공할 수 있는 속도에 제한이 있어요. 이 속도는 보통 초당 입출력 작업 수(IOPS)와 초당 바이트 수(처리량)라는 2차원 값으로 측정돼요. 물론 이 파라미터들은 각 특정 디스크에 대해서도 돌에 새겨진 게 아니고, 최대 요청 수나 바이트 수는 요청 분포, 큐잉과 동시성, 버퍼링이나 캐싱, 디스크 연식 등 많은 요인에 크게 좌우돼요.

그래서 I/O를 수행할 때 디스크는 항상 두 비효율성 사이에서 균형을 맞춰야 해요. 요청으로 디스크를 압도하는 것과 디스크를 충분히 활용하지 못하는 것 사이죠.

디스크를 압도하는 건 피해야 하는데, 디스크가 요청으로 가득 차면 특정 요청의 중요도를 구별할 수 없거든요. 물론 모든 요청이 중요하지만 레이턴시에 민감한 요청을 우선시하는 게 합리적이에요.

예를 들어 ScyllaDB는 한 자릿수 밀리초 이하로 완료되어야 하는 실시간 쿼리를 제공하면서, 동시에 테라바이트 단위의 데이터를 compaction, 스트리밍, 디커미션 등으로 처리해요. 전자는 강한 레이턴시 민감성이 있고, 후자는 덜해요. I/O 대역폭을 최대화하면서 레이턴시에 민감한 작업의 레이턴시를 가능한 낮게 유지하는 I/O 관리는 충분히 복잡해서 IO 스케줄러라는 독립 컴포넌트가 됐어요.

디스크를 평가할 때 아마 네 가지 파라미터를 볼 거예요. 읽기/쓰기 IOPS와 읽기/쓰기 처리량(MB/s 같은). 이 숫자들을 서로 비교해서 디스크 간 성능을 평가하고, 앞서 언급한 디스크의 “대역폭 용량”을 Little의 법칙을 적용해서 추정하는 게 인기 있는 방법이에요.

IO 스케줄러의 역할은 디스크 내에서 특정 수준의 동시성을 제공해서 최대 대역폭을 얻되, 이 동시성이 너무 높아져서 디스크가 요청을 필요 이상으로 오래 큐잉하는 걸 방지하는 거예요.

실제 디스크 동작 측정하기

예를 들어, 아래 그림은 작은 읽기(IOPS 테스트)의 강도 대 큰 쓰기(대역폭 테스트)의 강도에 따라 읽기 요청 레이턴시가 어떻게 달라지는지 보여줘요. 레이턴시 값은 색상으로 코딩되어 있고, “흥미로운 영역”은 cyan으로 칠해져 있어요. 여기가 레이턴시가 1밀리초 미만으로 유지되는 곳이죠. 측정에 사용한 드라이브는 AWS EC2 i3en.3xlarge 인스턴스에 포함된 NVMe 디스크예요.

그림: 읽기 요청 레이턴시가 작은 읽기 강도(IOPS 테스트) 대 큰 쓰기 강도(대역폭 테스트)에 어떻게 의존하는지 보여주는 대역폭/레이턴시 그래프. AWS EC2 i3en.3xlarge의 NVMe 디스크 측정 결과.

이 드라이브는 거의 완벽한 half-duplex 동작을 보여줘요. 읽기 강도를 여러 배 늘리면 같은 속도로 디스크를 작동시키기 위해 쓰기 강도를 거의 같은 비율로 줄여야 해요.

💡 팁: 부하 하의 디스크 동작 측정하기

자신의 디스크가 부하 하에서 어떻게 동작하는지 잘 이해할 수록 “sweet spot”을 활용하도록 더 잘 튜닝할 수 있어요. 한 가지 방법은 오픈소스 디스크 레이턴시/대역폭 탐색 도구인 Diskplorer를 사용하는 거예요.

내부적으로 리눅스 fio를 사용해서 일련의 측정을 실행해 특정 하드웨어 구성의 성능 특성을 발견하고, 서버 스토리지 I/O가 부하 하에서 어떻게 동작할지 한눈에 볼 수 있는 뷰를 제공하죠.

이 도구 사용법을 알아보려면 Linux Foundation 비디오 “Understanding Storage I/O Under Load”를 참고하세요.

마치며

리눅스 I/O 접근 방식 각각은 고유한 트레이드오프가 있어요. 전통적인 read/write와 mmap은 사용하기 쉽지만 캐시와 I/O 스케줄링 제어를 커널에 맡겨요. Direct I/O와 Asynchronous I/O는 애플리케이션에 더 많은 제어를 주지만 상당한 복잡성을 추가하죠.

데이터베이스를 설계할 때는 워크로드의 특성(캐시 히트율, 동시성 요구사항, 레이턴시 민감도)을 이해하고 적절한 I/O 방식과 스토리지 구성을 선택하는 게 중요해요. 그리고 Diskplorer 같은 도구로 실제 하드웨어 동작을 측정하면 성능을 최적화하는 데 큰 도움이 돼요.

저자 소개: Pavel “Xemul” Emelyanov는 ScyllaDB의 수석 엔지니어예요. 전직 리눅스 커널 해커로, 지금은 row cache 속도 향상, IO 스케줄러 튜닝, 컴포넌트 간 의존성의 기술 부채 상환을 돕고 있어요.

출처: 이 글은 무료로 제공되는 Database Performance at Scale 책의 3장 일부를 발췌하고 번역한 내용이에요. 이 책은 규모에서 종종 간과되는 데이터베이스 성능 영향 요인들을 다룹니다.

원문: Database Internals: Working with IO - Pavel “Xemul” Emelyanov, ScyllaDB (2024년 11월 25일)

번역: Claude (Anthropic)

총괄: (디노이저denoiser)