| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- playwright
- github actions
- contextBridge
- typescript
- FastAPI
- sqlite
- JWT
- 인증
- 메일자동화
- 배포
- 온디바이스AI
- 마이그레이션
- 크롤링
- REST API
- 도메인설계
- php
- Vite
- React
- Kotlin
- 소셜로그인
- Android
- 보안
- electron
- 아키텍처
- 프론트엔드
- javascript
- next.js
- python
- 상태관리
- 백엔드
- Today
- Total
HM소프트
데이터 보호 시스템 개발기 (1) — 블록체인 구조를 빌린 DB 암호화와 키 관리 본문

수출 영업 메일 자동화를 FastAPI로 구현한 기록. 사람이 승인해야만 발송되는 Outbox 패턴과 CAS 상태 전이, 멱등키, 4-eyes, DB 부분 유니크 인덱스로 중복 발송을 막고, 온디바이스 LLM과 클라우드 fail-closed, 무음 폴백 금지, 프롬프트 인젝션 방어, CP949·RFC 2047 한글 인코딩 처리까지 다룹니다.
코인 없는 블록체인 — 왜 감사 로그에 해시 체인을 빌렸나
DB에 저장되는 민감 필드를 보호하는 암호화·키 관리 시스템을 Python으로 만들었다. 시작은 단순한 요구였다. "주민번호나 연락처 같은 필드가 평문으로 DB에 박혀 있는데, 이걸 암호화하고, 누가 언제 풀어 봤는지를 믿을 수 있게 남겨 달라."
암호화 자체는 라이브러리를 쓰면 어렵지 않다. cryptography 패키지에 AES도 RSA도 다 들어 있다. 진짜 어려운 건 그 주변의 두 질문이었다.
- 키를 어떻게 보관하고 언제 어떻게 교체하느냐. 암호화는 결국 "데이터를 잠그는 문제"를 "키를 지키는 문제"로 바꾸는 일이다. 키가 새면 암호화는 무의미하고, 키가 영원히 안 바뀌면 그것도 위험하다.
- "누가 언제 무엇을 암호화·복호화했는지"를 어떻게 신뢰할 수 있게 남기느냐. 감사 로그가 일반 테이블이면, DB 관리자 권한을 가진 사람이 로그 자체를 지우거나 고칠 수 있다. 그러면 "누가 봤는지"의 기록을 정작 그 누군가가 조작할 수 있다.
두 번째 질문에 답하려고 감사 로그에 블록체인의 핵심 구조 — 해시 체인 — 를 빌려왔다. 코인도, 분산 네트워크도, 합의 알고리즘도 없다. 그냥 "이전 기록의 해시를 다음 기록이 품는다"는 자료구조 하나만 가져왔다. 이러면 과거 기록 하나를 몰래 고치는 순간 그 뒤 모든 기록의 해시가 어긋나서, 변조 사실이 검증 시점에 반드시 드러난다. 막는 게 아니라 드러나게 하는 것 — 이게 이 글 전체를 관통하는 설계 철학이다.
이번 글에서는 그 시스템을 모듈 단위로 따라간다. 암호화 원시 함수(crypto_utils), 하이브리드 암호화 엔진, 해시 체인 감사 로그, 키 버전 관리와 로테이션, JSON에서 SQLite로의 저장소 전환, 백업, 그리고 JWT 인증까지. 중간중간 솔직하게 "이건 데모 수준이라 운영에선 이렇게 바꿔야 한다"고 적은 부분도 있다.
시스템을 한눈에 — 계층과 책임 분리
먼저 전체 그림부터 보자. 이 시스템은 프레임워크 없이 모듈 함수와 클래스의 얇은 층으로 짜여 있다. 가장 아래에 순수 암호 연산만 하는 crypto_utils가 있고, 그 위에 데이터를 암호화하면서 감사 로그를 남기는 엔진이 있다. 엔진은 다시 키 관리자와 블록체인 관리자에 의존한다.

