최근에는 거의 항상 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"를 함께 사용하는 것이 좋습니다.

 

반응형

2025.02.24 - [FrontEnd/React.js] - [React.js] Stream 데이터 fetch로 처리하기 - 2

 

[React.js] Stream 데이터 fetch로 처리하기 - 2

[React.js] Stream 데이터 처리하기 - 1지난 포스팅에 Stream Data 관련해서 글을 작성하였다. 이번 포스팅에서는 해당 내용을 실제로 적용시켜보는 작업을 한다.  How To Decode?그런데 stream 데이터를 가

code-hoon.tistory.com

지난 포스팅에서 Streaming 을 통해서 데이터를 노출하는 것까지 예시를 통해서 완료하였다.

이번에는 ai text 챗. Chatgpt 처럼 중간에 "정지"할 수 있는 기능을 추가하려고 한다.

 

1. AbortController

// 새로운 AbortController 인스턴스 생성
const abortController = new AbortController()

바로 abortController 기능을 사용하는건데, 해당 기능은 node15 이상부터 사용할 수 있으니 저버전의 서버를 사용하고 계신다면 주의 바랍니다. (그 이하를 쓰는거면 얼마나 오래된겁니까..)

이렇게 abrotController 를 선언하였다면, 

// API 요청에 signal 추가
const response = await fetch(`/api/streaming`, {
    method: 'POST',
    headers: Config.headers,
    body: JSON.stringify(requestBody),
    signal: abortController.signal, // AbortController의 signal 추가
})

signal 을 추가하여 처리할 수 있다.

 


결과 맛보기

zustand(store) 을 이용하여 분리되어있는 api 와 화면을 연결하였고,

실제로 사용한 모습은 다음과 같다.

//api.js
const abortController = new AbortController()

const response = await fetch(`api/`, {
    method: 'POST',
    headers: Config.headers,
    body: JSON.stringify(requestBody),
    signal: useStore.getState().abortController.signal,
})

 

React.js 화면

// 검색종료버튼
const handleStop = useCallback(() => {
    const store = useStore.getState()
    store.abortController.abort()
    store.setIsStopped(true)
}, [])

return (
    <button onClick={handleStop}>
        <FaRegStopCircle className='text-lg' /> {/* 정지아이콘 */}
    </button>
)

 

이제 실제로 동작하는 모습은 "개발자도구 -  네트워크 - REST API 명 - 응답"탭에서 볼 수 있는데,

Streaming 응답 데이터 모습

이런식으로 쭉 하단으로 나열되다가 중단을 시키면

데이터가 생성되다가 끊긴 모습

이렇게 결과가 나온다.

그때 화면에서는 아래와 같이 플로팅 버튼이 노출되도록 설정했고, "재생성하기" 버튼을 누르면 기존에 입력했던(송신했던) 정보를 가지고 python에서는 해당 내용을 가지고 다시 응답을 생성하도록 선언 해놓았다.

재생성하기 버튼

 

중요한건 Streaming 데이터를 REST API 를 통해서 호출하고,
이를 받아서 처리하는 과정에 있으며, 중간에 정지(abort) 하는 기능까지 만들었다는 것이다.

 

반응형

[React.js] Stream 데이터 처리하기 - 1

지난 포스팅에 Stream Data 관련해서 글을 작성하였다.

이번 포스팅에서는 해당 내용을 실제로 적용시켜보는 작업을 한다.

 

 

How To Decode?

그런데 stream 데이터를 가져온다고 바로 쓸 수 있는 게 아니라, 변환 및 화면에서 사용하는 방법에 대해 포스팅해보려고 한다.

//api.js
export const retrieveStreamingData = async ({ inputValue, selectedItems }) => {
    try {
        const requestBody = { data: 'some data...' }

        // API 요청
        const response = await fetch(`${Config.baseURL}/api/v-1/retrieveStreamingData`, {
            method: 'POST',
            headers: Config.headers,
            body: JSON.stringify(requestBody),
        })

        useRetrieveStore.getState().setText('') //초기화
        useRetrieveStore.getState().setTitle('') //초기화

        const reader = response.body.getReader()
        const decoder = new TextDecoder()

        while (true) {
            const { done, value } = await reader.read()

            const decodedChunk = decoder.decode(value, { stream: true })
            if (decodedChunk) {
                try {
                    const beforeData = removeLeadingData(decodeUnicodeString(decodedChunk))
                    const afterData = remmoveBackslash(
                        removeFirstAndLastQuotes(decodeUnicodeString(beforeData)),
                    )
                    const cleanData = parseNestedJSON(afterData)

                    if (
                        cleanData?.msg === 'process_generating' &&
                        cleanData?.output?.data[0][0] &&
                        cleanData?.output?.data[0][0].length >= 1
                    ) {
                        if (cleanData?.output?.data[0][0][0] === 'append')
                            //store에 저장
                            useRetrieveStore
                                .getState()
                                .appendText(
                                    cleanData.output.data[0][0][2].replace(/\\n/g, '\n'),
                                )
                    } else if (cleanData?.result || cleanData?.thread_id) {
                        // Streaming 종료, 
                        // title이 마지막에 나오기 때문에 마지막에 title 값을 할당
                        useRetrieveStore
                            .getState()
                            .setTitle(cleanData.result?.created_title ?? '')
                    }
                } catch (error) {
                    console.error('Error:', error)
                }
            }

            //done이 true이면 루프 종료
            if (done) break
        }
    } catch (error) {
        console.error('🚨 [API 요청 중 오류 발생]: ', error.message)
    }
}
//utils.js
// 백슬래시를 제거하되 줄바꿈은 유지
const remmoveBackslash = (str) => str.replace(/\\(?![n\r])/g, '').replace(/""/g, '"')

