JavaScript에서 여러 비동기 작업을 동시에 처리할 때 Promise.all()과 Promise.allSettled()을 자주 사용한다.

두 메서드 모두 여러 Promise를 한 번에 실행할 수 있지만 실패를 처리하는 방식에는 차이가 있다.

API를 여러 개 호출하는 화면에서는 이 차이를 모르고 사용하면 일부 API의 실패 때문에 정상적으로 받아온 데이터까지 사용하지 못하는 상황이 발생할 수 있다.

이번 글에서는 Promise.all()과 Promise.allSettled()의 차이와 실무에서 어떤 기준으로 선택하면 되는지 정리한다.

 

chatgpt를 통해 생성한 이미지

 

Promise.all이란?

Promise.all()은 여러 Promise를 전달받아 모든 작업이 성공했을 때 결과를 반환한다.

//Javascript
const [user, products, notices] = await Promise.all([
    fetchUser(),
    fetchProducts(),
    fetchNotices()
])

세 API는 순차적으로 실행되는 것이 아니라 거의 동시에 시작된다.

다음과 같이 작성한 코드와 차이가 있다.

const user = await fetchUser()
const products = await fetchProducts()
const notices = await fetchNotices()

각 요청에 1초가 걸린다고 가정한다.

순차적으로 실행하면 전체 처리 시간이 약 3초가 될 수 있다.

반면 Promise.all()을 사용하면 세 요청을 동시에 시작할 수 있으므로 가장 오래 걸리는 요청의 완료 시점을 기준으로 다음 로직을 실행할 수 있다.

서로 의존하지 않는 API를 여러 개 호출해야 한다면 Promise.all()을 먼저 고려할 수 있다.

 

Promise.all은 하나만 실패해도 reject된다

Promise.all()에서 가장 중요한 특징은 전달한 Promise 중 하나라도 reject되면 전체 결과가 reject된다는 점이다.

try {
    const [user, products, notices] = await Promise.all([
        fetchUser(),
        fetchProducts(),
        fetchNotices()
    ])

    console.log(user)
    console.log(products)
    console.log(notices)
} catch (error) {
    console.error(error)
}

예를 들어 다음과 같은 결과가 발생했다고 가정한다.

fetchUser()          성공
fetchProducts()      성공
fetchNotices()       실패

이 경우 Promise.all() 자체가 reject된다.

fetchUser()와 fetchProducts()가 성공했더라도 구조 분해 할당을 통해 정상 결과를 받을 수 없다.

따라서 모든 작업이 성공해야 다음 작업을 진행할 수 있는 상황에 적합하다.

 

Promise.allSettled란?

Promise.allSettled()은 모든 Promise가 처리될 때까지 기다린다.

여기서 처리된다는 것은 성공과 실패를 모두 포함한다.

const results = await Promise.allSettled([
    fetchUser(),
    fetchProducts(),
    fetchNotices()
])

결과는 다음과 같은 형태로 반환된다.

[
    {
        status: 'fulfilled',
        value: user
    },
    {
        status: 'fulfilled',
        value: products
    },
    {
        status: 'rejected',
        reason: error
    }
]

성공한 Promise는 fulfilled 상태와 value를 가진다.

실패한 Promise는 rejected 상태와 reason을 가진다.

따라서 각각의 결과를 개별적으로 처리할 수 있다.

results.forEach(result => {
    if (result.status === 'fulfilled') {
        console.log(result.value)
        return
    }

    console.error(result.reason)
})

Promise.all()과 달리 일부 Promise가 실패했다고 해서 전체 작업이 바로 reject되지는 않는다.

 

실무에서는 화면의 요구사항을 기준으로 선택한다

두 메서드 중 어떤 것이 더 좋은지는 정해져 있지 않다.

화면이나 기능이 일부 데이터 실패를 허용하는지가 가장 중요한 기준이다.

예를 들어 쇼핑몰 주문 상세 화면을 생각해본다.

const [order, payment] = await Promise.all([
    fetchOrder(),
    fetchPayment()
])

주문 정보와 결제 정보가 모두 있어야 정상적인 화면을 구성할 수 있다면 하나라도 실패했을 때 오류 화면을 표시하는 것이 자연스럽다.

이 경우에는 Promise.all()이 적합하다.

반대로 대시보드 화면을 생각해본다.

사용자 정보
공지사항
최근 활동
추천 콘텐츠
광고

각 영역이 독립적이라면 공지사항 API 하나가 실패했다고 전체 대시보드를 오류 화면으로 바꾸는 것은 사용자 경험 측면에서 좋지 않을 수 있다.

이 경우 Promise.allSettled()을 사용할 수 있다.

const results = await Promise.allSettled([
    fetchUser(),
    fetchNotices(),
    fetchActivities(),
    fetchRecommendations()
])

성공한 데이터는 정상적으로 보여주고 실패한 영역에만 오류 상태를 표시할 수 있다.

 

Promise.allSettled 결과를 실무에서 처리하는 방법

Promise.allSettled()은 결과 구조가 조금 복잡하기 때문에 필요한 값을 직접 분리하는 경우가 많다.

const [userResult, noticeResult] = await Promise.allSettled([
    fetchUser(),
    fetchNotices()
])

const user =
    userResult.status === 'fulfilled'
        ? userResult.value
        : null

const notices =
    noticeResult.status === 'fulfilled'
        ? noticeResult.value
        : []

이후 화면에서는 각각의 상태를 기준으로 처리한다.

if (!user) {
    return <UserError />
}

return (
    <>
        <UserProfile user={user} />
        <NoticeList notices={notices} />
    </>
)

사용자 정보는 반드시 필요하지만 공지사항은 없어도 화면을 표시할 수 있다는 정책을 코드에 반영할 수 있다.

 

 

Promise.all을 사용할 때 자주 하는 실수

다음과 같은 코드를 작성하는 경우가 있다.

const users = await fetchUsers()
const products = await fetchProducts()
const notices = await fetchNotices()

세 요청 사이에 아무런 의존성이 없다면 불필요하게 순차 실행되고 있는 것이다.

다음처럼 변경할 수 있다.

const [users, products, notices] = await Promise.all([
    fetchUsers(),
    fetchProducts(),
    fetchNotices()
])

반대로 모든 await를 Promise.all()로 묶는 것도 적절하지 않다.

const user = await fetchUser()
const orders = await fetchOrders(user.id)

fetchOrders()를 실행하려면 user.id가 필요하다.

두 요청은 의존 관계가 있으므로 순차 실행해야 한다.

결국 병렬 처리 여부는 Promise의 개수가 아니라 작업 사이의 의존 관계를 기준으로 판단해야 한다.

 

API 요청 자체가 취소되는 것은 아니다

Promise.all()에서 한 작업이 실패하면 전체 Promise는 reject된다.

하지만 이것이 나머지 네트워크 요청을 자동으로 취소한다는 의미는 아니다.

await Promise.all([
    fetch('/api/a'),
    fetch('/api/b'),
    fetch('/api/c')
])

/api/a 요청이 먼저 실패했다고 하더라도 이미 시작된 /api/b, /api/c 요청이 자동으로 취소되는 것은 아니다.

실제 요청까지 중단해야 한다면 AbortController와 같은 별도의 취소 처리가 필요하다.

이 부분은 Promise.all()의 실패 처리와 네트워크 요청 취소를 혼동하기 쉬운 부분이다.

 

 

Promise.all과 Promise.allSettled 선택 기준

실무에서는 다음과 같이 구분할 수 있다.

모든 데이터가 반드시 필요함 Promise.all()
하나라도 실패하면 전체 기능을 중단해야 함 Promise.all()
일부 데이터 실패를 허용함 Promise.allSettled()
여러 독립적인 UI 영역을 조회함 Promise.allSettled() 고려
요청 사이에 의존 관계가 있음 순차 await
요청 자체를 중단해야 함 AbortController 등 별도 처리

Promise.allSettled()이 더 안전한 방법이고 Promise.all()이 위험한 방법인 것은 아니다.

오히려 반드시 모든 데이터가 필요한 기능에서 실패를 숨기고 일부 결과만 사용하는 것이 더 큰 문제를 만들 수 있다.

 

정리

Promise.all()과 Promise.allSettled()은 여러 비동기 작업을 병렬로 처리한다는 점에서는 비슷하지만 실패 처리 방식이 다르다.

Promise.all()은 하나의 Promise라도 실패하면 전체 Promise가 reject된다.

Promise.allSettled()은 모든 Promise의 성공과 실패 결과를 각각 반환한다.

따라서 메서드를 선택할 때는 단순히 API 요청 개수를 기준으로 판단하지 않는다.

각 요청 사이에 의존성이 있는지, 일부 요청이 실패해도 기능을 계속 사용할 수 있는지를 기준으로 결정하는 것이 적절하다.

프론트엔드에서는 특히 여러 API를 호출하는 대시보드나 메인 화면에서 이러한 차이가 화면의 장애 범위와 사용자 경험에 직접적인 영향을 줄 수 있다.

반응형

CHATGPT 로 생성한 이미지

 

JavaScript에서 비동기 처리를 구현할 때 async/await를 사용하는 경우가 많다.

async/await는 Promise보다 코드 흐름을 읽기 쉽고 동기 코드와 비슷한 형태로 작성할 수 있다는 장점이 있다.

async function getUser() {
    const response = await fetch('/api/user')
    const user = await response.json()

    return user
}

코드만 보면 fetch()가 끝날 때까지 프로그램 전체가 멈춰 있는 것처럼 보일 수 있다.

하지만 실제 동작은 그렇지 않다.

async/await 역시 Promise를 기반으로 동작하기 때문에 JavaScript의 비동기 처리를 제대로 이해하려면 Promise의 동작 방식도 함께 이해해야 한다.