설계에서 의식적으로 지킨 원칙은 "한 모듈은 한 가지만 책임진다"였다. crypto_utils는 키를 보관하지 않는다 — 받은 키로 암호화/복호화만 한다. KeyRotationManager는 암호화를 모른다 — 키를 만들고 버전을 매기고 꺼내 줄 뿐이다. 블록체인 관리자는 무슨 데이터가 들어오는지 모른다 — 받은 딕셔너리를 해시로 묶어 체인에 붙일 뿐이다. 이 셋을 엮어 "DB 필드를 암호화하면서 감사 로그까지 남기는" 사용 시나리오를 완성하는 게 엔진의 일이다.
이렇게 자르면 테스트가 쉽고, 한 조각을 바꿔도 다른 조각이 안 흔들린다. 실제로 뒤에서 블록체인 저장소를 JSON에서 SQLite로 바꿀 때, 엔진과 키 관리자는 한 줄도 안 고쳤다. 인터페이스만 같으면 속을 통째로 갈아도 위층이 모른다는 게 계층 분리의 본전이다.
가장 아래층 — 순수 암호 함수만 모은 crypto_utils
가장 먼저 짠 건 키도 상태도 갖지 않는 순수 함수 묶음이다. 여기엔 "어떤 키로 무엇을 암호화한다"는 결정이 없다. 그냥 바이트를 받아 바이트를 돌려주는 연산만 있다.

작아 보이지만 여기 박아 둔 결정이 두 개 있다. 첫째, 키는 os.urandom(32)로 만든다. 32바이트, 즉 256비트짜리 진짜 난수다. 파이썬의 일반 random이 아니라 OS의 암호학적 난수원을 쓴다는 게 중요하다. 둘째, IV(초기화 벡터)를 매 암호화마다 새로 만들어 암호문 앞에 붙인다. CFB 같은 스트림 계열 모드에서 같은 키로 같은 IV를 두 번 쓰면 패턴이 새기 때문에, IV는 절대 재사용하면 안 된다. 그래서 호출부가 IV를 따로 관리할 필요 없이 함수 안에서 매번 생성하고, 복호화할 때는 앞 16바이트를 잘라 쓴다. "IV 재사용 사고"를 호출부의 실수에 맡기지 않고 구조로 막은 셈이다.
다만 솔직히 적자면, CFB 모드는 무결성을 보장하지 않는다. 암호문 중간 바이트를 뒤집으면 복호화 결과가 조용히 망가질 뿐 "변조됐다"고 알려 주지 않는다. 운영에 올린다면 AES-GCM 같은 인증 암호(AEAD)로 바꿔서 암호문 자체에 변조 탐지를 붙이는 게 맞다. 이번 시스템에서는 변조 탐지를 데이터 레이어가 아니라 뒤에 나오는 감사 로그(해시 체인) 쪽에 몰아 두었는데, 만들고 나서 보니 데이터 본문에도 AEAD를 한 겹 더 두는 게 정석이었다.
비대칭 쪽은 RSA-2048에 OAEP 패딩을 썼다.

여기서 RSA로 데이터 본문을 직접 암호화하지 않는다는 점이 핵심이다. RSA-2048은 한 번에 200바이트 남짓밖에 못 감싸고, 큰 데이터엔 느리다. 그래서 RSA는 오직 "AES 키를 감싸는 용도"로만 쓴다. 패딩은 옛날 PKCS#1 v1.5가 아니라 OAEP를 골랐다. v1.5는 패딩 오라클 공격에 취약한 역사가 있어서, 새로 짜는 코드라면 OAEP가 기본값이어야 한다고 봤다.
하이브리드 암호화 — 대칭과 비대칭의 분업
이제 위 함수들을 엮을 차례다. 데이터 본문은 빠른 대칭 암호(AES)로 잠그고, 그 대칭 키 자체는 비대칭 암호(RSA)로 감싼다. 흔히 말하는 하이브리드 암호화인데, 이유는 단순하다. 대칭 암호는 빠르지만 "키를 어떻게 안전하게 전달하느냐"가 문제고, 비대칭 암호는 키 전달이 깔끔하지만 큰 데이터엔 느리다. 그래서 각자 잘하는 일만 시킨다 — 빠른 대칭 암호가 본문을 처리하고, 느리지만 안전한 비대칭 암호가 그 키 한 조각만 보호한다.

실제 호출부에서 보는 인터페이스는 이렇게 생겼다. 필드 단위로 암호화하면서, 어느 테이블의 어느 필드인지를 메타데이터로 함께 넘긴다.

