A Tale of Two Databases: How PostgreSQL and MySQL Handle Torn Pages
이 블로그 시리즈의 첫 번째 글에 오신 것을 환영합니다.
이 시리즈에서는 PostgreSQL 과 MySQL 이 관계형 데이터베이스의 다양한 측면을 어떻게 처리하는지를 살펴봅니다.
오랜 기간 오픈소스 데이터베이스 관리자(DBA)로 일하면서, 이 두 데이터베이스가 여러 문제를 처리하는 방식의 차이점에 매료되어 왔고, 한 쪽 기술에 익숙한 DBA 들이 다른 쪽에 대해서는 잘 모르는 경우가 많다는 점을 자주 보아왔습니다.
이 시리즈는 이러한 작동 방식을 깊이 이해하고, 두 커뮤니티 간의 간극을 메우는 데 도움을 주는 것을 목표로 합니다.
이미 숙련된 PostgreSQL 또는 MySQL DBA 라 해도, 이 글들을 통해 새로운 정보나 흥미로운 내용을 배울 수 있을 것입니다.
때로는 다소 어려운 주제일 수도 있지만, 최대한 재미있고 흥미롭게 전달하려고 노력하겠습니다.

What is a torn page?
제가 이전에 어떤 주제들은 이해하기 좀 어려울 수 있다고 말했었나요? 만약 소화하기 힘든 주제가 있다면, 아마도 ‘Torn pages(찢어진 페이지)’가 그중 하나일 겁니다.
하지만 셰익스피어가 썼듯이,
“존경하는 각하, 우리는 단지 인간일 뿐입니다. 수많은 이들이 산산조각 나지 않고 해낸 일을 우리도 해냈을 뿐이죠. 군대도 그들을 통제할 수 없습니다.”
불멸의 시인의 인용구를 사용한다고 해서 이 주제가 더 쉽게 소화될지는 모르겠지만, 그렇지 않으면 완전히 기술적인 블로그 게시물에 약간의 특별함과 교양을 더해줍니다.
그리고 우리 모두는 삶에서 약간의 교양이 필요하죠, 그렇지 않나요?
하지만 이제 잡담은 그만하고 본론으로 돌아가겠습니다.
데이터베이스에서 page 는 저장의 기본 단위인데, Torn pages 는 디스크에 부분적으로만 기록된 page 를 말합니다.
여러 가지 이유로 이런 일이 발생하는데, 첫 번째는 데이터베이스의 page 크기가 디스크의 물리적 섹터보다 크기 때문입니다.
또한 대부분의 파일 시스템은 원자적(atomic) 쓰기를 보장하지 않기 때문이죠.
그렇다면 데이터베이스 페이지를 쓰는 도중에 서버가 crash 가 발생하면 어떻게 될까요?
Page 는 부분적으로만 기록되어 데이터베이스를 일관성 없는 상태로 남기게 됩니다.
How do we avoid torn pages?
사실 Torn Page 현상을 완전히 피할 수는 없지만, 탐지는 가능합니다.
더 중요한 것은 복구가 가능하다는 점입니다.
어떻게 가능할까요?
바로 Redundancy(중복성)을 이용하면 됩니다.
예를 들어, 저는 앞서 page 라는 단어를 단락 내에서 6번이나 반복 사용했습니다.
이것이 바로 중복성입니다.
혹시 제가 정말로 “page” 를 몇 번 썼는지 세어보신 분은 없으시겠죠?
Torn pages 에서 복구하기 위해 중복성을 어떻게 활용할까요?
page 를 두 번 기록합니다.
한 번은 임시 위치에, 다른 한 번은 최종 위치에 기록하는 것이죠.
이후 복구 과정에서 두 버전이 다르다고 판단되면, 정상적인 버전을 선택해 사용합니다.
그런데 어떤 버전이 올바른지 어떻게 알 수 있을까요?
이 부분은 PostgreSQL 과 MySQL 의 차이점 중 하나이므로, 잠시 후에 자세히 논의하겠습니다.
Let’s synchronize our watches… I mean disks
본격적으로 PostgreSQL 과 MySQL 이 torn page 를 어떻게 처리하는지 알아보기 전에, 먼저 운영체제가 쓰기 작업을 어떻게 수행하는지 간단히 살펴봅시다.
운영체제는 종종 작은 거짓말쟁이라고도 불립니다.
애플리케이션이 운영체제에 page 를 디스크에 써 달라고 요청하면, 운영체제는 보통 그 page 를 메모리에 있는 캐시에 먼저 저장합니다.
그리고 나중에 디스크에 플러시(저장) 합니다.
이렇게 하면 성능은 좋아지지만, 문제는 운영체제가 쓰기 완료라고 알려줄 때, 실제로 디스크에 저장된 것이 아니라 “언젠가는 쓸 거야” 라는 약속일 뿐이라는 것입니다.
이건 ACID 원칙 중 Durability(내구성) 에 위배됩니다.
write() 함수 호출이 실제로 디스크에 쓰기 전에 완료될 수 있기 때문입니다.
그래서 이 문제를 해결하기 위해 운영체제는 sync() 라는 함수도 제공합니다.
이 함수는 진짜로 디스크에 데이터를 썼다는 걸 보장하게 해 줍니다.
fsync 매뉴얼 페이지에 따르면(인간이 이해할 수 있는 언어로 번역하면):
fsync() 는 지정된 파일의 모든 변경된 데이터를 디스크로 전송(플러시) 합니다.
시스템이 충돌하거나 재부팅 되더라도 해당 데이터를 복구할 수 있도록 보장합니다.
디스크 캐시가 있다면 이를 통과시켜 실제 저장 장치까지 전송이 완료될 때까지 기다립니다.
간단히 말해, 우리는 데이터를 디스크에 쓰기(write() 함수 호출)만 해서는 안 됩니다.
충돌이나 정전 시 데이터 손실이 없도록 보장하려면 반드시 디스크로 플러시(fsync())해야 합니다.
데이터 쓰기의 일반적인 흐름은 다음과 같습니다:
- 데이터베이스는 메모리 버퍼에 씁니다.
- 데이터베이스는 메모리 버퍼를 디스크의 파일에 씁니다.
- 데이터베이스는 fsync 를 호출해서 디스크의 특정 파일을 플러시합니다.
각 단계는 이전 단계보다 완료하는 데 더 오랜 시간이 걸립니다.
하지만 좋은 소식은 다음 단계를 수행하기 전에 각 단계를 여러 번 수행할 수 있다는 점입니다.
메모리 버퍼를 적절히 크기 조정하면 버퍼를 디스크에 쓰기 전에 여러 번 기록할 수 있습니다.
일부 파일의 크기를 적절히 조정하면 플러시하기 전에 여러개의 버퍼를 기록할 수 있습니다.
물론 이것은 지나치게 단순화된 설명이며, 버퍼에 얼마나 자주 쓰는지, 버퍼를 디스크에 얼마나 자주 쓰는지, 파일을 얼마나 자주 플러시하는지에는 여러 요인이 영향을 미칩니다.
하지만 핵심 개념은 이해하셨으리라 생각합니다.
Another brick in the WAL
이 블로그 포스트의 제목은 torn page 와 두가지 데이터베이스를 언급하고 있습니다.
지금까지 우리는 모든 데이터베이스에 적용되는 일반적인 내용, 아니면 더 나쁘게는 디스크에 데이터를 쓰는 모든 애플리케이션에 적용되는 내용만 이야기했습니다.
이제 여러분께 기다림이 끝났다고 말씀드리며 PostgreSQL 과 MySQL 이 torn page 를 어떻게 처리하는지 설명드릴 수 있으면 좋겠지만, 아쉽게도 다시 한번 실망을 드려야 할 것 같습니다.
이렇게 길게 이어진 서론이 끝난 후에는 모든 것이 이해가 되고, 모든 조각이 제자리를 찾으며, 모든 것이 명확해져서 제가 반복적으로 사용하는 글쓰기 스타일에서 벗어나 새로운 내용으로 넘어갈 수 있기를 바랍니다.
데이터베이스는 일반적으로 데이터를 여러 파일에 저장합니다.
이는 어떤 변경이 발생하면 해당 파일에 대한 버퍼에 먼저 기록한 후, 디스크에 있는 파일로 쓰고 나중에 플러시해야 한다는 뜻입니다.
이런 모든 쓰기와 플러시는 보통 많은 자원을 소모하게 됩니다.
따라서 이상적으로는 각 작업에서 가능한 한 많은 데이터를 쓰고 플러시하는 것이 바람직합니다.
하지만 너무 오래 기다리면 시스템이 충돌해서 데이터가 손실될 수 있습니다.
이것을 피하려면 어떻게 해야 할까요?
방법이 다소 직관적이지 않게 들릴 수 있지만, 데이터 손실을 방지하고 효율성을 높이는 방법은 필요한 것보다 더 많은 데이터를 쓰는 것입니다.
이게 바로 PostgreSQL 의 Write Ahead Log(WAL)이나 MySQL 의 Redo Log 가 하는 일입니다.
변경 사항을 여러 파일에 쓰는 대신, 우리는 그것들을 단일 파일에 씁니다.
이렇게 하면 내구성을 보장할 수 있고, 각 데이터 파일에 더 많은 시간(더 많은 데이터)을 할당하여 쓸 수 있습니다.
경우에 따라서는 데이터가 여러 번 덮어써져서 실제 최종 위치에 데이터를 쓸 필요조차 없어지고, 최종 버전만 쓰면 되는 상황도 생깁니다.
기억하시겠지만, 우리는 page 가 데이터베이스의 기본 저장 단위라고 말했습니다.
그러나 WAL 의 경우 전체 page 대신 변경된 내용만 기록하여 기록되는 데이터의 양을 최적화합니다.
이렇게 하면 각 쓰기 작업에서 더 많은 정보(여러 page 와 관련된 데이터)를 기록할 수 있습니다.
It is a checkpoint, Charlie!
Checkpoint 에 대해 이야기하지 않고는 아메리칸 섹터를 떠나지 않을 것입니다. (이 농담을 이해하기에 너무 어리더라도 걱정하지 마세요.)
WAL 또는 Redo 로그가 쓰기 효율성을 높이는 데 훌륭하다는 것을 우리는 보았습니다.
하지만 그 구조 방식은 읽기에 매우 비효율적입니다.
더 정확히 말하면, 데이터베이스의 특정 테이블에 대한 특정 데이터를 찾는 데 매우 비효율적입니다.
이는 결국 언젠가는 데이터를 최종 목적지에 써야 한다는 것을 의미하며, 그곳에서는 읽기가 훨씬 효율적입니다.
Page 에 최종 목적지에 기록되지 않은 데이터가 포함되어 있을 때, 우리는 이를 dirty page 라고 부릅니다.
그리고 누가 디스크에 dirty page 를 기록할까요?
PostgreSQL 에서는 두 가지 프로세스가 이 작업을 맡습니다: checkpointer 와 background writer 입니다.
MySQL 에서는 여러 개의 스레드가 이 작업을 수행하며, 그 기준은 다음과 같습니다: buffer pool 에서 사용 가능한 page 갯수(dirty page 는 디스크에 먼저 써야 buffer 에서 제거될 수 있음), 기록된 WAL 양, 마지막 checkpoint 이후 경과 시간, 그 외 여러 요소들입니다.
하지만 checkpoint 란 무엇일까요?
PostgreSQL 과 MySQL 모두 같은 이름인 “checkpoint” 를 사용하지만, 두 데이터베이스에서 의미하는 바는 다릅니다.
PostgreSQL 에서 checkpoint 란, 모든 dirty page 가 디스크에 기록되는 시점을 말합니다.
다시 말해, checkpoint 이후에는 데이터베이스 메모리에 더 이상 dirty page 가 남아 있지 않습니다.
반면, MySQL 에서 checkpoint 는 특정 시점을 기준으로 그 시점 이전에 생성된 모든 dirty page 가 디스크에 기록되었음을 의미합니다.
즉, MySQL checkpoint 는 checkpoint 가 발생한 시점 이후에 생성된 dirty page 는 아직 디스크에 쓰이지 않는다는 점에서 PostgreSQL 과 다릅니다.
이 설명이 완벽하진 않다는 것을 저도 알고 있습니다.
아마 위 설명에서 혼란을 느꼈을 수도 있습니다.
그러니 다시 단순하게 정리해 보겠습니다.
PostgreSQL 에서는 checkpoint 후에 dirty page 가 없습니다.
MySQL 에서는 checkpoint 후에도 dirty page 가 여전히 존재합니다.
(참고로, PostgreSQL 이 사용하는 방식은 Sharp Checkpoint 라고 부르고, MySQL이 사용하는 방식은 Fuzzy Checkpoint 라고 합니다.)
checkpoint 의 가장 큰 장점은 checkpoint 발생 시점에 데이터베이스의 일관된 스냅샷을 제공한다는 것입니다.
시스템 충돌이 발생하면 PostgreSQL 과 MySQL 모두 마지막 checkpoint 이후의 WAL(Write Ahead Log) 또는 Redo Log 변경 내용만 적용하면 되며, 이 과정을 recovery 라고 부릅니다.
checkpoint 의 문제점은 많은 dirty page 를 한꺼번에 기록해야 할 경우 I/O 급증을 일으켜 데이터베이스 성능에 영향을 줄 수 있다는 것입니다.
이를 방지하기 위해 checkpoint 사이에 dirty page 를 지속적으로 기록하는 방식이 사용됩니다.
요약하자면, 데이터는 checkpoint 중에 page 형태로 백그라운드 쓰기 프로세스에 의해 기록되거나, WAL 레코드 형태로 WAL 을 기록할 때 기록됩니다.
이러한 쓰기 작업이 진행되는 동안 시스템이 충돌하면 어떻게 될까요?
A torn page is born
맞습니다.
데이터베이스가 쓰기 작업 중에 충돌하면, 디스크에 데이터가 일부만 기록되는 경우가 생기며, 하나 이상의 Torn Page 가 발생할 수 있습니다.
잘 따라오셨습니다.
이제까지 1786단어를 지나, 처음에 이야기했던 지점으로 돌아왔습니다.
Crash(충돌) 중에 page 가 절반만 기록되면, 그게 바로 Torn Page 입니다.
하지만 중요한 건 어떻게 복구할 수 있는가 입니다.
이미 우리는 “page 를 두 번 쓰고, 올바른 것을 사용하면 복구할 수 있다”고 들었습니다.
그렇다면 이제 본격적으로 PostgreSQL 과 MySQL 이 Torn Page 를 어떻게 복구하는지 알아봅시다.
How does PostgreSQL recover from torn pages?
기억하신다면, checkpoint 이후에는 데이터베이스가 일관된 스냅샷을 갖게 됩니다.
즉, checkpoint 이후에는 Torn Page 가 존재하지 않습니다.
Torn Page 는 데이터베이스가 백그라운드 쓰기 도중에 충돌하거나, checkpoint 도중에 시스템이 다운될 경우에만 발생할 수 있습니다.
PostgreSQL 이 Torn Page 를 복구하는 방식은 Full Page Writes(FPW)라는 메커니즘에 기반합니다.
checkpoint 후에 page 가 처음 수정될 때, page 의 전체 복사본이 WAL 에 기록됩니다.
page 가 찢어졌는지 여부와 관계없이 해당 page 의 복사본은 복구 중에 사용될 것입니다.
FPW 이후에는 해당 page 에 대한 모든 수정은 변경된 내용만 WAL 에 기록하면 됩니다.
좋습니다.
그런데 만약 WAL 에 기록된 page 자체가 손상되었다면 어떻게 될까요?
복구 중에 page 의 checksum validation(체크섬 유효성 검사)가 실패할 것이고, 우리는 page 를 수정한 트랜잭션이 정상적으로 커밋되지 않았다는 것을 의미합니다.
따라서 해당 페이지는 복구 중에 무시되거나 폐기됩니다.
PostgreSQL에서는 커밋이 WAL에 성공적으로 기록되었을 때만 유효한 트랜잭션으로 인정하기 때문입니다.
그렇다면 또 다른 질문!
checkpoint 도중에 Torn Page 가 발생하면 어떻게 될까요?
이 경우, checkpoint 가 WAL 파일에 제대로 기록되지 않을 것이고, 우리는 복구를 수행하기 위해 이전 checkpoint 를 사용해야 할 것입니다.
이전 checkpoint 는 데이터베이스의 일관된 스냅샷을 가지고 있으므로, 해당 checkpoint 이후의 WAL 에서 변경 사항을 적용할 수 있으므로 문제가 되지 않습니다.
Concept to remember: PostgreSQL 은 중복 페이지를 WAL 에 기록하며, 이는 checkpoint 이후 page 가 처음 수정될 때 수행됩니다.
How does MySQL (InnoDB) recover from torn pages?
MySQL(InnoDB)가 Torn Page 를 복구하는 방식은 PostgreSQL 과 다르지만, 역시 Redundancy(중복성)에 기반합니다.
InnoDB 는 Doublewrite Buffer 를 사용합니다.
이는 변경된 page 를 최종 위치에 쓰기 전에 디스크의 별도 영역(파일)에 먼저 쓰는 공간입니다.
즉, MySQL 이 어떤 page 를 디스크에 기록해야 할 때, 그 page 를 먼저 Doublewrite Buffer 에 쓰고, 그 다음에 데이터 파일의 최종 위치에 다시 한 번 기록합니다.
복구할때에는 다음과 같은 순서로 동작합니다.
Doublewrite Buffer 와 최종 데이터 파일 위치에 있는 page 의 checksum 을 확인합니다.
둘 다 문제 없고 동일하다면 Torn Page 는 발생하지 않은 것입니다.
Doublewrite Buffer 쪽이 손상되었고, 최종 위치의 page 는 예전 버전이지만 정상이라면 Redo Log 를 통해 복구합니다.
그 외의 경우 Doublewrite Buffer 에 있는 page 로 최종 위치를 복원합니다.
성능 향상을 위해 MySQL은 단일 page 가 아니라 여러 page 를 하나의 블록으로 묶어 Doublewrite Buffer 에 기록하고, 그 후 단 한 번의 fsync 호출로 동기화를 수행합니다.
Remember: MySQL 은 디스크에 page 를 쓸 때마다, Doublewrite Buffer 에 먼저 중복 저장하여 Torn Page 발생 시 복구 가능성을 확보합니다.
Final thoughts
PostgreSQL 은 checkpoint 이후 page 가 수정되면 WAL 파일에 해당 page 의 전체 복사본을 기록합니다.
이로 인해 트랜잭션 처리 시 약간의 오버헤드가 발생할 수 있으며, checkpoint 가 자주 발생하면 full-page write 횟수가 많아져 성능에 영향을 줄 수 있습니다.
따라서 PostgreSQL 에서는 checkpoint 주기를 잘 조정하는 것이 성능에 큰 영향을 줄 수 있습니다.
반면에 MySQL 은 여러 페이지를 묶어 doublewrite buffer 에 한 번에 기록한 뒤, 이를 최종 위치로 다시 기록하는 방식을 사용합니다.
이 방식은 checkpoint 주기의 중요성이 PostgreSQL 보다 낮지만, fuzzy checkpoint 전략과 결합되면 시스템 장애(crash) 발생 시 복구 시간이 길어질 수 있습니다.
이 블로그 글을 읽어주셔서 감사합니다.
흥미롭고 유익한 내용이었기를 바랍니다.
질문이나 의견이 있다면 언제든지 연락주세요.
그리고 PostgreSQL 이나 MySQL DBA 라면 오늘 새로운 것을 하나쯤 배우셨길 바랍니다.
블로그 원문 : https://www.percona.com/blog/a-tale-of-two-databases-how-postgresql-and-mysql-handle-torn-pages/
자유롭게 댓글을 달아주세요! 언제나 환영합니다.
기타 문의: info@neoclova.co.kr
네오클로바 기술블로그 홈 바로가기: https://neoclova.net
네오클로바 홈페이지: http://neoclova.co.kr
