본문 바로가기
C.W.K.
Lesson 02 of 05 · published

파일이 먼저, 행은 나중

~15 min · 내구성, 콘텐츠 주소, fsync, 원본

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

docstring에 적힌 약속

스토어는 계약서 같은 문장으로 시작해. 파일이 DB 행보다 먼저 간다. 그래서 쓰다가 끊기면 아무도 안 가리키는 원본이 남을 수는 있어도, 커밋된 에셋에 원본이 빠져 있는 일은 절대 없다. 이건 실패 두 개 중에 하나를 고르는 얘기야. 하나는 행 없는 파일이야. 디스크가 조금 아깝고, 어느 화면에도 안 보이고, 해는 없어. 다른 하나는 파일 없는 행이야. 열어도 아무것도 안 나오는 라이브러리 카드, 발행할 수 없는 세트 프레임 같은 거지. 레퍼런스 프로필은 그 이미지를 여전히 목록에 올려 두는데, 바이트를 읽으려는 순간마다 503과 The original image is unavailable.가 돌아와. 원본 이미지를 쓸 수 없다는 뜻이야. Avatars는 해 없는 쪽을 골라. 방법은 일의 순서를 정하는 거고.

이름이 곧 체크섬

save_asset은 먼저 Pillow한테 이 바이트가 이미지이긴 하냐고 물어. verify()를 부르고, 바이트를 한 번 더 열어서 너비, 높이, 포맷을 읽어. Pillow 문서가 verify 다음엔 이미지를 다시 열라고 하거든. 확장자는 Pillow가 알아낸 포맷에서 나와. 올린 파일 이름은 안 봐. PNG, JPEG, WebP, GIF, HEIC만 받고, 나머지는 Use PNG, JPEG, WebP, GIF or HEIC.로 거절해. 이 다섯 가지 중에 하나로 올리라는 말이야.

이어서 바이트를 해시하고, 그 SHA-256이 파일 이름이 돼. <sha256>.png, <sha256>.heic 이런 식이야. 클립도 같은 규칙으로 <sha256>.mp4가 되고. 이름이 곧 내용이야. 같은 바이트를 두 번 올리면 같은 파일에 닿고, 어떤 파일이든 언제든 자기 이름과 맞춰 볼 수 있어.

한 번만 쓰고, 다시 쓸 땐 검사해

write_original의 docstring은 한 줄이야. 콘텐츠 주소로 이름 짓고 딱 한 번 쓴다, 이미 있는 파일은 여전히 맞아야 한다. 갈래는 둘인데, 어느 쪽도 이미 있는 원본을 쓰기용으로 열지 않아.

  • 파일이 이미 있으면. 읽어서 해시하고 이름과 비교해. 맞으면 바이트는 이미 안전하니까 아무것도 안 써. 틀리면 디스크가 이름이 약속한 걸 더는 안 들고 있다는 뜻이야. 스토어는 Stored original failed its checksum.을 503으로 내고 거절해. 저장된 원본이 체크섬 검사에 떨어졌다는 말이야. 덮어써서 고치지도 않아. 난 이게 좋아. 조용히 덮어쓰면 디스크가 뭘 잃었다는 사실까지 묻히거든.
  • 새 파일이면. 옆에 .<uuid>.tmp라는 임시 파일을 "xb" 모드로 열어. 이 모드는 같은 경로에 뭐가 있으면 자르지 않고 그냥 실패해. 쓰고, 파이썬 버퍼를 비우고, os.fsync로 운영체제 캐시에서 바이트를 밀어내. 마지막으로 os.replace로 임시 파일을 진짜 이름 자리에 옮겨. macOS나 Linux에선 이 이름 바꾸기가 원자적이야. 읽는 쪽은 파일이 없거나 통째로 있거나, 둘 중 하나만 봐.

틈마다 짚어 보는 순서

save_asset은 write_original이 돌아온 다음에야 add를 불러서 에셋 행을 넣어. 틈마다 엔진이 죽는다고 쳐 봐.

엔진이 죽는 때남는 것그 뒤
임시 파일을 쓰는 중숨은 .tmp 파일아무 일 없어. 가리키는 행이 없으니까
이름은 바꿨는데 삽입 커밋 전행 없는 온전한 원본눈에 띄는 건 없어. 같은 바이트를 다시 올리면 그 파일을 찾고, 해시를 확인하고, 그대로 써
삽입 커밋 뒤원본과 그 행라이브러리에 보여

이 표 어디에도 허공을 가리키는 행으로 끝나는 줄은 없어. 설계는 이게 전부고, 비용은 시스템 콜 두 번이야.

원칙: 저장소 두 개가 서로 맞아야 하면, 가리켜지는 쪽을 가리키는 쪽보다 먼저 써. 그러면 중간에 죽어도 쓰레기가 남지, 허공을 가리키는 포인터는 안 남아.

원본엔 다시는 손대지 않아

