← 뉴스레터

2026-07-12

확인 창이 없으면 승인된 걸로 치던 코드

앱스토어 설정을 바꾸는 스크립트에는 확인 질문이 있었다. 실제 변경 모드로 실행하면 “계속하려면 yes를 입력하세요”라고 묻는다. 나는 그 문장을 안전장치라고 믿었다. 그런데 코드를 읽어보니 사람이 답할 수 없는 환경에서는 질문을 생략하는 정도가 아니라, 자동으로 승인 처리하고 있었다. 확인 창이 없으면 멈춘 게 아니라 통과한 셈이었다.

다행히 실제 사고로 이어지기 전에 스킬 감사를 하다 발견했다. 작은 dry-run으로 재현해보니 표준입력이 터미널이 아닌 경우 확인 함수가 곧바로 참을 반환했다. 파이프, CI, 원격 무인 실행처럼 사람이 키보드 앞에 없을수록 오히려 게이트가 더 쉽게 열렸다.

코드에는 나름의 이유가 있었다.

“무인 실행까지 왔다는 건 앞 단계에서 이미 승인을 받았을 것이다.”

그 문장은 운영 관례였고, 함수는 그 관례를 사실로 가정했다. 하지만 승인받았다는 증거는 어디에도 없었다.

안전장치가 가장 필요한 곳에서 사라졌다

문제의 함수는 구조가 단순했다.

if not sys.stdin.isatty():
    return True

터미널이 있으면 사용자에게 yes를 묻는다. 터미널이 없으면 참을 반환한다.

작성 당시에는 편리했을 것이다. 자동화 세션에서 입력 대기 상태로 멈추는 걸 피할 수 있다. 이미 상위 작업에서 승인을 받았는데 하위 스크립트가 또 질문하면 파이프라인이 깨질 수 있다. 그래서 “사람이 답할 수 없으면 이미 확인된 것으로 본다”는 예외를 넣었을 가능성이 크다.

하지만 실행 경로가 앱스토어 API의 실제 변경 모드였다. 판매 국가를 바꾸거나, 거절된 제출을 취소하고 다시 연결하거나, 심사 응답을 보내는 도구가 같은 확인 함수를 썼다. dry-run이 기본이어서 평소에는 안전했지만, --apply가 붙는 순간 외부 상태를 바꿀 수 있었다.

가장 위험한 동작 앞의 문이, 사람이 없는 환경에서 가장 약했다.

이건 흔한 형태의 결함이다. 대화형 환경을 기본으로 만든 뒤 자동화를 덧붙이면서, 질문할 수 없는 상황을 “질문이 필요 없는 상황”으로 바꿔버린다. 입력 장치의 부재가 승인의 존재로 둔갑한다.

마이크가 없다고 대답이 “예”가 되는 것은 아니다.

확인 질문은 권한 증거가 아니다

나는 오랫동안 확인 프롬프트와 승인 게이트를 비슷하게 생각했다. 둘 다 실행 직전에 멈추고 사람에게 묻기 때문이다.

하지만 둘은 다르다.

확인 프롬프트는 사용자 인터페이스다. 지금 키보드 앞에 있는 사람에게 질문하는 방법이다. 터미널이 없으면 그 인터페이스를 사용할 수 없다.

승인 게이트는 권한 검증이다. 이 행동을 실행해도 된다는 근거가 있는지 확인하는 절차다. 터미널이 없어도 근거는 있어야 하고, 근거가 없으면 멈춰야 한다.

대화형 실행에서는 yes 입력이 두 역할을 동시에 한다. 사람이 보고 있다는 표시이자, 행동을 허용한다는 증거다. 그래서 둘의 차이가 잘 드러나지 않는다.

무인 환경에서는 역할이 갈라진다. 물어볼 화면은 없지만, 그렇다고 허가가 생기는 건 아니다. 오히려 별도 전달된 승인 증거가 필요하다.

이번 수리의 핵심은 프롬프트를 더 잘 만드는 게 아니었다. 프롬프트를 쓸 수 없는 환경에서 권한을 어떻게 증명할지 정하는 것이었다.

기본값을 거부로 돌렸다

첫 번째 원칙은 간단했다.

비대화 환경은 fail-closed.

사람이 답할 수 없고 별도 승인 증거도 없으면 실제 변경 모드를 열지 않는다. 스크립트는 왜 막혔는지 말하고 종료한다. 자동화가 멈추는 비용을 감수한다.

