안녕하세요 상훈입니다.

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 선언이 추가되어져 있다.

반응형

요즘 Cursor AI 를 많이 이용 하고 있는데 여기서 몰랐던 코드를 구현해주는 상황때문에 포스팅하게 되었다.

 

기본적인 a 태그 + _blank 일 때

<a href ="" target="_blank">A태그입니다.</a>

 

이것까지는 아주 간단하고 당연한 내용인데,

 

<a
    href={work.link}
    target="_blank"
    rel="noopener noreferrer"
>a태그입니다</a>

추가 된 내용이 바로 rel 속성이다.

 

Rel ?? 보안적 측면에서 필요하다.

  • noopener: 새 탭/창에서 열리는 페이지가 window.opener 속성을 통해 원래 페이지에 접근하는 것을 방지합니다.
    이것은 탭 내빙(tab nabbing)이라는 보안 취약점을 막아줍니다.
    탭 내빙은 새 탭에서 열린 페이지가 원래 페이지의 location을 변경하여 피싱 공격을 시도할 수 있는 방법입니다.

  • noreferrer: 새 페이지로 이동할 때 HTTP 리퍼러(Referer) 헤더를 전송하지 않도록 합니다.
    이는 방문자의 개인정보 보호에 도움이 되며, 새 페이지가 어디에서 방문자가 왔는지 알 수 없게 합니다.

 

 

 

 

 
 
이 속성들은 외부 링크를 사용할 때 보안개인정보 보호를 강화하기 위한 웹 개발 모범 사례입니다.
특히 target="_blank" 를 사용할 때는 항상 rel="noopener noreferrer"를 함께 사용하는 것이 좋습니다.

 

반응형

+ Recent posts