솔로프리너 보안 감사 실패담: Critical 0건 뒤 숨어있던 데드코드, FortuneTab에서 발생한 실수와 교훈
실제 작업 경험 기반 — 20개 작업 로그에서 생성
솔로프리너로 일하며 가장 뿌듯한 순간 중 하나는 끈질긴 노력 끝에 보안 감사를 'Critical 0건'으로 통과했을 때입니다. 마치 산 정상에 오른 듯한 안도감과 성취감이 밀려오죠. 저 역시 최근 FortuneTab 2.0 프로젝트의 Phase 1 MVP 개발을 마무리하며 비슷한 기분을 느꼈습니다. 4라운드에 걸친 꼼꼼한 감사를 통과하고 '이제 정말 안전하구나' 하고 잠시 방심했죠. 하지만 바로 그 방심이 저를 다시 한번 깊은 고민에 빠뜨렸습니다.
모든 세션이 500 에러? 신규 코드에 숨어있던 데드코드의 그림자
이번 주, 저는 FortuneTab의 핵심 기능 중 하나인 AI 채팅 세션 진입 경로를 구현했습니다. 새로운 세션을 생성할 때 사용자의 크레딧을 5개 차감하고, 사주 프로필을 저장하며, 채팅 세션을 삽입하는 비교적 단순한 로직이었죠. 이를 위해 Supabase의 Edge Function create-session을 만들고, 내부적으로 service_role 키를 이용해 spend_credits RPC(원격 프로시저 호출)를 호출하는 방식을 사용했습니다.
타입 체크와 200개가 넘는 테스트 케이스를 모두 통과했고, 코드 리뷰에서도 minor suggestion 정도만 받을 정도로 문제가 없어 보였습니다. '이 정도면 문제없겠지' 하는 마음으로 배포 준비를 했습니다.
하지만 저는 늘 그랬듯, 최종 배포 전 한 번 더 저만의 '정밀 보안 감사' 프로세스를 돌렸습니다. 보안과 코드 무결성이라는 두 가지 축으로 감사 에이전트를 병렬 실행시켰죠. 그리고 예상치 못한 결과가 나왔습니다. 보안 감사 에이전트가 create-session 함수에서 치명적인 문제를 지적한 겁니다.
문제는 바로 Supabase 마이그레이션 파일 간의 교차 충돌이었습니다. 이전 마이그레이션 00005에는 spend_credits RPC 함수 내부에 이런 가드(guard) 구문이 있었습니다.
IF auth.uid() IS NULL OR auth.uid() != p_user_id THEN
RETURN jsonb_build_object('ok', false, 'error', 'unauthorized');
END IF;
이 가드는 service_role 키로 호출되는 경우에도 auth.uid()가 NULL이므로 항상 unauthorized 에러를 반환하게 되어 있었습니다. 그런데 더 최근에 작성된 마이그레이션 00013에는 이런 구문이 추가되어 있었습니다.
REVOKE ALL ON FUNCTION public.spend_credits(...) FROM authenticated, anon;
GRANT EXECUTE ON FUNCTION public.spend_credits(...) TO service_role;
이 두 조건을 겹쳐보니 충격적인 사실을 알 수 있었습니다. authenticated 사용자는 00013 때문에 spend_credits 호출 권한이 없고, service_role은 00005의 가드에 막혀 실행될 수 없는 상황이었죠. 결과적으로 spend_credits 함수는 어떤 경로로도 호출될 수 없는 완전한 데드코드가 되어버렸습니다. 만약 이대로 배포했다면, 모든 AI 채팅 세션 생성 요청은 500 에러로 실패했을 겁니다. 상상만 해도 아찔하죠.
두 마이그레이션은 각각 '타인의 크레딧 조작 방지'와 '내부 RPC의 외부 노출 방지'라는 명확하고 개별적인 보안 목표를 가지고 작성되었습니다. 각자 놓고 보면 완벽하게 올바른 코드였지만, 겹쳐 쌓인 뒤 아무도 이 둘의 상호작용을 교차 검증하지 않았던 것입니다. 솔로프리너로서 저의 가장 큰 약점이자 강점인 '나 혼자 모든 것을 한다'는 양면성이 극명하게 드러난 순간이었죠.
세 가지 선택지, 그리고 원자적 RPC 래퍼의 힘
긴급하게 세 가지 해결책을 검토했습니다.
00005가드 역전:service_role호출 시 가드를 우회하도록 허용. 하지만 이는 기존 보안 강화를 약화시키는 방향이었습니다.- REVOKE 부분 해제:
authenticated사용자에게도spend_credits호출 권한을 부분적으로 다시 부여.00013마이그레이션의 의도와 충돌할 수 있었습니다. - 원자적(Atomic) RPC 래퍼 신규:
create_chat_session_atomic이라는 새로운 RPC 함수를 만들어service_role만 호출 가능하게 하고, 그 안에서 크레딧 차감, 프로필 저장, 세션 삽입, 감사 기록 등 모든 작업을 단일 트랜잭션으로 처리. 저는 3번 옵션을 선택했습니다. 이 방법은 구조적으로 가장 견고하고 안전했습니다. 단일 트랜잭션으로 처리하면 부분 실패 시 자동으로 롤백되므로, 수동으로 크레딧을 환불해주는 복잡한 로직도 필요 없어집니다. 또한, 이 감사가 찾아낸 다른 이슈인 '동시 요청 시 감사 추적 race condition'이나 '클라이언트 주입 필드가 DB에 영속되는 위험'까지 한 번에 해결할 수 있었습니다. 결국 마이그레이션00014를 작성하고 Edge Function을 이 새로운 RPC 호출 한 줄로 간소화했습니다. 다시 208개 테스트를 모두 통과했고, TypeScript 에러는 0개가 되었습니다.
Critical 0건은 과거형일 뿐, 매번 다시 감사해야 하는 이유
이 사건을 겪으며 저는 중요한 교훈을 얻었습니다. 'Critical 0건 수렴'은 말 그대로 과거의 사실일 뿐, 미래의 보안을 보장하는 예언이 아니라는 겁니다. 모든 새로운 코드 한 줄, 모든 새로운 기능은 잠재적인 취약점을 다시 도입할 수 있습니다. 특히 솔로프리너는 개발부터 배포, 운영, 보안까지 모든 과정을 혼자 감당해야 하므로, 저처럼 모든 것을 "기억·수집·정리"하려는 원칙을 가지고 있지 않다면 언제든 예측 불가능한 사고를 만날 수 있습니다. 저는 이 경험을 통해 '매번 다시 감사하는 이유'를 명확히 깨달았습니다. 보안은 한 번의 이벤트가 아니라 지속적인 프로세스입니다. 새로운 코드가 추가될 때마다, 기존 시스템과의 상호작용을 깊이 있게 검토하고, 잠재적인 충돌이나 예상치 못한 부작용을 찾아내야 합니다. 이는 비단 보안뿐 아니라 모든 개발 과정에서 적용되는 원칙일 겁니다. 이번에 제가 운영하는 솔로프리너 블로그의 홈 화면 UI를 대대적으로 개편(이번 주 작업 기록에도 HeroSection 대폭 수정 및 PostGrid/PostCard 개선 내역이 보일 겁니다)한 것도, 기존 방식의 한계를 인지하고 더 나은 사용자 경험과 확장성을 위해 끊임없이 개선하려는 의지의 일환입니다. 다른 솔로프리너 분들께도 이 경험이 도움이 되기를 바랍니다. 아무리 작은 변경이라도, 기존 시스템에 어떤 영향을 미칠지 다각도로 검토하는 습관을 들이세요. 그리고 가능하다면 저처럼 자동화된 감사 도구나 최소한의 교차 검증 시스템을 갖추는 것을 추천합니다. 혼자 일하는 만큼, 실수는 치명적일 수 있습니다. 하지만 그만큼 모든 교훈은 오롯이 내 것이 됩니다. 이 글이 솔로프리너 여정에서 맞닥뜨릴 수 있는 '예측 불가능한 위기'를 대비하는 데 작은 빛이 되었으면 합니다. 오늘도 저는 이 교훈을 바탕으로 더 단단한 제품을 만들어나가고 있습니다. 다음 주에는 또 어떤 도전이 기다리고 있을지, 그리고 어떤 새로운 '작업 기록'을 공유하게 될지 기대해 주세요.

AI 자동화 & 1인 사업 전략가
AI와 자동화 도구를 활용해 1인 사업을 설계하고 운영하는 전략가. n8n, Claude, Gemini 기반의 콘텐츠 자동화 시스템을 직접 구축하고, 솔로프리너가 더 스마트하게 일할 수 있는 실전 가이드를 제공합니다.

