[태그:] 설계 결함

  • 연습이 장부를 바꿨다 — dry-run 이 실제로 기록한 결함

    연습이 장부를 바꿨다 — dry-run 이 실제로 기록한 결함

    자동화 도구에는 대개 연습 모드가 있다. 실제로는 하지 않고 어떻게 될지만 보여 주는 기능이다. 이름은 도구마다 다르지만 뜻은 같다 — 이번엔 실제로 하지 않는다. 그래서 처음 쓰는 기능은 연습으로 한 번 돌려 보고 나서 실제로 돌린다. 우리도 그렇게 했다. 이 글은 그 연습이 실제 기록을 바꿔 놓은 이야기다.

    논지는 한 줄이다. 연습이라고 이름 붙인 것이 실제를 바꾸지 않는다는 보장은 그 이름에 들어 있지 않다. 이름은 라벨이고, 무엇을 건드리는지는 라벨이 아니라 코드가 정한다. 그리고 우리 경우 그 둘이 다른 자리에서 결정되고 있었다. 그 연습이 남긴 기록은 다음번 진짜 실행을 막는 형태로 남았다.

    먼저 이 글이 하지 않는 말을 밝혀 둔다. 우리는 다른 도구의 연습 모드가 어떻게 구현돼 있는지 재지 않았다. 이 글의 근거는 우리 저장소 한 곳의 코드와 기록이다. 그래서 연습 모드를 믿지 말라는 일반 규칙을 쓰지 않는다. 우리가 쓸 수 있는 형태는 좁다 — 우리 구현에서는 라벨과 저장 경로가 갈려 있었다.

    이 글에서 쓰는 말 몇 개를 풀어 둔다. 장부는 어떤 항목이 어떤 상태를 거쳐 왔는지를 적어 두는 기록 파일이다. 전이는 항목의 상태가 한 단계에서 다음 단계로 바뀌는 일이다. 전이 규칙표는 어떤 상태에서 어떤 상태로 갈 수 있는지를 미리 정해 둔 표다. 연습 모드는 실제로 하지 않고 결과만 보여 주기로 한 실행 방식이다. 라벨은 기록에 남기는 표시값이다.

    앞선 편과의 경계. 도구가 성공을 반환했는데 일이 안 된 경우들은 앞 편에 적었다. 이 글은 반대쪽이다 — 도구가 정확히 성공했는데, 하지 말아야 할 일을 한 경우다. 그 연습이 요금에 어떻게 얹히는지는 비용을 다룬 편에 있고, 이 글은 비용을 다루지 않는다.

    연습 모드가 장부를 바꾼 구조를 단계로 그린 도식. 첫 칸은 연습 호출로, 실행 없이 돌리는 모드로 전이 함수를 부르는 단계다. 인자 이름은 연습 여부이고 기본값이 참으로 되어 있어 안전한 쪽이 기본값이라는 의도가 보인다고 적었다. 둘째 칸은 라벨 기록으로, 기록 항목의 모드 칸에 연습이라고 정직하게 적는 단계다. 여기까지는 약속대로이며 표시는 참이라고 적었다. 셋째 칸은 갈림으로, 연습 여부 인자가 라벨을 정하는 데까지만 도달하고 그 아래로는 전달되지 않는 지점이다. 넷째 칸은 집행으로, 상태를 실제로 바꾸고 원격 정보 칸을 갱신하고 장부 파일을 저장하는 단계이며 연습 여부와 무관하게 실행됐다고 적었다. 아래 칸은 그 결과다. 장부에 남은 것은 연습이라고 표시된 전이 두 줄과 존재하지 않는 주소가 박힌 항목이다. 주소는 mock.invalid 이고 식별자는 900000번대 가짜 값이다. 그리고 전이 규칙표에서 초안 상태에서 갈 수 있는 곳이 검수 하나뿐이므로, 연습이 이미 초안 상태로 옮겨 놓으면 진짜 초안 작업이 하려던 대기에서 초안으로 가는 전이가 규칙 위반으로 거부된다고 적었다. 맨 아래 두 줄은 한정이다. 검수 상태에서는 초안으로 되돌아가는 경로가 규칙표에 있어서 연습이 항상 복구 불가를 만든다는 뜻이 아니라는 것, 그리고 우리는 코어를 고치지 않았고 코어 밖에 관문을 하나 덧댔을 뿐이며 모든 진입 경로를 세지 않았다는 것이다.
    연습 모드가 위험하다는 그림이 아니라 우리 구현에서 라벨과 집행이 갈린 자리를 그린 것이다. 연습 여부 인자는 라벨까지만 도달했고 저장은 그것과 무관하게 실행됐다. 아래 두 줄은 한정이다 — 되돌아가는 경로가 있는 상태도 있고, 우리는 코어를 고치지 않았다.

    연습 한 번이 남긴 흔적

    1. 연습이라는 이름은 실제를 바꾸지 않는다는 보장이 아니다

    증상. 새 기능을 연습 모드로 한 번 돌려 봤다. 화면에는 될 일이 표시됐고 오류는 없었다. 그런데 나중에 실제로 돌리려니 그 작업이 거부됐다. 이미 처리된 항목이라는 것이다. 실제로 처리한 적은 없었다.

    여기서 갈리는 두 가지 해석. 하나는 도구가 잘못 판단했다고 보는 것이다. 다른 하나는 내가 연습이 아니라 실제로 돌렸던 것이 아닐까 의심하는 것이다. 우리 기록에서는 둘 다 아니었다. 도구는 연습으로 돌았고 기록에도 연습이라고 적었다. 다만 적는 곳이 실제 장부였다.

    우리가 내린 결론. 어긋난 자리는 연습이라는 표시와 실제로 무엇을 건드리는지가 서로 다른 곳에서 결정된다는 구조였다. 표시는 기록 항목의 한 칸이 담당하고, 무엇을 건드리는지는 그 아래 코드가 담당한다. 두 담당이 같은 값을 보고 있지 않으면, 표시가 참인 채로 부작용도 참이 된다.

    그래서 이 글이 보는 자리는 여기다. 이 글은 연습 기능이 있는지 없는지가 아니라, 그 연습이 어디까지 연습인지를 본다. 2번과 3번이 우리가 겪은 결과이고, 4번과 5번이 코드에서 확인한 원인이며, 6번이 지금 상태다.

    먼저 분명히 해 둔다. 연습 모드가 있는 것은 좋은 일이고 우리도 계속 쓴다. 처음 쓰는 기능을 실제로 돌려 보기 전에 한 번 보는 것은 줄일 수 있는 위험을 줄이는 일이다. 이 글이 다루는 것은 그 기능을 쓰지 말라는 이야기가 아니라, 그 기능이 무엇까지 모의하는지를 확인하지 않고 썼을 때 생긴 일이다. 확인하지 않았다는 것이 우리 쪽 몫이다.

    연습이 무엇을 모의하는지는 대개 층으로 갈린다. 우리 경우를 층으로 적으면 이렇게 된다. 왼쪽이 연습이 실제로 모의한 것이고, 오른쪽이 연습이 그대로 실행한 것이다.

    
    연습이 모의한 것          연습이 그대로 실행한 것
    ------------------------  ------------------------
    바깥 서비스 호출          항목의 상태 변경
    가짜 식별자 발급          원격 정보 칸 갱신
                              장부 파일 저장
    

    이 표에서 어긋남의 자리가 보인다. 왼쪽은 바깥으로 나가는 일이고 오른쪽은 안쪽에 남는 일이다. 연습이라는 말을 들었을 때 우리가 기대한 것은 양쪽 다였는데, 실제로 모의된 것은 왼쪽뿐이었다. 그리고 왼쪽이 만들어 낸 가짜 값이 오른쪽 경로로 실제 기록에 저장됐다. 모의와 실행이 한 줄 안에서 만난 것이다.

    확인 방법. 연습 모드로 무언가를 한 번 돌린 뒤, 돌리기 전과 후에 파일이 달라졌는지를 확인하라. 수정 시각과 크기를 비교하면 된다. 화면에 연습이라고 표시된 것과 파일이 그대로인 것은 별개의 사실이다.

    2. 연습 한 번이 실제 장부에 가짜 값을 적었다

    이 항목은 우리가 실제로 겪은 것이고, 지금도 눈으로 확인할 수 있다.

    관측. 우리 콘텐츠 장부에 2026년 8월 31일 20시 43분 35초 시각의 전이 기록 두 줄이 있다. 두 줄 모두 모드 칸에 연습이라고 적혀 있다. 그런데 그 두 줄은 실제 장부 파일에 저장돼 있다. 연습이라고 적힌 기록이 연습이 아닌 곳에 남은 것이다.

    그 항목에 박힌 값. 원격 정보 칸을 열어 보면 존재하지 않는 주소가 들어가 있다. 주소는 mock.invalid 로 시작하고, 글 식별자는 900013 이다. 카테고리·태그·대표이미지 식별자도 전부 900000번대 가짜 값이다. 연습이 만들어 낸 가상의 값이 실제 장부에 그대로 들어앉았다.

    그리고 지워지지 않았다. 그 항목은 장부에 그대로 남아 있다 — 이 글을 쓰면서 다시 확인했다. 상태는 검수 대기로 되어 있다. 실제로 검수할 것이 없는 항목이 검수 대기 줄에 서 있는 셈이다.

    남아 있는 것이 왜 문제인가. 그 항목 하나가 잘못된 값을 갖고 있다는 것으로 끝나지 않는다. 상태별로 항목을 세는 곳에서 그 항목은 검수 대기 한 건으로 계산된다. 어떤 항목을 다음에 처리할지 고르는 자리에서도 그 항목은 후보로 올라온다. 우리가 사람 눈으로 그 항목을 알아보기 때문에 넘어가는 것이고, 세는 프로그램은 그것이 가짜인지 모른다. 가짜 값 하나가 남으면 그 값을 읽는 모든 곳이 그만큼 어긋난다.

    그런데 우리는 지우지 않았다. 지울 수는 있었지만 두었다. 그 항목이 이 결함이 실제로 일어났다는 증거이기 때문이다. 코어를 고치지 않은 상태에서 증거를 지우면, 다음에 같은 일이 생겼을 때 처음 겪는 일처럼 보인다. 그래서 지우는 대신 가짜임을 알아볼 수 있는 형태로 남겨 두는 쪽을 택했다 — 주소가 존재하지 않는 이름이고 식별자가 눈에 띄는 번호대인 것이 그 알아볼 수 있는 형태다.

    초보용으로 옮기면 이렇다. 연습 모드가 표시는 정직했다. 이건 연습이라고 기록에 적었다. 그런데 적는 곳이 실제 장부였다. 정직한 표시와 안전한 실행은 같은 것이 아니다.

    확인 방법. 자기 기록 파일에서 연습이라고 표시된 줄을 찾아보라. 있으면 그 줄이 연습용 파일이 아니라 실제 파일에 있는지 확인하면 된다. 실제 파일에 있다면 그 연습은 기록까지 연습이 아니었던 것이다.

    3. 그다음 연습들은 진짜 작업을 막았다

    이 항목도 우리가 겪은 것이고, 두 번 있었다.

    관측. 우리 저장소에는 장부 백업 파일이 두 개 남아 있다. 각각 44,939 바이트57,672 바이트이고, 두 파일 다 연습 오염이라는 뜻의 이름을 달고 있다. 그 안을 보면 각각 한 주제가 연습 전이로 초안 상태가 되어 있고, 가짜 글 식별자(900017900015)와 존재하지 않는 주소가 박혀 있다.

    왜 막히는가. 우리 전이 규칙표는 초안 상태에서 갈 수 있는 곳을 검수 하나로만 허용한다. 그래서 연습이 이미 그 주제를 초안 상태로 옮겨 놓으면, 진짜 초안 작업이 하려던 대기에서 초안으로 가는 전이가 규칙 위반으로 거부된다. 규칙표는 잘못 동작한 것이 아니다 — 적힌 대로 막았다. 다만 그 상태를 만든 것이 연습이었다.

    
    연습이 옮긴다     대기  ->  초안        (연습인데 저장됐다)
    진짜가 하려 한다  대기  ->  초안        거부 — 이미 초안이다
    규칙표가 허용     초안  ->  검수        만 있다
    

    처리. 두 번 다 작업자가 자기 오염을 발견해 백업에서 원상복구했다. 위 두 파일이 그 백업이다.

    그 발견 경로에 대해. 두 번 다 사람이 먼저 이상하다고 느꼈다. 절차가 잡아 준 것이 아니다. 첫 번째와 두 번째 사이에 우리는 이미 원인을 알고 있었는데도 두 번째가 났고, 두 번째도 사람이 찾았다. 이것이 우리가 나중에 경고문이 아니라 거부하는 검사를 넣은 이유다. 원인을 아는 것과 다시 밟지 않는 것은 다른 일이었다.

    초보용으로 옮기면 이렇다. 연습이 상태를 옮겼는지가 중요한 이유는, 상태가 다음 작업의 입력이기 때문이다. 상태를 보고 무엇을 할지 정하는 절차에서는 상태 하나가 바뀌면 그다음 전부가 달라진다. 연습이 결과물을 만들지 않아도 상태를 만들면 그것으로 충분히 실제를 바꾼다.

    한정을 함께 적는다. 연습이 항상 복구 불가를 만든다고 쓰지 않는다. 우리 규칙표에는 검수에서 초안으로 되돌아가는 경로가 있다. 우리가 본 봉쇄는 초안으로 옮겨진 경우였고, 되돌아가는 경로가 있는 상태였다면 같은 봉쇄가 생기지 않는다. 어느 상태로 옮겨지느냐에 따라 결과가 다르다는 것까지가 우리가 아는 범위다.

    확인 방법. 연습을 돌린 뒤 그 항목의 상태값을 확인하라. 연습 전과 다르면 연습이 상태를 옮긴 것이다. 그리고 자기 도구의 전이 규칙표를 한 번 읽어, 그 새 상태에서 원래 하려던 작업으로 돌아갈 경로가 있는지 확인하면 된다. 경로가 없으면 그 항목은 그대로 막힌다.

    4. 라벨은 정직했고 저장 경로에는 그 라벨이 닿지 않았다

    이 항목은 코드에서 확인한 원인이다.

    확인한 것. 상태를 바꾸는 함수는 연습 여부를 인자로 받는다. 그리고 기록 항목의 모드 칸에 그 값에 따라 연습 또는 실제정직하게 적는다. 여기까지는 약속대로다. 그런데 그 아래에서 상태를 실제로 바꾸고, 원격 정보 칸을 갱신하고, 장부 파일을 저장한다 — 연습 여부와 무관하게.

    한 문장으로 적으면 이렇다. 연습 여부 인자는 라벨에만 도달하고 저장 경로에는 도달하지 않았다. 그 인자가 함수 안에서 쓰이는 자리를 세어 보면, 라벨을 정하는 자리와 아래 단계로 그 값을 넘겨 주는 자리가 있고, 저장을 막는 자리는 없다.

    그래서 도구는 거짓말을 하지 않았다. 장부에 적힌 연습이라는 표시는 참이다. 그 실행은 실제로 연습으로 호출됐다. 동시에 그 실행이 파일을 바꿨다는 것도 참이다. 표시가 참인 것과 부작용이 없는 것은 서로 다른 주장인데, 우리가 앞쪽을 보고 뒤쪽을 읽었다.

    두 주장을 갈라 적어 두면 이렇게 된다. 하나는 이 실행은 연습으로 호출됐다이고, 다른 하나는 이 실행은 아무것도 바꾸지 않았다다. 앞쪽은 라벨이 증언할 수 있는 주장이고, 뒤쪽은 라벨이 증언할 수 없는 주장이다. 우리는 앞쪽 증언을 받고 뒤쪽을 결론으로 삼았다. 라벨은 자기가 아는 것만 말했다.

    그리고 이 형태는 눈에 잘 띄지 않는다. 연습으로 돌린 결과가 화면에 그럴듯하게 나오기 때문이다. 될 일이 표시되고 오류가 없으니 연습이 제 역할을 한 것으로 보인다. 어긋난 부분은 화면이 아니라 파일에 있었고, 파일을 열어 보는 것은 연습을 돌린 직후에 하는 일이 아니었다.

    초보용으로 옮기면 이렇다. 위험한 것은 연습 기능이 없는 것이 아니다. 연습이라는 표시와 실제로 무엇을 건드리는지가 다른 곳에서 결정되는 구조다. 그 두 곳이 같은 값을 보고 있는지는 표시만 봐서는 알 수 없다.

    확인 방법. 연습 모드가 있는 기능에서 무엇이 모의되고 무엇이 실제인지를 한 줄씩 갈라 적어 보라. 대개 모의되는 것은 바깥으로 나가는 호출이고, 안쪽에 남는 기록은 실제일 수 있다. 갈라 적을 수 없으면 그 기능의 연습 범위는 아직 확인되지 않은 것이다.

    5. 안전한 쪽이 기본값이었지만 그 안전이 저장까지 이어지지 않았다

    이 항목도 코드에서 확인한 것이고, 앞 절의 원인을 한 겹 더 들어간 자리다.

    확인한 것. 그 함수의 연습 여부 인자는 기본값이 연습이다. 아무 말 없이 부르면 연습으로 동작하도록 되어 있다. 즉 안전한 쪽을 기본값으로 두겠다는 의도가 코드에 보인다.

    그런데 그 의도가 어디까지 연결됐는가. 기본값이 연습이라는 것은 라벨이 기본적으로 연습으로 찍힌다는 뜻이었다. 그 안전이 저장을 막는 데까지 연결돼 있지 않았다. 그래서 기본값을 그대로 쓴 호출도 장부를 바꿨다. 의도가 있었다는 것과 그 의도가 집행됐다는 것이 갈린 자리다.

    여기서 우리가 배운 것. 안전한 기본값은 그 안전이 어디까지 연결되는지를 확인해야 안전이다. 기본값이 안전한 쪽으로 되어 있다는 사실은 읽기 쉽고, 그 안전이 실제로 무엇을 막는지는 읽기 어렵다. 우리는 읽기 쉬운 쪽을 보고 읽기 어려운 쪽을 짐작했다.

    이 자리가 특히 미끄러운 이유. 기본값이 안전한 쪽이면 읽는 사람이 안심하게 된다. 코드를 읽다가 그 기본값을 보면 설계자가 안전을 고려했다는 신호로 읽히고, 그 신호는 실제로 맞다 — 고려한 흔적이다. 다만 고려의 흔적과 집행은 다른 것이고, 흔적이 눈에 먼저 들어온다. 우리가 그 순서로 읽었다.

    초보용으로 옮기면 이렇다. 코드를 읽지 않는 사람에게도 같은 형태가 있다. 설정 화면에서 안전한 쪽으로 미리 선택돼 있는 항목을 볼 때다. 그 항목이 켜져 있다는 사실은 화면에 있고, 그것이 실제로 무엇을 막는지는 화면에 없다. 그래서 확인할 것은 그 항목이 켜져 있는지가 아니라 켜져 있는 상태에서 한 번 해 봤을 때 막히는지다. 막히는 것을 본 적이 없으면, 그 항목이 무엇을 막는지는 아직 모르는 것이다.

    확인 방법. 안전한 기본값이 있는 인자를 만나면, 그 값이 쓰이는 자리를 전부 찾아 보라. 표시를 정하는 자리에만 쓰이고 실행을 막는 자리에는 쓰이지 않는지가 확인할 지점이다. 찾을 수 없으면 그 기본값이 무엇을 안전하게 만드는지는 아직 모르는 것이다.

    6. 우리는 코어를 고치지 않았고 관문을 하나 덧댔다

    이 항목은 지금 상태이고, 조심해서 적어야 하는 자리다.

    이 결함은 미해결이다. 우리 작업 원장에 티켓으로 등록돼 있고 상태가 미결이다. 티켓의 목표 문안은 이렇게 적혀 있다 — 전이 함수에서 연습일 때 저장과 원격 반영을 건너뛰게 하고, 가짜 값이 장부에 들어가지 않게 하며, 회귀 테스트를 추가한다. 그 작업을 아직 하지 않았다.

    티켓 문안에서 한 가지가 더 읽힌다. 목표에 회귀 테스트를 추가한다가 들어 있다는 것은, 지금 그 테스트가 없다는 뜻이다. 즉 이 결함은 우리 테스트가 지켜 주는 범위 밖에 있다. 코어를 고치는 작업과 그것을 지키는 테스트를 만드는 작업이 같은 티켓에 함께 적혀 있고, 둘 다 아직 하지 않았다.

    지금 쓰고 있는 방어. 코어 밖에 덧댄 관문이다. 실제 장부를 대상으로 연습을 걸면 그 관문이 거부한다. 경고문이 아니라 실행 전에 멈추는 검사이고, 그 거부 동작은 우리 작업 증거에 실측으로 남아 있다.

    그 관문이 무엇을 하지 않는지도 적는다. 관문은 우리가 아는 진입 경로 하나를 막은 것이다. 우리는 모든 진입 경로를 세지 않았다. 상태를 바꾸는 함수를 직접 부르는 경로는 그 관문을 지나지 않는다. 그러므로 쓸 수 있는 형태는 하나다 — 우리는 코어를 고치지 않았고, 코어 밖에 관문을 하나 덧대 두었다.

    미결이라는 말의 뜻. 티켓이 열려 있다는 것은 누가 언제 하기로 정해지지 않았다는 뜻이다. 고칠 방법을 모르는 것이 아니라 아직 그 자리에 손을 대지 않았다는 상태다. 그 상태를 미결로 적어 두는 것관문이 있으니 됐다고 적는 것은 다음에 이 기록을 읽는 사람에게 서로 다른 일을 하게 만든다. 앞쪽은 남은 일을 보게 하고, 뒤쪽은 남은 일이 없다고 읽게 한다.

    왜 관문으로 먼저 막았는가. 코어를 고치는 일은 상태를 바꾸는 함수의 안쪽을 손대는 것이고, 그 함수는 우리 발행 절차 전부가 지나는 자리다. 그래서 그 수정에는 회귀 테스트가 함께 필요하다고 티켓에 적혀 있다. 반면 관문은 바깥에서 한 경로를 막는 것이라 그 함수를 건드리지 않는다. 우리는 먼저 지나는 길 하나를 막고, 안쪽 수정은 티켓으로 남겨 두는 순서를 택했다. 그 선택이 옳았는지는 우리가 재지 않았다 — 그 순서로 했다는 사실만 적는다.

    쓰지 않는 문장을 밝혀 둔다. 이 글은 해결했다고 쓰지 않는다. 이제 안전하다고도 쓰지 않는다. 관문이 있으므로 이 결함이 무해하다고도 쓰지 않는다. 세지 않은 경로를 막았다고 말할 수 없고, 미결인 티켓을 해결로 적을 수도 없다.

    확인 방법. 자기 기록에서 같은 형태를 찾았다면, 고친 뒤에 티켓 상태막은 경로의 개수를 나란히 적어 보라. 티켓이 열려 있으면 그 결함은 열려 있는 것이다. 그리고 막은 경로가 몇 개인지 적을 수 없으면, 몇 개를 막았는지 모르는 상태다.

    정리 — 확인할 것 여섯 가지

    첫째, 연습 모드를 처음 쓸 때는 돌린 뒤 남는 것이 있는지 확인한다. 파일 수정 시각과 크기를 앞뒤로 비교하면 된다. 화면에 연습이라고 표시된 것과 파일이 그대로인 것은 별개의 사실이다.

    둘째, 연습을 돌린 뒤 그 항목의 상태값을 확인한다. 연습 전과 다르면 연습이 상태를 옮긴 것이다. 우리 경우 그렇게 옮겨진 상태에서 원래 하려던 전이가 규칙 위반으로 거부됐다.

    셋째, 우리는 라벨을 보증으로 읽지 않는다. 우리 구현에서 연습 여부 인자는 라벨에만 도달하고 저장 경로에는 도달하지 않았다. 표시가 참인 것과 부작용이 없는 것은 서로 다른 주장이다.

    넷째, 안전한 기본값을 만나면 그 안전이 어디까지 연결되는지 확인한다. 우리 경우 기본값은 연습이었고, 그 기본값이 막은 것은 라벨이었지 저장이 아니었다.

    다섯째, 우리는 미해결을 미해결로 적는다. 우리는 이 결함의 코어를 고치지 않았고 코어 밖에 관문을 하나 덧댔다. 관문 하나가 결함을 없애지 않으며, 우리는 모든 진입 경로를 세지 않았다. 적는 형태는 두 칸으로 두면 흔들리지 않는다 — 막은 것막지 못한 것을 같은 자리에 나란히 적는 것이다. 한 칸만 적으면 그 칸이 전체로 읽힌다.

    여섯째, 우리는 연습 대상을 사본이나 시험용 자료로 둔다. 우리는 이 글을 쓰는 동안에도 실제 장부를 대상으로 연습 명령을 걸지 않았고, 확인이 필요한 것은 파일을 읽어서만 봤다. 연습의 안전 범위를 모르는 상태에서 실제 자료를 대상으로 삼으면, 확인하려는 행위 자체가 사고가 된다.

    이 글을 쓰면서 여섯째를 실제로 적용했다. 위에 적은 값들을 확인할 때 우리는 파일을 읽기만 했고 연습 명령을 실제 장부에 걸지 않았다. 이 글의 주제가 바로 그 사고이므로, 확인을 위해 같은 명령을 다시 돌리는 것은 확인이 아니라 재발이 된다. 읽어서 확인할 수 있는 것은 읽어서 확인하고, 읽어서 확인할 수 없는 것은 확인하지 않은 채로 남겨 두는 것이 이번에 우리가 택한 순서다.

    마지막으로 하나만 덧붙인다. 이 글은 연습 모드를 쓰지 말라는 이야기가 아니다. 우리도 계속 쓴다. 우리가 적은 것은 좁다 — 우리 구현에서 라벨과 저장 경로가 갈려 있었고, 그 때문에 연습이 실제 장부를 바꿨으며, 우리는 아직 그 코어를 고치지 않았다.

    장부와 코드 양쪽에서 확인했다

    우리 운영 기록과 코드에서 확인한 것 (우리 한 팀의 저장소다)

    • 우리 콘텐츠 장부에 2026년 8월 31일 20시 43분 35초 시각의 전이 기록 두 줄이 있고, 두 줄 모두 모드 칸이 연습으로 표시돼 있으며, 그 두 줄이 실제 장부 파일에 저장돼 있는 것
    • 그 항목의 원격 정보 칸에 존재하지 않는 주소(mock.invalid)와 가짜 글 식별자 900013 이 들어가 있고, 카테고리·태그·대표이미지 식별자도 전부 900000번대인 것
    • 그 항목이 지워지지 않고 장부에 남아 있으며 상태가 검수 대기인 것. 이 글을 쓰면서 다시 확인했다
    • 장부 백업 파일이 두 개(44,939 바이트 · 57,672 바이트) 남아 있고, 각각 한 주제가 연습 전이로 초안 상태가 되어 있으며 가짜 글 식별자 900017 과 900015 가 박혀 있는 것
    • 전이 규칙표가 초안에서 갈 수 있는 곳을 검수 하나로만 허용하므로, 연습이 초안 상태로 옮겨 놓으면 진짜 초안 작업의 전이가 규칙 위반으로 거부되는 것. 두 번 다 작업자가 자기 오염을 발견해 백업에서 원상복구했다
    • 한정 — 전이 규칙표에는 검수에서 초안으로 되돌아가는 경로가 있다. 우리가 본 봉쇄는 초안으로 옮겨진 경우이고, 연습이 항상 복구 불가를 만든다는 뜻이 아니다
    • 상태를 바꾸는 함수가 연습 여부를 인자로 받아 기록 항목의 모드 칸에 정직하게 적으면서, 그 아래에서 상태 변경·원격 정보 갱신·장부 저장을 연습 여부와 무관하게 실행하는 것
    • 그 인자의 기본값이 연습인 것. 안전한 쪽을 기본값으로 두려는 의도는 보이지만 그 안전이 저장을 막는 데까지 연결돼 있지 않은 것
    • 이 결함이 우리 작업 원장에 티켓으로 등록돼 있고 상태가 미결인 것. 지금 쓰는 방어는 코어 밖에 덧댄 관문이고, 실제 장부를 대상으로 연습을 걸면 거부하는 동작이 우리 작업 증거에 실측으로 남아 있는 것

    우리가 측정하지 않아 쓰지 않은 것

    • 다른 도구의 연습 모드가 어떻게 구현돼 있는지 우리는 재지 않았다. 이 글은 우리 저장소 한 곳의 코드와 장부를 근거로 한다. 그래서 연습 모드를 믿지 말라는 일반 규칙을 쓰지 않았다. 우리가 쓸 수 있는 형태는 우리 구현에서는 라벨과 저장 경로가 갈려 있었다는 것이다
    • 연습 오염이 몇 번 일어났는지 우리는 전수로 세지 않았다. 우리가 근거로 가진 것은 위에 적은 그 전이 두 줄과 백업 파일 두 개다. 그래서 이 일이 얼마나 자주 일어나는지에 대한 문장이 이 글에 없다
    • 이 결함이 지금 막혀 있는지 여부. 코어를 고치지 않았고, 관문은 우리가 아는 진입 경로 하나를 막은 것이며, 모든 진입 경로를 세지 않았다
    • 연습이 다른 상태로 옮겨졌을 때도 같은 봉쇄가 생기는지 여부. 우리가 본 것은 초안으로 옮겨진 경우다
    • 원상복구가 항상 가능한지 여부. 우리 두 사례에서는 백업이 있었고 그 백업으로 되돌렸다

    같은 계열을 다른 자리에서 다룬 글