추가 정보가 없으면 자동 작업이 멈추므로 사용성은 나빠진다. 그 대신 모르는 승인을 지어내지 않는다. 잘못 멈춘 작업은 사람이 다시 열 수 있지만, 잘못 진행된 스토어 변경은 심사 흐름을 꼬이게 만들 수 있다.

승인값을 대상에 묶었다

비대화 환경을 모두 막기만 하면 무인 자동화 자체가 불가능해진다. 이미 사람이 승인한 작업도 매번 터미널을 붙여야 한다. 그래서 두 번째 원칙이 필요했다.

승인 증거를 전달하되, 대상에 묶는다.

수리된 경로에서는 환경변수 하나로 승인을 전달할 수 있다. 중요한 건 값이 단순한 1이나 true가 아니라, 지금 변경하려는 앱의 식별자와 정확히 같아야 한다는 점이다.

예를 들어 A 앱을 승인한 값은 B 앱 작업을 열 수 없다. 이전 세션에 남은 포괄적인 YES=1이 다음 작업까지 통과시키지 못한다. 실행 대상과 승인 대상이 일치해야 한다.

이걸 스코프된 승인이라고 부른다.

승인에는 누가, 어떤 행동을, 어느 대상에, 언제까지 허용했는지가 붙어야 한다. 이번 수리는 그중 대상을 코드로 강제했다. 완벽한 승인 시스템은 아니지만 “한 번 승인했으니 앞으로 모든 앱에 적용”되는 담요 승인을 막았다.

권한은 넓을수록 편하지만, 넓을수록 오래 남아 다른 작업에 재사용된다. 자동화에서는 승인을 가능한 한 좁게 만들어야 한다.

세 개의 테스트가 문을 고정했다

안전 게이트는 코드 리뷰만으로 믿기 어렵다. 다음 리팩터링에서 편의를 이유로 다시 열릴 수 있다. 그래서 결함을 세 장면으로 고정했다.

첫째, 비대화 환경에서 승인 증거 없이 --apply를 실행한다. 반드시 차단돼야 한다.

둘째, 다른 앱에 대한 승인값을 넣는다. 값이 존재하더라도 대상이 다르면 차단돼야 한다.

셋째, 현재 앱과 정확히 일치하는 승인값을 넣는다. 이때만 게이트가 열린다.

세 테스트는 실제 스토어 API를 호출하지 않고 네트워크 진입 전에 막히는지 확인했다. 호출자 셸에 남아 있을 승인 환경변수도 제거했다. 그렇지 않으면 “승인 없음” 테스트가 사실은 승인 있는 환경에서 돌 수 있다.

테스트는 코드만 격리하는 게 아니다. 권한과 환경도 격리해야 한다.

무인 자동화는 사람이 없다는 뜻이 아니다

무인 실행이라는 말을 들으면 사람이 빠진 시스템을 떠올리기 쉽다. 하지만 실제로는 사람이 앞 단계에 있다. 목표를 정하고, 범위를 고르고, 위험한 행동을 승인한다. 다만 실행 순간에 키보드 앞에 없을 뿐이다.

좋은 무인 자동화는 사람을 제거하지 않는다. 사람의 결정을 기계가 검증할 수 있는 형태로 운반한다.

“강대종이 아까 승인했음”이라는 산문은 실행 함수가 확인하기 어렵다. 대상 식별자, 행동 종류, 만료 시각이 들어간 기록은 확인할 수 있다. 이번에는 환경변수에 대상 식별자를 넣는 작은 방식으로 시작했다. 형태는 달라도 원리는 같다. 사람이 없어서 승인하는 게 아니라, 사람이 남긴 좁고 검증 가능한 증거가 있어서 실행하는 것이다.

배운 것

확인 창은 승인 시스템이 아니다. 사람이 앞에 있을 때 승인을 받는 한 가지 인터페이스일 뿐이다. 창이 없다고 승인까지 생기지는 않는다.

비대화 환경에서는 기본적으로 멈추고, 별도의 승인 증거가 있을 때만 열어야 한다. 그 증거는 가능하면 행동과 대상에 좁게 묶여야 한다. YES=1보다 “이 앱의 이 변경”이 낫다.

그리고 안전 게이트의 테스트는 허용 경로보다 거부 경로를 더 많이 봐야 한다. 증거 없음, 다른 대상의 증거, 정확한 대상의 증거. 이 세 장면이 고정돼야 편의 리팩터링이 다시 문을 열지 못한다.

무인 자동화는 사람 없는 자동화가 아니다. 사람의 결정을 잃어버리지 않고 끝까지 운반하는 자동화다.


물어볼 수 없다는 사실은, 허락받았다는 증거가 아니다.

— 강대종 / @ssamssae