TECH NOTE · DESKTOP AI PET
되돌릴 수 없는 삭제는 불리언이 아니라 정확한 문자열로 확인받습니다. 프로세스 안 잠금과 OS 파일 잠금을 두 겹으로 거는 이유, 잠금 실패 시 저장을 포기하는 판단을 정리했습니다.
지우기는 되돌릴 수 없는 유일한 버튼이다
앞의 두 편에서 말을 알아듣고 프롬프트를 만들었다. 그렇게 오간 대화는 어딘가에 쌓인다.
데스크톱 펫은 대화를 기억한다. 사용자가 뭘 좋아한다고 말하면 적어 두고, 지난 대화를 요약해서 다음에 쓴다. 전부 로컬 파일에 있다.
기억하는 기능을 만들면 반드시 따라오는 게 지우는 기능이다. 사용자는 자기가 한 말을 없앨 수 있어야 한다.
그런데 이 버튼만 성격이 다르다. 다른 기능은 잘못 눌러도 되돌릴 수 있는데 지우기는 없다. 그래서 두 가지를 따로 만들었다.
- 실수로 호출되지 않게 하는 장치
- 지우는 도중에 다른 쪽이 끼어들지 못하게 하는 장치
이번 편의 코드다.
https://github.com/bluecafe/pet-reference/tree/main/store-lock
cargo run을 하면 잠금이 두 번째 접근을 실제로 막는 과정이 순서대로 나온다.
`DELETE`를 정확히 넘겨야 한다
첫 번째 장치다. 함수 전체가 이만큼이다.
fn require_delete_confirmation(confirmation: &str) -> Result<(), StoreError> {
if confirmation == "DELETE" {
Ok(())
} else {
Err(StoreError::ConfirmationRequired)
}
}
기억을 지우는 경로는 전부 이 함수를 먼저 지난다. 예제를 돌리면 무엇이 통과하는지 볼 수 있다.
"DELETE" 통과
"delete" 거절
"Delete" 거절
"DELETE " 거절
"yes" 거절
"true" 거절
소문자도, 뒤에 공백이 붙은 것도 통과하지 못한다.
왜 불리언이 아니라 문자열인가. 이게 핵심이다.
clear(scope, true) 같은 형태였다면 인자 순서를 헷갈리거나, 다른 플래그를 복사하다 실수로 true를 넣을 수 있다. 기본값이 잘못 들어가도 알아채기 어렵다. 무엇보다 호출하는 쪽 코드만 봐서는 그 true가 무슨 뜻인지 안 보인다.
"DELETE"는 우연히 만들어지지 않는다. 그 자리에 그 문자열이 있다는 건 누군가 지울 의도로 적었다는 뜻이다.
지우는 범위도 나뉘어 있다.
pub enum ClearScope {
Conversation,
Preferences,
Relationship,
Everything,
}
한 번에 다 지우는 것만 있으면 사용자는 다 지울 수밖에 없다. “어제 한 말만 없애고 싶다”는 요구에 답할 방법이 없어서, 결국 전부 지우고 처음부터 다시 시작하게 된다.
두 프로세스가 같은 파일을 열면
두 번째 장치다. 앱이 두 번 실행되는 상황은 생각보다 쉽게 생긴다. 아이콘을 두 번 눌렀거나, 업데이트 뒤 옛 프로세스가 안 죽었거나.
그 둘이 같은 기억 파일을 동시에 쓰면 내용이 섞인다. 한쪽이 읽고 고쳐 쓰는 사이에 다른 쪽이 써 버리면, 나중 것이 앞의 것을 통째로 덮는다.
예제가 이 상황을 그대로 만든다.
1) 첫 번째가 잠금을 잡는다 → 성공
2) 두 번째가 기다리지 않고 시도 → 빈손. 이미 잡혀 있다
3) 첫 번째가 놓는다 → 두 번째가 이제 잡는다
앱에서는 잠금을 두 겹으로 건다.
| 겹 | 막는 것 | 수단 |
|---|---|---|
| 안쪽 | 같은 앱의 다른 스레드 | 프로세스 안 잠금 |
| 바깥쪽 | 다른 프로세스 | OS 파일 잠금 |
한 겹만으로는 안 된다. 프로세스 안 잠금은 다른 프로세스를 모르고, 파일 잠금은 같은 프로세스 안의 스레드를 막아 주지 않는다. 예제에는 바깥쪽 한 겹만 넣었다.
OS마다 방식이 달라 갈래가 나뉜다. Unix 쪽은 flock을 직접 선언해 쓴다.
const LOCK_EX: c_int = 2;
const LOCK_NB: c_int = 4;
pub(super) fn lock_exclusive(file: &File) -> Result<(), StoreError> {
if unsafe { flock(file.as_raw_fd(), LOCK_EX) } == 0 {
Ok(())
} else {
Err(StoreError::StorageUnavailable)
}
}
pub(super) fn try_lock_exclusive(file: &File) -> bool {
unsafe { flock(file.as_raw_fd(), LOCK_EX | LOCK_NB) == 0 }
}
두 함수의 차이는 LOCK_NB 하나다. 그것만 붙이면 기다리지 않고 바로 돌아온다. Windows에서는 LockFileEx가 같은 일을 한다.
잠금을 못 걸면 저장하지 않는다
코드를 읽다 눈에 걸린 부분이다.
#[cfg(not(unix))]
return Err(StoreError::StorageUnavailable);
잠금을 여는 함수 전체가 이렇다.
pub fn acquire(path: &Path) -> Result<Self, StoreError> {
let file = open_lock_file(path)?;
#[cfg(unix)]
{
lock_exclusive(&file)?;
Ok(Self { file })
}
#[cfg(not(unix))]
{
let _ = file;
Err(StoreError::StorageUnavailable)
}
}
잠금을 걸 수단이 없는 환경에서는 저장 자체를 거절한다. 잠금 없이 그냥 쓰는 경로가 없다. flock이 실패했을 때도 같다 — lock_exclusive에 ?가 붙어 있어서 경고를 남기고 진행하는 길이 아예 없다.
만들다 보면 “잠금은 부가 기능이고 저장이 본체”라고 생각하기 쉽다. 그러면 잠금 실패는 무시하고 저장을 진행하게 된다. 이 코드는 반대로 본다. 안전하게 못 쓸 바에는 안 쓴다. 기억이 섞이는 것보다 이번 저장을 건너뛰는 게 낫다.
해제는 실패해도 넘어간다
거는 쪽은 엄격한데 푸는 쪽은 느슨하다. 함수 이름과 반환 타입에 그대로 드러난다.
impl Drop for StoreLock {
fn drop(&mut self) {
#[cfg(unix)]
unlock_best_effort(&self.file);
}
}
pub(super) fn unlock_best_effort(file: &File) {
let _ = unsafe { flock(file.as_raw_fd(), LOCK_UN) };
}
거는 쪽은 Result를 돌려주는데 푸는 쪽은 아무것도 안 돌려준다. let _ =로 결과를 버린다.
잠금 해제는 파일을 닫을 때 자동으로 실행된다. 이 시점에서 실패하면 할 수 있는 게 없다. 오류를 돌려줄 곳도 없고, 다시 시도해 봐야 같은 결과다.
거는 실패는 데이터를 지키기 위해 멈출 이유가 되지만, 푸는 실패는 멈춘다고 나아지지 않는다. 게다가 프로세스가 끝나면 OS가 어차피 정리한다.
같은 자원을 다루는데 방향에 따라 엄격함이 다른 게 맞다.
기다리는 방식도 두 가지다
잠금을 거는 경로가 둘이다.
- 끝까지 기다린다 — 저장처럼 반드시 해야 하는 일
- 기다리지 않고 포기한다 — 안 되면 넘어가야 하는 일
UI에서 부르는 작업이 무한정 기다리면 창이 멈춘다. 사용자는 앱이 죽은 줄 안다. 짧게 시도하고 포기하는 편이 낫다.
파일 권한도 같이 잠근다
잠금 파일을 열 때 Unix에서 권한을 명시한다.
options.mode(0o600);
읽고 쓰는 것은 소유자만. 같은 기계의 다른 사용자 계정은 열지 못한다.
대화 기록이 들어가는 파일이라 기본 권한에 맡기지 않는다. 기본값은 시스템 설정에 따라 달라지고, 달라지면 다른 계정이 읽을 수 있다. 예제 테스트가 실제 권한 비트를 확인한다.
정리
기억을 다루는 앱을 만든다면 지우기와 잠금은 처음부터 같이 설계해야 한다. 나중에 붙이면 이미 섞인 데이터가 있다.
- 되돌릴 수 없는 동작은 불리언이 아니라 정확한 문자열로 확인받는다
- 지우는 범위를 나눈다. 전부 아니면 전무면 사용자는 전부를 고른다
- 프로세스 안 잠금과 OS 파일 잠금은 다른 것을 막는다. 둘 다 건다
- 잠금을 못 걸면 저장하지 않는다
- 거는 실패는 멈출 이유지만, 푸는 실패는 아니다
- 기억이 담기는 파일은 권한을 직접 지정한다
"DELETE" 한 줄이 과해 보일 수 있다. 하지만 이 검사를 지나야 하는 함수가 앱에 네 군데다. 넷 중 하나만 실수로 불려도 사용자의 기억이 사라진다.
돌아가는 코드는 pet-reference/store-lock에 있다. flock을 직접 선언해 쓰므로 의존성이 없다.
앱에서는 설정에서 기억을 지울 수 있다. 그 버튼이 이 확인을 지난다.
다음 편은 기억과 대화에서 잠깐 벗어난다. 캐릭터에게 오목을 두게 하면서 알게 된 것 — 수를 고르는 코드보다 반응하는 코드가 더 많았다는 이야기다.