이미지를 바꾸는 것처럼 보이는 일은 전부 다른 곳에 새 파일을 만드는 일이야.

  • 업로드 라우트는 최대 100 MB까지 읽어. 거기 달린 주석이 이게 뭔지 정확히 짚어. 전송 크기 한도일 뿐 크기 줄이기가 아니고, 원본은 올린 바이트 그대로 남는다.
  • 폰에서 온 HEIC는 디스크에서도 HEIC야. 브라우저랑 생성기는 browser_image가 만든 무손실 PNG 파생본을 받아. 이 함수의 docstring은 보관된 바이트는 절대 안 바뀐다는 약속으로 끝나. 테스트도 파생본을 만들기 전과 후에 저장된 파일이 올린 것과 똑같은지 확인해.
  • 썸네일은 thumbnails/의 WebP 파일이야. 원본 해시로 이름 짓고, 처음 요청될 때 그려.
  • 클립의 포스터 프레임도 그 옆에 있어. 클립 docstring은 이걸 다시 만들 수 있는 파생본이라고 불러.

콘텐츠 주소에 규칙이 하나 더 얹혀 있어. 올린 바이트가 스토어가 이미 아는 에셋과 같으면, 새 에셋은 옛 에셋의 등장 대상을 전부 물려받고 그 에셋을 레퍼런스로 달아. 같은 파일을 다른 이름으로 올려도 누구 얼굴인지는 못 떼어 내. 그 혈통을 어떻게 따라가는지, 그게 왜 소울을 지키는지는 트랙 8에서 다뤄.

왜 이렇게까지 엄격하냐고? 생성된 이미지의 첫 사본은 Ember가 들고 있어. Ember 이력을 정리하다가 아빠가 고른 아바타까지 같이 쓸려 가지 않게, Avatars는 오래 가는 두 번째 사본을 따로 둬. 그 사본은 바이트가 진짜일 때만 값어치가 있어. 한 번만 쓰고 다시 쓸 때마다 검사해야 그 값어치가 지켜져.

Code

cwkAvatars, backend/store.py: save_asset, 검증하고 해시하고 파일을 쓴 다음에 행을 넣어 (원문 그대로, 153-172행)·python
    def save_asset(self, data: bytes, *, label: str, subjects: list[str], origin: str, references: list[str] | None = None, **metadata) -> dict:
        try:
            with Image.open(BytesIO(data)) as im:
                im.verify()
            with Image.open(BytesIO(data)) as im:
                width, height, format = im.width, im.height, im.format
        except (OSError, UnidentifiedImageError, Image.DecompressionBombError) as exc:
            raise StudioError("The original is not a supported image.") from exc
        ext = {"PNG": "png", "JPEG": "jpg", "WEBP": "webp", "GIF": "gif", "HEIF": "heic"}.get(format)
        if not ext:
            raise StudioError("Use PNG, JPEG, WebP, GIF or HEIC.")
        digest = hashlib.sha256(data).hexdigest()
        # Reimports retain every known subject; an upload cannot launder a
        # protected avatar by stripping its filename or embedded metadata.
        known = [a for a in self.list("asset") if a["sha256"] == digest]
        subjects = sorted(set(subjects).union(*(set(a["subjects"]) for a in known)))
        refs = list(dict.fromkeys([*(references or []), *(a["id"] for a in known)]))
        filename = f"{digest}.{ext}"
        self.write_original(filename, data, digest)
        return self.add("asset", label=label[:200], subjects=subjects, origin=origin, references=refs, filename=filename, sha256=digest, width=width, height=height, mime=Image.MIME[format], bytes=len(data), archived=False, **metadata)
cwkAvatars, backend/store.py: write_original, 한 번만 쓰고 다시 쓸 땐 검사해 (원문 그대로, 174-187행)·python
    def write_original(self, filename: str, data: bytes, digest: str) -> Path:
        """Content-addressed and written once; an existing file must still match."""
        path = self.root / "originals" / filename
        if path.exists():
            if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
                raise StudioError("Stored original failed its checksum.", 503)
        else:
            temporary = path.with_name(f".{identity()}.tmp")
            with temporary.open("xb") as handle:
                handle.write(data)
                handle.flush()
                os.fsync(handle.fileno())
            os.replace(temporary, path)
        return path

External links

Exercise

네 앱에 save_original(root, data)를 직접 짜 봐. 파일 이름은 SHA-256으로 지어. 대상이 이미 있으면 해시해서 안 맞을 때 거절해. 없으면 배타적으로 연 임시 파일에 쓰고, fsync하고, 제자리로 이름을 바꾸고, 디렉터리도 fsync해. 그런 다음 데이터베이스 행을 넣어. 이름 바꾸기와 삽입 사이에서 예외를 던지는 테스트를 써. 파일은 있고, 행은 없고, 같은 바이트로 다시 부르면 깔끔하게 끝나는지 확인해.
Hint
macOS나 Linux에서 디렉터리를 fsync하려면 os.open(path, os.O_RDONLY)로 열고 그 디스크립터에 os.fsync를 부르면 돼. 크래시 테스트는 삽입 함수를 몽키패치해서 예외를 던지게 해. 증명하려는 건 쓰기 순서지, 정전 흉내가 아니야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.