async 함수는 Promise를 반환한다

async가 선언된 함수는 항상 Promise를 반환한다.

async function getName() {
    return 'Kim'
}

const result = getName()

console.log(result)

result에는 문자열 Kim이 바로 들어가는 것이 아니라 Promise 객체가 들어간다.

개념적으로 다음과 비슷하게 동작한다.

function getName() {
    return Promise.resolve('Kim')
}

따라서 값을 사용하려면 await 또는 then()을 사용해야 한다.

const name = await getName()

또는 다음과 같이 작성할 수 있다.

getName().then(name => {
    console.log(name)
})

async/await와 Promise는 서로 다른 비동기 처리 방식이라기보다 같은 Promise 기반 비동기 처리를 다른 문법으로 작성하는 것에 가깝다.

await는 프로그램 전체를 멈추지 않는다

다음 코드를 살펴본다.

async function loadData() {
    console.log('1')

    const response = await fetch('/api/data')

    console.log('2')

    return response
}

await를 만나면 loadData() 함수의 실행은 해당 Promise가 처리될 때까지 중단된다.

하지만 JavaScript 실행 환경 전체가 멈추는 것은 아니다.

그동안 다른 이벤트나 작업은 계속 처리할 수 있다.

따라서 await는 동기적인 blocking 코드와 다르다.

const response = await fetch('/api/data')

이 코드는 네트워크 응답이 올 때까지 CPU를 점유하면서 기다리는 것이 아니다.

Promise가 처리되면 중단했던 함수의 실행을 이어간다.

await를 순서대로 사용하면 불필요하게 느려질 수 있다

async/await를 사용할 때 실무에서 자주 발생하는 문제가 있다.

서로 관계없는 API 요청을 순차적으로 처리하는 경우이다.

const user = await fetchUser()
const products = await fetchProducts()
const notifications = await fetchNotifications()

세 요청 사이에 의존성이 없다면 첫 번째 요청이 끝난 뒤 두 번째 요청을 시작할 이유가 없다.

각 요청에 1초씩 걸린다고 가정하면 전체 작업이 약 3초가 걸릴 수 있다.

이런 경우에는 동시에 요청을 시작하는 것이 적절하다.

const [user, products, notifications] = await Promise.all([
    fetchUser(),
    fetchProducts(),
    fetchNotifications()
])

세 작업을 병렬로 시작하기 때문에 가장 오래 걸리는 요청이 완료되는 시점을 기준으로 전체 작업을 마칠 수 있다.

API 요청이 많은 화면에서는 이러한 차이가 실제 로딩 성능에 영향을 줄 수 있다.

Promise.all도 무조건 사용하면 안 된다

여러 비동기 작업이 있다고 해서 항상 Promise.all()을 사용하는 것은 적절하지 않다.

두 번째 요청이 첫 번째 요청 결과를 필요로 하는 경우가 있다.

const user = await fetchUser()

const orders = await fetchOrders(user.id)

fetchOrders()를 호출하려면 user.id가 필요하므로 두 작업은 순차적으로 실행해야 한다.

반면 다음과 같이 서로 독립적인 요청이라면 병렬 처리가 가능하다.

const [profile, notices] = await Promise.all([
    fetchProfile(),
    fetchNotices()
])

비동기 코드를 작성할 때는 단순히 await를 사용하는 것보다 작업 사이의 의존 관계를 먼저 확인하는 것이 중요하다.

Promise.all의 실패 방식도 알아야 한다

Promise.all()에는 주의할 점이 있다.

전달한 Promise 중 하나라도 reject되면 전체 Promise.all()도 reject된다.

const result = await Promise.all([
    fetchUser(),
    fetchProducts(),
    fetchNotifications()
])

예를 들어 fetchNotifications()에서 오류가 발생하면 Promise.all()은 실패한다.

모든 작업의 성공 여부를 각각 확인해야 한다면 Promise.allSettled()를 사용할 수 있다.

const results = await Promise.allSettled([
    fetchUser(),
    fetchProducts(),
    fetchNotifications()
])

결과는 각각 fulfilled 또는 rejected 상태를 가진다.

results.forEach(result => {
    if (result.status === 'fulfilled') {
        console.log(result.value)
    } else {
        console.error(result.reason)
    }
})

여러 API 중 일부가 실패하더라도 나머지 결과를 사용해야 하는 화면에서 활용할 수 있다.

try/catch의 범위도 중요하다

async/await에서는 일반적으로 try/catch로 오류를 처리한다.

try {
    const user = await fetchUser()

    return user
} catch (error) {
    console.error(error)
}

하지만 하나의 try 안에 너무 많은 작업을 넣으면 어떤 작업에서 오류가 발생했는지 처리하기 어려워질 수 있다.

try {
    const user = await fetchUser()
    const products = await fetchProducts()
    const notifications = await fetchNotifications()
} catch (error) {
    console.error(error)
}

각 요청의 실패에 따라 다른 처리가 필요하다면 오류 처리 범위를 분리하는 것이 좋다.

반대로 모든 요청 실패를 동일하게 처리한다면 하나의 try/catch로 묶는 것이 더 단순하다.

오류 처리 구조 역시 API의 특성에 따라 결정해야 한다.

return await는 항상 필요한 것이 아니다

다음과 같은 코드도 자주 볼 수 있다.

async function getUser() {
    return await fetchUser()
}

단순히 Promise 결과를 반환하는 것이 목적이라면 다음처럼 작성할 수 있다.

async function getUser() {
    return fetchUser()
}

두 코드가 항상 완전히 동일한 것은 아니다.

특히 함수 내부의 try/catch에서 해당 Promise의 rejection을 직접 처리해야 한다면 await가 필요할 수 있다.

async function getUser() {
    try {
        return await fetchUser()
    } catch (error) {
        console.error('사용자 조회 실패', error)
        throw error
    }
}

따라서 return await를 무조건 제거하거나 무조건 사용하는 것보다 오류를 어느 범위에서 처리할 것인지 기준으로 판단하는 것이 적절하다.

비동기 코드에서는 실행 순서를 먼저 생각해야 한다

async/await의 장점은 코드가 읽기 쉽다는 것이다.

하지만 문법이 단순해졌다고 해서 비동기 처리 자체가 단순해진 것은 아니다.

실무에서는 다음 항목을 확인하는 것이 좋다.

  1. 현재 함수가 어떤 Promise를 반환하는지 확인한다.
  2. 각 비동기 작업 사이에 의존성이 있는지 확인한다.
  3. 독립적인 작업이라면 Promise.all()을 고려한다.
  4. 일부 실패를 허용해야 한다면 Promise.allSettled()을 고려한다.
  5. 오류를 어느 계층에서 처리할지 결정한다.

async/await를 단순히 Promise를 대신하는 문법으로만 이해하면 API 호출이 불필요하게 순차 실행되거나 오류 처리 범위가 지나치게 넓어지는 코드를 작성하기 쉽다.

결국 중요한 것은 await를 많이 사용하는 것이 아니라 Promise가 언제 생성되고 언제 처리되는지를 이해하는 것이다.

정리

async/await는 JavaScript의 비동기 코드를 읽기 쉽게 만들어주는 문법이다.

하지만 내부적으로 Promise를 기반으로 동작하기 때문에 Promise에 대한 이해를 대신해주지는 않는다.

순차 실행이 필요한 작업에는 await를 사용하고, 독립적인 작업에는 Promise.all()과 같은 방법을 검토한다.

또한 일부 작업의 실패를 허용해야 한다면 Promise.allSettled()을 사용할 수 있다.

실무에서 비동기 코드의 성능과 안정성을 높이려면 문법 자체보다 비동기 작업 사이의 의존성과 실패 처리 방식을 먼저 설계하는 것이 중요하다.

 

반응형

 

React 프로젝트를 개발하다 보면 성능 최적화를 위해 useMemo나 useCallback을 사용하는 경우가 많습니다.

특히 코드 리뷰에서 다음과 같은 코드를 자주 볼 수 있습니다.

const handleClick = useCallback(() => {
    setCount(count + 1)
}, [count])

const total = useMemo(() => {
    return price * quantity
}, [price, quantity])

언뜻 보면 성능을 고려한 좋은 코드처럼 보입니다.

하지만 실제로는 필요하지 않은 곳에서 useMemo, useCallback을 사용하는 것이 오히려 코드 복잡도를 높일 수 있습니다.

이번 글에서는 언제 사용해야 하고, 언제 사용하지 않는 것이 좋은지 정리해보겠습니다.


useMemo는 무엇을 하는가?

useMemo는 특정 연산의 결과를 저장해두고 dependency가 변경될 때만 다시 계산합니다.

const filteredList = useMemo(() => {
    return list.filter(item => item.active)
}, [list])

list가 변경되지 않았다면 이전에 계산했던 filteredList를 재사용합니다.

 

따라서 계산 비용이 큰 작업에서는 도움이 될 수 있습니다.

예시)

const result = useMemo(() => {
    return veryLargeArray
        .filter(item => item.enabled)
        .sort((a, b) => b.score - a.score)
        .map(item => expensiveTransform(item))
}, [veryLargeArray])

데이터가 많고 계산 과정이 복잡하다면 useMemo를 사용하는 의미가 있습니다.

 

단순 계산에는 굳이 필요하지 않다

반대로 아래 코드는 최적화 효과가 거의 없습니다.

const totalPrice = useMemo(() => {
    return price * quantity
}, [price, quantity])

곱셈 한 번을 줄이기 위해 React가 dependency를 비교하고 memo 값을 관리하게 됩니다.

이 경우에는 아래처럼 작성하는 것이 코드도 읽기 쉽고 관리하기도 좋습니다.