여기서 가장 신경 쓴 건 data_id와 key_version이다. 암호화할 때마다 uuid4()로 고유 식별자를 만들고, 그 식별자로 "이 데이터가 어떤 키 버전으로 잠겼는지"를 감사 로그에 박아 둔다. 나중에 복호화할 때 호출부는 키 버전을 몰라도 된다 — data_id만 주면 엔진이 감사 로그를 뒤져서 알아서 올바른 버전의 키를 꺼내 쓴다. 이 한 줄("key_version": key_version)이 뒤에 나올 키 로테이션 전체를 떠받친다.
복호화 경로에는 권한 검증이 한 겹 들어간다. 흥미로운 건, 권한이 없을 때 그냥 거부하고 끝내는 게 아니라 "거부했다"는 사실 자체를 감사 로그에 기록한다는 점이다.

성공이든 실패든 전부 기록한다. 보안에서 무서운 건 "성공한 접근"보다 "거부됐는데 아무도 모르는 시도"다. 누군가 권한 없는 데이터를 반복해서 긁으려 했다면, 그 흔적이 변조 불가능한 형태로 차곡차곡 쌓인다. 이게 감사 로그를 일반 테이블이 아니라 해시 체인에 둔 진짜 이유다.
해시 체인 감사 로그 — 변조를 막는 게 아니라 드러나게 한다
이제 시스템의 심장인 블록체인 관리자다. 거창한 이름이지만 실체는 "각 블록이 직전 블록의 해시를 품는 연결 리스트"다. 블록 하나의 해시를 계산할 때, 그 블록 안에는 이미 previous_hash(직전 블록의 해시)가 들어 있다. 그러니 과거 블록의 내용을 한 글자라도 고치면 그 블록의 해시가 바뀌고, 그 해시를 품고 있던 다음 블록의 해시도 어긋나고, 도미노처럼 체인 끝까지 무너진다.

검증 루틴은 체인을 처음부터 끝까지 두 가지로 본다. 연결 검증(내가 기억하는 앞 블록 해시 == 실제 앞 블록 해시)과 내용 검증(이 블록을 지금 다시 해시한 값 == 블록에 저장된 해시). 둘 중 하나라도 깨지면 그 지점에서 변조를 잡아낸다. 100블록 이상에서도 검증은 1초 안에 끝났다 — SHA-256은 빠르고, 하는 일은 그저 다시 해시해 보는 것뿐이라서다.
해시를 만들 때 한 가지 함정을 피해야 했다. json.dumps를 그냥 쓰면 딕셔너리 키 순서에 따라 직렬화 결과가 달라져서, 같은 내용인데도 해시가 달라질 수 있다. 그래서 sort_keys=True로 키를 항상 정렬해 직렬화한다. 이게 빠지면 "방금 만든 블록을 다시 해시했더니 값이 다르다"는 황당한 버그가 난다. 검증이 늘 통과하려면 만들 때와 검증할 때의 직렬화가 한 글자도 다르지 않아야 한다.
블록 생성에는 작업 증명(PoW)도 흉내 냈다. 다만 난이도는 일부러 아주 낮게(앞자리 0 두 개 정도) 잡았다.

코인 블록체인은 PoW 난이도를 높여 "위조에 막대한 연산이 들게" 만든다. 하지만 이건 내 서버 안에서만 도는 내부 감사 로그다. 난이도를 높여 봐야 변조를 막아 주는 외부 채굴자도 없고, 결국 내 서버만 느려진다. 그래서 PoW는 "쓰기에 아주 약간의 비용을 부과한다"는 상징적 수준으로만 남기고, 진짜 무결성은 해시 체인 + 백업으로 확보했다. 이 시스템에서 PoW는 사실 없어도 그만인 장식에 가깝다 — 만들면서 가장 솔직하게 인정하게 된 부분이다.
키 로테이션 — 키는 언젠가 바뀐다고 가정하고 설계
키 관리에서 가장 공들인 부분이다. 핵심 전제는 단순하다. 키는 언젠가 반드시 바뀐다. 정기 교체 정책 때문이든, 키가 샜다는 의심 때문이든. 그러니 "키는 안 바뀐다"고 가정하고 짜면, 정작 바꿔야 할 때 시스템이 무너진다. 그래서 처음부터 키를 버전 단위로 관리했다.

