Skip to content

암호화 앱이 느려지는 이유와 줄이는 법 — 열 때마다 도는 키 유도(PBKDF2)와 매번 전체를 훑는 목록 ​

비공개 앱을 하나 만들어 쓰고 있다. 저장되는 기록은 전부 비밀번호로 잠근다. 서버도, 저장소에 올라간 파일도 내용을 못 본다.

대가가 있다. 열 때 한 박자 쉰다(코드 주석에는 폰에서 1~2초). 원래 그런 거겠거니 했는데, 최적화 후보를 짚어 보니 그중 일부는 안 해도 되는 일이었다.

확인이 끝난 건 두 가지다. 열쇠를 여러 번 만들고 있었고, 목록을 만들 때마다 저장소 전체를 읽고 있었다. 세 번째 후보(앱 시작·화면 그리기)는 아직 확인 중이라 뺐다.

먼저 낱말 정하기 ​

글 끝까지 아래 뜻으로만 쓴다.

  • 열쇠: 파일을 실제로 잠그고 여는 값. 이 앱에서는 AES-GCM 256비트 열쇠다. 비밀번호 그 자체가 아니다.
  • 키 유도: 비밀번호에서 열쇠를 만들어 내는 일. "열쇠를 만든다"는 전부 이 뜻이다.
  • 해시: 데이터를 넣으면 정해진 길이의 값이 나오는, 되돌릴 수 없는 계산.
  • PBKDF2: 키 유도 방법 하나. 비밀번호와 salt를 섞어 해시를 일부러 아주 많이 반복한다. 이 앱은 SHA-256으로 25만 번.
  • salt: 키 유도에 비밀번호와 같이 넣는 무작위 값. 비밀이 아니고, 암호 파일 머리에 그대로 적힌다.

왜 일부러 느리게 하나. 열쇠 하나 깎을 때마다 줄질을 25만 번 하는 가게를 떠올리면 된다. 손님은 한 번 기다리면 끝이지만, 비밀번호를 하나씩 넣어 보는 도둑은 시도마다 25만 번을 다시 해야 한다.

세 가지가 자주 헷갈려서 표로 한 번 정리한다.

비밀번호salt열쇠
누가 정하나사람이 외운다프로그램이 무작위로키 유도가 계산
비밀인가비밀아니다비밀
어디 있나사람 머릿속암호 파일 머리메모리
바뀌면열쇠가 바뀐다열쇠가 바뀐다—

마지막 줄이 이 글의 절반이다. 비밀번호가 같아도 salt가 다르면 다른 열쇠가 나온다. salt가 다른 파일이 셋이면 키 유도도 세 번이다.

열 때 무슨 일이 일어나나 ​

기록은 암호 파일 몇 개다. 목차 역할의 색인, 대화 기록인 보관소(아카이브), 그 분석인 해석, 노트. 앱은 색인을 열고 이어서 보관소·해석·노트를 연다.

crypto.js에는 키 유도를 아끼는 캐시(한 번 계산한 결과를 저장해 두었다가 다시 꺼내 쓰는 것)가 이미 있다. 그런데 캐시의 이름표가 salt + 비밀번호라, 파일들의 salt가 같을 때만 효과가 있다.

원인 1: 고친 salt가 다음 날 다시 갈라졌다 ​

처음 겪는 문제가 아니다. 9월 7일에 한 번 고쳤다. 암호화 함수가 salt를 받을 수 있게 하고, 기존 파일의 salt를 색인의 것(이하 기준 salt)으로 맞추는 resalt 명령을 만들었다. 그 커밋의 파일은 실제로 전부 salt가 같다.

그런데 지금 저장소의 파일을 값은 보지 않고 "같은지"만 비교해 보니 이렇다.

색인    기준
보관소  다름
해석    다름 (보관소와도 다름)
노트    같음

커밋을 따라가 보니 바로 다음 날(9월 8일) 기록 갱신부터 다시 갈라져 지금까지 그대로다.

이유는 파일을 쓰는 도구에 있었다. 노트 쪽은 기준 salt를 넘기는데, 보관소(pack.mjs)와 해석(insights.mjs) 쪽은 안 넘긴다.