// 줄바꿈 관련 처리 제거
const removeFirstAndLastQuotes = (str) =>
    str
        .replace(/"{/g, '{')
        .replace(/}"/g, '}')
        .replace(/\\/g, '\\\\')
        .replace(/"/g, '\\"')
        .replace(/\t/g, '\\t')

//유니코드 문자열 디코딩
const decodeUnicodeString = (str) =>
    str.replace(/u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))

//중첩 JSON 파싱
const parseNestedJSON = (jsonString) => {
    let result = jsonString
    while (typeof result === 'string') {
        try {
            result = JSON.parse(result)
        } catch (e) {
            break
        }
    }
    if (result?.content && typeof result?.content === 'string') {
        result.content = JSON.parse(result.content)
    }
    return result
}

//"data:" 제거
const removeLeadingData = (str) => {
    if (str.startsWith(',data:')) {
        return str.slice(str.indexOf(':') + 1).trim()
    }
    if (str.startsWith('data:')) {
        return str.slice(str.indexOf(':') + 1).trim()
    }
    return str
}

조금 길지만 이런 식으로 사용하였다.

1. api.js

  1. /api/v-1/retrieveStreamingData 로 api 요청.
  2. Stream 데이터로 연속적으로 데이터를 내려주게 됨.
  3. 내려온 데이터들을 지속적으로 store에 저장.
    (while 문을 사용하여 단어로 끊어져서 내려오는 데이터들을 useRetrieveStore  set)
  4. React.js 4번 화면에서 사용 하도록 한다

 

2. utils.js - 데이터 전처리

  • 데이터의 포맷이 정상적으로 내려오지 않아서 1.5일 동안 개고생했다.
    이 부분인데,
  1. removeLeadingData(String) : 데이터의 가장 앞에 "data: " 로 내려오는 경우가 있어 해당 로직을 추가하였다. (",data:" 도 있었다..)
  2. decodeUnicodeString(String) : Unicode 로 된 데이터를 변환하는 작업.
  3. removeFirstAndLastQuotes(String) : Escape 문자열을 처리하는 방법. (진짜 할 때 속터져)
str
.replace(/"{/g, '{')    // 문자열 시작의 '"{' 를 '{' 로 변경 (JSON 객체 시작 부분 정리)
.replace(/}"/g, '}')    // 문자열 끝의 '}"' 를 '}' 로 변경 (JSON 객체 끝 부분 정리)
.replace(/\\/g, '\\\\') // 단일 백슬래시를 이중 백슬래시로 변경 (이스케이프 처리)
.replace(/"/g, '\\"')   // 따옴표를 이스케이프 처리된 따옴표로 변경
.replace(/\t/g, '\\t')  // 탭 문자를 이스케이프 처리된 탭으로 변경

4. parseNestedJSON(String|Object) : 결국 받아온 값들을 사용하려면 JSON.parse(String) 이 필요한데, 재귀함수처럼 사용하였다 (사실은 while 반복문)
한 번 파싱했을 때 한개의 요소만 JSON 파싱이 되는 경우가 있어서 만약 String 형태면 다시 JSON.parse() 해주는 작업을 추가하였다.

 

3. Store - zustand

Store는 React.js 에서 유명한 zustand를 사용하였다.

import { create } from 'zustand'

const useRetrieveStore = create((set) => ({
    retrievedText: '', // 텍스트
    retrievedTitle: '', // 제목

    setRetrievedText: (newText) =>
        set(() => ({
            retrievedText: newText,
        })),

    // title setter
    setRetrievedTitle: (newTitle) =>
        set(() => ({
            retrievedTitle: newTitle,
        })),

    // text appender (setter 변형) - 텍스트를 추가한다.
    appendText: (additionalContent) =>
        set((state) => ({
            retrievedText: state.retrievedText + additionalContent,
        })),
}))

export default useRetrieveStore

4. React.js 화면

Chatting 스타일로 만들었기 때문에 Array로 데이터 구조를 만들었다.

    const retrievedText = useRetrieveStore(
        (state) => state.retrievedText,
    )
    const retrievedTitle = useRetrieveStore(
        (state) => state.retrievedTitle,
    )

    // AI 메시지 추가 - store값을 가져와 실시간으로 반영하는것으로 처리.
    useEffect(() => {
        if (messageList.length === 0 || retrievedText === '') return

        const lastMessage = messageList[messageList.length - 1]

        if (lastMessage.type !== 'ai') {
            setMessageList((prev) => [...prev, { id: Date.now(), text: '', type: 'ai' }])
        } else if (lastMessage.type === 'ai') {
            setMessageList((prev) =>
                prev.map((message, index) =>
                    index === prev.length - 1
                        ? { ...message, text: retrievedText }
                        : message,
                ),
            )
        }
    }, [retrievedText])

    //title 수정 되면 기존의 값에 할당
    useEffect(() => {
        if (messageList.length === 0 || retrievedTitle === '') return
        
        const lastMessage = messageList[messageList.length - 1]

        if (lastMessage.type === 'ai') {
            setMessageList((prev) =>
                prev.map((message, index) =>
                    index === prev.length - 1
                        ? { ...message, title: retrievedTitle }
                        : message,
                ),
            )
        }
    }, [retrievedTitle])

각각 useEffect 를 사용하여 해당 값이 업데이트 되었을 때 리렌더링 되도록 유도하였다.

++ 약간 Vue.js의 watch 와 유사하다고 볼 수 있겠지만,
React.js 의 useEffect가 전반적인 LifeCycle에 관여할 수 있기 때문에 더 큰 범위라고 할 수 있다.

 

반응형
streaming

Stream 데이터란?

우리가 흔히 말하는 유튜버들의 스트리밍이라는 단어가 파생된 근본이다.
Stream Data 는 연속적으로 생성되고 전송되는 데이터이다.
그래서 실시간, 확장성, 대량으로 등 데이터를 다루기 편한 부분이다

뭔가 WebSocket이랑 비슷한 느낌?

React.js 에서 RestAPI로 써보기

const StreamingComponent = () => {
    const [messages, setMessages] = useState([]);

    useEffect(() => {
        const fetchData = async () => {
            const response = await fetch('http://localhost:5000/stream');
            const reader = response.body.getReader();
            const decoder = new TextDecoder();

            let receivedData = [];

            while (true) {
                const { done, value } = await reader.read();
                if (done) break;

                const chunk = decoder.decode(value, { stream: true });
                const lines = chunk.split('\n').filter(line => line.startsWith('data: '));

                for (const line of lines) {
                    const jsonData = JSON.parse(line.replace('data: ', ''));
                    receivedData.push(jsonData);
                    setMessages([...receivedData]); // UI 업데이트
                }
            }
        };

        fetchData();
    }, []);

    return (
        <div>
            <ul>
                {messages.map((msg, index) => (
                    <li key={index}>{index}: {msg.message}</li>
                ))}
            </ul>
        </div>
    );
};

await fetch(url) 하는 것은 일반 RestAPI 와 동일하다.

response.body.getReader() 로 해당 데이터를 가져올 수 있고,
decoder.decode(value, { stream: true }) 디코딩을 한 번 해야한다.

Done

그리고 마지막으로 데이터가 끝나면 done 을 호출하게 된다.
추가적으로 done 에 대한 로직을 구현할 수도 있다.

if (done) {
	alert('Streaming 이 종료되었습니다!')
}

Decoding 을 해야하는 이유

위의 소스를 보면, decoder.decode(value) 를 하고 있는데, 디코딩을 해야하는 이유는 다음과 같다.

  1. 데이터 압축 해제: 스트리밍 과정에서 데이터는 효율적인 전송을 위해 압축되고 인코딩됩니다. 수신 장치에서는 이 데이터를 원래의 형태로 복원하기 위해 디코딩이 필요합니다
  2. 호환성 확보: 인코딩된 데이터는 다양한 장치와 플랫폼에서 재생할 수 있도록 표준화된 형식으로 변환됩니다. 디코딩은 이 표준화된 형식을 각 장치에서 재생 가능한 형태로 변환하는 과정입니다
  3. 실시간 재생: 스트리밍 데이터는 작은 세그먼트로 나누어져 전송됩니다. 각 세그먼트를 수신할 때마다 디코딩하여 즉시 재생할 수 있게 합니다
  4. 품질 최적화: 디코딩 과정에서 네트워크 상태나 장치 성능에 따라 적절한 품질의 스트림을 선택하여 재생할 수 있습니다
  5. 다양한 형식 지원: 트랜스코딩(인코딩과 디코딩의 조합)을 통해 다양한 비디오 형식을 지원하고, 각 사용자의 환경에 맞는 최적의 형식으로 변환할 수 있습니다

WebSocket과 Streaming 의 차이점

왠지 비슷하다고 느껴졌는데, 그 이유가 여기 있었다.
단지 사용처가 조금 다를 뿐(텍스트+양방향, 동영상+단방향) 이었다.

 

반응형

+ Recent posts