오래된 감사 로그가 데이터베이스를 가득 채우고 있다.
가장 먼저 떠오르는 해결책은 간단하다.
오래된 로그를 압축한다.
→ 다른 저장소로 옮긴다.
→ 원본을 삭제한다.
깔끔해 보인다.
그런데 몇 달 뒤 감사 담당자가 묻는다.
“지난해 3월 14일, 특정 사용자가 운영 데이터에 접근한 기록을 보여 주세요.”
우리는 자신 있게 “보관해 뒀습니다”라고 답한다. 그리고 거대한 Object Storage 앞에서 멈춘다.
파일은 있다. 이름은 part-000173.jsonl.gz다.
이 파일이 맞는지, 어떤 권한으로 열어야 하는지, 안에 원하는 기록이 있는지, 누가 다운로드했는지는 모른다.
이사는 끝났는데 새집 주소를 아무도 적지 않은 셈이다.
아카이빙은 삭제 기능이 아니다
처음에는 Archiver를 “안전한 삭제 도구”로 생각하기 쉽다.
하지만 감사 로그는 일반 캐시나 임시 파일과 다르다. 나중에 사건을 설명하기 위해 남겨 둔 기록이다. 다시 찾지 못한다면 압축에 성공했어도 보관에는 실패한 것이다.
그래서 Archiver의 진짜 흐름은 조금 더 길다.
보관 대상을 찾는다.
→ 안전하게 옮긴다.
→ 옮긴 내용이 맞는지 검증한다.
→ 다시 찾을 수 있는지 시험한다.
→ 볼 수 있는 사람만 볼 수 있는지 확인한다.
→ 그다음 원본을 지운다.
이 순서를 설계하다 보면 재미있는 사실을 발견한다.
좋은 Archiver에는 서로 다른 두 팀이 필요하다.
하나는 이삿짐센터다. 데이터를 빠르고 안전하게 옮긴다.
다른 하나는 기록관리실이다. 어디에 무엇이 있고 누가 열람할 수 있는지 관리한다.
한쪽만 잘해서는 안 된다.
이삿짐센터와 기록관리실은 보는 장면이 다르다
이삿짐센터의 관심사는 이렇다.
- 운반 도중 프로세스가 죽으면 어디서 다시 시작할까?
- 같은 상자를 두 번 보내지 않으려면 어떻게 할까?
- 상자가 창고에 온전히 도착했다는 증거는 무엇일까?
- 일부만 삭제된 상태에서 자동 재시도해도 안전할까?
- 대량 압축 작업이 사용자 API를 느리게 만들지는 않을까?
기록관리실의 관심사는 다르다.
- 어떤 보존 정책에 따라 옮겼는가?
- 어느 기간의 어떤 로그가 어느 파일에 있는가?
- 사용자는 파일명이 아니라 날짜와 사용자 조건으로 찾을 수 있는가?
- 기존 권한과 데이터 범위를 그대로 적용할 수 있는가?
- 검색, 상세 열람, 다운로드도 감사 기록으로 남는가?
두 팀이 회의하면 자주 이런 대화가 나온다.
이삿짐센터: “모든 박스를 안전하게 옮겼습니다.”
기록관리실: “그래서 2025년 1월 7일 기록은 몇 번 박스에 있죠?”
이삿짐센터: “그건… 박스를 열어 보면 알 수 있습니다.”
이 순간 Archiver가 단순 배치 프로그램에서 제품으로 진화한다.
스케줄러는 알람시계고, Worker는 작업자다
오래된 로그는 보통 보존 기간에 따라 주기적으로 옮긴다. 그래서 스케줄러를 사용한다.
문제는 스케줄러 안에서 대량 작업까지 전부 실행할 때 생긴다.
스케줄 시작
→ 대량 DB 조회
→ 압축
→ Object Storage 업로드
→ 다시 읽어 검증
→ 대량 DELETE
이 작업은 오래 걸리고, CPU와 메모리를 쓰며, DB connection을 오래 잡는다. 사용자 요청을 처리하는 API 프로세스와 같은 공간에서 실행하면 이삿짐을 나르느라 관리사무소 창구가 멈출 수 있다.
더 나은 역할 분리는 이렇다.
Control Plane
- 보존 기간 계산
- Job 생성
- 권한과 승인
- 상태와 감사 기록
Archiver Worker
- DB를 순차적으로 읽기
- 압축하고 저장하기
- 다시 읽어 검증하기
- 실패 시 이어서 하기
- 승인된 범위 삭제하기
스케줄러는 알람을 울리고 작업표를 만든다. 실제 이삿짐은 별도 Worker가 나른다.
이 구조는 컴포넌트를 늘리기 위한 멋 부리기가 아니다.
- 압축 때문에 API의 GC가 길어지는 일을 막는다.
- Archive용 DB connection pool을 별도로 제한할 수 있다.
- Worker만 멈추거나 재시작할 수 있다.
- 사용자 서비스는 계속 제공하면서 작업 속도를 낮출 수 있다.
단, 프로세스를 분리했다고 DB 부하까지 사라지는 것은 아니다. 이삿짐센터가 별도 회사여도 같은 건물 엘리베이터를 막으면 주민은 불편하다.
그래서 Worker에는 처리율 제한, 작은 chunk, query timeout, lock 감시, pause와 cancel이 함께 필요하다.
로비에 짐을 전부 쌓지 마라
대량 로그를 옮길 때 가장 위험한 구현은 의외로 단순하다.
DB에서 한 chunk를 전부 읽는다.
→ 메모리 List에 담는다.
→ 한꺼번에 압축한다.
개발 데이터에서는 잘 된다. 운영 데이터에서는 row 하나가 커지는 순간 메모리 사용량이 튄다.
검증할 때 파일 전체를 byte[]로 읽는 구현도 같은 문제가 있다. Archive는 성공했는데 검증 과정에서 Out Of Memory가 날 수 있다.
안전한 이사는 컨베이어 벨트처럼 움직인다.
DB ResultSet 한 줄
→ JSON 한 줄
→ gzip stream
→ storage stream
검증도 streaming으로 한다.
storage input stream
→ checksum 계산
→ gzip decode
→ 줄 수 확인
상자 백 개를 로비에 쌓아 둔 뒤 트럭에 싣는 게 아니라, 상자가 나오는 대로 하나씩 트럭에 싣는 방식이다.
이렇게 하면 전체 데이터가 커져도 메모리 사용량은 제한된 buffer 안에서 유지된다.
Manifest는 인수증이면서 주소록이다
Archive 파일 옆에는 Manifest가 필요하다.
Manifest를 단순 checksum 메모로만 보면 반쪽짜리다.
첫 번째 역할은 인수증이다.
어느 범위를 옮겼는가?
몇 건인가?
파일 크기는 얼마인가?
SHA-256은 무엇인가?
어느 저장소 key에 있는가?
이 정보는 “원본을 지워도 되는가?”라는 질문에 답한다.
두 번째 역할은 주소록이다.
어떤 종류의 로그인가?
어느 기간인가?
어떤 사용자와 대상이 포함될 수 있는가?
어떤 schema와 format으로 기록됐는가?
이 정보는 “원하는 로그를 어디서 찾을까?”라는 질문에 답한다.
둘을 운영상 분리하면 구조가 더 선명해진다.
Job / Chunk 상태
= 운송 중인 작업표
Sidecar Manifest
= 파일 옆에 붙인 변조하기 어려운 인수증
Manifest Catalog
= 검색을 위한 주소록
Coarse Index
= 열어 볼 후보 파일을 줄이는 색인
예를 들어 사용자가 7일 범위의 특정 사용자 기록을 검색한다고 하자.
나쁜 방법은 저장소의 모든 gzip 파일을 하나씩 여는 것이다.
좋은 방법은 Manifest Catalog에서 기간과 로그 종류, 사용자 범위가 겹치는 파일만 고른 뒤 필요한 파일만 stream으로 읽는 것이다.
처음부터 거대한 검색 엔진을 도입할 필요는 없다. 파일 단위의 거친 색인부터 시작해도 충분하다.
핵심은 “모든 파일을 열지 않고 후보를 줄일 수 있는가?”다.
삭제 버튼보다 Viewer가 먼저다
Archiver 설계에서 가장 중요한 순서는 이것이다.
Purge보다 Viewer를 먼저 검증한다.
파일을 쓰고 checksum을 확인했다고 바로 원본을 삭제하면 안 된다.
다음 장면을 생각해 보자.
1. 지난해 로그를 성공적으로 압축했다.
2. checksum도 맞았다.
3. 원본 DB를 삭제했다.
4. 감사 요청이 들어왔다.
5. 파일은 있지만 기존 권한에 맞춰 검색할 방법이 없다.
기술적으로는 Archive 성공이다. 제품으로는 실패다.
원본 삭제 전에 최소한 다음 리허설을 해야 한다.
Archive Gate
- 저장소에서 파일을 다시 열 수 있다.
- checksum과 row count가 맞다.
- Manifest Catalog에서 찾을 수 있다.
- Viewer가 실제 데이터를 읽는다.
- 기존 권한과 scope가 적용된다.
Purge Gate
- Archive Gate를 통과했다.
- 유예 기간이 지났다.
- 삭제 기능이 명시적으로 활성화됐다.
- 승인과 실행 증적이 있다.
이건 백업 시스템의 restore drill과 같다.
백업 성공 알림은 “파일을 썼다”는 뜻이다. 복구 연습은 “필요할 때 되찾을 수 있다”는 증거다.
감사 로그도 똑같다.
“누가 지웠나”와 “누가 봤나”는 다른 감사다
Archive 기능에는 두 종류의 민감한 행동이 있다.
첫 번째는 원본 삭제다.
누가 요청했는가?
누가 승인했는가?
어떤 Manifest를 근거로 했는가?
몇 건을 삭제했는가?
결과는 성공인가, 실패인가?
두 번째는 과거 기록 열람이다.
누가 검색했는가?
어느 기간을 봤는가?
어떤 필터를 사용했는가?
상세 payload를 열었는가?
다운로드하거나 Export했는가?
둘은 같은 “Archive Audit”이라는 이름 아래 있지만 보안 질문이 다르다.
그래서 로그도 분리하는 편이 좋다.
Operation Audit
= 보관·검증·삭제 작업에 대한 기록
Access Audit
= 검색·상세 열람·다운로드에 대한 기록
여기서 중요한 원칙이 하나 더 있다.
승인은 기존 권한을 뚫는 만능열쇠가 아니다.
먼저 License, Role, 데이터 범위를 확인해야 한다. 그 범위 안에서 민감한 상세 열람이나 다운로드에 추가 인증과 승인을 붙인다.
기존 권한 확인
→ 데이터 범위 확인
→ 필요하면 재인증
→ 민감 행위 승인
→ Archive 파일 읽기
→ 접근 Audit 기록
Archive 저장소가 생겼다고 기존 접근통제가 사라져서는 안 된다.
실행 중인 로그는 아직 포장하면 안 된다
모든 오래된 row가 Archive 대상인 것도 아니다.
예를 들어 아직 실행 중인 작업의 로그는 계속 갱신될 수 있다. 날짜가 오래됐다는 이유로 옮기면 미완성 기록을 장기 보관본으로 확정하게 된다.
이삿짐 비유로 하면 아직 사람이 살고 있는 방의 가구를 운반하는 셈이다.
완료된 로그
→ Archive 대상
실행 중이거나 미완성인 로그
→ Archive 제외
→ 별도의 stale cleanup 정책
또 하나의 로그가 여러 테이블에 나뉘어 있다면 한 테이블만 옮겨서는 안 된다.
본문, 상세 payload, 무결성 hash가 함께 하나의 Dataset을 이룰 수 있다. Archive와 Purge의 단위는 테이블이 아니라 제품이 이해하는 논리적 기록이어야 한다.
Exactly-once보다 정직한 복구가 중요하다
Archive 파일은 Object Storage에 쓰고, 원본은 DB에서 삭제하며, 작업 상태는 또 다른 DB에 저장할 수 있다.
이 세 저장소를 하나의 transaction으로 묶기는 어렵다.
그렇다면 시스템은 애매한 순간을 만나게 된다.
DELETE는 commit됐다.
→ 완료 상태를 저장하기 전에 Worker가 죽었다.
재시작한 Worker가 아무 생각 없이 DELETE를 반복하면 위험하다.
그래서 “반드시 한 번만 실행된다”고 약속하기보다, 다시 확인하고 판정할 수 있게 만들어야 한다.
원본이 모두 남아 있다
→ 같은 chunk 재시도 가능
원본이 모두 없다
→ 이전 삭제 성공 여부를 확인하고 완료 처리
일부만 남아 있다
→ 자동 재삭제 금지
→ 수동 검토 상태로 전환
좋은 시스템은 모든 실패를 자동으로 숨기지 않는다.
자동 판단이 위험한 순간에는 정확하게 멈추고 사람에게 바통을 넘긴다.
현실적인 구현 순서
처음부터 모든 제품의 모든 감사 로그와 고급 검색을 지원하려 하면 Archiver가 너무 커진다.
기능 범위는 작게 시작하되, 안전성은 나중으로 미루지 않는 편이 좋다.
1단계: 대상과 규모를 알아낸다
- 보존 기간과 cutoff를 계산한다.
- 논리적 Dataset을 정의한다.
- 대상 건수와 예상 크기를 보여 준다.
- 아직 원본은 건드리지 않는다.
2단계: 옮기되 지우지 않는다
- streaming으로 읽고 쓴다.
- 파일을 다시 열어 검증한다.
- Sidecar Manifest와 Catalog를 만든다.
- Worker를 강제로 종료해도 이어서 할 수 있는지 시험한다.
3단계: 다시 찾아본다
- 기간과 로그 종류로 후보 파일을 찾는다.
- 기존 권한과 범위를 적용한다.
- 검색, 상세 열람, 다운로드를 기록한다.
- 조회 비용에 기간, 결과 수, scan bytes, timeout 제한을 둔다.
4단계: 그제야 지운다
- 유예 기간을 둔다.
- 기능을 명시적으로 활성화한다.
- 요청자, 승인자, 실행자를 기록한다.
- 작은 transaction으로 삭제한다.
- 일부 삭제 상태는 자동으로 덮지 않는다.
5단계: 사용량을 보고 확장한다
- 민감 상세 조회에 재인증과 승인을 붙인다.
- 긴 기간 요청은 비동기 Export로 전환한다.
- 실제 요구가 있는 로그 종류부터 추가한다.
- 검색량이 커질 때만 더 세밀한 index를 검토한다.
이 순서의 장점은 매 단계에서 되돌릴 수 있다는 것이다.
특히 삭제 기능은 가장 늦게 켠다.
좋은 Archiver를 판단하는 다섯 가지 질문
설계를 리뷰할 때 아래 질문이면 충분하다.
- Archive 작업이 사용자 API의 메모리와 DB pool을 압박하지 않는가?
- Worker가 중간에 죽어도 중복과 손실 없이 이어갈 수 있는가?
- 파일을 전부 열지 않고 필요한 후보를 찾을 수 있는가?
- 원본 삭제 전에 실제 조회와 권한 검증을 수행했는가?
- 누가 지웠는지뿐 아니라 누가 봤는지도 설명할 수 있는가?
다섯 질문 중 하나라도 답이 흐리다면, 아직 삭제 버튼을 활성화할 때가 아니다.
마지막으로 기억할 한 문장
좋은 Archiver는 오래된 데이터를 빨리 지우는 프로그램이 아니다.
잘 옮겼고, 다시 찾을 수 있고, 볼 수 있는 사람만 볼 수 있고, 무슨 일이 있었는지 설명할 수 있을 때만 원본을 지우는 시스템이다.
이삿짐을 트럭에 실었다고 이사가 끝난 게 아니다.
새집 주소가 있고, 열쇠가 있고, 박스 목록이 있고, 필요한 물건을 다시 꺼내는 연습까지 끝났을 때 비로소 옛집 열쇠를 반납할 수 있다.