js
await encryptJSON(pw, out, indexSalt());  // notes.mjs — 기준 salt
await encryptJSON(pw, payload);           // pack.mjs — 인자 없음 → 새 무작위 salt

salt를 안 주면 새 salt를 뽑는다. 옛 파일과 호환하려고 둔 동작이 함정이 됐다. 9월 7일엔 있던 파일만 맞췄고, 새로 쓰는 길 두 곳은 그대로였다.

솔직히 제일 아팠던 대목이다. 고친 버그가 하루 만에 돌아왔는데 아무도 몰랐다. 파일은 잘 열리니까. 느릴 뿐이다.

그래서 열 때 키 유도 횟수는:

  • 웹: 색인 + 보관소 + 해석 = 3번. salt가 같았다면 1번.
  • 안드로이드 앱: 로그인 때 만든 열쇠를 폰에 두었다가 열 때 캐시에 넣어 주는데, 그게 기준 salt의 열쇠뿐이라 보관소·해석에서 2번 돈다. 원래는 0번이다.

왜 그게 느린가 ​

같은 설정(PBKDF2, SHA-256, 25만 번)에 아무 비밀번호와 무작위 salt로 이 맥(Apple M4, Node 25)에서 재 봤다.

한 번            약 22ms (10회, 20~27ms)
salt 셋, 차례로   약 66ms
salt 셋, 동시에   약 25ms

맥에선 별것 아니다. crypto.js 주석엔 폰에서 한 번에 "0.5초 남짓"이라 적혀 있다(이번에 다시 재진 않았다).

셋을 동시에 던지면 거의 하나 분량이었고, 폰에서 여는 시간은 재지 않았다. 그러니 "몇 초 느려진다"고는 말 못 한다. 일부러 느리게 만든 계산을 필요 없이 두 번 더 한다는 데까지가 확실하다.

원인 2: 목록을 부를 때마다 버킷 전체를 훑는다 ​

이 앱엔 쪽지·약속 체크·하루 요약·사진을 암호 파일로 받아 두는 "우편함"이 있다. Cloudflare Worker가 받아 R2(Cloudflare의 파일 저장 서비스)의 버킷(파일을 이름으로 넣고 빼는 큰 저장 통)에 둔다. 앱은 여기서 목록 조회(버킷에 어떤 파일이 있는지 이름·크기를 받아 오는 일)를 한다.

js
do {
  const page = await env.MAIL.list({ cursor, limit: 1000 });
  for (const o of page.objects) { /* 종류·id 를 뽑아 items 에 */ }
  cursor = page.truncated ? page.cursor : null;
} while (cursor);

1000개씩 끝까지 넘겨 버킷 전체를 읽고 통째로 보낸다. 사진 원본·썸네일, 지난 채팅 묶음까지. 받는 쪽이 실제로 열어 보는 건 쪽지와 요약뿐이다.

이게 앱을 열 때, 돌아왔을 때(1분 지났으면), 채팅이 다시 이어졌을 때 불린다. 의외였던 곳도 있다. 서버가 파일 하나를 올릴 때마다 용량 한도를 보려고 같은 함수로 전체 크기를 처음부터 더한다.

지금 당장 느리다고는 못 하겠다. 파일 수도 조회 시간도 재지 않았다. 다만 사진은 한 장에 파일 둘(원본·썸네일)이 생기고 R2가 영구 저장소라 쌓여 간다. 쓸수록 목록은 길어지고, 1000개를 넘을 때마다 R2 왕복도 하나씩 는다.

받는 쪽은 10월 9일 커밋에서 이미 푼 쪽지·요약을 다시 풀지 않게 됐다. 서버가 매번 전체를 훑는 부분은 그대로다.

어떻게 줄이나 ​

아래는 아직 적용하지 않은 방법이고, 효과도 재지 않았다. 구조상 이렇게 하면 줄어든다는 이야기다.

salt: 쓰는 길을 모으고, 어긋나면 알게 한다 ​