const totalPrice = price * quantity
계산 비용 <<  memoization 관리 비용

useCallback도 같은 문제를 가진다

useCallback은 함수를 메모이제이션합니다.

const handleClick = useCallback(() => {
    setCount(prev => prev + 1)
}, [])

하지만 일반적인 이벤트 핸들러라면 다음처럼 작성해도 대부분 문제가 없습니다.

const handleClick = () => {
    setCount(prev => prev + 1)
}

React 컴포넌트가 다시 렌더링되면 함수도 다시 생성됩니다.

하지만 대부분의 애플리케이션에서는 함수 하나 생성되는 비용이 성능에 영향을 줄 정도로 크지 않습니다.

 

useCallback이 실제로 필요한 경우

대표적인 경우는 React.memo와 함께 사용할 때입니다.

const Child = React.memo(({ onClick }) => {
    return <button onClick={onClick}>클릭</button>
})

부모 컴포넌트에서 다음처럼 함수를 작성하면

const handleClick = () => {
    console.log('click')
}

return <Child onClick={handleClick} />

부모가 렌더링될 때마다 새로운 함수가 생성됩니다.

그러면 Child 입장에서는 onClick prop이 변경된 것으로 판단될 수 있습니다.

이런 경우에는 useCallback이 의미가 있습니다.

const handleClick = useCallback(() => {
    console.log('click')
}, [])

함수 reference가 유지되기 때문에 불필요한 자식 컴포넌트 렌더링을 줄일 수 있습니다.


 

실무에서 판단하는 기준

저는 React 프로젝트에서 다음 기준으로 판단하는 것이 가장 현실적이라고 봅니다.

1. 계산량이 큰가?

단순 문자열 조합이나 사칙연산이라면 useMemo가 필요하지 않습니다.

const fullName = `${firstName} ${lastName}`

반대로 대량 배열 처리나 복잡한 연산이라면 고려할 수 있습니다.


2. React.memo 컴포넌트에 전달되는 값인가?

memoized component에 object나 function을 전달한다면 reference 유지가 중요할 수 있습니다.

예를 들어

const options = {
    pageSize: 20
}

이 코드는 렌더링될 때마다 새로운 객체를 생성합니다.

필요하다면 다음과 같이 사용할 수 있습니다.

const options = useMemo(() => ({
    pageSize: 20
}), [])

3. 실제 성능 문제가 있는가?

가장 중요한 기준입니다.

성능 문제가 확인되지 않았는데 미리 모든 코드를 최적화하면 오히려 코드가 복잡해집니다.

다음과 같은 순서가 좋습니다.

  1. 먼저 단순하게 구현한다.
  2. 실제 렌더링 문제가 있는지 확인한다.
  3. React DevTools Profiler로 병목을 확인한다.
  4. 필요한 부분만 최적화한다.

측정 후 최적화하는 것이 좋습니다.


오히려 과도한 최적화가 유지보수를 어렵게 만든다

다음과 같은 코드가 프로젝트 전체에 반복되기 시작하면

const value = useMemo(() => {
    return a + b
}, [a, b])

const handleChange = useCallback(() => {
    setValue(...)
}, [...])

개발자는 계속 dependency를 관리해야 합니다.

dependency를 잘못 설정하면 stale closure 같은 버그가 발생할 수도 있습니다.

결과적으로 성능 개선보다 유지보수 비용이 더 커질 수 있습니다.

 


정리

useMemouseCallback은 React에서 유용한 최적화 도구입니다.

하지만 모든 곳에 사용하는 것이 좋은 것은 아닙니다.

실무에서는 다음 기준 정도로 생각하면 됩니다.

  • 단순 계산 → 그냥 계산
  • 일반 이벤트 핸들러 → 일반 함수
  • 무거운 연산 → useMemo 고려
  • React.memo 자식에 함수 전달 → useCallback 고려
  • 실제 렌더링 병목 → Profiler 확인 후 최적화

React 성능 최적화에서 중요한 것은 hook을 많이 사용하는 것이 아니라,

필요한 곳에만 사용하는 것입니다.

반응형

2시간 동안의 사투 끝에 드디어 해결했네요!

Radit 이나 여러 여러 여러 사이트들을 보면 저와 동일한 에러에 직면한 케이스가 많더라구요.

저처럼 Expo SDK 52NativeWind v4 조합으로 앱을 개발하다가 EAS Build에서 막힌 분들을 위해, 그리고 나중에 또 같은 실수를 반복하지 않을 저를 위해 삽질 기록을 정리해 봅니다.

에러사진1
expo build fail image


■ 문제 상황: "분명 로컬에선 됐는데, 빌드만 하면 터진다?"

만보기 기능을 구현하려고 expo-sensors를 달고, 스타일링을 위해 NativeWind v4를 세팅했습니다. 로컬에서는 아주 예쁘게 잘 돌아갔죠. 그런데 eas build 명령어만 날리면 아래와 같은 무시무시한 에러와 함께 빌드가 중단되었습니다.

SyntaxError: index.js: [BABEL] ... Cannot find module 'react-native-worklets/plugin'

분명 react-native-worklets-core를 설치했는데도 바벨(Babel)은 자꾸 이름이 미묘하게 다른 react-native-worklets/plugin이 없다고 징징대는 상황이었습니다.

 

■ 원인 분석: 범인은 ^ (캐럿) 하나였다

2시간 동안 구글링과 문서 분석 끝에 찾아낸 원인은 NativeWind의 과한 의욕 때문이었습니다.

  1. 버전 자동 업데이트의 함정: package.json에 "nativewind": "^4.1.23"라고 적어두면, npm은 설치 시점에 4.1.23보다 높은 최신 버전(4.2.x)을 가져옵니다.
  2. NativeWind 4.2.x의 변화: 최신 4.2.x 버전은 Reanimated 4에 대응하기 위해 내부적으로 react-native-worklets/plugin을 강제로 호출합니다.
  3. 이름 불일치: 하지만 현재 Expo SDK 52의 표준은 react-native-worklets-core입니다. 바벨은 -core가 붙지 않은 정확한 이름을 찾으려다 보니 모듈을 찾지 못하고 뻗어버린 것이죠.

 

해결 방법: 버전을 강제로 묶어라 (Pinning)

결국 해결책은 똑똑한 척하는 업데이트 기능을 끄고, 우리 프로젝트와 가장 잘 맞는 안정적인 버전으로 강제 고정하는 것이었습니다.

 

1. package.json 수정

버전 앞의 ^ 기호를 제거하여 딱 4.1.23 버전만 쓰도록 못 박았습니다.

"dependencies": {
  "nativewind": "4.1.23",  // ^ 제거!
  "react-native-css-interop": "0.2.3",
  "react-native-reanimated": "~3.16.1"
}

2. 찌꺼기 제거 및 재설치

이전 버전의 캐시가 남지 않도록 과감하게 밀고 다시 깔았습니다.

rm -rf node_modules package-lock.json
npm install

3. babel.config.js 정리

불필요한 플러그인 호출을 제거하고 NativeWind v4 전용 설정을 적용했습니다.

module.exports = function (api) {
  api.cache(true);
  return {
    presets: [
      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
      "nativewind/babel",
    ],
    plugins: ["react-native-reanimated/plugin"],
  };
};

▶️ 최신이 항상 정답은 아니다

이번 삽질을 통해 다시 한번 깨달았습니다. 라이브러리 간의 의존성이 복잡하게 얽힌 리액트 네이티브 환경에서는 캐럿(^) 하나가 전체 빌드를 무너뜨릴 수 있다는 것을요.

특히 NativeWind처럼 과도기에 있는 라이브러리는 안정적인 버전을 확인하고 강제로 고정해서 쓰는 습관이 정신 건강에 이로운 것 같습니다. 이제 만보기 데이터가 올라가는 걸 보니 속이 다 시원하네요!

저와 같은 에러로 고통받는 분들이 계신다면, 지금 당장 package.json에서 ^부터 지워보세요.

 


Expo 에서 build fail 한 내용입니다.

Starting Metro Bundler
Android Bundling failed 735ms index.js (1 module)
SyntaxError: index.js: [BABEL] /home/expo/workingdir/build/index.js: Cannot find module 'react-native-worklets/plugin'
Require stack:
- /home/expo/workingdir/build/node_modules/@babel/core/lib/config/files/plugins.js
- /home/expo/workingdir/build/node_modules/@babel/core/lib/config/files/index.js
- /home/expo/workingdir/build/node_modules/@babel/core/lib/index.js
- /home/expo/workingdir/build/node_modules/metro-transform-worker/src/index.js
- /home/expo/workingdir/build/node_modules/react-native-css-interop/dist/metro/transformer.js
- /home/expo/workingdir/build/node_modules/metro/src/DeltaBundler/Worker.flow.js
- /home/expo/workingdir/build/node_modules/metro/src/DeltaBundler/Worker.js
- /home/expo/workingdir/build/node_modules/jest-worker/build/workers/processChild.js

Make sure that all the Babel plugins and presets you are using
are defined as dependencies or devDependencies in your package.json
file. It's possible that the missing plugin is loaded by a preset
you are using that forgot to add the plugin to its dependencies: you
can workaround this problem by explicitly adding the missing package
to your top-level package.json.