로테이션이 일어나면 이전 버전 키는 사라지지 않는다. deprecated로 표시될 뿐, 저장소에 그대로 남는다. 새 암호화는 최신 버전 키로 하되, 예전 버전 키로 잠긴 데이터는 그 버전 키로 계속 정상 복호화된다. 앞에서 암호화할 때 감사 로그에 key_version을 박아 둔 게 여기서 빛을 본다 — 복호화할 데이터가 몇 번 키로 잠겼는지를 로그가 알려 주니, 올바른 키를 골라 쓸 수 있다.
def rotate_key(self, user_id, reason="manual_rotation") -> int:
current_version = self.keys[user_id]["current_version"]
new_version = current_version + 1
new_master_key = generate_aes_key()
# 이전 버전은 폐기 표시(삭제 아님) — 옛 데이터 복호화를 위해 남겨 둔다
self.keys[user_id]["versions"][str(current_version)]["status"] = "deprecated"
# 새 버전 추가, 이후 암호화는 이 키로
self.keys[user_id]["versions"][str(new_version)] = {
"key_hex": new_master_key.hex(),
"created_at": str(datetime.datetime.utcnow()),
"status": "active"
}
self.keys[user_id]["current_version"] = new_version
# 언제·왜 돌렸는지 이력으로 남긴다 (정기/긴급/손상)
self.keys[user_id]["rotation_history"].append({
"version": new_version, "action": reason,
"timestamp": str(datetime.datetime.utcnow()),
"previous_version": current_version
})
self._save_keys()
return new_version
로테이션을 설계하며 정한 원칙은 이거였다. 로테이션은 평상시 운영의 일부여야 한다. 키 이상 징후가 감지됐을 때만 허둥지둥 키를 바꾸는 구조라면, 그 비상 절차는 실전에서 반드시 깨진다 — 평소에 안 돌려 본 경로니까. 그래서 정기 로테이션과 긴급 로테이션이 똑같은 코드 경로를 타게 하고, 차이는 reason 인자 하나로만 뒀다. 정기 교체는 reason="scheduled_monthly_rotation", 손상 의심은 reason="compromised_emergency". 같은 길을 매달 걸어 두면, 위급할 때 그 길이 막혀 있을 일이 없다.
손상 대응은 한 발 더 나갔다. 키를 손상으로 표시하면, 그게 현재 활성 키일 경우 즉시 자동 로테이션까지 일으킨다.
def mark_key_compromised(self, user_id, version):
self.keys[user_id]["versions"][str(version)]["status"] = "compromised"
self.keys[user_id]["versions"][str(version)]["compromised_at"] = str(datetime.datetime.utcnow())
self._save_keys()
# 손상된 게 '지금 쓰는 키'라면 — 사람 손을 기다리지 않고 즉시 교체
if version == self.keys[user_id]["current_version"]:
print("[ACTION] 활성 키가 손상되었습니다. 긴급 로테이션을 수행합니다.")
self.rotate_key(user_id, reason="compromised_emergency")
"손상됐다"고 표시만 하고 교체는 사람이 따로 하게 두면, 그 틈에 손상된 키로 새 데이터가 계속 암호화될 수 있다. 그래서 표시와 교체를 한 동작으로 묶었다. 그리고 옛 키 의존을 줄이기 위해, 여유 있을 때 옛 데이터를 새 키로 다시 잠그는 reencrypt_with_new_key도 뒀다 — 옛 키로 풀고, 최신 키로 다시 암호화하고, "재암호화했다"는 사실을 감사 로그에 남긴다. 충분히 재암호화가 끝난 뒤에야 옛 버전 키를 cleanup_old_versions로 정리할 수 있다. 순서를 지키지 않고 옛 키부터 지우면 그 키로 잠긴 데이터가 영영 안 풀리니, 이 순서는 절대 어기면 안 되는 규칙이다.
저장소를 갈아 끼우다 — JSON에서 SQLite로, 호출부는 그대로
처음엔 블록체인을 통짜 JSON 파일에 저장했다. 만들기 쉽고 눈으로 읽히는 게 장점이지만, 치명적인 약점이 있다. 블록을 하나 추가할 때마다 전체 파일을 다시 읽고 다시 쓴다. 블록이 수천 개로 늘면 매 쓰기가 점점 느려지고, 특정 data_id를 찾으려면 체인 전체를 순회해야 한다.
그래서 같은 인터페이스를 가진 SQLite 버전을 따로 만들었다. 블록을 행으로 저장하고, hash·previous_hash·event_type·data_id·user_id에 인덱스를 걸었다.
cursor.execute('CREATE INDEX IF NOT EXISTS idx_hash ON blocks(hash)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_prev_hash ON blocks(previous_hash)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_timestamp ON blocks(timestamp)')
# 이벤트 타입/데이터ID/사용자별 빠른 검색을 위한 별도 색인 테이블
cursor.execute('''CREATE TABLE IF NOT EXISTS block_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
block_index INTEGER NOT NULL,
event_type TEXT NOT NULL,
data_id TEXT, user_id TEXT,
FOREIGN KEY (block_index) REFERENCES blocks(block_index))''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_event_type ON block_events(event_type)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_id ON block_events(data_id)')
블록의 핵심 필드(해시·이전 해시·논스)는 컬럼으로 빼서 인덱스를 걸고, 가변적인 본문은 data_json 컬럼에 통째로 넣었다. 거기에 이벤트 색인 테이블을 따로 둬서, "특정 사용자의 모든 접근 거부 이벤트"나 "이 data_id의 전체 이력" 같은 질의가 풀스캔 없이 인덱스로 풀리게 했다. 결과적으로 SQLite 쪽은 블록 검색이 0.01초 미만, 100블록 삽입이 3초 수준으로 안정적이었다. JSON처럼 데이터가 늘수록 느려지지 않는 게 핵심 이득이다.
가장 마음에 든 건 이 전환을 호출부가 전혀 몰랐다는 점이다. 엔진은 블록체인 파일 경로의 확장자만 보고 구현을 자동 선택한다.
# 파일 확장자에 따라 블록체인 타입 자동 선택 — 호출부 코드는 동일
if blockchain_file.endswith('.db'):
from blockchain_manager_sqlite import BlockchainManagerSQLite
self.blockchain = BlockchainManagerSQLite(blockchain_file, difficulty=difficulty)
else:
from blockchain_manager import BlockchainManager
self.blockchain = BlockchainManager(blockchain_file, difficulty=difficulty)
audit.json을 주면 JSON 구현이, audit.db를 주면 SQLite 구현이 붙는다. 소규모 환경이나 디버깅 때는 눈으로 읽히는 JSON을, 규모가 커지면 SQLite를 — 같은 코드로 둘 다 굴린다. 앞에서 계층을 잘 잘라 둔 덕분에, 저장소를 갈아 끼우는 작업이 if 한 덩어리로 끝났다. 두 구현이 같은 메서드 이름(_add_block, find_user_public_key, verify_chain_integrity…)을 노출하도록 인터페이스를 맞춰 둔 게 이 유연함의 값을 치른 부분이다.
백업 — 복원 전 무결성 검증이 핵심
암호화된 데이터와 키 저장소는 자동 백업으로 묶었다. 전체 백업과 증분 백업 두 전략을 두고, 백업본을 ZIP으로 압축하면서 각 파일의 SHA-256 해시를 메타데이터에 함께 기록한다.
def _calculate_file_hash(self, file_path: str) -> str:
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
while chunk := f.read(8192): # 큰 파일도 메모리 안 터지게 청크 단위로
sha256.update(chunk)
return sha256.hexdigest()
해시를 같이 남기는 이유는 단 하나, 복원 전에 무결성부터 검증하기 위해서다. 백업의 최악의 시나리오는 "백업이 없는 것"이 아니라 "손상된 백업을 멀쩡한 줄 알고 복원하는 것"이다. 멀쩡한 상태를 손상된 백업으로 덮어 버리면 사고가 두 배가 된다. 그래서 복원 루틴은 백업본을 바로 적용하지 않는다 — 먼저 임시로 읽어 해시와 체인 무결성을 검증하고, 통과해야만 실제로 교체한다. 검증에 실패하면 원래 상태로 되돌린다. 큰 파일도 메모리에 통째로 올리지 않고 8KB 청크로 해시하는 작은 배려도 넣었다.
오래된 백업은 최신 N개만 남기고 자동 정리(로테이션)한다. 백업이 무한정 쌓이면 디스크가 차고, 정작 어느 게 멀쩡한지 헷갈린다. "충분히 최근의, 검증된 백업 몇 개"가 "수백 개의 검증 안 된 백업"보다 낫다.
인증 — JWT와, 솔직히 적는 한 가지 빚
마지막 조각은 API 인증이다. JWT로 액세스 토큰과 리프레시 토큰을 분리 발급하고, 로그아웃 시 토큰을 블랙리스트에 넣어 무효화하며, 역할 기반 접근 제어(RBAC)를 얹었다. 그리고 인증 이벤트 — 로그인 성공/실패, 권한 거부 — 도 전부 위의 해시 체인 감사 로그로 흘려보냈다. 누가 로그인했고 어떤 시도가 실패했는지가 변조 불가능한 형태로 남는다.
여기서 만들면서 가장 솔직하게 마주한 빚이 하나 있다. 비밀번호 저장이다.
def _hash_password(self, password: str) -> str:
# 데모 수준 구현. SHA-256 단일 해시 + 솔트 없음 → 운영 부적합.
return hashlib.sha256(password.encode()).hexdigest()
hashlib.sha256 한 방으로 비밀번호를 해시한다. 솔트도 없고, 의도적 느림(키 스트레칭)도 없다. SHA-256은 일부러 빠르게 설계된 해시라, 비밀번호 저장엔 오히려 부적합하다. 빠르다는 건 공격자가 초당 수억 번 대입(레인보우 테이블·무차별 대입)을 돌릴 수 있다는 뜻이기 때문이다. 운영에 올린다면 여긴 bcrypt나 argon2처럼 솔트가 내장되고 의도적으로 느린 비밀번호 전용 해시로 반드시 바꿔야 한다. 데모 단계라 단순화해 둔 부분이지만, 이런 자리야말로 "나중에"가 영영 안 오기 쉬운 곳이라 코드와 글 양쪽에 빚으로 명시해 둔다.
전체 기능은 검증 테스트들로 묶어 통과를 확인한 뒤 마무리했다. 암호화→복호화 왕복, 권한 없는 접근 거부, 키 로테이션 후 옛 데이터 복호화, 체인 변조 탐지, 백업·복원 무결성까지 — "되는 것"보다 "안 돼야 할 때 제대로 안 되는 것"을 더 많이 확인했다.
정리하며
- 감사 로그에 SHA-256 해시 체인을 빌린 핵심 동기는 "변조를 막는 것"이 아니라 "변조 사실을 검증 시점에 반드시 드러나게 하는 것"이었다. 일반 테이블이면 로그를 가진 사람이 로그를 조작할 수 있다.
- 암호화는 AES-256(본문) + RSA-OAEP(키 래핑) 하이브리드로 각 방식의 강점만 썼다. 다만 본문에 AES-GCM 같은 인증 암호(AEAD)를 쓰지 않은 건 보완할 지점으로 남겼다.
- 키는 버전 단위로 관리해, 옛 키로 잠긴 데이터를 계속 읽으면서 새 데이터는 새 키로 잠근다. 그 단서가 감사 로그에 박아 둔
key_version이다. - 로테이션은 평상시 운영의 일부. 정기와 긴급이 같은 코드 경로(
reason만 다름)를 타게 해서, 위급할 때 비상 절차가 깨지지 않게 했다. 손상 표시는 즉시 자동 로테이션까지 일으킨다. - 저장소는 JSON↔SQLite를 확장자 하나로 갈아 끼웠다. 계층을 잘 자른 덕에 엔진·키 관리자는 한 줄도 안 고쳤다.
- 백업의 핵심은 양이 아니라 복원 전 무결성 검증. 손상된 백업으로 멀쩡한 상태를 덮는 게 최악이다.
- 인증의 비밀번호 해시(
sha256단일·무솔트)는 데모 수준의 빚으로, 운영에선bcrypt/argon2로 교체가 전제다.
다음 편을 쓰게 된다면, JSON에서 SQLite로 블록체인 저장소를 옮기며 측정한 성능 비교와, AES-CFB를 AES-GCM(AEAD)으로 바꾸면서 감사 체인과의 역할 분담을 다시 정리한 과정을 더 깊게 다뤄 볼 생각이다.
'외주 개발일지' 카테고리의 다른 글
| 부품 유통 관리 ERP 개발기 (1) — 6개 모듈 프로토타입과 파일 하나로 끝낸 배포 (0) | 2026.06.15 |
|---|---|
| 수출 영업 메일 자동화 개발기 (1) — AI 분류·초안과 '사람이 승인하는' 발송 파이프라인 (0) | 2026.06.15 |
| 거래 API 보안 게이트웨이 개발기 (1) — 이진 경로 챌린지와 동적 난이도 (0) | 2026.06.14 |
| 연구기관 웹사이트 PHP 버전 개발기 (1) — 조직도 관리와 관리자 편집 기능 (0) | 2026.06.14 |
| PWA 풀스크린으로 태블릿 키오스크 만들기 — 주소창 없는 앱처럼 (2) (0) | 2026.06.13 |