← 블로그 목록
성능 최적화React번들 크기N+1 쿼리Core Web Vitals

AI 코딩으로 만든 웹앱이 느린 이유: 렌더링·번들·쿼리 성능 점검 순서

AI 생성 코드에서 자주 나오는 성능 병목을 측정 방법과 함께 정리했습니다. 효과 없는 최적화와 실제로 효과가 있는 처방을 코드로 비교합니다.

Vibeollio 팀-

AI가 만든 코드는 왜 느린가

AI 코딩 도구는 "일단 동작하는 코드"를 목표로 생성합니다. 요청에 성능 조건이 없으면 최적화는 고려 대상이 아닙니다. 그래서 혼자 테스트할 때는 멀쩡하다가 데이터가 쌓이고 사용자가 늘면 갑자기 느려집니다.

문제는 어디가 느린지 감으로 찍는 것입니다. 대부분의 최적화 시도가 헛돌아가는 이유가 여기 있습니다. 측정 먼저, 수정은 그다음입니다. 렌더링·번들·쿼리 세 영역을 순서대로 봅니다.

렌더링: 무엇이 다시 그려지는지부터 본다

React 앱에서 흔한 증상은 입력창에 글자를 칠 때마다 화면 전체가 버벅이는 것입니다. 부모의 상태가 바뀌면 자식 컴포넌트가 전부 다시 렌더링되기 때문입니다.

측정: React DevTools의 Profiler 탭에서 녹화한 뒤 문제 동작을 재현합니다. 어떤 컴포넌트가 몇 번, 몇 ms 걸려 렌더링됐는지 나옵니다. 설정에서 렌더링되는 컴포넌트를 화면에 표시해주는 옵션을 켜면, 다시 그려지는 영역이 테두리로 보여 더 직관적입니다.

여기서 자주 나오는 잘못된 처방이 있습니다.

// ❌ 아무 효과 없음 — users 는 이미 안정적인 참조다
const memoizedUsers = useMemo(() => users, [users]);

이 코드는 하는 일이 없습니다. useMemo는 값의 재계산을 막을 뿐이고, 자식이 다시 렌더링되는 것 자체를 막지 못합니다. 부모가 렌더링되면 자식도 렌더링되는 게 기본 동작이기 때문입니다.

자식의 재렌더링을 실제로 막으려면 자식을 React.memo로 감싸고, 넘기는 props의 참조를 안정적으로 유지해야 합니다. 셋이 함께 가야 효과가 납니다.

// ✅ 자식을 memo 로 감싸고, 넘기는 함수·객체의 참조를 고정한다
const UserItem = React.memo(function UserItem({ users, onSelect }) {
  return <ul>{users.map((u) => <li key={u.id} onClick={() => onSelect(u.id)}>{u.name}</li>)}</ul>;
});

function UserList({ users }) {
  const [search, setSearch] = useState("");
  // 매 렌더마다 새 함수가 만들어지면 memo 가 무력화된다
  const handleSelect = useCallback((id) => console.log(id), []);

  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <UserItem users={users} onSelect={handleSelect} />
    </>
  );
}

onSelect={() => ...}처럼 인라인 함수를 그대로 넘기면 매번 새 참조가 만들어져 React.memo가 통과시켜 버립니다. 이게 memo를 붙였는데도 효과가 없는 가장 흔한 원인입니다.

목록이 수백 줄을 넘어가면 메모이제이션보다 가상 스크롤(화면에 보이는 항목만 렌더링)이 훨씬 큰 차이를 만듭니다.

번들: 무엇이 들어 있는지 본다

AI에게 기능을 요청하면 그 기능을 가장 쉽게 구현하는 라이브러리를 함께 가져옵니다. 하나씩은 합리적인데 쌓이면 초기 로딩이 무거워집니다.

측정: 번들 분석 도구로 무엇이 자리를 차지하는지 시각화합니다. Next.js면 @next/bundle-analyzer, Vite면 rollup-plugin-visualizer를 쓰면 됩니다. 짐작으로 지우지 말고 그림을 먼저 보세요.

개선에는 세 가지 방향이 있습니다.

무거운 라이브러리를 가벼운 것이나 표준 API로 바꾸는 방법이 첫째입니다. 날짜 포매팅 정도면 브라우저 내장 Intl.DateTimeFormat으로 충분한 경우가 많습니다.

new Intl.DateTimeFormat("ko-KR", { dateStyle: "long" }).format(date);

둘째는 필요한 것만 가져오는 것입니다. import * as Icons from "..." 같은 네임스페이스 임포트는 트리 셰이킹을 방해합니다. 쓰는 것만 명시적으로 가져오세요.

