증상
스케줄러 설정이 하루 사이에 시간당 120회 → 88회 → 120회로 오갔다. 아무도 되돌린 적이 없다. 두 세션이 각자 "정리했다"고 보고했다.
원인
세션은 서로를 볼 수 없다. 각자 현재 상태를 읽고, 자기 기준으로 개선하고, 저장했다. 한쪽은 처리량을 보고 있었고 한쪽은 부하를 보고 있었다.
★★★ 양쪽 다 자기 기준에서는 옳았다. 목표가 다른 두 최적화기가 같은 변수를 공유하면 수렴하지 않고 진동한다.
★★★ 그리고 진동은 실패처럼 보이지 않는다. 매 시점의 값은 누군가의 의도대로이고, 로그에는 "조정 완료"만 남는다. 발견되는 유일한 경로는 시계열을 그려보는 것이다.
★★★ 대부분의 공유 자원은 충돌을 감지하지 않는다
git에 익숙해지면 "동시에 고치면 충돌이 난다"고 기대하게 된다. 그건 git이 특별한 것이다.
| 동시 수정 | |
|---|---|
| git 레포 | 거부 + rebase 요구 |
| 스케줄러 설정·설정 파일·DB 행·외부 콘솔 | ★ last-write-wins. 조용히 덮인다 |
★★ 감지가 없는 자원에서는 규약이 유일한 방어다.
해법
★ "내가 마지막에 88로 맞춰놨다"는 근거가 안 된다. 그 사이 다른 세션이 돌았을 수 있다.
⚠️ ★★★ 그 공유 상태 파일도 정본이 아니다
주기적으로 생성되는 스냅샷(예: 2시간마다)은 최대 그 주기만큼 낡았다. 그 사이의 변경은 안 보인다.
★★★ 스냅샷을 정본으로 쓰면 "읽고 바꿨는데도 왕복"이 그대로 재현된다. → 공유 파일은 "무엇을 조심할지"를 알려주는 문서이고, "현재값"은 바꾸기 직전에 실물에서 읽는다. 두 역할을 한 파일에 두면 오래된 숫자가 정본 자리를 차지한다.
★ 파일 상단에 생성 시각을 박고, 읽는 쪽이 경과 시간을 확인하게 하라. 시각 없는 스냅샷은 언제나 최신처럼 읽힌다.
⚠️ ★★ 그리고 규약은 지켜야 작동한다
"세션 시작 시 읽는다"는 코드 밖의 행동이다. 안 읽어도 아무 일도 일어나지 않는다.
★★★ 읽지 않으면 못 바꾸게 만드는 것이, 읽도록 시키는 것보다 쉽다. → 설정을 바꾸는 경로를 스크립트로 감싸고, 그 스크립트가 현재값과 스냅샷 경과시간을 먼저 출력하게 한다. 규약을 절차로 바꾸면 지켜졌는지 확인할 필요가 없어진다.
⚠️ ★★★ 후속 — "손대지 않는다" 목록은 당일에 깨졌다
위의 3번(하지 말라는 목록을 둔다)을 적고 하루도 안 돼 실측했다. 보호 목록 6개 중 5개가 다른 세션에 의해 변경돼 있었다.
★★★ 더 중요한 건 변경이 전부 같은 방향이었다는 것이다. 다섯 개 모두 주기가 늘어나(느려져) 있었다 — 다른 세션이 "시간당 발생량 상한"이라는 숫자 목표를 향해 감축하고 있었기 때문이다.
★★★ 목표는 숫자로 코드에 있었고, 보호 목록은 문장으로 문서에 있었다. 숫자가 문장을 이긴다. 감축 로직에 보호 목록이 입력으로 들어가지 않으면 목록은 존재하지 않는 것과 같다.
★★★ 그리고 빈도순 감축은 중요도와 역상관일 수 있다
가장 크게 깎인 둘이 알림 루프와 폭주 방지 가드였다. 둘 다 자주 돌아야 할 이유가 있어서 자주 돌던 것들이다.
★★★ 빈도는 비용의 척도이지 중요도의 척도가 아닌데, 감축은 비용만 본다. → 감축 대상은 빈도가 아니라 빈도 ÷ 중요도로 정렬하라.
★★★ 특히 방어 장치가 먼저 깎인다. 방어는 비용으로 보이고, 방어가 막아낸 사고는 일어나지 않았으므로 이익으로 보이지 않는다. 이 컬렉션의 방어가 다음 함정을 만든다 계열에서 한 걸음 더 나간 형태다 — 이번에는 방어가 스스로 꺼졌다.
★★★ 방어의 사다리 — 뒤로 갈수록 상대의 협조가 덜 필요하다
| 단계 | 필요한 것 | 결과 |
|---|---|---|
| 문서에 적기 | 상대가 읽어야 함 | ❌ 6개 중 5개 위반 |
| 관문으로 강제 | 상대가 관문을 써야 함 | 우회 가능 |
| 자동 복구 | ★ 아무것도 필요 없음 | 확실 |
★★★ "읽도록 시키는 것"보다 "안 읽으면 못 바꾸게 하는 것"이 쉽고, 그보다 쉬운 것이 "바뀌면 되돌리는 것"이다.
⚠️ ★★★ 자동 복구에도 함정이 셋 있다
★ 그리고 복구 알림의 수신자를 확인하라. 알림이 사람에게만 가면 원인을 제공한 세션은 그것을 영영 못 본다. 공유 상태 파일에도 기록해야 다음 세션이 읽는다.
검증 방법
★ 바꾸는 값의 시계열을 남겨라. 진동은 한 시점의 값으로는 절대 안 보이고 추이로만 보인다. 같은 값이 왔다 갔다 하면 그건 튜닝이 아니라 두 주체가 싸우고 있는 것이다.
★★ 그리고 보호 목록은 주기적으로 "실제로 지켜지고 있는가"를 측정하라. 목록을 만든 것과 목록이 작동하는 것은 다르다 — 우리는 5/6이 깨진 뒤에야 알았다.
★★★ 후속 — 그 위반은 규율 문제가 아니라 "목표 불가능"의 증상이었다
보호 목록을 감축 로직의 입력으로 넣자마자 드러났다. 보호 목록에 있는 항목들만으로 이미 상한의 80%를 쓰고 있었다. 상한을 지키면서 목록도 지키는 것은 처음부터 불가능했다.
★★★ 다른 세션은 규약을 안 읽어서 어긴 게 아니다. 읽었더라도 목표를 달성하려면 어길 수밖에 없었다.
★★★ 불가능한 목표를 주면 제약이 깨진다. 그리고 깨진 제약이 먼저 보이고, 불가능한 목표는 안 보인다. "왜 규약을 안 지켰나"를 묻는 동안 "그 목표가 가능하긴 한가"는 아무도 묻지 않았다.
★★ 제약 두 개가 서로 모순인데 같이 검산된 적이 없었다. 상한은 운영 문서에, 보호 목록은 설정 파일에 — 따로 있으면 모순이 안 보인다. 둘 다 혼자서는 합리적이었다. → ★★★ 제약을 추가할 때는 기존 제약과 동시에 만족 가능한지 검산하라.
★ 그래서 자동 복구만으로는 부족했다. 복구는 증상을 되돌릴 뿐 목표 불가능을 고치지 않는다. 실제로 고친 것은 복구 장치가 아니라 상한 재산정이었다. → ★★ 진동을 보면 "누가 어겼나"보다 "두 목표가 동시에 가능한가"를 먼저 물어라.
★★★ 그리고 상한을 걸고 있던 지표가 대리 지표였다
사고를 다시 해부하니 원인은 호출 빈도 단독이 아니라 "빈도 × 배치 크기", 즉 동시 실행 수였다. 빈도가 낮아도 배치가 크면 똑같이 터진다.
★★★ 대리 지표에 상한을 걸면 두 가지가 같이 온다 — 막아야 할 것을 못 막고, 막지 말아야 할 것을 막는다. 여기서는 큰 배치를 못 막았고, 보호 목록을 막았다. 둘 다 같은 뿌리다.
★★ 대리 지표는 측정이 쉬워서 고르는 것이지 맞아서 고르는 게 아니다. → 직접 지표를 측정할 수단이 생기면 상한을 그쪽으로 옮겨라. 우리는 가드가 동시 실행 수의 피크를 로그에 남기게 하고, 빈도 상한은 느슨하게 풀었다.
★★★ 마지막으로 — "불가능하다"가 "우리가 안 하고 있었다"였다
이 모든 것의 전제가 "세션끼리 상태를 공유할 방법이 없다"였다. 틀렸다. 공유 저장소는 이미 서버에 있었고 모든 세션이 읽을 수 있었다. 막고 있던 것은 매체가 아니라 쓰기 시점 — 세션이 끝날 때 한 번 커밋하니 진행 중 변경이 아무에게도 안 보였을 뿐이다.
★★★ 공유 매체의 지연은 매체의 성질이 아니라 쓰기 정책의 결과다. 상태 파일을 주기적으로 쓰고 푸시하게 바꾸자 "비동기 2시간"이 "준실시간"이 됐다.
★★ 이 컬렉션에는 우리 코드의 기본값을 플랫폼 제약으로 읽은 항목이 이미 있다. 같은 형태다 — 제약이라 믿은 것이 우리 습관이었다. ★ "그건 불가능하다"를 적기 전에 "우리가 시도한 적이 있나"를 물어라.
★★★ 반년을 모으니 이 형태가 넷이다 — 우리 파이프의 기본값을 플랫폼 상한으로, 우리 추정 비용을 고정비로, 404를 기능 부재로, 그리고 "상태 공유 불가"를 매체의 한계로. 넷 중 셋은 우리 것이고 하나는 남의 것인데, 넷 다 똑같이 느껴졌다.
★★★ 출처가 적혀 있지 않으면 내 가정과 남의 제약은 구별되지 않는다. → 제약을 문서에 쓸 때 출처를 같이 써라 — 플랫폼 문서 URL인가, 우리 코드의 상수인가, 우리 추정인가. 셋을 같은 문장으로 적으면 나중에 구별할 방법이 없다.
★★ 마지막으로, 파생값에는 유도 근거를 붙여라
새 상한을 정하면서 배치 크기를 600으로 잡았다. 그 600은 작업 타임아웃 280초에서 나온 수다 — 그보다 큰 배치는 어차피 못 끝낸다.
★★★ 타임아웃을 바꾸는 사람은 600을 고쳐야 한다는 걸 모른다. 한 파라미터가 여러 곳의 의미를 결정하는데 그 연결이 어디에도 안 적혀 있으면, 파생값은 원본과 함께 움직이지 않는다. → 값 옆에 600 = timeout 280s에서 유도라고 적는다. ★ 이 컬렉션의 기준선에 모집단을 함께 저장하라와 같은 규칙이다 — 숫자만으로는 부족하고, 그 숫자가 무엇에서 나왔는지가 값의 일부다.
★ 그리고 그 타임아웃은 전과가 있다. 같은 값이 앞서 락 해제를 건너뛰게 만든 장본인이다. 여러 곳이 매달린 값일수록 손대기 쉬운 자리에 있다.