클라이언트를 절대 믿지 마세요: 앱을 혼자 개발하며 얻은 5개월간의 9가지 프로덕션 교훈
요약
1인 창업자가 AI 코딩 에이전트를 활용해 앱을 개발하며 겪은 5개월간의 실전 교훈을 공유합니다. 클라이언트 측 보안의 취약점과 LLM 프롬프팅 시 명시적 지시의 중요성을 강조합니다.
핵심 포인트
- 클라이언트의 데이터는 신뢰할 수 없으므로 모든 권한 검증은 서버에서 수행해야 함
- 데이터베이스의 행 수준 보안(RLS) 설정이 올바른지 반드시 직접 테스트해야 함
- LLM 프롬프트 작성 시 응답 언어와 형식을 고통스러울 정도로 명시해야 함
- AI 에이전트 활용 시 개발자의 역할은 타이핑에서 사고(Thinking)로 전환됨
저는 1인 창업자입니다. 저의 유일한 팀원은 AI 코딩 에이전트(AI coding agent)입니다. 5개월 동안 우리 둘은 Flutter + Supabase를 사용하여 당신의 저녁 메뉴를 골라주는 앱을 약 **40회 출시(releases)**했습니다.
사람들은 그것이 AI가 앱을 작성하고 저는 커피를 마시기만 한다는 뜻이라고 생각합니다. 하지만 정반대입니다. AI는 취향은 전혀 없지만 손은 매우 빠르기 때문에, 병목 현상(bottleneck)이 _타이핑(typing)_에서 _사고(thinking)_로 옮겨갔습니다. 그리고 이것이 바로 제가 있어야 할 지점입니다. 그리고 사고(thinking) 과정에서 저는 이 모든 실수들을 저질렀습니다.
이 실수들은
2. 클라이언트를 절대 믿지 마세요. 서버에서 검증하고, 각 행(row)을 읽을 수 있는 권한을 제한하세요.
제 앱에는 프리미엄 티어(premium tier)가 있습니다. 버그는 이랬습니다: 사용자가 프리미엄인지 여부를 결정하도록 _휴대전화(phone)_에 맡겨버린 것입니다. 클라이언트가 제 서버에 "저 결제한 고객이에요"라고 말하면, 서버는 그냥... 그걸 믿어버렸습니다. 아무런 증거도 없이 말이죠.
무엇을 하는지 아는 사람이라면 누구나 그 플래그(flag)를 뒤집어서 영원히 프리미엄 기능을 무료로 이용할 수 있었습니다.
하지만 그 문제를 수정하던 중, 그 밑에 숨겨진 더 무서운 문제를 발견했습니다. 제 데이터베이스의 행 수준 보안(row-level security, RLS)에 허점이 있어, 이론적으로 사용자가 다른 사람의 데이터를 읽을 수 있다는 것이었습니다. 이것은 기능 누락 버그가 아닙니다. 누군가 피해를 입을 수 있는 버그입니다.
저는 모든 권한 확인(entitlement check)을 서버(사용자가 건드릴 수 없는 곳)로 옮겼고, 프리미엄 상태를 클라이언트의 말 대신 결제 제공업체의 웹훅(webhook)에 연결했으며, 모든 테이블의 읽기 정책(read policy)을 감사(audit)했습니다.
적용하기: 사용자가 자신의 기기(휴대전화, 브라우저, 네트워크 탭 등)에서 실행되는 모든 것에 거짓말을 할 수 있다고 가정하세요. 실제로 그럴 수 있기 때문입니다. 권한 부여(Authorization)는 서버에 존재해야 합니다. 그리고 "행 수준 보안이 켜져 있다"는 것이 "행 수준 보안이 올바르게 설정되어 있다"와 같은 의미는 아닙니다. 사용자 A가 사용자 B의 행을 읽을 수 없는지 실제로 테스트하세요.
3. LLM을 사용할 때, 비워둔 모든 빈칸은 혼돈으로 채워집니다.
초기에 제 앱은 저녁 메뉴를 추천하기 시작했는데... 중국어로 추천했습니다. 영어 사용자들에게 말이죠. 아무도 중국어를 요청하지 않았습니다. 저도 중국어를 못 합니다.
무슨 일이 일어났냐면: 모델 프롬프트(model prompt)를 설정할 때, 어떤 언어로 응답해야 하는지 명시적으로 말해주지 않았습니다. 그리고 LLM은 빈칸을 그냥 빈칸으로 남겨두지 않습니다. 자신이 원하는 대로 자신 있게 채워버립니다. 그래서 언어를 하나 골랐고, 때로는 중국어로, 때로는 무엇인지도 모를 언어로 응답했습니다.
해결책은 단 한 줄이었습니다: respond in English (영어로 응답하세요).
이것이 프로덕션 (production) 환경에서 프롬프팅 (prompting)을 수행하는 일의 전부입니다. 모델은 당신의 마음을 읽는 것이 아니라, 당신이 남겨둔 빈틈을 패턴 매칭 (pattern-matching)할 뿐입니다. (몇 달 후 동일한 모델은 이 교훈의 속편을 가르쳐 주었습니다. 오늘날의 "사고하는 (thinking)" 모델들은 당신의 출력 예산 (output budget)을 소모하며 숨겨진 추론 토큰 (reasoning tokens)을 태웁니다. 따라서 이를 명시적으로 줄이지 않으면, 응답이 조용히 잘려 나갑니다 (truncate). 또 다른 빈칸, 또 다른 의외의 상황이 발생하는 것이죠.)
적용하기: 고통스러울 정도로 명시적이어야 합니다. 언어, 형식, 길이, 어조, 불확실할 때 어떻게 행동해야 하는지 — 이 모든 것을 고정하세요. 그런 다음 테스트 단계에서 모델에 쓰레기 데이터와 적대적 입력 (adversarial inputs)을 주입하여 모델이 그 빈틈을 어떻게 처리하는지 지켜보세요. 당신의 사용자들이 반드시 그 빈틈을 찾아낼 것이기 때문입니다.
4. 그 무엇보다 돈이 흐르는 경로를 더 철저히 모니터링하세요.
이유: 스타트업 초기, 앱이 스토어에 구독 가격을 요청했는데 그 요청이 조용히 실패했고, 코드는 결코 재시도 (retry)하지 않았습니다. 결과적으로 일부 사용자들에게는 비즈니스 전체에서 가장 중요한 단 하나의 화면이 고장 난 채 아무것도 보여주지 못했습니다.
저는 전혀 몰랐습니다. 아무도 당신에게 "돈을 지불하려 했는데 안 됐어요"라고 이메일을 보내지 않습니다. 그들은 그냥 떠날 뿐입니다. 저는 정확히 그 시점에 재시도 로직과 모니터링을 추가하여 문제를 해결했지만, 처음에 얼마나 많은 사람을 놓쳤는지는 영원히 알 수 없을 것입니다.
적용하기: 누군가 당신에게 결제하려고 시도하는 순간을 앱 내의 그 어떤 이벤트보다 더 강력하게 계측 (instrument)하세요. "가격이 포함된 페이월 (paywall) 렌더링"을 명시적인 성공 지표로 추적하고, 이 수치가 떨어지면 알림을 받도록 설정하세요. 그리고 단 한 번의 네트워크 호출 (one-shot network call)이 재시도 없이 당신의 수익을 책임지게 두지 마세요. 조용한 수익 손실은 버그로 나타나지 않기 때문에, 세상에서 가장 비용이 많이 드는 버그입니다.
5. 가볍게 출시하세요. 사용하지 않는 모든 권한, 라이브러리, 그리고 코드 한 줄은 부채 (liability)입니다.
Apple은 제 앱을 거절했습니다. 세 가지 이유가 있었으며, 이는 당신이 앱을 제출하기 직전이라면 모두 유용한 정보입니다:
- 내 구독 상품(Subscription products) 설정이 App Store Connect와 코드상에서 정확히 일치하지 않았습니다 (가격과 약관이 완벽하게 일치해야 합니다).
- 유료 앱을 승인받기 전에 온라인에 공개된 환불 정책(Refund-policy) 페이지가 필요했습니다.
- 제가 가장 좋아하게 된 이유입니다: 심지어 사용하지도 않는 위치 및 추적 코드(Location and tracking code) 때문에 플래그가 지정되었습니다. 그것은 버려진 아이디어에서 남겨진 데드 코드(Dead code)였고, 그냥 그 자리에 놓여 있었을 뿐입니다. Apple은 API 참조를 보고 제가 사람들을 추적한다고 가정하여 거절했습니다.
저는 데드 코드를 뽑아내고, 구독 설정을 수정하고, 정책 페이지를 게시한 뒤 다시 제출하여 승인을 받았습니다.
적용하기: 당신이 선언하는 모든 권한, 연결하는 모든 SDK, 남겨두는 모든 코드 한 줄은 리뷰어, 보안 감사, 그리고 미래의 당신 자신에게 방어해야 하는 대상입니다. 누군가가 강요하기 전에 사용하지 않는 것은 삭제하세요. 거절은 실패가 아닙니다; 그것은 당신이 존재하는지 몰랐던 체크리스트입니다.
6. AI 모델을 교체 가능한 상품(Swappable commodity)으로 취급하세요. 특정 제공업체와 결혼하지 마세요.
저녁 메뉴를 골라주는 모델은 예전에 한 제공업체의 것이었습니다. 잘 작동했지만, 조금 느리고 조금 비쌌습니다. 그리고 앱의 모든 마법이 _속도_에 달려 있는 앱에게 느림은 곧 죽음입니다.
저는 어느 오후에 다른 회사의 다른 모델로 그것을 교체했습니다. 동일한 앱, 동일한 기능, 추천 결과가 약 42% 더 빠르게 돌아오며, 실행 비용은 더 적게 듭니다.
그 작업이 재작성이 아닌 단 한 오후 만에 끝날 수 있었던 유일한 이유는, 모델 ID를 코드 내 정확히 한 곳에 두고 모델 뒤에 얇은 프록시 계층(Proxy layer)을 두었기 때문입니다. 새로운 모델은 몇 달마다 출시되며, 각각은 이전 모델보다 더 저렴하거나 더 빠릅니다. 만약 당신의 제공업체가 40개의 호출 지점(Call sites)에 용접되어 있다면, 당신은 그 혜택을 누릴 수 없습니다.
적용하기: 앱과 모든 LLM 사이에 하나의 이음새(프록시 함수, 인터페이스 등 무엇이든)를 두세요. 모델 이름을 단일 상수(Constant)로 유지하세요. 그러면 "새 모델이 2배 더 저렴하다"는 상황은 프로젝트가 아닌 설정 변경(Config change)이 됩니다. 보너스: 아무도 "42% 더 빠름"이라는 스크린샷을 올리지는 않지만, 당신의 사용자들은 이름을 붙일 수는 없어도 보이지 않는 승리를 느낍니다.
7. 뺄셈도 기능입니다. 코드를 삭제하는 것이 본업이지, 업무의 휴식기가 아닙니다.
이번 달 제가 했던 일 중 가장 좋았던 일은 새로운 기능을 만드는 것이 아니었습니다. 바로 제가 작성한 코드 2,012줄을 삭제한 것이었습니다.
끝내지 못한 오래된 실험들, 더 이상 존재하지 않는 문제들에 대한 영리한 해결책들, 그리고 같은 일을 수행하는 세 가지의 서로 다른 방식들. 저는 그 모든 것을 뜯어냈고, 앱은 이전과 정확히 똑같이 작동합니다. 기능도, 속도도 동일하지만, 버그가 숨어들 수 있는 표면적(surface area)만 줄어들었습니다.
남겨두는 모든 줄은 당신이 영원히 유지보수하고, 디버깅하며, 짊어지고 가야 할 줄입니다. 코드가 적을수록 부채(liability)도 적어집니다. 이것은 코딩만의 특이한 습관이 아닙니다. 작가는 문단을 삭제하고, 디자이너는 요소를 제거합니다. 모든 창의적인 작업에서 어렵고 가치 있는 기술은, 제 자리를 증명하지 못하는 것을 제거할 수 있는 배짱입니다.
적용하기: 기능을 계획하듯 삭제도 계획하세요. 만약 어떤 코드 경로(code path)가 몇 달 동안 그 가치를 증명하지 못했다면, 그것은 자산이 아니라 깔끔하게 머리만 다듬은 부채(debt)일 뿐입니다.
8. 되돌릴 수 있는 베팅을 하고, 실제로 되돌릴 수 있는 배짱을 가지세요.
한번은 오전 9시에 마케팅 사이트에 대대적인 업데이트를 배포하고 기분이 좋아 커피를 마셨습니다. 그러다 낯선 사람의 시선으로 사이트를 다시 보게 되었고, 제가 사람들이 전달받기를 원했던 단 하나의 명확한 메시지를 흐리고 있다는 사실을 깨달았습니다. 그것은 나쁜 것이 아니었습니다. 단지 지금 이 순간에는 잘못된 것이었습니다.
저는 그날 바로 모든 것을 되돌렸습니다(revert). 몇 시간 동안의 작업이 무효가 되었습니다.
빠르게 배포하는 것(Shipping fast)은 항상 주목받지만, 언제 빠르게 _배포를 취소(un-ship)_해야 하는지를 아는 것 역시 그만큼 중요하며 거의 논의되지 않습니다. 함정은 매몰 비용(sunk cost)입니다. "내가 여기에 공을 들였으니, 계속 유지해야 해."라고 생각하는 것이죠. 하지만 작업은 어느 쪽이든 이미 지나간 일입니다. 남은 유일한 질문은 그것을 유지하는 것이 제품을 더 좋게 만드는가 하는 점뿐입니다.
적용하기: 깔끔하게 롤백(roll back)할 수 있는 변경 방식을 선호하세요 (피처 플래그(feature flags), 작은 PR, 결합도가 낮은 배포(decoupled deploys)). 그런 다음, 이미 투입한 노력이 아니라 현재의 제품 상태를 기준으로 배포된 변경 사항을 판단하세요. 당신의 코드와 사랑에 빠지지 마세요.
9. 구축의 대부분은 완벽해야만 하는 보이지 않는 배관 작업(plumbing)입니다. 그것이 본업이지, 우회로가 아닙니다.
내가 했던 가장 빛나지 않았던 일들 중, 사용자들은 절대 나에게 고맙다고 말해주지 않을 것들이에요:
- 웹 결제, 세금, 환불, 다국가 처리가 실제로 제대로 작동하도록 한 주에 걸쳐 결제 시스템을 두 번 마이그레이션했습니다. 가장 지루하고 가장 중요한 앱의 부분을 7일 만에 완전히 다시 작성한 것이죠.
- 3개월 동안 SEO 작업에 매달렸습니다. 페이지, 작은 태그, 리디렉트, 구조화된 데이터, 하나의 키워드를 위해 같은 게시물을 네 번이나 재작성했습니다. 도파민은 제로였죠. 하지만 세상에서 가장 좋은 앱을 만들어도 아무도 찾지 못하면 존재하지 않는 것과 같습니다. 아무도 발견하지 못한 훌륭한 제품은 제품이 아니라 비밀일 뿐입니다.
- 직접 설치한 적 없는, 깊숙한 세 레벨의 전이 의존성(transitive dependency) 하나가 깨진 배포를 추적하는 데 하루를 보냈습니다. 이 의존성이 조용히 제 웹 빌드와 호환되지 않게 되어 제 사이트 두 개 모두를 다운시켰죠. 제 코드는 문제가 없었습니다. 버그는, 언제나 그랬듯이, 제가 생각했던 곳에 있지 않았습니다.
'빌드를 공개하는(build-in-public)' 하이라이트는 반짝이는 기능들과 출시일의 샴페인 파티가 전부입니다. 실제 업무는 대부분 이렇습니다: 아무도 20%를 보기 전에 작동해야 하는, 빛나지 않는 80% 말이죠.
적용해 보세요: 만약 '실질적인 진전' 대신 인프라(infra), 세금, 예외 케이스, 배포에 매달리고 있다고 느껴진다면 — 당신은 뒤처지고 있는 것이 아닙니다. 그 자체가 바로 업무입니다. 의존성 버전을 고정하고,
node_modules/pubspec에 실제로 무엇이 들어있는지 읽어보고, 배포를 사후 작업이 아니라 제품의 절반으로 취급하세요.
버그는 아니지만 배우는 데 가장 많은 비용이 드는 두 가지 더
정직함은 복리로 쌓이지만, 가짜 사회적 증거(Social Proof)는 신뢰를 탕진합니다. 제가 서비스를 출시했을 때, 모든 템플릿은 "후기를 추가해서 더 커 보이게 만드세요"라고 외치고 있었습니다. 그래서 제 사이트에 자리 표시용 리뷰와 부풀려진 사용자 수를 올려두었고, 나중에 수정하겠다고 스스로 다짐했지만 사이트를 열 때마다 기분이 조금 찝찝했습니다. 결국 저는 그 모든 것을 삭제하고 훨씬 적은 실제 수치를 게시했습니다. 사람들은 가짜를 알아챕니다. 누군가 가짜 숫자 하나를 발견하는 순간, 그들은 진짜 숫자조차 믿지 않게 됩니다. 작더라도 정직한 것이 크지만 가짜인 것보다 낫습니다. 왜냐하면 정직함은 거대 기업들이 보통 제공하지 못하는, 아주 작은 앱이 제공할 수 있는 유일한 것이기 때문입니다.
사용자가 당신과 같을 것이라고 가정하지 마세요. 저는 우크라이나인입니다. 코딩할 때 영어로 생각하기 때문에 영어 전용으로 출시했습니다. 하지만 지구상의 대부분의 사람들은 영어로 브라우징하지 않습니다. 이제 이 앱은 15개 언어를 지원하며, 단순히 버튼만 번역하는 게으른 방식이 아니라 실제 요리 이름과 레시피를 실시간으로 다시 작성합니다. 개발자로서 할 수 있는 가장 비용이 많이 드는 가정은 사용자가 당신과 같은 언어, 같은 전화기, 그리고 같은 삶을 공유할 것이라고 생각하는 것입니다. 그들은 그렇지 않습니다.
또한 저는 의도적으로 사용자들에게 무료로 제공하는 것을 줄였고(일일 무료 한도를 강화함), 모든 트래킹(Tracking)을 제거했습니다. 광고 SDK, 위치 정보, 앱 간 쿠키(Cross-app cookies) 등을 모두 없앴습니다. 왜냐하면 저는 계속해서 "앱을 좋게 만드는 데 실제로 이것이 필요한가?"라고 자문했고, 그 대답은 거의 항상 '아니오'였기 때문입니다. 무료가 무한할 수는 없으며, 그러면 서비스는 죽고 아무에게도 도움이 되지 않습니다. 그리고 사람들에게 매일 무엇을 먹는지 말해달라고 요청할 때는 데이터보다 신뢰가 더 가치 있습니다.
정직한 점수판
AI를 유일한 팀원으로 삼아 혼자서 보낸 5개월 동안, 저는 다음과 같은 일을 했습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기