셋째는 코드 스플리팅입니다. 첫 화면에 필요 없는 것은 나중에 불러옵니다. 차트, 에디터, 지도처럼 특정 화면에서만 쓰이면서 덩치가 큰 라이브러리가 1순위 후보입니다.

const Chart = dynamic(() => import("./Chart"), { ssr: false });

라이브러리 용량을 비교할 때는 minified 크기가 아니라 gzip/brotli 압축 후 크기를 기준으로 보세요. 실제 전송량은 그쪽입니다.

쿼리: N+1을 찾는다

서버가 느린 경우 원인은 대개 쿼리 개수입니다. 목록을 가져온 뒤 각 항목마다 추가 쿼리를 도는 패턴이 전형적입니다. 로컬에서 데이터 10건일 때는 티가 안 나다가 1,000건이 되면 그대로 드러납니다.

측정: ORM의 쿼리 로그를 켜고 페이지 하나를 열어봅니다. 화면 한 번에 수십 개가 찍히면 거의 확실합니다.

// ❌ 사용자 수만큼 쿼리가 추가로 나간다
const users = await User.findAll();
const withProfiles = await Promise.all(users.map((u) => u.getProfile()));

// ✅ 조인으로 한 번에
const withProfiles = await User.findAll({ include: [{ association: "profile" }] });

그다음은 인덱스입니다. WHERE나 ORDER BY에 자주 쓰는 컬럼에 인덱스가 없으면 테이블 전체를 훑습니다. EXPLAIN으로 실행 계획을 확인해 순차 스캔이 잡히는 쿼리부터 처리하세요.

CREATE INDEX idx_posts_user_created ON posts(user_id, created_at DESC);

인덱스는 공짜가 아닙니다. 쓰기 성능과 저장 공간을 대가로 내주므로 필요한 것만 만듭니다.

마지막은 페이지네이션입니다. 전체를 한 번에 내려주는 API는 데이터가 늘어나면 반드시 문제가 됩니다.

개선 효과를 어떻게 확인하나

체감은 믿을 게 못 됩니다. 지표로 확인하세요. 구글이 정의한 핵심 지표는 세 가지입니다.

  • LCP (Largest Contentful Paint): 주요 콘텐츠가 보이기까지의 시간. 2.5초 이내가 권장값입니다.
  • INP (Interaction to Next Paint): 사용자 입력에 화면이 반응하기까지의 시간. 200ms 이내가 권장값입니다. 예전에 쓰던 FID를 대체한 지표이므로, 오래된 글을 보고 FID를 기준으로 잡지 마세요.
  • CLS (Cumulative Layout Shift): 레이아웃이 예기치 않게 밀리는 정도. 0.1 이하가 권장값입니다.

Lighthouse로 배포 전후를 비교하고, 실제 사용자 환경의 값이 필요하면 web-vitals 라이브러리로 수집합니다. 개발 기기는 대체로 사용자 기기보다 빠르므로 로컬 측정치는 낙관적으로 나옵니다.

어디부터 손대야 하나요?

측정에서 가장 큰 숫자가 나온 곳입니다. 순서를 정해두고 싶다면 대체로 서버 응답 시간(쿼리) → 초기 로딩(번들) → 상호작용(렌더링) 순으로 사용자 체감이 큽니다. 다만 이건 일반적인 경향일 뿐이라 실제 순서는 측정 결과가 정합니다.

처음부터 최적화하면서 만들면 안 되나요?

권하지 않습니다. 어디가 병목이 될지는 실제 데이터와 사용 패턴이 생기기 전에는 알 수 없어서, 미리 한 최적화는 대개 필요 없는 곳에 복잡도만 더합니다. 다만 페이지네이션 없는 전체 조회나 N+1처럼 나중에 구조를 바꿔야 하는 것들은 처음부터 피하는 편이 낫습니다.

프롬프트로 예방할 수 있나요?

어느 정도는 됩니다. "목록 조회는 페이지네이션을 포함할 것", "연관 데이터는 조인으로 한 번에 가져올 것" 같은 조건을 프로젝트 규칙 파일에 적어두면 생성 코드의 기본값이 달라집니다. 다만 생성된 코드가 실제로 그런지는 확인해야 합니다.

측정은 사용자가 있어야 완성된다

로컬 측정에는 한계가 있습니다. 개발 기기는 빠르고, 네트워크는 가깝고, 데이터는 적습니다. 실제 사용자의 기기와 회선에서 나오는 숫자는 대체로 이보다 나쁩니다.

만든 웹앱이 배포된 상태라면 Vibeollio에 프로젝트 등록해서 공개해보세요. 다양한 환경의 방문자가 실제로 눌러보면서 남기는 반응은 혼자 돌리는 Lighthouse 점수가 알려주지 않는 것을 알려줍니다.