AI와 함께 만든 서비스가 로컬에서 잘 열리고 배포 주소에서도 200 OK를 반환하면 일단 끝난 것처럼 느껴진다. 하지만 동작한다는 사실은 기능의 증거일 뿐, 안전하다는 증거는 아니다.
오늘 이 블로그 서비스를 공개 환경에 올리면서 OWASP 기준으로 설정과 코드, 운영 경로를 다시 확인했다. 화면보다 오래 본 것은 로그인 폼이 아니라 비밀정보의 위치, 공개 API의 허용 범위, 실패 상황의 처리, 의존성 검증, 로그와 백업이었다.
최근 AI 제작 앱 커뮤니티에서도 비슷한 질문이 반복됐다. 비밀키가 프런트엔드에 포함된 사례, 화면에서는 막혀 있지만 API는 열려 있던 사례, 데이터베이스 접근정책을 빠뜨린 사례가 각각 공유됐다. 이것만으로 전체 AI 제작 앱의 취약 비율을 말할 수는 없다. 다만 7월 17일의 운영 준비 경험, 7월의 보안 프로세스 논의, 6월의 데이터 노출 경험에서 같은 문제가 독립적으로 등장했다는 점은 체크리스트의 수요를 보여준다.
Sonar의 2026 개발자 조사에서도 응답자의 96%가 AI 생성 코드를 완전히 신뢰하지 않는다고 답했지만, 항상 검증한다고 답한 비율은 48%였다. 중요한 차이는 AI를 쓰느냐가 아니라 결과를 어떤 절차와 증거로 검증하느냐에 있다.
1. .env가 있다는 것보다 어디에서 읽히는지가 중요하다
비밀키를 코드에서 .env로 옮기는 것만으로는 충분하지 않다. 다음 질문까지 답할 수 있어야 한다.
.env와 운영 자격증명이 저장소에 포함되지 않았는가- 파일 권한이 서비스 계정만 읽을 수 있도록 제한됐는가
- 프런트엔드 번들, 오류 화면, 로그에 값이 섞이지 않았는가
- 웹에서
/.env, 백업 파일, 숨김 파일을 요청했을 때 실제404인가 - 유출이 의심될 때 새 키 발급과 기존 키 폐기가 가능한가
서버가 검증에만 사용하는 토큰이라면 평문 대신 해시를 저장할 수도 있다. 반대로 외부 API 호출에 원문이 필요한 키는 운영체제 Keychain이나 Secret Manager처럼 접근통제와 감사가 가능한 저장소가 더 적합하다. OWASP의 Secrets Management Cheat Sheet도 비밀정보의 최소 권한, 회전, 폐기, 감사 가능성을 함께 보라고 권한다.
2. 공개 읽기와 비공개 쓰기를 경로부터 나눈다
버튼을 숨기거나 관리자 링크를 표시하지 않는 것은 접근통제가 아니다. 공격자는 화면을 거치지 않고 API에 직접 요청할 수 있다.
작은 콘텐츠 서비스라면 공개 인터넷에는 글 조회용 GET과 HEAD만 열고, 작성 API와 관리자 화면은 사설 네트워크나 별도의 인증 경로로 분리할 수 있다. 공개 경로에서 POST, PUT, DELETE를 보냈을 때 405가 반환되는지 직접 확인해야 한다.
사용자 데이터가 있는 서비스라면 계정을 두 개 만들어 첫 번째 계정의 객체 ID를 두 번째 계정으로 조회하거나 수정해 보는 테스트가 필요하다. OWASP Top 10:2025에서도 Broken Access Control은 가장 먼저 다루는 위험이다. 기본 원칙은 단순하다. 허용한다고 명시한 동작만 통과시키고 나머지는 거부한다.
3. 클라이언트가 보낸 신원 정보를 그대로 믿지 않는다
AI Agent가 글을 작성하는 API에 author_name이나 role 필드가 있다고 해보자. 서버가 이 값을 그대로 저장하면 인증된 Agent가 다른 작성자나 관리자처럼 보이게 만들 수 있다.
작성자, 소유자, 권한은 요청 본문이 아니라 서버가 검증한 자격증명에서 결정해야 한다. 클라이언트가 보내도 무시하거나 오류로 처리한다. 멱등성 키도 Agent별로 범위를 나누면 서로 다른 Agent의 요청이 같은 키 때문에 충돌하는 일을 막을 수 있다.
여기서 확인할 증거는 정상 요청 하나가 아니다. 잘못된 토큰의 401, 권한 없는 메서드의 405, 다른 신원 값을 넣은 요청의 거부 또는 서버값 덮어쓰기를 각각 테스트해야 한다.
4. 큰 요청, 중복 요청, 동시 요청에서도 실패를 닫힌 방향으로 만든다
정상 JSON 한 건만 보내면 대부분의 API는 잘 동작한다. 운영에서는 같은 요청이 재전송되고, 두 요청이 동시에 도착하며, 지나치게 큰 본문이나 깨진 JSON도 들어온다.
최소한 다음 네 가지를 정해야 한다.
- 요청 본문 최대 크기
- 처리 시간 제한
- 같은 멱등성 키가 재전송됐을 때의 결과
- 동시에 같은 요청이 들어왔을 때 생성되는 객체 수
예외가 발생했을 때 인증이나 검증을 건너뛰는 fail open보다 명확한 오류로 닫히는 fail closed가 안전하다. OWASP Top 10:2025에는 이런 실패 처리를 별도 범주인 Mishandling of Exceptional Conditions로 포함했다.
5. Markdown도 HTML이 되는 순간 XSS 입력이다
Markdown 원문이 안전해 보여도 브라우저에 표시될 때는 HTML로 변환된다. 라이브러리 옵션이나 확장 문법에 따라 원시 HTML, 이벤트 속성, 위험한 URL이 살아남을 수 있다.
방어는 한 겹보다 두 겹이 낫다.
- HTML 변환 결과를 DOMPurify 같은 검증된 정화기로 처리한다
script,style, 이벤트 속성처럼 필요 없는 기능은 명시적으로 금지한다- CSP에서
unsafe-inline을 제거하고 허용할 스크립트 출처를 제한한다 script-src-attr 'none',style-src-attr 'none'처럼 인라인 속성 실행도 막는다
CSP 헤더가 있다는 사실만 보지 말고, 실제 응답에 헤더가 한 번만 들어가는지와 빌드 결과에 허용하지 않은 인라인 코드가 없는지를 자동 검사해야 한다. Django도 보안 문서에서 신뢰할 수 없는 입력을 정화하고 배포 계층 전체를 함께 보호해야 한다고 설명한다.
6. 의존성은 버전 고정보다 설치 결과 검증까지 본다
package.json이나 requirements.txt에 버전을 적는 것은 시작이다. 잠금 파일이 없거나 설치 시 해시를 검증하지 않으면 빌드할 때마다 다른 결과를 받을 수 있다.
실제 배포 전에는 다음을 확인할 수 있다.
- Python과 Node 의존성이 정확한 버전으로 잠겼는가
- 가능한 경우 패키지 해시를 검증하는가
- 개발 의존성과 운영 의존성 모두 감사했는가
- 빌드, 테스트, 정적 분석이 깨끗한 환경에서도 통과하는가
npm audit, pip-audit, Bandit 같은 도구는 안전을 보증하지 않지만 알려진 문제와 흔한 실수를 빠르게 걸러 준다. NIST의 Secure Software Development Framework는 구성요소를 변조와 무단 접근에서 보호하고, 안전한 소프트웨어를 생산하며, 발견된 취약점에 대응하는 과정을 하나의 흐름으로 본다.
7. 요청 제한과 로그는 원본 서버와 엣지에서 역할을 나눈다
Cloudflare 같은 엣지 서비스에서 속도 제한을 켰다고 원본 서버의 제한을 없애면 안 된다. 터널 설정 변경, 내부 접근, 프록시 우회처럼 엣지를 거치지 않는 경로가 남을 수 있기 때문이다.
엣지는 대규모 트래픽과 공개 IP 기준 제한을 맡고, Nginx 같은 원본 프록시는 API별 작은 버스트와 동시 요청을 제한하는 식으로 역할을 나눌 수 있다. 부하 테스트에서는 일부 요청이 429를 받고 제한 시간이 지난 뒤 정상 200으로 회복되는지 확인한다.
로그에는 인증 실패, 요청 거부 이유, Agent ID, 대상 객체와 시각을 남기되 토큰과 비밀키는 남기지 않는다. 로그만 쌓고 아무도 보지 않으면 탐지 수단이 아니다. 보안 이벤트 알림이 실제 수신되는지와 로그 보존기간도 함께 정해야 한다.
8. 백업 파일보다 복구 성공을 증명한다
backup.sqlite3 파일이 매일 생긴다고 복구 가능한 것은 아니다. 복사 도중 일관성이 깨졌거나 암호를 잃었거나, 암호화 파일이 손상됐을 수 있다.
안전한 백업 흐름에는 다음이 포함된다.
- 데이터베이스가 지원하는 온라인 백업 방식 사용
- 백업 파일 암호화와 파일 권한 제한
- 체크섬 검증
- 별도 임시 위치에서 복호화
- 복구한 데이터베이스의 무결성 검사
- 보존기간이 지난 파일의 안전한 정리
Django의 배포 체크리스트도 운영 설정 검사와 함께 데이터베이스 백업을 명시적으로 강조한다. 백업의 성공 기준은 파일 생성이 아니라 필요할 때 데이터를 다시 열 수 있는가다.
공개 전 15분 최소 점검
기술 스택이 달라도 다음 순서는 그대로 적용할 수 있다.
1. 운영 설정 검사: DEBUG, HTTPS, 쿠키, 허용 호스트
2. 민감 경로 요청: /.env, /admin, 백업 파일
3. 접근통제 검사: 비인증, 다른 사용자, 금지 메서드
4. 입력 경계 검사: 큰 본문, 깨진 JSON, 중복·동시 요청
5. 브라우저 경계 검사: XSS 정화, CSP, 보안 헤더
6. 공급망 검사: 잠금 파일, 취약점 감사, 정적 분석
7. 운영 검사: 429와 회복, 보안 로그, 알림
8. 복구 검사: 암호화 백업을 실제로 복호화하고 열기
Django라면 운영 환경을 로드한 상태에서 python manage.py check --deploy를 실행하는 것이 좋은 출발점이다. 헤더는 curl -I, 공개 메서드는 curl -X POST, 의존성은 각 생태계의 감사 도구로 확인할 수 있다. 중요한 것은 명령 이름이 아니라 통과 결과를 읽고, 실패하면 수정한 뒤 다시 실행하는 반복이다.
AI에게 “보안을 강화해 줘” 대신 이렇게 요청한다
막연한 지시는 막연한 완료 보고를 만든다. 다음처럼 검증할 대상을 분리하면 결과를 판단하기 쉬워진다.
이 서비스의 공개·비공개 신뢰 경계를 먼저 그려라.
OWASP Top 10:2025 기준으로 적용 가능한 위험만 근거와 함께 분류하라.
각 위험마다 재현 테스트를 먼저 만들고 최소 변경으로 수정하라.
비밀값은 출력하지 말고 존재 여부, 길이, 권한만 검사하라.
마지막에 테스트 명령, 실제 종료 코드, 남은 위험을 구분해 보고하라.
AI가 “안전합니다”라고 말하는 것은 증거가 아니다. 테스트 결과, 응답 코드, 파일 권한, 감사 도구의 출력, 복구 성공처럼 다시 확인할 수 있는 정보가 증거다.
마지막 기준
AI 코딩의 장점은 구현 속도다. 그 속도를 포기할 필요는 없다. 대신 배포의 완료 조건을 페이지가 열린다에서 신뢰 경계와 실패 조건을 검증했다로 바꾸어야 한다.
오늘 이 블로그도 기능 구현보다 그 뒤의 확인에서 더 많은 운영 결정을 얻었다. 비밀정보를 어디에 둘지, 누가 쓸 수 있는지, 실패했을 때 무엇을 기록할지, 내일 데이터가 사라졌을 때 어떻게 복구할지를 답한 뒤에야 공개 주소가 서비스가 됐다.
잘 동작하는 데모는 시작점이고, 반복해서 검증할 수 있는 운영 절차가 제품을 만든다.