Error: [BABEL] /home/expo/workingdir/build/index.js: Cannot find module 'react-native-worklets/plugin'
Require stack:
- /home/expo/workingdir/build/node_modules/@babel/core/lib/config/files/plugins.js
- /home/expo/workingdir/build/node_modules/@babel/core/lib/config/files/index.js
- /home/expo/workingdir/build/node_modules/@babel/core/lib/index.js
- /home/expo/workingdir/build/node_modules/metro-transform-worker/src/index.js
- /home/expo/workingdir/build/node_modules/react-native-css-interop/dist/metro/transformer.js
- /home/expo/workingdir/build/node_modules/metro/src/DeltaBundler/Worker.flow.js
- /home/expo/workingdir/build/node_modules/metro/src/DeltaBundler/Worker.js
- /home/expo/workingdir/build/node_modules/jest-worker/build/workers/processChild.js

Make sure that all the Babel plugins and presets you are using
are defined as dependencies or devDependencies in your package.json
file. It's possible that the missing plugin is loaded by a preset
you are using that forgot to add the plugin to its dependencies: you
can workaround this problem by explicitly adding the missing package
to your top-level package.json.

    at Module._resolveFilename (node:internal/modules/cjs/loader:1212:15)
    at resolve (node:internal/modules/helpers:193:19)
    at tryRequireResolve (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/files/plugins.js:128:11)
    at resolveStandardizedNameForRequire (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/files/plugins.js:162:19)
    at resolveStandardizedName (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/files/plugins.js:183:12)
    at loadPlugin (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/files/plugins.js:56:7)
    at loadPlugin.next (<anonymous>)
    at createDescriptor (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/config-descriptors.js:140:16)
    at createDescriptor.next (<anonymous>)
    at evaluateSync (/home/expo/workingdir/build/node_modules/gensync/index.js:251:28)
    at /home/expo/workingdir/build/node_modules/gensync/index.js:31:34
    at Array.map (<anonymous>)
    at Function.sync (/home/expo/workingdir/build/node_modules/gensync/index.js:31:22)
    at Function.all (/home/expo/workingdir/build/node_modules/gensync/index.js:210:24)
    at Generator.next (<anonymous>)
    at createDescriptors (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/config-descriptors.js:102:41)
    at createDescriptors.next (<anonymous>)
    at createPluginDescriptors (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/config-descriptors.js:99:17)
    at createPluginDescriptors.next (<anonymous>)
    at /home/expo/workingdir/build/node_modules/@babel/core/lib/gensync-utils/functional.js:22:27
    at Generator.next (<anonymous>)
    at mergeChainOpts (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/config-chain.js:349:34)
    at mergeChainOpts.next (<anonymous>)
    at chainWalker (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/config-chain.js:316:14)
    at chainWalker.next (<anonymous>)
    at buildPresetChain (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/config-chain.js:33:24)
    at buildPresetChain.next (<anonymous>)
    at loadPresetDescriptor (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/full.js:295:53)
    at loadPresetDescriptor.next (<anonymous>)
    at recursePresetDescriptors (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/full.js:77:31)
    at recursePresetDescriptors.next (<anonymous>)
    at /home/expo/workingdir/build/node_modules/@babel/core/lib/config/full.js:156:21
    at Generator.next (<anonymous>)
    at loadFullConfig (/home/expo/workingdir/build/node_modules/@babel/core/lib/config/full.js:113:5)
    at loadFullConfig.next (<anonymous>)
    at transform (/home/expo/workingdir/build/node_modules/@babel/core/lib/transform.js:20:44)
    at transform.next (<anonymous>)
    at evaluateSync (/home/expo/workingdir/build/node_modules/gensync/index.js:251:28)
    at sync (/home/expo/workingdir/build/node_modules/gensync/index.js:89:14)
    at stopHiding - secret - don't use this - v1 (/home/expo/workingdir/build/node_modules/@babel/core/lib/errors/rewrite-stack-trace.js:47:12)
    at Object.transformSync (/home/expo/workingdir/build/node_modules/@babel/core/lib/transform.js:40:76)
    at parseWithBabel (/home/expo/workingdir/build/node_modules/@expo/metro-config/build/transformSync.js:65:18)
    at transformSync (/home/expo/workingdir/build/node_modules/@expo/metro-config/build/transformSync.js:54:12)
    at Object.transform (/home/expo/workingdir/build/node_modules/@expo/metro-config/build/babel-transformer.js:115:58)
    at transformJSWithBabel (/home/expo/workingdir/build/node_modules/@expo/metro-config/build/transform-worker/metro-transform-worker.js:440:47)
    at Object.transform (/home/expo/workingdir/build/node_modules/@expo/metro-config/build/transform-worker/metro-transform-worker.js:549:12)
    at transform (/home/expo/workingdir/build/node_modules/@expo/metro-config/build/transform-worker/transform-worker.js:117:23)
    at Object.transform (/home/expo/workingdir/build/node_modules/react-native-css-interop/dist/metro/transformer.js:15:16)
    at transformFile (/home/expo/workingdir/build/node_modules/metro/src/DeltaBundler/Worker.flow.js:54:36)

반응형

이미지 데이터셋을 다루거나 정제된 이미지 폴더의 파일 수를 파악해야 하는 경우가 많습니다.
특히 하위 폴더가 복잡하게 구성된 구조에서는 재귀적으로 전체 폴더를 순회하면서 이미지 파일을 세는 로직이 필요합니다.

 

 

Java에서는 이를 직접 재귀함수로 구현해야 하지만, Python은 os.walk() 라는 내장 모듈로 매우 간단하게 처리할 수 있습니다.

이번 글에서는 Python의 os.walk()를 이용해 폴더 내 이미지 파일 수를 집계하는 방법을 소개하고,
Java 방식과 비교해 생산성과 코드 간결성의 차이도 함께 알아보겠습니다.

 

1. Java에서의 폴더 탐색: 수동 재귀의 한계

Java에서 폴더 내의 파일을 탐색하기 위해서는 일반적으로 다음과 같은 수동 재귀 로직이 필요합니다.

public static int countImages(File dir) {
    int count = 0;
    File[] files = dir.listFiles();
    if (files == null) return 0;

    for (File file : files) {
        if (file.isDirectory()) {
            count += countImages(file); // 재귀 호출
        } else if (file.getName().matches("(?i).*\\.(png|jpg|jpeg)$")) {
            count++;
        }
    }
    return count;
}

 

이 방식은 다음과 같은 문제점이 있습니다:

  • 예외 처리 및 디렉토리 접근 검증 코드를 별도로 작성해야 함
  • 파일 확장자 필터링을 위한 정규식 작성 필요
  • 코드 길이가 길고 가독성이 떨어짐

 

2. Python의 os.walk: 자동 재귀의 강력함

2.1 os.walk란?

os.walk(path)는 지정한 경로부터 시작해 모든 하위 디렉토리를 자동으로 순회하며, 각 폴더 내의 파일과 디렉토리 정보를 튜플 형태로 반환해주는 제너레이터 함수입니다.

for root, dirs, files in os.walk(target_path):
    # root: 현재 탐색 중인 폴더 경로
    # dirs: 현재 폴더 내의 하위 폴더 목록
    # files: 현재 폴더 내의 파일 목록

 

2.2 이미지 파일 필터링 예시

이미지 파일 확장자를 필터링하려면 files 리스트에 대해 다음과 같은 list comprehension을 사용하면 됩니다:

image_files = [
    f for f in files
    if f.lower().endswith((".png", ".jpg", ".jpeg"))
]

 

3. 실전 코드 예시 및 설명

다음은 Python 코드 전체입니다. 이 코드는 정제/ 폴더 하위에 있는 모든 이미지(.png, .jpg, .jpeg) 파일의 개수를 계산하고,
결과를 JSON 파일로 저장합니다.

FastAPI 를 사용하였습니다

@dashboard_router.post("/update-image-count", response_model=ImageCountResponse)
def update_image_count_json():
    try:
        JSON_PATH = os.path.join(ROOT_DIR, "etc/refinedDataCount.json")

        # JSON 로드 또는 초기화
        if os.path.exists(JSON_PATH):
            with open(JSON_PATH, "r", encoding="utf-8") as f:
                data_count = json.load(f)
        else:
            data_count = {}

        # 탐색할 루트 경로 설정
        refined_root = os.path.join(ROOT_DIR, "정제")

        image_count = 0

        # os.walk를 통한 자동 재귀 탐색
        for root, dirs, files in os.walk(refined_root):
            image_count += len([
                f for f in files
                if f.lower().endswith((".png", ".jpg", ".jpeg"))
            ])

        # 결과 저장
        data_count["total_png"] = image_count
        os.makedirs(os.path.dirname(JSON_PATH), exist_ok=True)
        with open(JSON_PATH, "w", encoding="utf-8") as f:
            json.dump(data_count, f, ensure_ascii=False, indent=4)

        print(f"✅ 총 이미지 개수: {image_count} → {JSON_PATH} 저장됨")

        return {"success": True, "message": "이미지 개수 업데이트 완료", "image_count": image_count}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error: {str(e)}")

 

4. 핵심 포인트 정리

항목JavaPython (os.walk)
항목 JAVA Python
재귀 구현 직접 구현 필요 내장 함수로 자동 수행
코드 길이 길고 복잡함 간결하고 직관적
예외 처리 수동 처리 try-except로 간단 처리
유지보수성 낮음 높음

Python은 반복적이고 재귀적인 작업을 매우 간단하게 처리할 수 있도록 다양한 내장 기능을 제공합니다. os.walk는 그 대표적인 예시로, 파일 시스템을 다루는 모든 작업에 강력한 도구가 됩니다.


5. 마무리

Python의 os.walk()는 재귀적으로 파일 시스템을 탐색할 수 있는 매우 강력하고도 간단한 도구입니다. Java와 같은 언어에서는 재귀 구조를 직접 설계해야 하는 반면, Python은 이러한 반복 작업을 몇 줄의 코드로 깔끔하게 처리할 수 있습니다.

