RSS듀오랩스

모바일 앱 백업 설계: 암호화를 걷어내고 이미지 복원을 고친 과정

모바일 개발글: , Duolabs11분 읽기blogexpomobile-backuptechnical-note

영업 자료를 단말 안에 보관하는 앱에 백업 기능을 붙였습니다. 저장 대상은 멘트, 사진, 문서, 링크, 정보 카드였습니다. 처음에는 백업 파일 전체를 비밀번호로 암호화했습니다. 백업이 앱 밖으로 나가니 당연한 선택처럼 보였습니다.

실제 단말에서 써보니 문제가 달랐습니다. 화면은 백업을 암호화하는 중이에요에서 오래 머물렀고, 만들어진 백업을 다시 여는 일도 느렸습니다. 복원을 마치면 멘트는 돌아왔지만 이미지는 보이지 않는 경우도 있었습니다.

저는 결국 새 백업에서 암호화를 제거했습니다. 다만 단순히 보안 기능 하나를 삭제한 것은 아닙니다. 백업 포맷을 나누고, 예전 암호화 백업을 계속 읽게 하고, 이미지 파일을 검증한 뒤 교체하는 구조로 다시 만들었습니다.

백업은 데이터베이스 복사 하나로 끝나지 않았습니다

이 앱의 자료는 크게 두 종류입니다.

종류 예시 저장 방식
메타데이터 제목, 멘트 본문, 파일 이름, 사용 횟수 JSON
바이너리 파일 JPEG, PNG, PDF, 첨부 문서 앱 전용 파일 디렉터리

JSON은 텍스트입니다. 멘트는 그대로 JSON 안에 넣을 수 있습니다. 하지만 JPEG나 PDF는 바이너리 데이터라 JSON 문자열에 바로 넣을 수 없습니다. 그래서 첫 구현에서는 파일을 Base64 문자열로 바꿔 JSON에 담았습니다.

사진·문서 파일
  → Base64 문자열
  → 메타데이터와 함께 JSON 생성
  → .saleskit 파일로 저장

Base64는 바이너리를 텍스트로 표현하는 인코딩입니다. 암호화가 아닙니다. 누구든지 Base64를 다시 디코딩하면 원본을 얻을 수 있습니다.

Base64에는 크기 비용도 있습니다. 원본 3바이트를 문자 4개로 표현하므로 데이터가 대략 33% 커집니다. 사진 30MB를 넣으면 Base64 부분만 단순 계산으로 약 40MB가 됩니다. 여기에 JSON 구조와 다른 자료가 더해집니다.

PBKDF2와 AES-GCM은 각각 무슨 일을 했나

초기 백업은 PBKDF2-SHA256과 AES-256-GCM을 사용했습니다. 이름만 보면 한 덩어리처럼 보이지만 역할이 다릅니다.

사용자가 입력한 비밀번호는 길이도 제각각이고 대개 암호화 키로 바로 쓰기에 적합하지 않습니다. PBKDF2는 비밀번호와 무작위 salt를 여러 번 계산해 길이가 고정된 키를 만듭니다. 첫 구현의 반복 횟수는 310,000회였습니다.

비밀번호 + salt
  → PBKDF2-SHA256 310,000회
  → 256비트 키

그 키를 AES-256-GCM에 전달해 실제 백업 내용을 암호화했습니다. GCM은 내용이 바뀌었는지도 함께 확인할 수 있는 인증 암호화 방식입니다.

JSON 백업
  → AES-256-GCM 암호화
  → 암호문
  → 파일에 담기 위한 Base64 문자열
  → .saleskit 파일

여기서 비용이 겹쳤습니다. 사진과 문서를 한 번 Base64로 바꿨고, 전체 JSON을 메모리에 올렸으며, 암호화 결과도 파일에 넣기 위해 다시 문자열로 만들었습니다. 동시에 PBKDF2 계산도 수행했습니다. 특정 알고리즘 하나만 느린 문제라기보다, 큰 문자열과 바이트 배열을 한꺼번에 만드는 설계가 모바일 단말에 부담을 줬습니다.

