상세 컨텐츠

본문 제목

AI가 짜주는 React 코드, 레거시를 피하려면? (Vercel React Best Practices) -1-

FE

by 팝삐 2026. 1. 22. 11:29

본문

서론

최근에 “AI가 작성해주는 React 코드는 2020~2021년 부근의 오래된 문서/예제 코드를 학습해서 레거시 패턴이 섞일 수 있다”는 글을 봤다.
요즘은 AI가 만들어주는 코드를 아예 외면하기도 어려운 만큼, 결국 중요한 건 내가 그 코드를 검증하고 더 나은 방향으로 고칠 수 있는 기준을 갖는 일이라고 느꼈다.

마침 Vercel에서 React 코드의 모범 사례를 정리한 Best Practices GitHub Repo를 공개했다.
이를 하나씩 공부해가며 몰랐던 개념도 깨우치고, 대충 쌓아올린 기초 지식들도 다시 점검해보는 시간을 가져야겠다.


React Best Practices (Vercel)


1. advanced-event-handler-refs

이벤트 핸들러를 Ref에 저장해서, 불필요한 재구독을 막기

요약

콜백(핸들러) 함수가 바뀌더라도 useEffect가 매번 다시 구독(리스너 재등록)하지 않게 해야 하는 경우, 콜백을 ref에 저장해서 사용한다.

❌ 잘못된 예

아래 코드는 handler가 바뀔 때마다 useEffect가 다시 실행되면서
addEventListener/removeEventListener가 계속 반복된다.

function useWindowEvent(event: string, handler: (e: any) => void) {
  useEffect(() => {
    window.addEventListener(event, handler)
    return () => window.removeEventListener(event, handler)
  }, [event, handler])
}

✅ 올바른 예

핵심은 두 가지다.

  • handler는 ref에 저장해서 최신으로 유지
  • window에 등록하는 listener 함수는 고정(안정적인 참조)
function useWindowEvent(event: string, handler: (e: any) => void) {
  const handlerRef = useRef(handler)

  useEffect(() => {
    handlerRef.current = handler           // handler가 바뀔 때마다 ref만 업데이트
  }, [handler])

  useEffect(() => {
    const listener = (e: any) => handlerRef.current(e)

    window.addEventListener(event, listener)
    return () => window.removeEventListener(event, listener)
  }, [event])            // 실제 구독(리스너 등록)은 event가 바뀔 때만
}

✅ 대안: 최신 React라면 useEffectEvent 사용 (React 19.2 부터 추가된 Hook)

useEffectEvent는 같은 패턴을 더 깔끔하게 쓸 수 있게 해주는 API다.
즉, 함수 참조는 안정적으로 유지하면서, 내부적으로는 항상 최신 핸들러를 호출해준다.

import { useEffect, useEffectEvent } from 'react'

function useWindowEvent(event: string, handler: (e: any) => void) {
  const onEvent = useEffectEvent(handler)

  useEffect(() => {
    window.addEventListener(event, onEvent)
    return () => window.removeEventListener(event, onEvent)
  }, [event])
}

2. advanced-use-latest

useEffectEvent로 “안정적인 콜백(ref)” 만들기

요약

콜백 안에서 최신 값(query)을 쓰고 싶을 때, 보통 useEffect의 deps 배열에 onSearch를 넣게 된다.
하지만 그러면 콜백이 바뀔 때마다 effect가 다시 실행될 수 있다.
useEffectEvent를 사용하면 deps 배열에 콜백을 넣지 않아도 되고, stale closure도 피하면서 effect 재실행도 막을 수 있다.

 

❌ 잘못된 예 (콜백이 바뀔 때마다 effect가 재실행됨)

아래 코드는 [query, onSearch]를 deps로 두기 때문에, onSearch가 바뀌면 useEffect가 다시 돌면서 타이머가 재설정된다.

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')

  useEffect(() => {
    const timeout = setTimeout(() => onSearch(query), 300)
    return () => clearTimeout(timeout)
  }, [query, onSearch])
}

✅ 올바른 예 (React의 useEffectEvent 사용)

useEffectEvent(onSearch)안정적인 함수인 onSearchEvent를 만든다.
이 함수는 참조가 안정적이라 deps에 넣을 필요가 없고, 호출 시점에는 최신 로직/값을 사용하도록 도와준다.
따라서 deps는 [query]만 유지해도 된다.

import { useEffectEvent } from 'react';

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')
  const onSearchEvent = useEffectEvent(onSearch)

  useEffect(() => {
    const timeout = setTimeout(() => onSearchEvent(query), 300)
    return () => clearTimeout(timeout)
  }, [query])
}

 


3. async-api-routes

API Routes / Server Actions에서 워터폴 피하기

요약

서로 독립인 작업은 await로 줄 세우지 말고, 먼저 시작해놓고 나중에 한 번에 기다린다.

 

❌ 잘못된 예

export async function GET(request: Request) {
  const session = await auth() // 도착할 경우 다움 문장 실행
  const config = await fetchConfig() // 도착할 경우 다음 문장 실행
  const data = await fetchData(session.user.id)
  return Response.json({ data, config })
}

