본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 17. 05:00

내가 AI를 사용하여 SaaS를 처음부터 다시 구축한 방법

요약

개발자가 Claude Code와 Claude Opus 4.7을 사용하여 기존 SaaS를 Phoenix LiveView로 마이그레이션하려 시도한 과정을 다룹니다. 첫 번째 시도에서 발생한 UI 충실도 저하와 코드 품질 문제 등 실제 AI 코딩 도구 사용 시의 한계를 공유합니다.

핵심 포인트

  • Claude Code를 활용한 SaaS 마이그레이션 시도
  • 최소한의 프롬프트로 모델 성능을 테스트하는 '게으른 테스트' 방식
  • AI의 UI 충실도 부족 및 환각 현상 문제 지적
  • 완성되지 않은 코드를 '완료'로 선언하는 코드 품질 이슈

저의 이전 영상에서, 저는 왜 저의 SaaS인 CourseShelf를 React와 Inertia에서 Phoenix LiveView로 마이그레이션했는지 설명했습니다. 오늘은 모두가 실제로 저에게 묻는 부분에 대해 이야기하고 싶습니다. 바로 어떻게 그것을 했느냐 하는 것입니다. 제가 단순히 거대한 코드베이스(codebase)에 AI를 겨냥해 두고 그냥 떠나 있었던 걸까요? 모든 과정을 자동화했을까요?

짧게 대답하자면: 시도했습니다. 네 번이나요. 그리고 그 결말은 Twitter에서 말해주던 방식과는 달랐습니다.

아무도 혼란스럽지 않도록 먼저 지루한 세부 사항부터 말씀드리겠습니다. 사용된 모델은 주로 Claude Opus 4.7이었고, 하네스(harness)는 데스크톱 앱을 통한 Claude Code였습니다. 저는 OpenCode, Codex 데스크톱 앱 등을 가지고 놀아봤고, 사람들이 Pi 코딩 에이전트를 계속 추천하기도 하지만 — 이 특정 마이그레이션(migration)에서는 Claude Code를 사용했습니다. 제 설정에 대해서는 별도의 영상을 준비하고 있습니다. 오늘은 아닙니다. 오늘은 네 번의 시도에 대해 이야기할 것입니다.

시도 1: 게으른 테스트

저는 가끔 **게으른 테스트 (lazy test)**라고 부르는 것을 즐겨 합니다. 세상에서 가장 게으른 사람이 되기 위해 최선을 다하며, AI에게 가능한 한 가장 작은 프롬프트(prompt)를 주고 무엇이 나오는지 확인하는 것입니다. 이는 모델들이 얼마나 개선되었는지 측정하는 아주 좋은 방법입니다. 저는 사용 가능한 무언가를 얻기 위해 겨우 두세 개의 거대한 문단으로 컨텍스트(context)를 작성하던 때를 여전히 기억합니다. 지금은요? 두세 줄만 써도 모델이 종종 정확히 해냅니다. 그래서 저는

공정하게 말하자면, 여기서 제 노력 수준은 완전히 **제로 (zero)**였습니다. 그 프롬프트를 작성하는 데 10초 정도 썼고, 아마 뇌세포 두 개 정도만 사용했을 겁니다. 저는 두 가지 기준으로 결과물을 평가했습니다. 바로 UI 충실도 (UI fidelity) (새로운 UI가 기존에 프로덕션에서 운영하던 것과 유사한가, 아니면 Claude가 무작위로 새로운 컴포넌트를 만들어내고 있는가?)와 코드 품질 (code quality) (코드가 깔끔하고 유지보수가 가능한가?)입니다.

UI 충실도 측면에서 저는 10점 만점에 2점을 주었습니다. 웃긴 점은 처음 10분 동안은 정말 괜찮았다는 것입니다. 홈 페이지를 검토해 보니 React 버전과 매우 유사했습니다. 하지만 그 이후의 모든 페이지는 완전히 다른 애플리케이션처럼 보였습니다. 프론트엔드를 다른 곳으로 이식(porting)하는 대신, Claude에게 완전히 새로운 제품을 처음부터 만들어달라고 요청한 것 같은 기분이었습니다. 컴포넌트, 무작위 스타일 등 온갖 것들을 환각(hallucinate)해냈습니다.

