기획부터 출시까지 혼자, 그리고 AI와 함께 만든 두 개의 서비스 이야기입니다.
왜 이 시기에 두 프로젝트를 몰아서 만들었고, AI를 어떻게 통제하며 협업했는지에 대한 기록입니다.
전역 이후 다시 개발자로 돌아오기 위해, 저는 전부터 구상해두었던 아이디어들을 직접 만들어보기로 했습니다. 기획도, 설계도, 개발도, 출시도 전부 혼자 해내는 완주에 도전한 것입니다.
군 복무를 하는 사이 세상은 빠르게 달라져 있었습니다. 제가 회사에 다닐 때까지만 해도 AI는 그저 코드를 파악하는 데 도움을 주는 정도의 도구였습니다.
스스로 코드를 온전히 작성하지도 못했고, 무언가를 맡기기에는 확실성이 부족한 보조 수단에 가까웠습니다. 그런데 전역하고 보니, AI는 어느새 프롬프트 하나만으로 앱 하나를 만들어낼 만큼 강력한 무기가 되어 있었습니다.
이런 급격한 변화를 마주하고 나니, 저는 두 가지 목표를 세우게 되었습니다.
하나는 AI를 개발에 제대로 활용하는 방법을 익히는 것,
다른 하나는 그 과정에서 서비스의 전반적인 구조를 스스로 파악하고 설계할 수 있는 개발자가 되는 것이었습니다.
단순히 코드를 짜는 사람을 넘어, 하나의 서비스를 처음부터 끝까지 조망할 수 있는 시야를 갖고 싶었습니다.
그 목표를 향해, 저는 성격이 서로 다른 두 개의 서비스를 동시에 만들기로 했습니다.
하나는 모바일 앱, 하나는 웹 서비스.
의도적으로 다른 플랫폼과 다른 문제를 골랐습니다.
서로 다른 환경을 겪어봐야 "서비스 전반을 보는 시야"라는 목표에 더 가까워질 수 있다고 생각했기 때문입니다.
솔직히 처음에는 저도 AI를 코드 생성기로만 썼습니다. "이 함수 짜줘", "이 컴포넌트 만들어줘." 확실히 빨랐습니다.
그런데 며칠 지나자 이상한 낌새가 들었습니다.
로직이 화면 컴포넌트 안에 들어가 있기도 하고, 데이터 처리를 해야 할 곳에 UI 코드가 섞여 있기도 했습니다.
AI에게 매번 "여기에 만들어줘"라고 말하지 않으면, 코드는 아무 데나 생겨났습니다.
무엇보다 무서웠던 건, 제가 이해하지 못한 코드가 프로젝트 안에 조금씩 쌓이고 있다는 것이었습니다.
이대로 가면 프로젝트가 커질수록 무너지겠다는 생각이 들었습니다. 그래서 방식을 바꾸기로 했습니다.
가장 먼저 한 일은 프로젝트 루트에 CLAUDE.md를 만드는 것이었습니다. Claude Code가 대화를 시작할 때마다 자동으로 읽는 파일입니다. 여기에 파일 구조부터 못 박았습니다.
Toonify에서는 이렇게 경계를 그었습니다.
src/
├── screens/ ← 화면만
├── services/ ← 데이터 처리만
├── utils/ ← 앱·서버 공통 로직만
└── components/ ← UI 컴포넌트만
이 한 장의 지도가 생기자, "이 함수는 어디에 두어야 하나"라는 질문이 사라졌습니다. AI도, 저도 같은 지도를 보고 움직이게 된 것입니다.
효과를 확인하고 나니 욕심이 생겼습니다.
그다음 만든 CrossStitch에서는 구조를 훨씬 더 촘촘하게 문서화했습니다.
폴더마다 역할을 주석으로 달아, AI가 어떤 파일이 무슨 일을 하는지 헷갈리지 않게 한 것입니다.
CrossStitch/
├── .github/workflows/
│ ├── ci.yml # lint → typecheck → test → deploy (main push 시)
│ └── daily-update.yml # 매일 자정 UTC 크론 (커밋 집계 업데이트)
├── __tests__/ # 그리드·홈 상태·뒤로가기 단위 테스트
├── app/
│ ├── api/
│ │ ├── cron/ # 크론 엔드포인트 (커밋 집계 → Firestore)
│ │ ├── github/ # OAuth·커밋·통계·유저 조회
│ │ └── readme-card/[uid]/ # 동적 SVG 카드 생성 API
│ ├── home/_components/ # 격자 에디터·홈 상태 관리·모달
│ ├── lib/ # firebase.ts / firebase-admin.ts(서버 전용)
│ └── src/
│ ├── hooks/ # useAuth · useGridPersistence 등
│ ├── providers/ # AuthProvider · StitchProvider
│ └── utils/
│ ├── gridLogic.ts # 그리드 셀 계산 로직
│ └── homeState.ts # 홈 상태 파생 순수 함수 (단일 소스)
├── CLAUDE.md
├── SPEC.md
└── TROUBLESHOOTING.md
그리고 CrossStitch에서는 파일 구조를 넘어 개발 절차 자체까지 문서에 적었습니다. "브랜치를 만들고 → 커밋하고 → PR을 올리고 → CI를 확인한다"는 흐름을요. 그랬더니 제가 "이 버그 고쳐줘"라고만 해도, AI가 알아서 새 브랜치를 만들고 커밋한 뒤 PR을 생성하기 시작했습니다.
문서로 경계를 그은 뒤에 얻은 건, 결국 하나였습니다. AI가 아무 데나 코드를 흩뿌리는 대신, 정해진 자리에 정해진 방식으로 일하기 시작했다는 것입니다.
절차를 문서로 정하고 나니, 정작 그 절차를 매번 사람이 챙기는 게 비효율적으로 느껴졌습니다. 브랜치 만들고, PR 올리고, CI 통과했는지 확인하고, 머지하고… 귀찮고 빠뜨리기 쉬운 일이었습니다.
그래서 CrossStitch에서는 이 절차들을 하나씩 자동화에 넘겼습니다. 원래 매번 손으로 챙기던 흐름은 이랬습니다.
브랜치 생성 → 기능 구현 → Commit → Push → Pull Request 생성
먼저, PR 생성을 자동화했습니다. non-main 브랜치에 push하면 GitHub Actions가 알아서 PR을 열어줍니다.
위 흐름에서 앞의 네 단계는 AI가 CLAUDE.md 규칙대로 수행하고, 마지막 PR 생성은 push를 감지한 Actions가 자동으로 처리하는 식입니다.
AI가 브랜치를 만들고 push하면, 제가 GitHub 페이지를 열지 않아도 PR이 이미 생성돼 있는 것입니다.
다음으로, 검증을 파이프라인으로 묶었습니다. 모든 PR은 lint → typecheck → test를 순서대로 거칩니다.
각 단계는 실패해도 멈추지 않고 끝까지 돌린 뒤, 마지막에 전체 결과를 한 번에 리포트하도록 했습니다.
그리고, 실패를 눈앞에 가져다 놓았습니다.
어느 단계에서 왜 실패했는지, 어떻게 고치면 되는지가 PR 코멘트로 자동으로 달립니다.
GitHub Actions 탭을 파고들 필요 없이, PR 페이지에서 바로 원인을 확인할 수 있게 된 것입니다.
마지막으로, 배포를 CI에 종속시켰습니다.
CI 전체를 통과한 코드만 배포되도록 묶었습니다. 테스트가 깨진 코드는 애초에 프로덕션에 올라갈 수 없습니다.
이 자동화가 준 진짜 선물은 시간이 아니라 집중력이었습니다.
"브랜치 맞나? PR 올렸나? CI 통과했나?"에 쓰던 신경을, 이제 코드의 의도와 구조를 들여다보는 데 쓸 수 있게 됐으니까요.
여유가 생기자 저는 한 가지 원칙을 세웠습니다. 내가 이해하지 못한 코드는 배포하지 않는다. 그래서 AI가 코드를 주면 "이 로직이 어떻게 동작하는지 설명해줘"를 먼저 물었습니다.
이 원칙이 왜 필요한지는 곧 실전에서 증명됐습니다. CrossStitch에서 셀을 무작위로 제거하는 함수를 AI가 만들었는데, 코드가 이랬습니다.
javascript
return suffled.slice(0, removeCount);
변수명 오타(suffled)는 눈에 띕니다. 진짜 문제는 slice(0, removeCount)였습니다. 이건 "removeCount개만 남기고 나머지를 버린다"는 뜻인데, 이 함수의 의도는 정반대였습니다. 제거해야 할 걸 남기고, 남겨야 할 걸 버리는 코드였던 겁니다. 겉보기엔 멀쩡히 돌아가는 코드였습니다.
만약 설명을 요구하지 않고 그대로 배포했다면 결코 못 잡았을 버그였습니다. 이 일을 겪고 나서 저는 확신했습니다.
AI는 초안을 만들고, 판단은 사람이 한다.
마지막으로, 같은 실수를 두 번 하고 나서야 배웠습니다.
검증 원칙을 세운 뒤에도 실수는 이어졌습니다. 이번 무대는 Toonify였습니다.
버그는 하나씩 모습을 드러냈습니다.
첫 번째. OCR 결과에 "완결"이라는 글자만 있어도 매칭이 통과됐습니다. 시리즈와 아무 상관없는 이미지가 완결로 인식된 것입니다. 시리즈 키워드 확인을 건너뛰고 isCompleteEpisode(ocrText) 하나만으로 통과할 수 있는 구조가 원인이었습니다.
// 문제
const ocrMatched =
allWords.some((w) => ocrText.includes(w)) || isCompleteEpisode(ocrText);
// ↑ 시리즈 키워드 없어도 통과
// 수정
const ocrMatched = allWords.some((w) => ocrText.includes(w));
고쳤습니다. 그런데 다음 버그가 나왔습니다.
두 번째. 시리즈명이 짧아 키워드가 비면 minMatch가 0이 됐습니다. 0 >= 0은 언제나 참이니, 결국 모든 캡션이 매칭됐습니다. keyWords.length > 0 가드 한 줄이 없어서 벌어진 일이었습니다.
// 문제
const captionMatched =
(captionIsComplete && hasAnySeriesWord) || matchCount >= minMatch;
// keyWords=[], minMatch=0 → 0 >= 0 → 항상 true
// 수정
const captionMatched = keyWords.length > 0 && matchCount >= minMatch;
이것도 고쳤습니다. 그런데 또 나왔습니다.
세 번째. 완결을 감지할 때는 시리즈 키워드가 1개만 있어도 통과하는 분기가 있었습니다. 일반 매칭은 2개가 필요한데 완결만 유독 느슨했던 것입니다. 그 탓에 다른 시리즈의 완결 포스트가 내 툰의 완결로 잡혔습니다.
// 문제
const captionMatched =
keyWords.length > 0 &&
((captionIsComplete && hasAnySeriesWord) || matchCount >= minMatch);
// ↑ 완결이면 키워드 1개만 있어도 통과
// 수정
const captionMatched = keyWords.length > 0 && matchCount >= minMatch;
// isCompleteEpisode는 완결 여부 판단에만 사용, 매칭 기준에는 영향 없음
네 번째. 캡션은 키워드 2개 이상을 요구했지만, OCR은 더 넓은 단어 목록에서 1개만 맞아도 통과였습니다. 그래서 캡션에서 막힌 포스트가 OCR을 우회 경로 삼아 슬그머니 들어왔습니다.
// 문제 — OCR은 allWords 중 1개만 있으면 통과
const ocrMatched = allWords.some((w) => ocrText.includes(w));
// 수정 — 캡션과 동일한 keyWords + minMatch 기준 적용
const ocrMatchedWords = keyWords.filter((w) => ocrText.includes(w));
const ocrMatched = ocrMatchedWords.length >= minMatch;
다섯 번째. 알림은 3화가 왔다는데, 앱 목록에는 5화까지 떠 있었습니다. 감지 로직(checkToon)은 엄격한 기준으로 고쳤는데, 정작 목록을 만드는 buildUnreadPosts는 옛 느슨한 조건을 그대로 쓰고 있었기 때문입니다.
// 문제 — buildUnreadPosts의 매칭 조건
const matched =
captionIsComplete || allWords.some((w) => cap.includes(w));
// ↑ 완결 단독 통과 + 1개 단어 substring 매칭
// 수정 — checkToon과 동일한 기준
const { ok } = captionMatches(keyWords, minMatch, cap);
여기까지 오자 슬슬 이상했습니다. 분명 앱에서 다 고쳤는데, 왜 문제가 끝나지 않을까.
여섯 번째, 그리고 진짜 원인. 저는 이 버그들을 전부 앱에서만 고치고 있었습니다. 서버(check-toons.js)는 처음부터 끝까지 구버전 로직으로 돌아가고 있었던 것입니다. 같은 버그를 두 번 고치고, 한 곳만 고치고 다른 곳을 빠뜨리는 일이 반복된 진짜 이유가 여기 있었습니다.
버그 하나하나가 문제가 아니었습니다. 같은 로직을 두 벌 들고 있었다는 것이 문제의 뿌리였습니다.
결국 공통 로직을 matchingUtils.js 하나로 추출했습니다.
src/utils/matchingUtils.js ← SSOT(단일 진실 공급원)
↓ import ↓ require
check-service.js check-toons.js
(앱) (서버)
// src/utils/matchingUtils.js
function buildSeriesKeys(seriesName) {
// keyWords(3자 이상 단어 우선), minMatch 계산
}
function captionMatches(keyWords, minMatch, caption) {
// 토큰 단위 매칭
}
function ocrMatches(keyWords, minMatch, text) {
// 서브스트링 매칭 (조사 붙은 한국어 대응)
}
module.exports = { buildSeriesKeys, captionMatches, ocrMatches, /* ... */ };
js
// check-service.js (앱)
import { captionMatches, ocrMatches } from '../utils/matchingUtils';
// check-toons.js (서버)
const { captionMatches, ocrMatches } = require('../src/utils/matchingUtils');
이후로는 버그를 한 번만 고치면 두 곳이 동시에 고쳐집니다. 그리고 이 경험은 그대로 CLAUDE.md의 규칙 8번이 됐습니다.
앱과 서버가 함께 쓰는 로직은 반드시 공유 파일로 분리하고 양쪽이 import해서 사용한다. 같은 로직을 두 곳에 따로 작성하거나 복붙하는 것은 금지한다.
이렇게 겪은 교훈들은 TROUBLESHOOTING.md에 "무엇을 고쳤나"가 아니라 "왜 틀렸나" 중심으로 남겼습니다. 지금 이 기록이 16개까지 쌓였고, 새 기능을 건드릴 때마다 먼저 이 문서를 펼쳐봅니다. 같은 실수를 세 번은 하지 않기 위해서입니다.
그리고 이 문서를 참고하는 건 저만이 아닙니다. AI에게 작업을 맡길 때도 이 기록을 함께 참조하도록 했습니다. 과거에 어떤 실수를 왜 했는지를 AI가 알고 있으면, 같은 함정에 다시 빠지는 코드를 만들 확률이 눈에 띄게 줄었습니다. 결국 TROUBLESHOOTING.md는 저와 AI가 함께 보는 실패의 지도가 된 셈입니다.
인스타그램 인스타툰의 새 에피소드를 자동 감지해 푸시 알림으로 알려주는 React Native 앱입니다.
재미있는 인스타툰을 발견해도 다음 편이 올라왔는지 매번 직접 확인해야 하는 불편함에서 출발했습니다. 관심 작가를 등록해두면 새 화가 올라올 때 알림을 받을 수 있게 만들었습니다.
기술적으로 가장 고민한 지점은 비용이었습니다. 별도 서버 없이 GitHub Actions를 3시간 배치 서버로 활용했고, 캡션과 OCR을 결합한 2단계 감지 엔진을 설계했습니다.
초기에는 매 사이클마다 OCR을 최대 12회씩 호출하는 구조라 무료 한도가 금방 소진됐는데, "마지막으로 처리한 지점을 기억하는" 설계로 OCR 호출을 약 93% 절감했습니다.
온디바이스 OCR(ML Kit)을 도입했다가 인식률 한계로 되돌린 판단 과정도 함께 담았습니다.
🔗 Toonify 프로젝트 자세히 보기
🔗 Live Demo:
🍎 iOS : https://apps.apple.com/kr/app/toonify/id6794237802
🤖 Android : 심사중..
Stack: React Native, Expo, Supabase, GitHub Actions, OCR API, Expo Push API
회고록 작성중..
GitHub 커밋 기록으로 20×20 픽셀 아트를 만들어 README에 삽입할 수 있는 웹 서비스입니다.
GitHub 프로필 README를 개성 있게 꾸미고 싶어도 마땅한 방법이 없다는 데서 출발했습니다. "내 커밋으로 픽셀 아트를 그려 README에 박으면 어떨까?"라는 아이디어였습니다.
특히 CHALLENGE 모드에서는 이번 달 실제 커밋 수만큼만 셀을 채울 수 있어, 커밋을 더 할수록 그림이 완성되는 구조로 잔디 심는 동기를 자극합니다.
기술적으로는 보안과 자동화에 집중했습니다. GitHub OAuth 토큰을 httpOnly 쿠키로만 관리해 XSS 탈취 경로를 차단했고, 매달 1일 크론 잡이 커밋 수를 비교해 초과된 셀을 자연스럽게 정리하도록 설계했습니다.
별도 이미지 서버 없이 SVG를 서버사이드에서 즉시 생성해 마크다운 문법만으로 README에 삽입되게 했고, main 브랜치 보호부터 ESLint → 타입 체크 → 테스트 → 자동 코드리뷰로 이어지는 CI/CD 파이프라인까지 갖췄습니다.
🔗 CrossStitch 프로젝트 자세히 보기
🔗 Live Demo: https://www.gitcrossstitch.site
Stack: Next.js 15, React 19, TypeScript, Firebase, Vitest, GitHub Actions, Vercel
회고록 작성중..
두 프로젝트는 플랫폼도, 다룬 기술도 완전히 달랐습니다.
하나는 앱에서 비용 최적화와 감지 엔진을 고민했고, 다른 하나는 웹에서 보안과 CI/CD 자동화를 파고들었습니다. 하지만 그 밑바탕에는 똑같은 두 가지 목표가 흐르고 있었습니다 — AI를 제대로 활용하는 법을 익히는 것, 그리고 서비스 전체를 관리할 수 있는 개발자가 되는 것.
돌아보면 화려한 결과물은 아닙니다. 하지만 내 불편에서 출발한 아이디어를 실제로 동작하는 서비스로 만들어냈고, 서로 다른 두 환경에서 문제를 스스로 정의하고 풀어냈으며, AI를 통제 아래 두고 협업하는 저만의 방식을 세울 수 있었습니다.
무엇보다, 혼자서도 끝까지 해낼 수 있다는 사실을 스스로 증명한 시간이었습니다. 각 프로젝트의 구체적인 이야기가 궁금하시다면, 위의 회고록을 참고부탁드립니다.
감사합니다.
🔗 GitHub: https://github.com/GSMYunsung
🔗 포트폴리오: https://www.choiyunsung.dev