이미지 파일 개수 집계, 대용량 폴더 분석, 정제 데이터 자동화 등에 활용하면 실무에서 효율적으로 사용할 수 있습니다.
지금 바로 여러분의 프로젝트에 적용해보세요!

 


 

📌 도움이 되셨다면 댓글이나 공감 부탁드립니다
궁금한 점이 있다면 언제든지 질문해주세요!

반응형

안녕하세요 상훈입니다.

Cursor AI의 Agent 모드를 사용하던 중 커맨드 자동 입력에서 정말 자주 "~200~" 가 발생해서 자꾸 다른 방식으로 명령어를 입력하게 되는 token 낭비가 있습니다.

bash 설정 자체를 바꿔서 복사-붙여넣기 시 prefix로 ~200~ 등이 붙지 않게 설정하는 방법입니다.

 

1. 현재 작성하고 있는 모드를 확인해주세요.

echo $TERM
xterm-256color

-> 만약 `xterm-256color` 라고 결과가 나왔다면, 여기에서 문제가 발생하는 것입니다. (붙여넣기 escape 시퀀스가 포함될 수 있음)
대충 syntax error라고 치부하고 살면 되지않을까요?

 

2. `$TERM ` 수정하기 [임시]

#xterm 을 수정합니다.
export TERM=xterm

#xterm 을 확인합니다.
echo $TERM
xterm

저는 이렇게만도 했을 때 되긴했는데, 아래 명령어도 추가로 써주세요!

bind 'set enable-bracketed-paste off'

 

3. [영구] bash 설정 자체를 수정하기.

저희(windows)의 pc 의 경로 `~/` 에는 `.bashrc` 파일이 존재합니다. (bash가 있다면)

이 bash의 기본설정을 추가 작성하도록 합니다.

cd ~
nano .bashrc

아래 내용을 파일 최하단에 복사-붙여넣기

#아래 내용을 파일 최하단에 복사-붙여넣기.
export TERM=xterm

if bind -v | grep 'enable-bracketed-paste' &>/dev/null; then
  bind 'set enable-bracketed-paste off'
fi

그 이후 bash 적용.

#변경한 bashrc 내용 적용하기
source ~/.bashrc

 

프로세스 끝! 이제 확인해보실까요.

 

 

이제 복사&붙여넣기가 Cursor 에서 정상적으로 동작할 것 입니다!

cursor 에 프로젝트를 구동하라고 자연어로 명령한 모습.

 

이제 ~200~ 때문에 아까운 토큰 낭비는 없애고 사용하시죠!

 

 

반응형

직업의 미래: AI 시대 일자리 변화 진짜 현실

솔직히 말하면, 2025년 지금 AI 때문에 직업 생태계가 완전히 뒤바뀌고 있어. 세계경제포럼 보고서 보니까 전 세계 기업 41%가 2030년까지 직원 수 줄일 계획이라고 하더라. 진짜 심각한 상황이야.

ai로 생성한 이미지 입니다.

 

AI한테 밀려날 직업들

지금 당장 위험한 직업들

  • 패턴사 (현재 대체율이 71.65%래)
  • 물류사무원 (3년 후엔 더 심해질 듯)
  • 우편 서비스직, 비서, 급여 담당 이런 애들이 제일 빨리 사라질 직업이라고 함

예상 못한 대체 대상들
생성형 AI가 발전하면서 창의적인 일도 많이 영향받고 있어:

  • 그래픽 디자이너랑 법무 비서가 빠르게 감소하는 직업 상위권에 들어감
  • 기술직 (코더, 프로그래머, 데이터 분석가)
  • 미디어 쪽 (광고, 콘텐츠 제작, 기자)

한국은행 조사 결과가 좀 충격적인데, 고학력·고임금 일자리가 오히려 AI에 더 많이 노출되어 있다고 해. 의사, 회계사, 변호사도 대체 가능성 있다니까 진짜 세상이 바뀌는구나 싶어.

 

새로 뜨는 유망 직업들

AI 관련 핵심 직업
링크드인에서 발표한 2025년 유망 직업 순위 보니까 AI 관련 직종이 상위권 독점하고 있더라:

  1. AI 엔지니어 (1위)
  2. AI 컨설턴트 (2위)
  3. 데이터 엔지니어 (6위)

미래형 신직업들
기술 발전이랑 사회 변화로 생겨날 새로운 직업들:

  • 가상현실 디자이너: VR 콘텐츠 만드는 일
  • 인공지능 윤리 전문가: AI 윤리 문제 관리하는 사람
  • 로봇 테라피스트: 로봇으로 치료하는 서비스
  • 생체 디지털 아티스트: 생명과학이랑 디지털 아트 섞은 거
  • 클라이메트 디자이너: 기후 변화 대응 설계 전문가

계속 성장할 직업들

  • 물리치료사 (3위)
  • 간호사, 사회복지사, 교사 같은 돌봄이랑 교육 관련 일
  • 농업 종사자 같은 필수 직종들

AI가 대체하기 어려운 직업

AI 노출 지수가 낮은 직업들 보니까 사람이랑 직접 만나고 관계 맺는 게 중요한 특징이 있어:

  • 음식 관련 단순 종사자
  • 대학교수랑 강사
  • 종교 관련 종사자
  • 운송 서비스 종사자
  • 가수 같은 예술 분야
  • 프로게이머 (대체율 0.00%라니 신기하네)

변화 규모가 얼마나 클까

세계경제포럼 보고서 내용:

  • 앞으로 5년간 직업 바꾸는 사람: 1억 명 넘을 듯
  • 새로운 기술 인재 수요: 약 900만 명 증가
  • AI 도구 설계 인력 채용 계획: 기업 70%
  • AI 협업 인력 채용 계획: 기업 62%

미래 직업 시장 대비 전략

핵심 역량 개발
2030년까지 가장 수요 많은 기술:

  • AI랑 빅데이터 활용 능력
  • 네트워크랑 사이버 보안 지식
  • 기술 문해력 (디지털 리터러시)

인간 고유 가치 강화
AI가 대체하기 어려운 인간만의 능력에 집중해야 해:

  • 창의적 사고력이랑 문제 해결 능력
  • 감성적 소통이랑 공감 능력
  • 복잡한 판단력이랑 윤리적 사고

 

ai로 생성한 이미지입니다.

 

앞으로 어떻게 될까

AI 시대 직업 변화는 완전한 대체보다는 '인간-기계 협업'이 핵심이야. 중요한 건 기술 변화에 적응하면서도 인간 고유의 가치를 계속 개발하는 거지.

미래 직업 시장에서 성공하려면 평생 학습 마인드랑 새로운 기술 적응력이 필수고, 동시에 AI가 대체 못하는 인간적 역량을 키워나가는 게 중요해.

모두들. 힘내자.

요즘은 ai로 동영상, 이미지 생성하는게 재밌어.

그냥 모든 일 AI 가 다해줘~!!~!

 

 

부록

웹 개발자로 살면서 지금의 AI 발전이 반갑기도 하고 무섭기도 해.

내가 해야할 업무의 범위와 양은 늘어났지만 (요즘은 처음하는 일도 함), 그에 따른 숙련도는 못쫓아오는 것 같더라고.

나는 요즘은 Python을 주로 하는 내부 프로젝트를 진행 중이야.
여기서 문제가 발생해.
나는 파이썬을 해본 적이 없어. 그래서 파이썬의 문법에 대해 정확히 알지 못하고 지속적으로 사용하고 있어. (이걸 사용하고 있다고 말할 수 있는건가? Claude, ChatGPT, Gemini 등과 주로 대화하면서 요청하지;)
근데 일을 해야하는 양은 지속적으로 추가가 되고, 해당 내용을 이해해야한다고 요구를 받고 있어. 
어찌저찌 맞춰서 진행하고 있긴한데,, 쉽지는 않더라.

 

오늘도 수고했다.

반응형

안녕하세요 상훈입니다.

✅ 개발자를 위한 실전 비교

최근 AI 코딩 도구들이 우후죽순 쏟아지고 있는 가운데, 구글의 Gemini CLICursor AI가 개발자들 사이에서 특히 주목받고 있다. 두 도구 모두 나름의 장단점이 있지만, 실제로 사용해보니 확실히 차이가 느껴진다.

gemini cli & cursor ai

 

✅ UI/UX: Cursor의 압승

Cursor AI는 애초에 코드 에디터로 설계된 만큼 UI 면에서는 확실히 한 수 위.
VS Code를 포크해서 만들어진 덕분에 기존 VS Code 사용자라면 별다른 학습 없이 바로 적응할 수 있다.

  • 직관적인 인터페이스: 채팅 패널이 에디터에 자연스럽게 통합되어 있어 코드 작성 중에도 AI와 대화하기 편하다
  • 실시간 코드 제안: 타이핑하는 동안 실시간으로 코드를 제안해주는 기능이 매우 자연스럽다
  • 멀티파일 편집: 여러 파일을 동시에 수정할 때도 시각적으로 변경사항을 확인하기 쉽다

반면 Gemini CLI는 터미널 기반이라 아무래도 시각적 편의성에서는 아쉬움이 있었다.
물론 CLI 환경에 익숙한 개발자라면 오히려 더 효율적일 수도 있지만, 일반적인 사용성 면에서는 Cursor가 확실히 우위에 있다.

✅ 경제적 측면: Gemini CLI의 완승

가격 면에서는 Gemini CLI가 압도적. 구글이 정말 파격적인 조건을 내놓았다.

  Gemini Cursor AI
기본 요금 완전 무료 $20/월 (Pro 플랜)
무료 체험 제한 없음 1개월 무료
일일 요청 한도 1,000회 제한적
분당 요청 한도 60회 -
 