비밀번호 반복 횟수를 낮추면 빨라질 수 있습니다. 하지만 그만큼 비밀번호 추측 공격에 버티는 힘도 약해집니다. 약한 암호화를 남겨 놓고 안전하다고 표시하는 방식은 선택하지 않았습니다.

새 백업은 평문 JSON으로 단순화했습니다

현재 앱은 서버에 자동 백업하지 않습니다. 사용자가 직접 백업 파일을 만들고 개인 저장 공간에 보관한 뒤, 새 단말에서 직접 선택하는 흐름입니다. 이 사용 범위를 기준으로 새 백업에서는 애플리케이션 수준 암호화를 제거했습니다.

새 파일의 개념적인 구조는 다음과 같습니다.

type BackupEnvelope = {
  format: 'sales-kit-backup';
  version: 1;
  createdAt: number;
  payload: {
    schema: 1;
    vault: Vault;
    files: Record<string, string>; // 파일명 → Base64
  };
};

백업 과정도 짧아졌습니다.

메타데이터 읽기
  → 사진과 문서를 Base64로 수집
  → JSON 생성
  → .saleskit 파일 저장

.saleskit은 영업킷 백업을 구분하기 위한 확장자일 뿐입니다. 확장자를 바꿔도 암호화되지 않습니다. 그래서 화면에도 백업 파일이 암호화되지 않았고, 다른 사람이나 공개된 저장 공간에 노출하면 안 된다고 명시했습니다.

이 선택은 모든 앱에 적용할 정답이 아닙니다. 서버 업로드, 이메일 전송, 공용 저장 공간 보관이 기본 흐름이라면 암호화를 유지하는 편이 맞습니다. 저는 현재 앱의 사용 범위와 실제 단말 사용성을 기준으로 결정했습니다.

예전 암호화 백업은 포맷으로 구분합니다

새 구조를 적용해도 이미 만든 암호화 백업을 버릴 수는 없습니다. 파일을 선택하면 먼저 format 값을 확인합니다.

sales-kit-backup
  → 암호 없이 검사하고 복원 확인

sales-kit-encrypted-backup
  → 예전 백업 암호 입력
  → PBKDF2와 AES-GCM으로 복호화
  → 복원 확인

새 백업을 만들 때는 암호 입력 창이 나오지 않습니다. 암호 입력은 이전 포맷을 선택했을 때만 나타납니다. 새 쓰기 경로는 단순하게 만들면서 과거 읽기 경로는 남긴 셈입니다.

이런 호환 방식은 파일 포맷을 바꿀 때 유용합니다. 버전 숫자와 포맷 이름을 먼저 확인하면, 앱이 자신이 이해하지 못하는 파일을 억지로 열지 않고 명확한 오류를 보여줄 수 있습니다.

멘트는 돌아왔는데 이미지는 왜 안 보였나

멘트 복원과 이미지 복원은 같은 일이 아닙니다. 멘트는 JSON 문자열만 돌아오면 됩니다. 이미지는 아래 세 조건이 모두 맞아야 합니다.

  1. 이미지 바이트가 파일로 정상 기록돼야 합니다.
  2. 메타데이터의 path가 그 파일을 가리켜야 합니다.
  3. 화면의 이미지 로더가 새 파일을 다시 읽어야 합니다.

기존 방식은 백업 전과 같은 파일명을 복원 후에도 사용했습니다.

복원 전  files/abc123.jpg
복원 후  files/abc123.jpg

파일 내용은 바뀌었는데 URI가 같으면 이미지 캐시가 이전 상태를 계속 들고 있을 가능성이 있습니다. 이것이 유일한 원인이라고 단정할 수는 없었습니다. 더 확실한 문제는 파일을 쓴 뒤 실제 크기를 검증하지 않았다는 점이었습니다. 메타데이터만 저장되면 복원이 성공한 것처럼 보일 수 있었습니다.