당장은 pack.mjs·insights.mjs도 기준 salt를 넘기고 resalt를 한 번 돌리면 된다. 이론상 웹은 열 때 키 유도 1번, 앱은 0번이 된다.

이것만 하면 9월 7일의 반복이다. 도구의 기본값을 "빠뜨려도 기준 salt"로 바꾸고, 배포 전에 암호 파일의 salt가 전부 같은지 보는 검사를 두는 게 좋겠다. salt는 비밀이 아니라 비교 검사는 안전하다.

salt를 같게 해도 안전한가 ​

"salt는 하나하나 달라야 하지 않나?" 무엇마다 달라야 하는지가 중요하다. salt가 막는 건 흔한 비밀번호의 결과를 미리 계산해 두고 여러 사용자에게 한꺼번에 대 보는 공격이고, OWASP 비밀번호 저장 가이드도 salt를 비밀번호마다 고유한 값으로 설명한다.

이 앱은 비밀번호 하나가 모든 파일을 지킨다. 도둑은 색인 파일 하나로 비밀번호를 맞히면 나머지도 다 연다. crypto.js 주석 말대로 파일마다 다른 salt는 "보안을 1비트도 더 주지 않는다". 늘어나는 건 정상 사용자의 수고뿐이다.

그래도 달라지는 점은 적어 둔다.

  • 열쇠 하나가 새면 전부 열린다. 다만 이 앱은 화면이 비밀번호를 이미 쥐고 있어 잃는 방어가 크지 않다고 본다. 의견이 갈릴 수 있다.
  • IV는 계속 파일마다 새로 뽑는다. AES-GCM에서 진짜 위험한 건 같은 열쇠로 같은 IV(한 번 쓰고 버리는 시작값)를 두 번 쓰는 것이다. 지금 코드는 잠글 때마다 새로 뽑고, 이건 그대로 둬야 한다.
  • 인증 토큰의 salt는 합치지 않는다. 서버 인증 토큰은 다른 고정 salt로 따로 만든다. 파일 열쇠를 인증에 돌려 쓰지 않으려는 분리다.

목록: 필요한 것만 묻는다 ​

파일 이름이 note/…, photo/…처럼 종류로 시작하고, R2 목록 조회는 이름 앞부분(prefix)으로 거를 수 있다. 쪽지·요약만 물으면 쌓여 가는 사진 이름을 매번 받지 않는다. 올릴 때의 용량 확인은 합계를 따로 들고 있으면 되는데, 두 기기가 동시에 올리면 어긋날 수 있어 더 생각해 봐야 한다.

배운 점 세 가지 ​

하나. "지금 상태"만 고치면 안 고친 거다. 데이터를 맞추는 일과 다시 틀어지지 않게 막는 일은 다르다.

둘. 느린 버그는 테스트가 못 잡는다. 결과가 맞으니까. 성능에 걸린 약속("모든 파일은 같은 salt")은 약속 자체를 검사로 만들어야 지켜진다.

셋. 캐시는 이름표가 맞아야 캐시다. 열쇠 캐시는 처음부터 있었다. salt가 갈라진 순간 아무 일도 안 했을 뿐이다.

다른 앱에도 던져 볼 질문 ​

  • 같은 계산을 두 번 하고 있지 않은가? 일부러 비싸게 만든 계산(키 유도, 비밀번호 해시)이 열 때 몇 번 도는지, 캐시 이름표가 생각보다 자주 바뀌지 않는지 세어 본다.
  • 목록을 만들 때 전체를 읽고 있지 않은가? 그중 실제로 쓰는 건 몇 할인지, 그 목록이 어디서 불리는지 본다. 이번엔 "파일 하나 올리기" 안에 숨어 있었다.

아직 고민 중인 것도 있다. "바뀐 것만" 받게 하려면 서버가 색인을 따로 들어야 하는데(R2 목록은 이름 순서로만 나온다), 쓰는 사람이 적은 앱에서 그 복잡함을 질 만큼 버킷이 커질지는 더 쌓여 봐야 알 것 같다. 앱 시작·화면 그리기 쪽은 확인이 끝나면 이어서 적겠다.

멸종 위기 개발자