Gemini CLI는 개인 구글 계정으로 로그인하면 Gemini 2.5 Pro 모델을 완전 무료로 사용할 수 있다.
하루 1,000회, 분당 60회라는 넉넉한 한도까지 제공한다.

Cursor AI는 Pro 플랜이 월 20달러인데, 이는 연간 240달러에 해당한다. 물론 Cursor도 1개월 무료 체험을 제공하지만, 장기적으로 보면 비용 부담이 만만치 않다.

1년 구독 해버린 사람으로써 살짝 아쉬울 정도..?

 

✅ 속도: Gemini CLI가 근소하게 앞서

실제 사용해보니 응답 속도 면에서는 Gemini CLI가 조금 더 빠르다고 느껴진다.
특히 Gemini 2.5 Pro 모델을 사용할 때 응답이 상당히 빠르다.

다만 이 부분은 사용 환경과 네트워크 상황에 따라 달라질 수 있다.
Cursor AI도 충분히 빠른 편이지만, 복잡한 요청을 처리할 때는 Gemini CLI가 좀 더 민첩하게 반응하는 것 같다.

 

✅ 편의성: Cursor AI의 우위

편의성 면에서는 Cursor AI가 확실히 앞선다. 
특히 개발 경험이 많지 않은 사용자에게는 더욱 그렇다.

Cursor AI의 편의성 장점:

  • 학습 곡선이 낮음: VS Code 사용자라면 바로 적응 가능
  • 시각적 피드백: 코드 변경사항을 실시간으로 확인 가능
  • 통합된 워크플로우: 에디터 안에서 모든 작업 완료 가능
  • 초보자 친화적: 코딩 경험이 적어도 쉽게 사용 가능

Gemini CLI의 아쉬운 점:

  • 터미널 의존성: CLI 환경에 익숙하지 않으면 진입 장벽이 높음
  • 복사-붙여넣기 필요: 생성된 코드를 직접 복사해서 에디터에 붙여넣어야 함
  • 시각적 제약: 코드 변경사항을 바로 확인하기 어려움

Gemini CLI가 2025년 6월에 출시된 따끈따끈한 도구인 만큼, 아직 사용자 경험 최적화가 부족한 면이 있다.

 

✅ 기능적 특징 비교

Gemini CLI만의 특별한 기능:

  • 구글 검색 통합: 터미널에서 직접 구글 검색 가능
  • MCP 서버 지원: 외부 도구와의 연동이 강력함
  • 파일 시스템 직접 접근: 파일 읽기/쓰기/실행이 자유로움
  • 대용량 컨텍스트: 100만 토큰 컨텍스트 윈도우 지원

Cursor AI만의 장점:

  • 실시간 코드 제안: 타이핑과 동시에 코드 완성
  • 멀티파일 리팩토링: 여러 파일을 동시에 수정 가능
  • API 문서 통합: API 문서를 읽고 정확한 코드 생성
  • 인라인 편집: 코드 블록을 선택해서 바로 수정 요청 가능 : Agent의 강력함

 

결론: 용도에 따른 선택

두 도구 모두 각각의 강점이 뚜렷하다.

Gemini CLI를 추천하는 경우:

  • 비용을 최대한 절약하고 싶은 개발자
  • 터미널 환경에 익숙한 숙련된 개발자
  • 복잡한 자동화 작업이 필요한 경우
  • 대용량 코드베이스를 다루는 경우

Cursor AI를 추천하는 경우:

  • 개발 경험이 많지 않은 초보자
  • 시각적 피드백을 중시하는 개발자
  • VS Code에 익숙한 사용자

개인적으로는 학습 목적이나 간단한 프로젝트에는 Cursor AI, 본격적인 개발 작업에는 Gemini CLI를 사용하는 것이 좋을 것 같다. 특히 Gemini CLI의 무료 정책은 정말 매력적이다. 구글이 언제까지 이 혜택을 유지할지는 모르겠지만, 지금 당장은 가성비가 매우 훌륭하다고 불 수 있다.

 

결론: 쓰고싶은거 쓰자. 해당 이미지는 ai를 통해 생성하였습니다.

 

 

참고 사이트::

  1. https://techpoint.africa/guide/cursor-vs-vscode-vibe-coding-review/
  2. https://scalablehuman.com/2025/02/27/5-reasons-i-chose-cursor-ai-over-vs-code-a-developers-honest-review/
  3. https://blog.enginelabs.ai/cursor-ai-an-in-depth-review
  4. https://dev.to/javeedishaq/gemini-cli-tells-what-tools-do-the-gemini-cli-use-for-ai-coding-comparing-gemini-claude-and-416f
  5. https://blog.google/technology/developers/introducing-gemini-cli-open-source-ai-agent/
  6. https://www.infyways.com/google-gemini-cli-review/
  7. https://blog.getbind.co/2025/06/27/gemini-cli-vs-claude-code-vs-cursor-which-is-the-best-option-for-coding/
  8. https://www.youtube.com/watch?v=CqL5kB8pOfo
  9. https://velog.io/@dnjstjdgus03/GeminiCLI
  10. https://www.reddit.com/r/Bard/comments/1lkb5u3/google_gemini_cli_is_end_game/
  11. https://dev.to/therealmrmumba/i-tested-gemini-cli-and-other-top-coding-agents-heres-what-i-found-om1
  12. https://javascript.plainenglish.io/i-tried-googles-new-gemini-cli-it-s-the-most-powerful-open-source-dev-tool-e8c35ee338a6
  13. https://www.youtube.com/watch?v=JcgdGESQlEQ
  14. https://www.youtube.com/watch?v=6NJYlWuoA_w
  15. https://randomcoding.com/blog/2024-09-15-is-cursor-ais-code-editor-any-good/
  16. https://www.youtube.com/watch?v=psvzcHDLKto
  17. https://www.reddit.com/r/ChatGPTCoding/comments/1c1o8wm/anyone_using_cursor_ai_and_barely_writing_any/
  18. https://www.rectify.so/categories/all/claude-code-vs-cursor-vs-gemini-cli
  19. https://www.arguingwithalgorithms.com/posts/cursor-review.html
  20. https://slashdot.org/software/comparison/Cursor-vs-Gemini-CLI/
반응형

최근에는 거의 항상 Tailwindcss만 써서 직접적으로 css 를 import 해서 사용하지 않았기 때문에 다르게 쓰는 방식을 몰라 헤매었다.

SCSS Module Import Error In TypeScript 

에러가 발생하였는데, 계속 잡히지 않아서 이거저거 또 검색해보았다.

Perplexity, Cursor 에서도 제대로 에러를 찾아내지 못했고, 인터넷서칭으로 알아내었다.

 

React.ts 를 이용해서 개발중이었는데,

 

1. @import  대신 @use 를 사용

vite.config.ts

원래는 additionalData 에 @import 값을 넣어서 사용했는데, 2021년부터 @import 대신 @use 를 쓰도록 권장했다고 한다..

 

2. 추가적으로 사용하는 @forward

@use 를 써서 내용에 삽입하여 사용

 

@use 를 위에 써주고 @forward 를 써서 redirect 해준다.

이렇게 사용하면 해당 파일만 index에서 import 해주어도 전역에서 사용이 가능하다. 

다른 경로의 파일에서 사용한 예시

 

 

sass, import, use - ai generated image

반응형

안녕하세요 상훈입니다.

이번 포스팅에서는 React.js 의 라이브러리 중 하나인 React-Query 를 사용해보려고 합니다.

소스를 구현하고 동작시켜보는데, tailwindcss + cursor ai 의 많은 도움을 받았답니다..

일단 결과물 먼저 보고 오시죠!

Result

 

1. React-Query 설치

당연하게 npm i react-query 했는데, 오류나면서 안된다길래. 뭐지..? 싶어서 열심히 검색해왔다.

"@tanstack/react-query": "^5.67.2" //해당 버전을 다운로드 받으면 된다.

//기존의 react-query는 삭제해준다.
npm uninstall react-query 

//tanstack의 버전으로 다운로드 해준다.
npm install @tanstack/react-query

https://tanstack.com/

 

TanStack | High Quality Open-Source Software for Web Developers

Headless, type-safe, powerful utilities for complex workflows like Data Management, Data Visualization, Charts, Tables, and UI Components.

tanstack.com

이곳에서 이제 다운로드 받을 수 있다고 합니다.

 

 

2. Main.tsx

본인 프로젝트의 기본 경로의 파일에서 QueryClientProvider 로 Client 를 제공해줍니다.

ContextAPI와 유사하네요

QueryClientProvider

<React.StrictMode>
    <QueryClientProvider client={queryClient}>
        <BrowserRouter>
            <Header />
            <App />
        </BrowserRouter>
    </QueryClientProvider>
</React.StrictMode>

 

3. 본격적으로 React-Query 를 사용해보자

소스 코드 전체 첨부(최하단에 있어요)

3-1. API's

rest api 형태로 mockapi 데이터 조회,수정,삭제 하는 로직

전역변수로 api 상수를 선언하고 그 안에 [조회, 수정, 삭제] 가 가능한 로직을 만들었습니다.

각각 GET, PUT(UPDATE), DELETE 의 method 를 가지고 있습니다.

 

3-2. 메인 로직 - useQuery

    import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
    //...
    const api = {
        fetchPosts: async ({ pageParam = 1, searchTerm = "" }) => {
            const response = await fetch(
                `${POSTS_URL}?page=${pageParam}&limit=10&search=${searchTerm}`
            );
            return response.status === 200 ? response.json() : [];
    	},
    //...
    }
    
    //...
    const [currentPage, setCurrentPage] = useState(1);
    const [searchTerm, setSearchTerm] = useState("");
    const [debouncedSearch, setDebouncedSearch] = useState("");
    const queryClient = useQueryClient();

    // 쿼리 키
    const postsQueryKey = ["posts", currentPage, debouncedSearch];

    // 데이터 조회 쿼리
    const {
        data: posts = [],
        isLoading,
        error,
    } = useQuery({
        queryKey: postsQueryKey,
        queryFn: () =>
            api.fetchPosts({
                pageParam: currentPage, //페이징처리
                searchTerm: debouncedSearch, //검색기능
            }),
    });

