버퍼 — 속도가 다른 두 쪽을 잇는 방법
왜 필요한가
버퍼는 계층마다 이름을 달리해 나타난다. 자바에서는 BufferedOutputStream, DBMS에서는 버퍼 풀, 커널에서는 소켓 버퍼다. 이름이 다르니 따로 배우게 되는데, 해결하는 문제는 같다.
두 쪽 사이를 한 번 오갈 때마다 고정비용이 붙는다. 사용자 모드에서 커널 모드로 전환했다 돌아오는 비용, 디스크가 자리를 잡는 시간, 상대 호스트의 응답을 기다리는 왕복이다. 이 비용은 한 번에 1바이트를 옮기든 8KB를 옮기든 거의 같다. 그래서 잘게 나눠 보내면 옮긴 양은 그대로인데 고정비용만 횟수만큼 곱해진다. 1바이트씩 100만 번 쓰는 코드가 4초 걸리고, 같은 100만 바이트를 8192바이트씩 모아 보내면 18ms로 끝나는 차이가 여기서 나온다(측정).
버퍼는 그 사이에 메모리를 한 겹 두고, 양이 찰 때까지 모았다가 한 번에 넘겨 이 횟수를 줄인다. 읽는 쪽도 같다. DBMS는 저장소를 고정 크기 블록으로 다루므로 한 행만 필요한 쿼리도 그 행이 든 블록을 통째로 읽는데(PostgreSQL 기본 8KB, InnoDB 16KB), 읽어 온 블록을 버퍼 풀에 남겨 두면 다음 요청은 왕복 없이 끝난다. 속도가 어긋날 때도 같은 층을 쓴다. 보내는 쪽이 받는 쪽보다 빠르면 그 차이를 버퍼가 받아 두고, 버퍼에 자리가 있는 동안 보내는 쪽은 멈추지 않는다.
핵심 정리
| 층위 | 무엇과 무엇 사이인가 | 무엇을 줄이는가 | 크기를 정하는 것 |
|---|---|---|---|
| 자바 I/O 스트림 | 애플리케이션 ↔ 커널 | 시스템 호출 횟수 | BufferedOutputStream의 생성자 인자 |
| DB 버퍼 풀 | DBMS ↔ 하위 저장 계층 | 블록 재요청 | shared_buffers |
ByteBuffer |
애플리케이션 ↔ 채널 | 채널 읽기·쓰기 호출 횟수 | allocate, allocateDirect의 인자 |
| 소켓 버퍼 | 송신 호스트 ↔ 수신 호스트 | 상대를 기다리며 멈추는 시간 | SO_SNDBUF, SO_RCVBUF |
예시
층위와 무관하게 버퍼 하나가 지나는 상태는 같다.
비어 있음 --채운다--> 일부 채워짐 --채운다--> 가득 참
|
내보낸다
v
비어 있음 <--------------------------------- 비워짐
가득 차야만 나가는 것은 아니다. 갈리는 것은 “내보낸다”를 누가 언제 부르느냐다. 자바 I/O는 버퍼가 차거나 flush를 부를 때 내보낸다. 소켓 버퍼는 상대가 알린 여유와 혼잡 윈도우가 허락하는 만큼 내보낸다. DB 버퍼 풀은 자리가 부족해 블록을 밀어낼 때 비운다. 체크포인트는 수정된 블록을 디스크에 쓸 뿐 버퍼를 비우지 않는다. 트리거가 다를 뿐 구조는 하나다.
항목별 설명
네 편 중 자바 I/O 버퍼를 먼저 읽으면 나머지가 쉬워진다. 셋은 순서가 상관없다.
자바 I/O 버퍼 — 1바이트씩 100만 번 쓰는 코드에서 하위 스트림에 도달하는 호출을 세어 보면 버퍼가 무엇을 줄이는지 그대로 드러난다. 8192바이트 버퍼는 그 호출을 123번으로 줄인다. flush를 부르지 않아 마지막 조각을 잃는 실수도 여기서 나온다.
DB 버퍼 풀 — 디스크 블록을 메모리에 붙들어 두는 층이다. EXPLAIN (ANALYZE, BUFFERS)의 shared hit과 read가 이 층의 성적표다. 큰 테이블을 반복해서 읽어도 캐시에 남지 않는 경우가 있는데, 버퍼 풀이 부족해서가 아니라 큰 스캔을 일부러 격리하기 때문이다.
ByteBuffer — 하나의 버퍼로 읽기와 쓰기를 겸하려다 보니 position과 limit을 밖으로 드러냈고, 그래서 flip이라는 전환 동작이 생겼다. 힙 안과 밖 중 어디에 잡느냐도 여기서 갈린다.
소켓 버퍼 — 송신 측과 수신 측에 각각 하나씩 있다. 수신 앱이 한 바이트도 읽지 않아도 송신 측은 두 버퍼가 다 찰 때까지 성공적으로 써넣는다. write가 리턴했다는 사실이 전달을 뜻하지 않는 이유다.
혼동하기 쉬운 것
버퍼와 캐시는 다르다. 버퍼는 속도나 단위를 맞추려고 모으고 내보내면 비우는 반면, 캐시는 다시 쓰일 것을 기대해 남긴다. 비어 있는 버퍼는 정상이지만 비어 있는 캐시는 히트율이 0인 상태다. 둘을 가르는 기준은 자바 I/O 버퍼에 표로 정리했다.
버퍼는 공짜가 아니다. 세 가지를 대가로 낸다.
| 대가 | 어떻게 드러나는가 |
|---|---|
| 지연 | 데이터가 버퍼에 머무는 동안 상대에게 도달하지 않는다. 로그가 뭉텅이로 늦게 찍힌다 |
| 유실 위험 | 내보내기 전에 프로세스가 죽으면 버퍼 안의 데이터는 사라진다 |
| 메모리 | 연결마다, 스트림마다 잡힌다. 크기 × 개수가 곧 비용이다 |
크기를 키워 얻는 이득은 빠르게 줄어드는데, 메모리는 크기에 비례해 계속 는다. 기본값을 바꾸려면 측정한 근거가 있어야 한다.
버퍼가 있다는 사실이 아니라 얼마나 모으는지가 성능을 정한다. 크기 1인 버퍼는 버퍼가 없는 것과 같다. 1바이트마다 곧바로 내보내기 때문이고, 자바 I/O 버퍼의 측정에서 둘의 시간 차이는 4019ms 대 4060ms로 1% 남짓이었다.