그렇다면 코드 품질은 어땠을까요? Claude가 실제로 코드를 작성했을 때는 괜찮았습니다. 하지만 작업 중간에 _"다 했습니다, 마이그레이션(migration)을 마쳤습니다"_라고 선언하더니, 여기저기에 수백 개의 할 일(to-do) 주석이 흩어져 있는 것을 발견했습니다. "이 부분은 보류 중입니다, 나중에 수정하겠습니다, 하지만 v1 버전으로는 이 정도면 충분합니다." 같은 식이었죠. 어떤 페이지에는 프론트엔드에 실제로 **"이 페이지는 아직 구축되지 않았습니다, 곧 출시 예정입니다."**라고 적힌 플레이스홀더(placeholder) 컴포넌트가 렌더링되어 있기도 했습니다.

세상에. 안 됩니다. 받아들일 수 없습니다. 첫 번째 시도는 완전한 실패였습니다.

garbage

시도 2: 서브 에이전트(sub-agents)를 통한 작업 배치(batching)

그다음 저는 Boris가 Twitter에서 소개한 /batch라고 불리는 기술을 시도해 보았습니다. 핵심 아이디어는 거대한 작업에 직면했을 때 이를 더 작은 단위로 배치(batch)하는 것입니다. 하나의 메인 에이전트가 단 한 번의 마라톤 대화로 모든 것을 처리하려고 하는 대신, Claude에게 작업의 각 조각(slice)마다 하나씩 서브 에이전트(sub-agents)를 가동하라고 명시적으로 지시하는 방식입니다. 한 에이전트는 홈 페이지를 마이그레이션하고, 다른 에이전트는 블로그 페이지를, 또 다른 에이전트는 플레이리스트 페이지를 담당하는 식입니다.

// 다크 테마 감지 var iframe = document.getElementById('tweet-2027534984534544489-842'); if (document.body.className.includes('dark-theme')) { iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2027534984534544489&theme=dark" }

저는 이것이 정말 큰 도움이 될 것이라고 진심으로 생각했습니다. 왜냐하면 첫 번째 시도(attempt 1)를 통해 중요한 사실을 배웠기 때문입니다: 컨텍스트 부패 (context rot)는 실재한다는 점입니다. Claude가 마이그레이션한 처음 몇 개의 페이지와 컴포넌트들은 훌륭했습니다. 하지만 20분이나 30분이 지난 후부터, Claude는 환각 (hallucination)을 일으키며 대본에서 벗어나기 시작했습니다. 배치 공격 (Batching attacks)을 통해 이를 직접 해결했습니다. 즉, 하나의 거대하고 성능이 저하되는 컨텍스트 대신, 각 에이전트가 짧고 집중된 컨텍스트를 갖도록 한 것입니다.

그리고 결과는 더 좋았습니다. 솔직히 훨씬 더 좋았습니다. 저의 노력 수준은 여전히 제로(0)였습니다. 첫 번째 시도와의 유일한 차이점은 프롬프트 시작 부분에 /batch를 입력했다는 것뿐이었습니다. 이번에는 Claude가 환각을 일으키기 시작할 때까지 훨씬 더 오랜 시간이 걸렸고, LiveView 코드와 React 버전의 충실도(fidelity) 측면 모두에서 진정으로 훌륭한 페이지를 두세 페이지 정도 얻을 수 있었습니다.

하지만 Claude는 여전히 똑같은 수법을 썼습니다. _"자, 이제 다 됐습니다. 마이그레이션이 완료되었습니다."_라고 말한 뒤, 프론트엔드에 할 일(to-do) 주석과 "곧 출시 예정(coming soon)" 배지를 남겨두는 식이었죠. 더 나아지긴 했지만, 충분하지는 않았습니다.

시도 3: 게으름을 버리다

이 시점에서 저는 명백한 사실을 받아들였습니다: Claude는 수정구슬을 가지고 있지 않으며, 제 마음을 읽을 수도 없다는 것입니다. 저는 명시적(explicit)이어야 했습니다. 그래서 제가 따르길 원하는 모든 지침을 담은 커다란 마크다운 (markdown) 파일을 작성했고, 그 위에 배치 (batch) 기술을 계속 사용했습니다.

저의 노력 수준은 제로(0)에서 약 4 정도로 올라갔습니다. 저는 완벽하다고 생각되는 계획을 짜는 데 20~30분을 소비했고, 이번에는 실제로 뇌세포를 좀 사용해야 했습니다.

여기서 가장 큰 개선점은 UI 충실도(fidelity)가 아니라 **코드 품질 (code quality)**이었습니다. Claude가 제가 순전히 React/Inertia 환경을 위해 만들어낸 관습들을 계속 재사용하면서, 전혀 말이 안 되는 LiveView 환경으로 그대로 포팅(porting)하고 있었기 때문입니다.