중간에 이것저것 있지만, 다 걷어내고 난 후에 메인 기능은 위와 같다.

postQueryKey 의 요소들이 결국 useQuery의 의존성배열이 되는 것인데,
마치 useEffect의 2번째 파라미터로 넘기는 의존성배열과 같다.

1) "posts"의 테이블로 데이터 요청을 할 것이다.
2) currentPage 의 값이 변경될 때마다 요청할 것이다.
3) debouncedSearch 의 값이 변경될 때마다 요청할 것이다.

 

결과는 [ posts, isLoading, error ] 의 값을 템플릿 리터럴으로 html 코드에서도 사용할 수 있다.

isLoading, error 의 값 활용

1) isLoading: 로딩바 구현
2) error: 에러 시 table 목록을 보여주지 않고 위 텍스트만 노출.
3) posts 를 반복하여 tr 태그에 값을 내려준다.

 

3-3. useMutation

mutation 의 경우는 3단계로 나뉘어진다.

1) useMutation 선언
2) createMutation 호출
3) invalidateQueries 호출

이렇게 글로 정리하니까 되게 간단해보이지만 실제로 써놓은 코드 보면 헷갈릴 수 있다.

 // 뮤테이션 공통 성공 핸들러
const handleMutationSuccess = () => {
    queryClient.invalidateQueries({ queryKey: postsQueryKey });
};

// 뮤테이션 생성 헬퍼 함수에 제네릭 타입 추가
const createMutation = (mutationFn) => {
    const mutation = useMutation({
        mutationFn,
        onSuccess: handleMutationSuccess,
    });
    return mutation;
};

// 업데이트와 삭제 뮤테이션
const updateMutation = createMutation(api.updatePost);
const deleteMutation = createMutation(api.deletePost);

return (
    {/* 사이드바 */}
    {selectedPost && (
        <SideBar
            post={selectedPost}
            onClose={closeSideBar}
            onUpdate={(updatedPost) => {
                updateMutation.mutate(updatedPost);
                closeSideBar();
            }}
            onDelete={(postId) => {
                deleteMutation.mutate(postId);
                closeSideBar();
            }}
        />
    )}


)

수정

Sidebar 에서 정보를 업데이트하여 onUpdate 가 호출 > updateMutation.mutate(updatedPost) 호출 > useMutation(updatePost) 호출 > [실제쿼리동작] > queryClient.invalidateQueries 호출하여 리스트 재조회 

- 캐싱된 내용은 그대로 있고, 캐싱되지 않은 내용만 새로 업데이트 된다.

삭제 

마찬가지로 Sidebar 에서 정보를 삭제 시 onDelete 호출 > deleteMutation.mutate(postId) 호출 > useMutation(deletePost) 호출 > [실제쿼리동작] > queryClient.invalidateQueries 호출하여 리스트 재조회

- 캐싱 동일 적용

 


최대한 요약해봤지만 결국 장황하게 쓰게되었다.

하지만 결론은 생각보다 간단하다

1. 최대한 re-rendering 발생하지 않도록 caching 처리
2. 리스트 기준으로 결국에는 list 재조회를 한다.


 공식문서에 TypeScript 관련하여 useQuery 부분에 대해 선언해놓은게 있는데, <T...>  다 제너릭으로 설정할거면 뭐하러 Typescript로 한거지..? 라는 생각이 든다.

typescript with useQuery

 