✅ 올바른 예

export async function GET(request: Request) {
// 둘 다 동시에 시작
  const sessionPromise = auth()
  const configPromise = fetchConfig()

// 둘 중 더 오래 걸리는 것까지 도착하면 종료
  const session = await sessionPromise
  const [config, data] = await Promise.all([
    configPromise,
    fetchData(session.user.id),
  ])

  return Response.json({ data, config })
}

 


4. async-defer-await

꼭 필요할 때까지 await 미루기

요약

await는 흐름을 멈춘다. 그래서 “어차피 안 탈 수도 있는 분기(early return)” 앞에서 미리 기다리지 말고,
실제로 필요한 분기 안에서만 await 한다.

❌ 잘못된 예 (양쪽 분기를 다 막아버림)

async function handleRequest(userId: string, skipProcessing: boolean) {
  const userData = await fetchUserData(userId)

  if (skipProcessing) {
    return { skipped: true } // 사실 userData 필요 없는데 굳이 기다렸다가 실행해버림
  }

  return processUserData(userData)
}

✅ 올바른 예 (필요한 경우에만 기다림)

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    return { skipped: true }  // 필요없는 경우는 바로 리턴해서 처리
  }

  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

 


5. async-dependencies

의존 관계 기반 병렬화

요약

작업들 중 일부만 서로 의존한다면, “가능한 것부터” 최대한 병렬로 돌려야 빠르다.

❌ 잘못된 예

const [user, config] = await Promise.all([
  fetchUser(),
  fetchConfig(),
])

const profile = await fetchProfile(user.id) // profile은 config까지 기다릴 필요가 없음!

✅ 올바른 예

const userPromise = fetchUser()
// userPromise 끝난 후 fetchProfile 바로 실행
const profilePromise = userPromise.then(user => fetchProfile(user.id))

// fetchConfig는 기다릴 필요 없으니까 시작부터 함께 실행
const [user, config, profile] = await Promise.all([
  userPromise,
  fetchConfig(),
  profilePromise,
])

✅ 대안: better-all 사용

import { all } from 'better-all'

const { user, config, profile } = await all({
  async user() { return fetchUser() },
  async config() { return fetchConfig() },
  async profile() {
                         // user 작업의 promise
    return fetchProfile((await this.$.user).id)
  },
})

 


6. async-parallel

Promise / Promise.all로 병렬 처리하기

  • 만약 fetchA()가 3ms, fetchB()가 5ms를 소비한다면?

❌ 잘못된 예 (순차 대기)

const a = await fetchA()   // 3ms
const b = await fetchB()   // 5ms

// total 8ms

✅ 올바른 예 (동시에 시작 후 한 번에 기다림)

const aPromise = fetchA()   // 3ms
const bPromise = fetchB()   // 5ms

const [a, b] = await Promise.all([aPromise, bPromise]) // 동시 실행 : total 5ms

 


7. async-suspense-boundaries

Suspense 경계를 전략적으로 두기

요약

페이지 컴포넌트에서 데이터를 먼저 await 해버리면 화면 전체가 막힐 수 있기 때문에,
<Suspense fallback=<Skeleton />>로 감싸서 바깥 레이아웃은 먼저 보여주고, 데이터가 필요한 부분만 나중에 채운다.

❌ 잘못된 예

async function Page() {
  const data = await fetchData()
  // await이기 때문에 도착할 때까지 이 아래 전체 렌더링 x

  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <DataDisplay data={data} />
      </div>
      <div>Footer</div>
    </div>
  )
}

✅ 올바른 예 (wrapper 먼저 렌더, 데이터는 스트리밍처럼)

function Page() {
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <Suspense fallback={<Skeleton />}>
          <DataDisplay />
        </Suspense>
      </div>
      <div>Footer</div>
    </div>
  )
}

async function DataDisplay() {
  const data = await fetchData()
  return <div>{data.content}</div>
}

✅ 대안

function Page() {
  const dataPromise = fetchData()

  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>

      <Suspense fallback={<Skeleton />}>
        <DataDisplay dataPromise={dataPromise} />
        <DataSummary dataPromise={dataPromise} />
      </Suspense>

      <div>Footer</div>
    </div>
  )
}

function DataDisplay({ dataPromise }: { dataPromise: Promise<any> }) {
  const data = use(dataPromise)
  return <div>{data.content}</div>
}

function DataSummary({ dataPromise }: { dataPromise: Promise<any> }) {
  const data = use(dataPromise)
  return <div>{data.summary}</div>
}

 


마무리

뭔가 당연한 듯 하면서도 자연스럽게 나오지 않는 코드 형식들인 것 같아서 지금까지의 코드 작성에 대해 뒤돌아볼 수 있었다. 특히, 비동기에 대해 깊게 생각하지 않으며 코드를 작성해서 비효율적인 코드를 작성하는 일이 많았는데 앞으로 개선해나가야겠다는 생각을 했다. 

 

다음은 bundle - barrel - imports 파트부터 공부하며 2편에 이어 작성 할 예정이다.