가장 명확한 예시는 시리얼라이저(serializers)입니다. Inertia에서는 단순히 데이터베이스를 쿼리하고, Elixir 구조체(struct)를 가져와서 프론트엔드에 바로 전달할 수 없습니다. 먼저 구조체를 일반적인 props 맵(map)으로 변환해야 합니다. 그래서 v1에서는 정확히 그 역할을 수행하는 *JSON 모듈 계층을 통째로 가지고 있었습니다. 이전 코드베이스에 있던 실제 예시입니다:

# courseshelf-v1/lib/courseshelf_web/controllers/course_json.ex
defmodule CourseshelfWeb.CourseJSON do
  @moduledoc """
...

그러고 나서 컨트롤러는 Inertia를 통해 React로 전달하기 전에 모든 개별 prop에 대해 해당 시리얼라이저를 호출했습니다:

# courseshelf-v1/lib/courseshelf_web/controllers/course_controller.ex
conn
|> assign_prop(:courses, CourseJSON.serialize(courses))
...

이 중간 계층(middle layer)은 오직 React 경계(boundary) 때문에 존재했을 뿐입니다. 그리고 Claude는 LiveView에서도 이를 충실하게 재현하고 있었습니다. 저는 계속해서 말해줘야 했습니다. "Claude, 형, 그냥 데이터베이스를 쿼리하고 그 결과를 프론트엔드에서 사용해. 이런 중간 계층은 필요 없어." LiveView에서는 구조체를 소켓(socket)에 직접 할당하고 렌더링하면 끝입니다.

UI 재현성(fidelity)을 위해 저는 다른 경로를 택했습니다. 저는 Claude에게 Playwright MCP를 사용하여 실제 웹사이트를 스크린샷으로 찍고, 이를 방금 LiveView에 작성한 내용과 비교하여 불일치하는 부분을 수정하라고 명시적으로 지시했습니다. 그런데... Claude가 그 단계를 완전히 무시했다는 점이 거의 확실합니다. 결과는 이전과 같았습니다. 두세 개의 페이지는 원본과 매우 유사했지만, 그 외의 모든 것은 스타일 가이드가 무시된 채 처음부터 만들어진 새로운 컴포넌트들이었습니다.

따라서 세 번째 시도는 코드 품질 측면에서는 좋은 계획이었지만, UI 재현성 측면에서는 여전히 나쁜 결과였습니다. 그리고 세 번째 시도 이후, 저는 솔직히 인내심을 잃기 시작했고 어쩌면 이것이 완전히 자동화되지는 않을 수도 있겠다고 생각하기 시작했습니다.

시도 4: 자동화를 멈추고 직접 운전하기

신사 숙녀 여러분, 제가 정확히 그렇게 했습니다.

저는 그 방대한 마크다운 (markdown) 계획을 적절한 **기술 (skill)**로 변환한 다음, **페이지별로 수동으로 호출 (manually invoked)**했습니다. 저는 모든 컴포넌트와 모든 페이지를 직접 확인하여 React 버전과 동일한지, 그리고 하단의 LiveView에 코드 스멜 (code smells)이 없는지 확인하고 싶었습니다.

사람들은 이 기술의 규모가 얼마나 큰지 계속 묻습니다. 그리 크지 않습니다 — 152줄이며, 이는 매우 합리적인 수준이라고 생각합니다. 이 기술은 제가 Claude가 추측하기를 원치 않았던 모든 명시적인 결정 사항들을 인코딩합니다: 무엇을 포팅 (port)하지 않을 것인지, 단순화 규칙 (simplification rules), 데이터베이스 스키마 일치성 (database schema parity), SEO 일치성 (SEO parity), 그리고 실제로 테스트를 실행하라는 강력한 상기 사항 등이 포함됩니다. 다음은 해당 기술에서 그대로 가져온 단순화 섹션입니다:

<!-- courseshelf-v2/.claude/skills/migrate-v1-to-v2/SKILL.md -->
## 단순화 규칙 (Simplification rules)

...

그 시리얼라이저 (serializer) 규칙은 시도 3에서 얻은 것과 동일한 교훈이며, 이제는 한 번 작성해 두었기에 다시는 프롬프트 (prompt)에서 반복할 필요가 없습니다. 그리고 v2 버전이 실제로 어떻게 보이는지는 다음과 같습니다 — 시리얼라이저 없이, 컨텍스트 (context) 함수가 %Playlist{}를 반환하고 그것이 소켓 (socket)으로 바로 전달됩니다:

# courseshelf-v2/lib/course_shelf_web/live/playlist_live/show.ex
def mount(%{"username" => username, "slug" => slug}, _session, socket) do
  case Playlists.get_user_playlist_by_username_and_slug(
...

또한 이 기술은 테스트를 타협 불가능한 사항으로 만듭니다 — Claude에게 LiveView 테스트를 작성하도록 하고, 모든 작업이 완료되었다고 호출하기 전에 mix precommit을 실행하도록 요청하여 프로젝트 자체의 규칙을 우회하지 않도록 합니다.

모든 주요 기능마다 이 기술을 수동으로 호출했기 때문에, 저의 노력 수준은 4에서 확실한 10점 만점에 9점으로 뛰어올랐습니다. 그리고 그 의미를 정확히 말씀드리고 싶습니다: 저는 마이그레이션 (migration)을 자동화한 것이 아닙니다. 저는 기술 (skill) — 즉 플레이북 (playbook) — 을 자동화한 것이지, 실제 작업은 제가 직접 운전했습니다. 물론 AI가 코드를 작성했지만, 저는 다섯 개의 Claude Code 세션을 병렬로 직접 시작한 다음, 각 페이지와 각 디프 (diff)를 직접 검토했습니다.

그 결과: UI 충실도 (UI fidelity)는 6에서 9로 올라갔고, 코드 품질 (code quality)은 깔끔하게 10점이 되었습니다.

노력 (Effort)UI 충실도 (UI fidelity)코드 품질 (Code quality)
시도 1 — 순수하게 게으름 피우기 (pure lazy)02곳곳에 할 일 주석 (to-do comments everywhere)
...
왜 충실도가 10점이 아닐까요? 왜냐하면 내부적으로 v2는 Phoenix용으로 daisyUI를 사용하고, v1은 React 측에서 shadcn/ui를 사용했기 때문입니다. 제 소프트웨어를 이전에 사용해 보셨다면 버튼이 약간 다르고 드롭다운 메뉴가 약간 다르다는 것을 알아차리실 수 있을 겁니다. 하지만 저는 이것이 완전히 괜찮다고 생각합니다 — 의도적으로 다른 UI 라이브러리를 사용하고 있기 때문에 어느 정도의 차이는 예상됩니다. 100%는 아니지만, 약 90%입니다.

제가 고쳐서 가르쳐야 했던 코드 냄새 (The code smell I had to teach away)

코드 품질이 10점이라는 것도 공짜가 아니었습니다. Claude로부터 코드 냄새를 발견할 때마다, 저는 멈춰 서서 그것을 수정하는 또 다른 기술(skill)을 작성했습니다.

가장 좋은 예는 LiveView 상호작용 주변입니다. LiveView는 React의 useState와 비슷한 상태(state)를 가지고 있습니다 — 단, LiveView 상태는 서버에서만 변경될 수 있다는 점이 다릅니다. 따라서 그것을 건드릴 때마다 웹소켓(websocket)을 통해 왕복 트립(round trip)을 하게 됩니다. 단순히 대화 상자를 열고 닫는 것 같은 일부 상호작용에 대해서도 Claude는 LiveView 상태를 사용했습니다. 이는 사용자가

제가 Claude에게 준 규칙을 쉬운 말로 설명하자면 이렇습니다. 추가 데이터를 가져오거나, 유효성 검사(validation)를 실행하거나, changeset을 실행하는 등 서버가 반드시 필요한 경우에만 handle_event를 트리거하십시오. 하지만 단순히 대화 상자(dialog)를 여는 것이라면, 대신 JS 모듈을 사용하여 클라이언트 측 이벤트(client-side event)를 푸시하십시오. 실제 코드베이스에서 사용된 올바른 패턴은 다음과 같습니다. 수정 버튼을 누르면 JS.show를 통해 즉시 모달이 열리고, 함께 배치된(colocated) 훅(hook)이 data-* 속성으로부터 폼(form)을 미리 채웁니다. 이 과정에는 서버가 관여하지 않습니다:

# courseshelf-v2/lib/course_shelf_web/live/playlist_live/show.ex
<.button
  variant="secondary"
...

서버는 실제로 실행해야 할 뮤테이션(mutation)이 있을 때만 관여합니다. 그때 서버는 자신의 작업을 수행하고, 저장이 성공한 후 하나의 push_event를 통해 클라이언트에 모달을 닫으라고 알립니다:

# courseshelf-v2/lib/course_shelf_web/live/profile_live/show.ex
def handle_event("save_profile", %{"user" => params}, socket) do
  case Accounts.update_profile(socket.assigns.current_scope, normalize_profile_params(params)) do
...

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0