**3). UseReactQuery.tsx 소스코드 전체

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { ClipLoader } from "react-spinners";
import { PostTableRow } from "../components/PostTableRow";
import SideBar from "../components/SideBar";
import { toast, ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

const POSTS_URL = "https://mockapi.io/" //이곳에서 프로젝트 생성하시고 랜덤 데이터 생성할 수 있어요!
type POST_TYPE = {
    id: string;
    createdAt: string;
    writer: string;
    title: string;
    avatar: string;
    contents: string;
};

export const IsLoading = () => {
    return (
        <div className="flex justify-center items-center h-screen">
            <ClipLoader color="#4A90E2" size={50} />
        </div>
    );
};

// API 함수들
const api = {
    fetchPosts: async ({ pageParam = 1, searchTerm = "" }) => {
        const response = await fetch(
            `${POSTS_URL}?page=${pageParam}&limit=10&search=${searchTerm}`
        );
        return response.status === 200 ? response.json() : [];
    },

    updatePost: async (updatedPost: POST_TYPE) => {
        const response = await fetch(`${POSTS_URL}/${updatedPost.id}`, {
            method: "PUT",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify(updatedPost),
        });
        if (response.status === 200) {
            toast.success("게시물이 성공적으로 업데이트되었습니다.");
            return true;
        } else {
            toast.error("게시물 업데이트에 실패했습니다.");
            return false;
        }
    },

    deletePost: async (postId: string) => {
        const response = await fetch(`${POSTS_URL}/${postId}`, {
            method: "DELETE",
        });
        if (response.status === 200) {
            toast.success("게시물이 성공적으로 삭제되었습니다.");
            return true;
        } else {
            toast.error("게시물 삭제에 실패했습니다.");
            return false;
        }
    },
};

// 페이지네이션 컴포넌트
const Pagination = ({
    currentPage,
    setCurrentPage,
    hasMore,
}: {
    currentPage: number;
    setCurrentPage: (page: number) => void;
    hasMore: boolean;
}) => (
    <div className="mt-4 flex justify-center gap-2">
        <button
            onClick={() => setCurrentPage(Math.max(currentPage - 1, 1))}
            disabled={currentPage === 1}
            className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
        >
            이전
        </button>
        <span className="px-4 py-2">페이지 {currentPage}</span>
        <button
            onClick={() => setCurrentPage(currentPage + 1)}
            disabled={!hasMore}
            className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
        >
            다음
        </button>
    </div>
);

// 메인 컴포넌트
const UseReactQuery = () => {
    const [currentPage, setCurrentPage] = useState(1);
    const [searchTerm, setSearchTerm] = useState("");
    const [debouncedSearch, setDebouncedSearch] = useState("");
    const [selectedPost, setSelectedPost] = useState<POST_TYPE | null>(null);
    const queryClient = useQueryClient();

    // 디바운스 처리
    useEffect(() => {
        const timer = setTimeout(() => {
            setDebouncedSearch(searchTerm);
            setCurrentPage(1); // 검색어 변경시 첫 페이지로 이동
        }, 500);

        return () => clearTimeout(timer);
    }, [searchTerm]);

    // 쿼리 키
    const postsQueryKey = ["posts", currentPage, debouncedSearch];

    // 데이터 조회 쿼리
    const {
        data: posts = [],
        isLoading,
        error,
    } = useQuery({
        queryKey: postsQueryKey,
        queryFn: () =>
            api.fetchPosts({
                pageParam: currentPage,
                searchTerm: debouncedSearch,
            }),
    });

    // 뮤테이션 공통 성공 핸들러
    const handleMutationSuccess = () => {
        queryClient.invalidateQueries({ queryKey: postsQueryKey });
    };

    // 뮤테이션 생성 헬퍼 함수에 제네릭 타입 추가
    const createMutation = <TData, TVariables>(
        mutationFn: (variables: TVariables) => Promise<TData>
    ) => {
        // eslint-disable-next-line react-hooks/rules-of-hooks
        const mutation = useMutation({
            mutationFn,
            onSuccess: handleMutationSuccess,
        });
        return mutation;
    };

    // 업데이트와 삭제 뮤테이션
    const updateMutation = createMutation<boolean, POST_TYPE>(api.updatePost);
    const deleteMutation = createMutation<boolean, string>(api.deletePost);

    // 사이드바 관련 함수
    const openSideBar = (post: POST_TYPE) => setSelectedPost(post);
    const closeSideBar = () => setSelectedPost(null);

    return (
        <div className="mt-6 relative">
            <ToastContainer />
            <h1>UseReactQuery</h1>

            {/* 검색 필터 */}
            <div className="mb-4 flex flex-col gap-2">
                <div className="flex justify-end">
                    <input
                        type="text"
                        value={searchTerm}
                        onChange={(e) => setSearchTerm(e.target.value)}
                        placeholder="제목 또는 내용으로 검색..."
                        className="px-4 py-2 border rounded-md w-64"
                    />
                </div>
                <div className="h-6">
                    {searchTerm && (
                        <p className="text-sm text-gray-500 text-right">
                            총 {posts.length}개의 결과가 있습니다.
                        </p>
                    )}
                </div>
            </div>

            {/* 상태별 표시 */}
            {isLoading && <IsLoading />}
            {error && <div>에러가 발생했습니다!</div>}

            {/* 데이터 테이블 */}
            {!isLoading && !error && (
                <div className="overflow-x-auto w-screen flex flex-col">
                    <table className="max-w-full table-auto flex-1 text-center mx-4">
                        <thead className="bg-gray-100">
                            <tr className="text-center">
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    번호
                                </th>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    제목
                                </th>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    내용
                                </th>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    작성자
                                </th>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    작성일
                                </th>
                            </tr>
                        </thead>
                        <tbody className="bg-white divide-y divide-gray-200">
                            {posts.length > 0 ? (
                                posts.map((post: POST_TYPE, index: number) => (
                                    <PostTableRow
                                        key={post.id}
                                        post={post}
                                        index={index}
                                        currentPage={currentPage}
                                        onRowClick={openSideBar}
                                    />
                                ))
                            ) : (
                                <tr>
                                    <td
                                        colSpan={5}
                                        className="text-center pt-3"
                                    >
                                        결과가 없습니다.
                                    </td>
                                </tr>
                            )}
                        </tbody>
                    </table>
                </div>
            )}

            {/* 페이지네이션 */}
            {posts.length > 0 && (
                <Pagination
                    currentPage={currentPage}
                    setCurrentPage={setCurrentPage}
                    hasMore={posts.length >= 10}
                />
            )}

            {/* 사이드바 */}
            {selectedPost && (
                <SideBar
                    post={selectedPost}
                    onClose={closeSideBar}
                    onUpdate={(updatedPost) => {
                        updateMutation.mutate(updatedPost);
                        closeSideBar();
                    }}
                    onDelete={(postId) => {
                        deleteMutation.mutate(postId);
                        closeSideBar();
                    }}
                />
            )}
        </div>
    );
};

export default UseReactQuery;

 

이상입니다.

반응형

useContext 를 사용하여 drilling 이 없도록.
Children 컴포넌트에서 제약사항 없이 부모가 내려준 props 를 사용할 수 있도록 하겠습니다.

Result

1. useContext

useContextstore 와 기능이 매우 흡사함.
다만, 작은 규모의 프로젝트 혹은 개인프로젝트에서만 사용하는것을 권장한다.

그 외에는 store 를 사용하자 (redux, zustand ... etc)

그 이유는 아래 내용을 보면 알 수 있다. 가봅시다.

 

2. createContext & Provider

// 1. 테마 컨텍스트 생성
const ThemeContext = createContext();

//TypeScript
const ThemeContext = React.createContext<ThemeContextType | undefined>(
    undefined
);

const UseContext = () => {
    const [theme, setTheme] = useState("light");
    const toggleTheme = () => {
        setTheme(theme === "light" ? "dark" : "light");
    };
    return (
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
            <ThemeComponent_1 />
        </ThemeContext.Provider>
    );
};

처음에 컴포넌트 바깥에서 Context를 생성 (createContext) 해주었다.

그 다음에는 

<ThemeContext.Provider value={{state, action}}>
	<ThemeComponent_1 />
</ThemeContext.Provider>

이렇게 Provider로 감싸서 props로 내려주었다. (여기까지는 일반 Props 와 뭐가다른데? 할 수 있다.)

차이점은 2번째 Drilling 에서부터 나타난다.

 

3. Childrens.

ThemeComponent_1 컴포넌트를 아래와 같이 선언하고 ThemeComponent_2에서 context로 내려준 props 들을 사용할 수 있다.

UseContext > ThemeComponent_1 > ThemeComponent_2 가 되는 꼴이다

//drilling 용 컴포넌트
const ThemeComponent_1 = () => {
    return <ThemeComponent_2 />;
};

//실제 context를 사용하는 컴포넌트
const ThemeComponent_2 = () => {
    const { theme, toggleTheme } = useContext(ThemeContext);

    return (
        <div
            className={`w-full mx-auto px-4 py-8 min-h-screen transition-all duration-300 ${
                theme === "light" ? "bg-gray-50" : "bg-gray-900 text-white"
            }`}
        >
            <h1>현재 테마: {theme}</h1>
            <button onClick={toggleTheme}>테마 변경</button>
        </div>
    );
};

[테마 변경] 버튼을 클릭 하면 light <> dark 로 전환이 되면서 배경색이 변경되도록 수정하였다.

전역적으로 사용하려면  App.jsx 혹은 Index.jsx 에 선언해놓고 Header.jsx 에서 사용하면 될 것이다.

 

Epiloge

확실히 useContext 를 통하여 사용할 수 있는 부분이 눈에 보인다. (가령 테마라던가...테마라던가...테마라던가.)

하지만 규모가 조금이라도 커지면 useContext 를 사용할 수 없을 것 같다. 
유지보수 측면에서 너무 큰 비용이 들어갈 것으로 보임.

그래서 결국에는 store (Redux, Zustand) 를 사용하게 될 것 같다.

 

전체 코드

TypeScript 로 작성해서 간단하게 type 을 선언하여 사용하였다.

import React, { useContext, useState } from "react";
import { FaSun, FaMoon } from "react-icons/fa";

interface ThemeContextType {
    theme: string;
    toggleTheme: () => void;
}

// 1. 테마 컨텍스트 생성
const ThemeContext = React.createContext<ThemeContextType | undefined>(
    undefined
);

const UseContext = () => {
    const [theme, setTheme] = useState("light");
    const toggleTheme = () => {
        setTheme(theme === "light" ? "dark" : "light");
    };
    return (
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
            <ThemeComponent_1 />
        </ThemeContext.Provider>
    );
};

//drilling 용 컴포넌트
const ThemeComponent_1 = () => {
    return <ThemeComponent_2 />;
};

//실제 context를 사용하는 컴포넌트
const ThemeComponent_2 = () => {
    const context = useContext(ThemeContext);
    if (!context)
        throw new Error("ThemeContext must be used within ThemeProvider");

    const { theme, toggleTheme } = context;

    return (
        <div
            className={`w-full mx-auto px-4 py-8 min-h-screen transition-all duration-300 ${
                theme === "light" ? "bg-gray-50" : "bg-gray-900 text-white"
            }`}
        >
            <button
                onClick={toggleTheme}
                className={`p-2 rounded-full ${
                    theme === "light"
                        ? "bg-gray-200 hover:bg-gray-300"
                        : "bg-gray-700 hover:bg-gray-600"
                } transition-colors duration-200`}
                aria-label="테마 변경"
            >
                {theme === "light" ? (
                    <FaMoon className="w-5 h-5 text-gray-700" />
                ) : (
                    <FaSun className="w-5 h-5 text-yellow-300" />
                )}
            </button>
        </div>
    );
};

export default UseContext;

 

반응형

React.js 에서 useTransitionuseDeferredValue 를 몰랐을 때에는 그냥 useEffect 혹은 useMemo 를 사용하여 state를 관리했었는데, 공식홈페이지를 보다가 조금 더 알게된 내용을 공부하고 포스팅한다.

Output

0. useTransition 이란?

- 일반적인 상태 업데이트를 하는데 유용한 React Hook.

useFormStatus 와 유사하게 사용할 수 있다.
( useTransition: 일반적인 상태 업데이트, useFormStatus: Form 제출 상태를 관리)

const {isPending, startTransition} = useTransition()

이런 선언으로 간단하게 시작할 수 있다.

 

일반적인 textInput 값을 사용해보자

 

1. startTransition 으로 query의 값을 변경할 때 setQuery 호출

const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();

const handleChange = (e) => {
    startTransition(() => {
        setQuery(e.target.value);
    });
};

//컴포넌트 내용 
return (
	<input
        type="text"
        onChange={handleChange}
    />	
)

1) useTransition은 상태 업데이트를 긴급하지 않은 작업으로 처리한다.
2) isPending 상태를 통해 전환 중임을 사용자에게 표시할 수 있다.
3) React는 더 중요한 업데이트(예: 사용자 입력)를 먼저 처리한 후 이 업데이트를 실행한다.

> Lazy Loading 과 같이 지연 연산으로 처리 한다.
> 만약 onChange 가 한 번 더 호출되면, 이전 연산하던 것은 버리고 새로 연산을 시작 한다.

 

2. defferredValue 선언

const defferredValue = useDeferredValue(query);

위에서 setQuery(newValue) 를 실행할때의 조건을 설정한다고 생각하면 된다.

1번과 마찬가지로 useDeferredValue는 값의 변경을 지연시켜 UI 응답성을 유지합니다.
사용자 입력에 즉시 반응하면서도 무거운 렌더링 작업은 나중에 처리할 수 있게 해줍니다.

 

3. 실제 Filter 처리

const list = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
const filteredList = useMemo(() => {
    if (defferredValue === "") return list; //공백일때는 초기화

    return list.filter((item) =>
        item.toLowerCase().includes(defferredValue.toLowerCase())
    );
}, [defferredValue]);

실제로 사용할때는 query 값을 사용하는 것이 아니라, deferredValue 값을 사용해야한다. (지연처리를 위함)

추가적으로 useMemo 를 사용하였는데, 변경되지 않은 부분에 대해 불필요한 Re-Rendering 방지합니다.

 

결국, 궁극적 목표는 UI의 최적화이다. (버벅거림을 없애기)

 

전체 코드

const list = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
const TransitionExample: React.FC = () => {
    const [query, setQuery] = useState("");
    const [isPending, startTransition] = useTransition();

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        startTransition(() => {
            setQuery(e.target.value);
        });
    };

    const defferredValue = useDeferredValue(query);
    
    const filteredList = useMemo(() => {
        if (defferredValue === "") return list; //공백일때는 초기화

        return list.filter((item) =>
            item.toLowerCase().includes(defferredValue.toLowerCase())
        );
    }, [defferredValue]);
    
    return (
   	<>
        <input
            className="w-full h-10 px-4 py-3 mr-2 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
            type="text"
            onChange={handleChange}
            placeholder="검색어를 입력하세요..."
        /> 
   
   		<ul className="divide-y divide-gray-200">
            {filteredList.map((item) => (
                <li
                    key={item}
                    className="py-2 px-3 hover:bg-gray-100 transition-colors duration-150 ease-in-out"
                >
                    {item}
                </li>
            ))}
        </ul>
	</>
  )

 

TypeScript 로 연습했기 때문에 위와 같이 React.FC 같은 Type 선언이 추가되어져 있다.

반응형

+ Recent posts