그래서 복원할 때마다 새로운 파일명을 만들고, 메타데이터의 경로도 함께 바꾸었습니다.

백업 안의 이름  abc123.jpg
복원한 이름     restored-고유값-0.jpg

URI가 달라지므로 이미지 로더는 새 파일로 인식합니다. 같은 방식으로 PDF와 다른 첨부 파일의 경로도 갱신합니다.

파일을 썼다는 사실만 믿지 않고 크기를 확인했습니다

Base64 문자열만으로 원본 바이트 크기를 계산할 수 있습니다. 끝의 = 문자는 3바이트 단위가 맞지 않을 때 붙는 패딩입니다.

function decodedBase64Size(base64: string) {
  if (!base64.length) return 0;

  const padding = base64.endsWith('==')
    ? 2
    : base64.endsWith('=')
      ? 1
      : 0;

  return (base64.length / 4) * 3 - padding;
}

복원에서는 계산한 크기와 실제 파일 크기를 비교합니다.

file.write(base64, { encoding: 'base64' });

if (!file.exists || file.size !== expectedSize) {
  throw new Error('파일을 정상적으로 복원하지 못했습니다.');
}

이 검사를 통과한 파일만 새 경로에 연결합니다. 파일 쓰기가 실패했는데도 복원했어요라고 보여주는 상황을 막기 위한 최소한의 검증입니다.

복원은 임시 공간에서 끝낸 뒤 한 번에 교체합니다

현재 자료 디렉터리에 파일을 하나씩 바로 덮어쓰면 중간 실패가 위험합니다. 사진 10개 중 6개를 쓴 시점에 오류가 나면 이전 자료와 새 자료가 섞일 수 있습니다.

복원 순서를 다음처럼 바꿨습니다.

1. 캐시의 임시 디렉터리에 모든 파일 기록
2. 각 파일의 존재 여부와 크기 검사
3. 새 파일명에 맞춰 메타데이터 경로 변경
4. 현재 파일 디렉터리를 롤백용 이름으로 이동
5. 검사가 끝난 임시 디렉터리를 실제 위치로 이동
6. 새 메타데이터 저장
7. 모두 성공하면 이전 디렉터리 삭제

5번이나 6번에서 실패하면 롤백용 디렉터리를 원래 위치로 되돌립니다. 데이터베이스에서 말하는 트랜잭션과 완전히 같지는 않지만, 파일 시스템에서도 비슷한 목표를 얻을 수 있습니다. 성공하기 전까지 기존 자료를 버리지 않는 것입니다.

Expo SDK 56의 FileSystem API가 제공하는 File, Directory, move, size를 이 흐름에 사용했습니다.

평문 백업에도 아직 비용은 남아 있습니다

암호화를 제거했다고 백업이 즉시 끝나는 것은 아닙니다. 사진과 문서를 Base64로 읽고 큰 JSON 문자열을 만드는 작업은 남아 있습니다. 현재는 사진과 파일 원본 합계를 100MB로 제한해 메모리 사용이 끝없이 커지는 일을 막았습니다.

자료가 더 커진다면 다음 단계는 암호화 여부가 아니라 백업 컨테이너 자체를 바꾸는 일입니다. 파일을 한꺼번에 JSON에 넣는 대신 아카이브에 메타데이터와 원본 파일을 따로 담고, 스트림이나 FileHandle로 조금씩 읽고 쓰는 방식이 필요합니다. 그러면 전체 백업을 메모리에 동시에 올리지 않아도 됩니다.

이번 수정에서 제가 얻은 기준은 단순합니다. 백업 파일을 만들었다는 표시보다, 실제 파일을 다시 읽을 수 있는지가 먼저입니다. 보안 수준을 바꿀 때는 그 사실을 숨기지 않아야 하고, 포맷을 바꿀 때는 이전 파일을 읽을 경로를 남겨야 합니다. 모바일 백업은 저장 버튼 하나가 아니라 포맷, 파일 시스템, 메모리, 캐시, 실패 복구가 함께 움직이는 기능이었습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.