
React, Diffing, Virtual DOM
[React] 리액트에서 Virtual DOM과 diffing 알고리즘
| Diffing 알고리즘이란Diff는 두 대상 간의 "차이점"을 의미합니다. 말 그대로 이전 트리와의 차이점을 구하는 알고리즘 입니다.리액트는 우리가 작성한 UI를 브라우저에 효율적으로 그리기 위해 Virtual DOM이라는 중간 단계를 거치면서 Diffing 알고리즘을 활용해 차이를 비교 합니다.효율적으로 그린다?리액트는 UI 상태가 바뀔 때마다 새로운 Virtual DOM을 만들고,이전 Virtual DOM과 비교하여 실제로 바뀐 부분만 실제 DOM에 반영합니다.이 비교하는 과정을 바로 Diffing 알고리즘이라고 부릅니다.| Virtual DOMVirtual DOM은 실제 DOM을 추상화한 자바스크립트 객체입니다.리액트에서는 Virtual DOM을 활용해 최소한의 변경사항만을 실제 DOM에 한 번에 반영하여 성능을 최적화합니다.이 때 최소한의 변경 사항을 확인하기 위한 과정이 Diffing입니다.한번에 반영하는 이유가 있나요?변경 사항을 개별적으로 DOM에 적용하면, 브라우저가 매번 렌더링 과정을 반복하므로 성능에 큰 비용이 발생합니다.따라서 한 번에 변경 사항을 반영해 렌더링 비용을 최소화합니다.자바스크립트의 DocumentFragment가 이와 유사한 개념입니다.잘못된 예:PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst list = document.querySelector("#list");const fruits = ["Apple", "Orange", "Banana", "Melon"];fruits.forEach((fruit) => { const li = document.createElement("li"); li.textContent = fruit; list.appendChild(li); // 개별적으로 DOM에 추가});좋은 예:PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst list = document.querySelector("#list");const fruits = ["Apple", "Orange", "Banana", "Melon"];const fragment = new DocumentFragment();fruits.forEach((fruit) => { const li = document.createElement("li"); li.textContent = fruit; fragment.appendChild(li); // 한 번에 추가할 fragment에 모음});list.appendChild(fragment); // DOM에 한 번만 적용그럼 Virtual DOM을 사용하면 빠른 건가요?빠를수도 있지만 아닐수도 있습니다.리액트 개발 팀원이자, Redux의 개발자인 Dan Abramov의 트윗을 보면 다음과 같이 말합니다.Myth: React is “faster than DOM”. Reality: it helps create maintainable applications, and is “fast enough” for most use cases.결국 Virtual DOM을 생성하고, 비교(Diffing)한 뒤,이를 실제 DOM에 반영하는 과정이 있기 때문에 때에 따라서는 더 느릴 수도 있습니다.다만 대부분은 충분히 빠르면서 유지 보수하기 좋은 코드를 작성할 수 있게 도와줍니다.Reconciliation(재조정) 과 Diffing 알고리즘리액트에서 DOM이 업데이트되는 과정을 보면 다음과 같습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML상태 변경 → 새로운 Virtual DOM 생성 → Reconciliation(Diffing) → 업데이트 예약 → 실제 DOM 적용 이 과정에서 Reconciliation은 이전 Virtual DOM 트리와 새로운 Virtual DOM 트리를 비교하여 변경된 부분을 찾는 과정입니다.이 비교하는 과정에 사용하는 알고리즘이 Diffing 알고리즘 입니다.Diffing 알고리즘이 중요한가요?Reconciliation 과정에서 변경된 부분을 빠르게 찾을수록 성능이 향상됩니다.기본적으로 하나의 트리를 다른 트리로 변환하기 위해서는 O(n³)의 시간 복잡도를 갖고 있습니다.만약 리액트에서 해당 시간 복잡도의 알고리즘을 그대로 사용하게 되면 1000개의 엘리먼트를 그리기 위해 10억 번의 비교 연산을 수행해야 합니다.그래서 리액트는 어떻게 하는데요?리액트는 이를 효율적으로 수행하기 위해 두 가지 휴리스틱을 적용한 O(n) 복잡도의 알고리즘을 사용합니다.서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.리액트에서 비교가 어떤식으로 일어나나요?리액트는 두 개의 트리를 비교할 때 두 엘리먼트의 루트 엘리먼트부터 비교하게 되고,이후의 동작은 루트 엘리먼트의 타입에 따라 아래와 같이 동작합니다.다른 타입의 요소는 삭제 후 재생성합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 이전<div> <Counter /></div>// 이후<span> <Counter /></span>위 예시에서 div가 span으로 변경되었기 때문에 리액트는 하위 컴포넌트(Counter)를 포함해 전체를 삭제하고 새로 생성합니다.같은 타입의 요소는 속성만 갱신합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 이전<div className="old" title="hello">Hello</div>// 이후<div className="new" title="hello">Hello</div>이 경우 리액트는 변경된 className 속성만 업데이트하고, 다른 속성(title 등)은 변경하지 않습니다.리스트에서는 key로 요소의 변화를 확인합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 이전<ul> <li key="2015">Duke</li> <li key="2016">Villanova</li></ul>// 이후<ul> <li key="2014">UConn</li> <li key="2015">Duke</li> <li key="2016">Villanova</li></ul>리액트는 자식들이 key 속성을 가지고 있으면, 이를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인하고, 트리 변환 작업이 효율적으로 수행되도록 돕습니다.만약 휴리스틱이 기반하고 있는 가정에 부합하지 않으면?그런 경우 성능이 나빠질 수 있습니다.따라서 리스트의 key 값을 설정할 때 반드시 변하지 않고, 유일한 값을 설정해야 합니다.만약 변하는 값(리스트의 index, Math.random() 등)을 key로 사용하면 불필요한 DOM 노드를 재생성할 수 있습니다.| 끝만약 나중에 UI 라이브러리나 프레임워크를 만들게 된다면,우리가 가장 많이 사용하는 React의 기본 동작 방식을 이해하는 것이 큰 도움이 될 겁니다.실제 코드는 훨씬 복잡하고 어렵지만, 전체적인 흐름만 파악해도 충분히 유익하다고 생각합니다.특히, React의 Reconciliation 과정이 코드로 어떻게 구현되어 있는지 궁금하다면 아래 링크에서 직접 확인해볼 수 있습니다.https://github.com/facebook/react/tree/v19.0.0/packages/react-reconciler/src
Next.js, 애니메이션
Next.js에서 페이지 전환 애니메이션 구현
웹사이트에서 페이지가 전환될 때, 단순한 화면 변경보다 부드러운 애니메이션이 적용되면사용자 경험에 있어 긍정적인 영향을 미친다고 생각합니다.Next.js에서는 기본적으로 페이지 이동 시 애니메이션이 없지만,이를 직접 구현하여 자연스러운 페이지 전환을 만들 수 있습니다.이번 글에서는 좌우 슬라이드 전환 애니메이션을 적용하는 방법을 공유하겠습니다.이를 위해 Context API를 활용하여 애니메이션 상태를 관리하고,커스텀 훅을 만들어 애니메이션을 쉽게 적용할 수 있도록 해보겠습니다.본 예시에서는 Next.js Tailwind CSS를 사용하였습니다.| Context 생성하기먼저, 페이지 전환 애니메이션을 관리할 Context를 생성합니다.이 Context는 애니메이션을 위한 className과 애니메이션 타입(left 또는 right),그리고 className을 변경하는 함수를 포함하고 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML"use clinet";import { createContext } from "react";export type PageAnimation = "left" | "right";interface PageTransitionContext { animation: React.RefObject<PageAnimation>; className: string; setClassName: React.Dispatch<React.SetStateAction<string>>;}const PageTransitionContext = createContext<PageTransitionContext | null>(null);export default PageTransitionContext;여기서 Context의 역할은 다음과 같습니다.animation : 현재 적용할 애니메이션 방향 (left 또는 right)className : 애니메이션이 적용될 페이지의 클래스setClassName : 클래스명을 변경하는 함수| Provider 생성하기이제 생성한 Context를 하위 컴포넌트에 공급할 Provider를 만들어줍니다.이 Provider는 기본적인 페이지 레이아웃(헤더, 푸터 등)을 포함하고,애니메이션을 관리하는 값을 Context에 설정합니다.또한, 애니메이션이 적용될 페이지를 children으로 받아 div 박스로 감싸 애니메이션을 적용합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML"use client";import PageTransitionContext, { PageAnimation,} from "@/app/context/page-transition-context";import cn from "@/utils/cn";import { useRef, useState } from "react";import Navbar from "../components/navbar/navbar";const PageTransitionProvider = ({ className, children,}: React.PropsWithChildren<{ className?: React.ComponentProps<"div">["className"];}>) => { const [animationClass, setAnimationClass] = useState(""); const animation = useRef<PageAnimation>("left"); return ( <PageTransitionContext.Provider value={{ animation, className: animationClass, setClassName: setAnimationClass, }} > <Navbar /> <div className={cn("relative p-16", className, animationClass)}> {children} </div> </PageTransitionContext.Provider> );};export default PageTransitionProvider;여기서 cn 함수는 tailwind CSS 클래스를 안전하게 합쳐주는 유틸 함수입니다. 이제 Provider를 Next.js의 layout.tsx에서 감싸주면 모든 페이지에서 애니메이션을 사용할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML<PageTransitionProvider>{children}</PageTransitionProvider>| 커스텀 훅 만들기이제 슬라이드 애니메이션을 위한 커스텀 훅을 만들겠습니다.이 훅은 애니메이션 타입에 따라 Context값을 변경하여 페이지가 적절히 슬라이드 되도록 합니다.애니메이션 클래스 반환 함수PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst getOutAnimation = (animation: PageAnimation) => { return animation === "left" ? "animate-slide-left-out" : "animate-slide-right-out";};const getInAnimation = (animation: PageAnimation) => { return animation === "left" ? "animate-slide-left-in" : "animate-slide-right-in";};애니메이션 적용 함수PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst animate = (animation: PageAnimation, context: PageTransitionContext) => { return new Promise((resolve) => { const className = getOutAnimation(animation); context.setClassName(className); context.animation.current = animation; setTimeout(resolve, SLIDE_ANIMATION_DURATION); });};커스텀 훅 완성PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst usePageTransition = () => { const pageTransitionContext = useContext(PageTransitionContext); if (!pageTransitionContext) { throw new Error( "usePageTransition은 PageTransitionContext 내부에서 사용해야 합니다." ); } const context = pageTransitionContext; const slideLeft = () => animate("left", context); const slideRight = () => animate("right", context); const slideIn = () => { if (context.animation.current) { const animation = getInAnimation(context.animation.current); context.setClassName(animation); } }; return { slideLeft, slideRight, slideIn };};| 애니메이션 적용하기기본적으로 페이지 전환 기능은 다음과 같이 동작합니다.링크를 클릭하면 애니메이션 className을 붙여서 페이지가 슬라이드 되도록 한다.슬라이드가 완료되면 페이지를 이동시킨다.이동이 완료되면 페이지가 슬라이드 되어 들어오도록 한다.페이지 입장 시 애니메이션 적용먼저 페이지에 입장할 때 해당 페이지가 적절히 슬라이드 되어 오도록 초기화해 줘야 합니다.따라서, 애니메이션이 적용될 페이지에 커스텀 훅에 있는 slideIn 함수를 실행시켜 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLexport default function Home() { const { slideIn } = usePageTransition(); useEffect(() => { slideIn(); }, []); return ( <div> <h1>Main Page</h1> </div> );}페이지 이동 시 애니메이션 적용이제 페이지에서 나갈 때 슬라이드 되어 사라지도록 해야 합니다.이를 구현하려면 링크를 클릭하면 커스텀 훅에 있는 slideLeft나 slideRight를 사용하여 페이지가 슬라이드 되도록 한 후 해당 애니메이션이 완료되면 Next.js의 useRouter를 사용해 페이지를 이동시켜 줍니다.링크를 클릭하면 실행될 navigation 함수를 다음과 같이 만들어 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 링크 데이터const navLink = [ { title: "Home", url: "/" }, { title: "About", url: "/about" }, { title: "Services", url: "/services" }, { title: "Contact", url: "/contact" },];// 함수const navigation = (url: string) => { if (pathname === url) return; const curIndex = navLink.findIndex((link) => link.url === pathname); const clickIndex = navLink.findIndex((link) => link.url === `${url}`); if (curIndex > clickIndex) { slideRight().then(() => { router.push(url); }); } else { slideLeft().then(() => { router.push(url); }); }};여기서 usePathname을 활용해 같은 url에서는 동작하지 않게 합니다.또한, 링크 순서에 따라 적절하게 오른쪽, 왼쪽 애니메이션이 구분되어 작동되도록 하면더욱 보기 좋은 애니메이션이 구현될 수 있습니다.| 끝이제 Next.js에서 페이지 이동 시 자연스러운 슬라이드 애니메이션이 적용되었습니다.해당 애니메이션은 transform: translateX(...)를 활용하여 구현되었으며,이는 브라우저의 GPU에서 처리되므로 reflow나 repaint 없이 성능적으로 매우 효율적인 방식입니다.하지만 과도한 애니메이션 사용이나, 여러 애니메이션이 동시에 실행되는 경우GPU 리소스가 증가하면서 성능 저하가 발생할 수 있습니다.또한 애니메이션이 항상 사용자 경험을 향상시키는 것은 아닙니다.지나치게 길거나 불필요한 애니메이션은 오히려 사용자의 피로감을 유발할 수 있습니다.따라서 페이지 전환 애니메이션을 적용할 때는 연출의 목적과 사용자 경험을 종합적으로 고려하여적절한 타이밍과 강도로 적용하는 것이 중요합니다.
React, useRef, useState, 상태관리
[React] useState와 useRef를 통한 상태 관리 (애니메이션 상태 최적화)
리액트에서 훅은 함수형 컴포넌트에서 상태와 생명주기 기능을 사용할 수 있게 해주는 강력한 도구입니다.그중에서도 useState와 useRef는 상태 관리를 위해 자주 사용됩니다.하지만 코드를 보다 보면 어떤 상황에서는 useState로,또 어떤 경우에는 useRef로 상태를 관리하는 모습을 종종 보게 됩니다.특히 DOM 조작을 위해 useRef를 사용하는 경우는 자주 보며 이해하기 쉽지만,이 외에 이유로 상태 관리를 위해 useRef를 사용하는 사례는 헷갈릴 때가 있습니다.이번 글에서 이 두 가지 훅을 통한 상태 관리의 차이점을 공부해 보고,상황에 따라 어떤 것을 사용하는 것이 적절한지 정리해 보겠습니다.| React의 상태 관리와 UI 업데이트 방식리액트는 상태(state)나 props의 변경에 따라 컴포넌트를 재렌더링하여 UI를 업데이트합니다.이 과정은 리액트의 렌더링 사이클을 통해 이루어지며,상태 관리를 위해 주로 useState와 useRef 같은 훅이 사용됩니다.이때 상태 관리와 UI 업데이트 방식은 크게 두 가지로 나눌 수 있을 거 같습니다.1. 리액트의 렌더링 사이클을 통한 업데이트- 상태나 props가 변경되면 리액트는 해당 컴포넌트를 재렌더링하고, 이를 통해 UI가 자동으로 갱신됩니다.2. 직접적인 DOM 조작을 통한 업데이트- 리액트를 거치지 않고, DOM 요소의 속성을 직접 변경하여 UI를 업데이트합니다.이 두 방식은 각각 장단접이 있으며, 리액트에서는 상황에 따라 적합한 상태 관리 방식을 선택해야 합니다.| useState와 useRefuseStateuseState는 상태 값이 변경될 때 컴포넌트를 재렌더링하며, 보통 UI와 상태를 동기화하는 데 사용됩니다.이를 통해 UI는 최신 상태를 반영하게 됩니다.useState 상태의 초기값을 설정할 수 있고, 초기값은 반드시 렌더링 시접에 결정됩니다.또한, 상태 값 변경이 비동기적으로 처리되고,여러 상태 업데이트가 있을 경우 리액트는 이를 효율적으로 배치(batch)하여 한번에 처리합니다.보통 UI와 동기화 되는 데이터 관리를 위해 사용되고, 흔한 예시로는 카운터 기능이 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { useState } from "react";const Counter = () => { const [count, setCount] = useState<number>(0); const handleIncrement = () => setCount((prev) => prev + 1); return ( <div> <p>현재 카운트: {count}</p> <button onClick={handleIncrement}>증가</button> </div> );};export default Counter;여기서 count는 useState를 통해 관리되는 상태 값입니다.상태가 변경될 때 컴포넌트가 재렌더링되어, 새로운 값이 UI에 반영됩니다.useRefuseRef는 값이 변경되어도 컴포넌트를 재렌더링하지 않으며,컴포넌트의 수명 주기 동안 동일한 값을 유지합니다.보통 useRef를 사용하여 DOM 요소를 직접 참조하거나 제어하고,값은 ref.current를 통해 동기적으로 업데이트됩니다.또한 상태 변경이 자주 발생하더라도 성능에 영향을 주지 않기 때문에애니메이션 작업과 같은 빠르게 변하는 값을 관리하거나 이전 값을 추적하는 데 사용되기도 합니다.다음은 useRef를 활용하여 버튼을 클릭해 입력 필드를 포커싱 하는 코드입니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { useRef } from "react";const App = () => { const inputRef = useRef<HTMLInputElement>(null); const handleFocus = () => { if (inputRef.current) { inputRef.current.focus(); } }; return ( <div> <input ref={inputRef} type="text" placeholder="입력하세요" /> <button onClick={handleFocus}>포커스 이동</button> </div> );};export default App;위 코드에서 inputRef는 useRef를 사용하여 DOM 요소를 참조합니다.버튼을 클릭하면 DOM 조작을 통해 입력 필드에 포커스가 설정됩니다.이 작업은 상태 변경이 필요 없으므로 useRef를 사용하는 것이 적합합니다.| 애니메이션 작업에서의 useRef애니메이션 작업에서는 상태를 지속적으로 업데이트해야 하지만,상태 변경으로 인해 렌더링 비용이 높아지는 것을 방지해야 합니다.이런 경우 useRef는 컴포넌트를 재렌더링하지 않고 값을 업데이트할 수 있습니다.예시로 progress 값을 사용해 애니메이션의 진행 상태를 추적하는 코드를간단하게 작성해 보겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { useRef, useEffect } from "react";const App = () => { const requestRef = useRef<number>(); const progress = useRef<number>(0); const animate = () => { progress.current += 1; console.log(`진행 상태: ${progress.current}`); requestRef.current = requestAnimationFrame(animate); }; useEffect(() => { requestRef.current = requestAnimationFrame(animate); return () => { if (requestRef.current) { cancelAnimationFrame(requestRef.current); } }; }, []); return <div>애니메이션 실행 중...</div>;};export default App;위 코드에서 progress는 애니메이션의 진행 상태를 저장하는 변수입니다.애니메이션의 progress 상태는 화면에 직접적으로 반영되지 않습니다.즉, 이 값이 변경되어도 컴포넌트를 다시 렌더링 할 필요가 없으므로 useRef를 사용하여 성능을 최적화합니다.만약 위 코드에서 useRef가 아닌 useState를 사용해 progress 값을 관리하면 어떻게 될까요?그렇게 되면 매 프레임마다 상태를 업데이트하며 컴포넌트가 재렌더링되어 성능 저하가 생길 겁니다.한번 위에 예시 코드를 useState로 작성해 보겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { useState, useEffect } from "react";const App = () => { const [progress, setProgress] = useState<number>(0); const animate = () => { setProgress((prevProgress) => prevProgress + 1); // 상태 업데이트 requestAnimationFrame(animate); }; useEffect(() => { const animationId = requestAnimationFrame(animate); return () => cancelAnimationFrame(animationId); }, []); useEffect(() => { console.log(`진행 상태: ${progress}`); }, [progress]); return <div>애니메이션 실행 중...</div>;};export default App;이런 식으로 코드를 작성하면 setProgress가 호출되면리액트에서 상태가 변경되었다고 판단하고 컴포넌트를 재렌더링합니다.이는 애니메이션이 끊기거나 느려지는 원인이 될 수 있습니다.리액트 프로파일러의 컴포넌트 렌더링 하이라이팅을 보면 아래와 같이useState를 사용했을 때 컴포넌트가 계속 렌더링 되는 것을 확인해 볼 수 있습니다.반면 useRef를 사용했을 때는 렌더링이 발생하지 않는 것을 볼 수 있습니다.이런 식으로 애니메이션 상태를 지속적으로 업데이트하면서도 렌더링을 방지할 수 있어성능 최적화에 적합합니다.| UI 변경에 대한 의문점과 해소이 주제에 대해 관심을 갖게 된 계기는,다른 사람이 구현한 슬라이드 기능 코드를 보고 의문을 가지게 되면서였습니다.저는 주로 useRef를 DOM 요소를 참조하거나 조작하는 경우에만 사용했고,대부분의 상태는 useState로 관리했습니다.하지만 제가 본 다른 사람의 코드에서는 UI 변경에 직접적으로 영향을 미치지 않는 값들을모두 useRef로 관리하고 있었습니다.예를 들면PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst isDragging = useRef<boolean>(false); // 사용자의 드래그 여부const startX = useRef<number>(0); // 드래그 시작 시 마우스 좌표const scrollLeft = useRef<number>(0); // 슬라이드 초기 스크롤 위치const animationProgress = useRef<number>(0); // 애니메이션 진행 상태이처럼 렌더링이 필요 없는 값들은 모두 useRef로 관리되고 있었습니다.이를 통해 상태 변경이 UI에 직접적으로 영향을 미치지 않는 경우,굳이 useState를 사용할 필요가 없다는 것을 배웠습니다.| 끝리액트로 프로젝트를 개발할 때, UI 변경의 방식을 먼저 고려해야 합니다.1. 리액트의 렌더링 사이클을 통한 UI 변경인지- 상태 변경이 UI와 직접적으로 연동된다면 useState를 사용합니다2. 직접적인 DOM 조작을 통한 UI 변경인지- 상태 변경이 렌더링을 트리거할 필요가 없다면 useRef로 관리하여 성능을 최적화할 수 있습니다.이처럼 상태 변경과 UI 변경의 연관성을 잘 판단하여,적절한 훅을 선택하는 것이 리액트 개발의 중요한 부분임을 다시금 느끼게 되었습니다.| 참고https://react.dev/reference/react/useRefhttps://react.dev/reference/react/useState

최적화, Debounce, Throttle
디바운스와 쓰로틀을 사용한 최적화
자바스크립트 애프리케이션에서는 사용자가 웹페이지와 상호작용하는 동안 발생하는 다양한 이벤트들을 다루게 됩니다.특히, scroll, resize, input, mousemove와 같은 이벤트들은 매우 짧은 시간 간격으로 연속해서 발생하는데,이런 식으로 이벤트 핸들러가 과도하게 호출되면 성능 저하나 불필요한 서버 요청이 발생할 수 있습니다.이런 문제를 해결하기 위해 디바운스(Debounce)와 쓰로틀(Throttle)이라는 기법을 사용해 볼 수 있습니다.이 두 기법은 연속해서 발생하는 이벤트를 효율적으로 제어하여 성능을 최적화하는 데 도움을 줍니다.이번 글에서 디바운스와 쓰로틀에의 활용 방법에 대해 간단하게 한번 알아보겠습니다.| 디바운스디바운스는 연속된 이벤트 호출을 그룹화하여 마지막에 한 번만 실행되도록 하는 방식입니다.즉, 이벤트가 발생한 후 일정 시간이 지날 때까지 추가적인 이벤트가 발생하지 않으면 그때 한 번만 이벤트 핸들러를 호출합니다.디바운스의 동작디바운스는 다음과 같은 과정으로 동작합니다.이벤트가 발생하면 이전에 설정된 타이머를 취소합니다.새로운 타이머를 설정하여 일정 시간(delay) 후에 콜백 함수를 실행하도록 합니다.만약 delay 시간 내에 다시 이벤트가 발생하면 1번 과정으로 돌아가 타이머를 취소하고 다시 설정합니다.이벤트가 더 이상 발생하지 않고 delay 시간이 지나면 콜백 함수가 실행됩니다.사용디바운스는 보통 input 이벤트에서 자주 사용됩니다.검색 기능을 구현할 때 자동완성을 위해 사용자가 입력할 때마다 API 요청을 보내면 서버에 부하가 걸리고,사용자에게 불필요한 대기 시간이 발생할 수 있습니다.이때 디바운스를 사용하여 사용자가 입력을 멈춘 후에 한 번만 API 요청을 보내도록 할 수 있습니다.자바스크립트에서 debounce 함수는 간단하게 다음과 같이 구현할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst debounce = (cb, delay) => { let timer; // timer를 가지고 있는 클로저를 반환합니다. return e => { // delay가 경과하기 이전에 이벤트가 발생하면 이전 타이머를 취소하고 새로운 타이머를 재설정합니다. // 따라서 delay보다 짧은 간격으로 이벤트가 발생하면 콜백 함수는 호출되지 않습니다. if(timer) clearTimeout(timer); timer = setTimeout(cb, delay, e); }}다음으로 아래와 같은 input이 있다고 하면 PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML<input type="text" id="search" placeholder="검색어를 입력하세요">다음과 같이 이벤트 핸들러를 적용할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst searchInput = document.getElementById("search");const handleInput = debounce((e) => { const query = e.target.value; console.log(`검색어: ${query}`);}, 200);searchInput.addEventListener("input", handleInput);이런 디바운스는 다음과 같은 상황에서 주로 활용됩니다.검색어 자동완성 - 사용자가 입력을 멈춘 후에 검색을 수행윈도우 리사이즈 이벤트 - 창 크기 변경이 끝난 후에 레이아웃 재계산폼 검증 - 사용자가 입력을 완료한 후에 검증 로직 실행버튼 클릭 방지 - 짧은 시간 내에 중복 클릭을 방지하여 중복 요청을 막음| 쓰로틀쓰로틀은 연속된 이벤트 호출을 일정한 간격마다 실행되도록 하는 방식입니다.즉, 이벤트가 아무리 많이 발생해도 정해진 시간 간격마다 최대 한 번씩만 이벤트 핸들러가 호출됩니다.쓰로틀의 동작쓰로틀은 다음과 같은 과정으로 동작합니다.이벤트가 발생하면 현재 시간이 이전에 실행된 시간과의 차이가 delay 이상인지 확인합니다.delay 이상이면 이벤트 핸들러를 실행하고, 마지막 실행 시간을 현재 시간으로 업데이트합니다.delay 미만이면 이벤트를 무시합니다.사용쓰로틀은 scroll 이벤트에서 자주 사용됩니다. scroll 이벤트는 사용자가 스크롤할 때마다 매우 빈번하게 발생합니다.이때 이벤트가 과도하게 호출되면 성능 문제가 발생할 수 있기 때문에쓰로틀을 사용하여 일정 시간마다 한 번씩만 핸들러가 실행되도록 제어할 수 있습니다.자바스크립트에서 throttle 함수는 간단하게 다음과 같이 구현할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst throttle = (cb, delay) => { let timer; return e => { // delay가 경과하기 이전에 이벤트가 발생하면 아무것도 하지 않습니다. // delay가 경과했을 때 이벤트가 발생하면 새로운 타이머를 재설정합니다. // 따라서 delay 간격으로 콜백 함수가 호출됩니다. if(timer) return; timer = setTimeout(() => { cb(e); timer = null; }, delay, e) }}다음으로 아래와 같이 이벤트 핸들러를 적용할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst handleScroll = throttle(() => { console.log(`스크롤 위치: ${window.scrollY}`);}, 200);window.addEventListener("scroll", handleScroll);이런 쓰로틀은 보통 무한 스크롤을 구현하는 데 사용될 수 있습니다.| 디바운스와 쓰로틀의 차이| 끝디바운스와 쓰로틀은 자바스크립트에서 성능 최적화를 위해 한번쯤 반드시 공부해봐야 할 기법이라 생각합니다.두 방법 모두 이벤트 핸들러의 호출 빈도를 제어하여 불필요한 연산을 줄이고,사용자 경험을 향상 시키는데 큰 도움을 줄 수 있습니다.마지막으로 위 예시는 이해를 위해 간략하게 작성된 코드이고,실제 프로젝트에 사용할 때는 Lodash와 같은 검증된 라이브러리를 사용하는 것이 권장됩니다.

AJAX
Ajax (를 알아야 하는 이유)
Ajax(Asynchronous JavaScript and XML)는 자바스크립트를 사용하여브라우저가 서버와 비동기적으로 데이터를 주고받을 수 있게 하는 기술입니다.즉, 웹 페이지를 처음부터 끝까지 새로 고치지 않고도 필요한 부분만 업데이트할 수 있어페이지가 더 빠르고 동적으로 반응할 수 있습니다.| Ajax의 시작Ajax의 핵심은 브라우저에서 제공하는 XMLHttpRequest 객체입니다.1999년 마이크로소프트가 개발한 이 객체는HTTP 비동기 통신을 위한 다양한 메서드와 프로퍼티를 제공하며,이를 통해 서버와 데이터를 주고받을 수 있게 했습니다.Ajax는 초기에는 주목받지 못했으나,2005년 구글이 Google Maps에 Ajax를 적용하면서 큰 주목을 받았습니다.Ajax 덕분에 Google Maps는 새로고침 없이 지도를 부드럽게 움직이고 확대/축소할 수 있었고,이를 통해 웹 애플리케이션이 데스크톱 애플리케이션처럼매끄러운 사용자 경험을 제공할 수 있다는 가능성을 보여주었습니다.| 전통적인 웹 페이지 로딩 방식의 문제Ajax가 등장하기 전에는 웹 페이지를 새로 고칠 때마다전체 HTML을 서버로부터 다시 받아와 렌더링 하는 방식이 일반적이었습니다.이 전통적인 방식은 다음과 같은 문제점을 가지고 있었습니다.불필요한 데이터 통신: 변경이 필요 없는 부분까지 포함된 전체 HTML을 서버로부터 다시 받아오므로 데이터 통신이 비효율적이었습니다.깜빡임 현상: 화면 전환 시 모든 요소를 새로 렌더링 하기 때문에 순간적으로 화면이 깜빡이는 불편함이 있었습니다.동기 처리 문제: 클라이언트가 서버에 요청을 보내면 응답이 도착할 때까지 블로킹되므로 다른 작업이 지연될 수 있었습니다.| Ajax가 가져온 장점Ajax의 등장으로 웹 페이지는 필요할 때 필요한 데이터만 비동기적으로 받아와변경할 부분만 업데이트할 수 있게 되었습니다.이를 통해 다음과 같은 장점이 생겼습니다.효율적인 데이터 통신: 필요한 데이터만 받아오므로 네트워크 사용이 최적화되었습니다.부드러운 화면 전환: 변경이 필요 없는 부분은 다시 렌더링 하지 않으므로 화면이 깜빡이지 않고 부드럽게 전환됩니다.비동기적 처리: 서버와의 통신이 비동기로 이루어지기 때문에, 블로킹 없이 다른 작업을 계속 진행할 수 있습니다.| fetch 함수와 XMLHttpRequestXMLHttpRequestXMLHttpRequest는 Ajax를 위해 예전부터 사용해 온 방법으로,콜백 함수를 사용하여 비동기 요청을 처리합니다.아래는 XMLHttpRequest로 GET 요청을 보내는 예시입니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst xhr = new XMLHttpRequest();xhr.open("GET", "https://api주소", true);xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { const data = JSON.parse(xhr.responseText); console.log(data); } else if (xhr.readyState === 4) { console.error("Error:", xhr.statusText); }};xhr.send();fetch 함수최근 JavaScript에서는 fetch 함수가 Ajax의 기본 도구로 자리 잡았습니다.fetch는 프로미스를 기반으로 한 HTTP 요청 API로,기존 XMLHttpRequest보다 간결하고 직관적인 코드를 작성할 수 있게 해 줍니다.fetch를 사용하면 아래와 같은 코드로 비동기 요청을 쉽게 처리할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLfetch("https://api주소") .then(response => { if (!response.ok) { throw new Error("네트워크 응답이 올바르지 않습니다"); } return response.json(); }) .then(data => console.log(data)) .catch(error => console.error("Error:", error));또한 async/await와 함께 사용하면 코드가 더 읽기 쉽고 유지보수하기 쉬워집니다.| Ajax에 대한 개인적인 생각웹 애플리케이션은 기본적으로 사용자가 사용하기 위해 만들어집니다.따라서 사용자에게 최적의 경험을 제공하는 것이 매우 중요합니다.Ajax는 웹 애플리케이션이 이러한 최적의 경험을 제공할 수 있게 해주는 핵심 기술이라고 생각합니다.특히 fetch 함수와 이를 더욱 쉽게 사용할 수 있게 만들어주는 라이브러리(axios 등) 덕분에비동기 프로그래밍의 문턱이 크게 낮아져,이제는 복잡한 기능도 더 쉽게 구현할 수 있는 시대가 되었습니다.이는 웹 개발의 큰 발전이라 할 수 있습니다.처음에 저는 데이터가 화면 새로고침 없이 당연히 전환되는 줄로만 알았습니다.하지만 Ajax를 공부하면서 과거의 웹 개발 방식과 그에 따른 한계들을 알게 되었고,Ajax가 그런 문제들을 어떻게 해결했는지 이해하게 되었습니다.이런 변화를 알게 되니 앞으로의 웹 개발에서도 어떻게 더 개선할 수 있을지고민하는 데 큰 도움이 되는 것 같습니다.
React Native, Location, UX
React Native Expo에서 WebView와 Geolocation API 통신 문제 해결하기
최근 프로젝트에서 Geolocation API를 사용하여사용자의 위치 정보를 가져와 위치 기반 서비스를 제공하였습니다.해당 서비스의 모바일 사용자가 증가함에 따라React Native Expo와 WebView를 이용한 간단한 앱을 만들어 보았습니다.이 글에서는 React Native Expo를 사용하여WebView와 Geolocation API를 통합하는 과정에서 발생한 문제와이를 해결하기 위한 두 가지 방법을 소개하려 합니다.| 구현해당 프로젝트에서 사용자가 위치 동의를 하지 않은 상태일 때,웹과 앱에 따라 다음과 같은 각각 다른 경고창을 보여주도록 구현되어 있었습니다.이때 앱에서 사용자가 바로 설정창으로 이동할 수 있도록 하기 위해“설정 가기” 버튼을 누르면 웹에서 postMessage를 사용하여다음과 같이 앱에 메시지를 보낸 다음PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 웹 코드// 웹 코드에서 React Native WebView로 메시지 전송window.ReactNativeWebView.postMessage(JSON.stringify({type: "OPEN_SETTING"}))아래 코드처럼 앱의 WebView에서 onMessage를 통해 데이터를 확인한 후React Native의 Linking을 통해 설정창으로 이동할 수 있도록 하였습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 앱 코드// React Native 앱에서 메시지 수신 및 처리const handleMessage = (event: WebViewMessageEvent) => { const message = JSON.parse(event.nativeEvent.data); if (message.type === "OPEN_SETTING") { Linking.openSettings(); }};<WebView onMessage={handleMessage} ...다른것들/>이때 데이터는 문자열만 주고받을 수 있기 때문에JSON.stringify를 사용해 JSON형태의 문자열로 바꿔서 보내주고,JSON.parse로 자바스크립트의 객체 형식으로 바꿔 사용해 줘야 합니다.-> 참고https://archive.reactnative.dev/docs/0.54/webview#onmessagehttps://reactnative.dev/docs/linking| 문제이때 문제가 하나 있었습니다.사용자가 설정창으로 이동하여 위치 권한을 허용한 후 앱으로 돌아오면,권한 상태가 즉시 반영되지 않고 여전히 거부된 상태로 인식되어 경고창이 다시 나타났습니다.이를 해결하기 위해 설정창으로 돌아올 때 웹뷰를 새로고침 하는 기능을 추가할 필요가 생겼습니다.| 방법 1: 앱 상태 변화를 감지해서 웹뷰 새로고침먼저 설정창에서 돌아오면 앱을 새로고침 할 수 있도록 적용해 보기로 했습니다.우선 앱의 상태 변화를 저장할 useState 상태를 만들어 주고,다음과 같이 앱이 백그라운드에 있거나 활성화되지 않은 상태에서 돌아오면 새로고침 되도록 하였습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 앱 코드const [appState, setAppState] = useState(AppState.currentState);useEffect(() => { const handleChange = (nextAppState) => { if (appState.match(/inactive|background/) && nextAppState === "active") { // 앱이 다시 활성화되면 웹뷰 새로고침 if (webViewRef.current) { webViewRef.current.reload(); } } setAppState(nextAppState); }; const subscription = AppState.addEventListener("change", handleChange); return () => { subscription.remove(); };}, [appState]);여기서 앱의 상태인 AppState.currentState는 외부 값이기 때문에useState훅으로 상태를 따로 만들어 줘야 자동으로 리렌더링 되며 값이 바뀌는 것을 확인할 수 있습니다.이제 앱의 설정으로 이동했다가 돌아올 경우에만 새로고침 되도록fromSettings라는 false값을 가진 상태를 하나 만들어 주고설정창으로 이동하는 버튼을 눌렀을 때 해당 값을 true로 바꾸면서,앱이 활성화될 때 이 값을 확인한 다음 새로고침시켜주는 방식으로 바꿨습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 앱 코드const [appState, setAppState] = useState<AppStateStatus>( AppState.currentState);const [fromSettings, setFromSettings] = useState(false); useEffect(() => { const handleChange = (nextAppState: AppStateStatus) => { if (appState.match(/inactive|background/) && nextAppState === "active") { if (fromSettings) { // 설정창에서 돌아온 경우 setFromSettings(false); if (webViewRef.current) { webViewRef.current.reload(); } } } setAppState(nextAppState); }; const subscription = AppState.addEventListener("change", handleChange); return () => { subscription.remove(); };}, [appState, fromSettings]);이런 식으로 하면서 어느 정도는 해결된 거 같았습니다.하지만 새로고침이 무조건 돼 야하기 때문에 사용자 경험 측면에서 좋지 않을 거라고 판단하였습니다.또한, 권한 관리를 좀 더 세밀하게 제어하기 어려웠고,모바일 측면에서 좀 더 세밀한 위치 기반 서비스를 제공하기 위해 다른 방법이 필요했습니다.| 방법 2: 기능 구분이에 저는 웹에서는 기존의 브라우저의 Geolocation API를 계속 사용하고,모바일에서는 Expo의 Location 라이브러리를 사용해 보기로 결정하였습니다.사용 방법은 아래 링크에 나와있습니다.https://docs.expo.dev/versions/latest/sdk/location/우선 웹 코드에서 리액트 네이티브 앱으로 접속했을 때 여부를 확인하고,위치 정보 요청이 들어오면 다른 로직이 실행되도록 하였습니다.이번에도 ReactNative WebView의 postMessage를 사용하여웹뷰로 들어왔을 때 앱에 알리는 형식으로 구현했습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 웹 코드// 리액트 네이티브 웹뷰로 들어왔는지 확인const isReactNativeWebView = typeof window != "undefined" && window.ReactNativeWebView != null;if(isReactNativeWebView) { // 리액트 네이티브 앱이면 postMessage로 전송 window.ReactNativeWebView.postMessage(JSON.stringify({type: "GPS_PERMISSIONS"})); return;} else { // 아니면 그대로 if (navigator.geolocation) { const watchId = navigator.geolocation.watchPosition( ...코드 ); return () => { navigator.geolocation.clearWatch(watchId); } }}이후 앱 코드에서 이를 웹뷰의 onMessage로 확인하고expo의 Location을 통해 사용자의 위치 정보를 받도록 구현하였습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 앱 코드const handleMessage = async (event: WebViewMessageEvent) => { const message = JSON.parse(event.nativeEvent.data); if (message.type === "GPS_PERMISSIONS") { ...여기다 위치 정보 받는 코드 }};<WebView onMessage={handleMessage} ...다른것들/>그다음 기존에 웹에서 위치 동의가 안 돼있으면 띄웠던 경고창을앱에서 위치동의 여부를 확인하고, 앱에서 경고창을 띄우도록 하였습니다.먼저 기기에 위치 서비스 허용 여부를 확인하는 함수를 만들어 주고,허용 안 해놨으면 설정으로 이동할 수 있도록 React Native의 Alert를 사용하여 경고창을 만들어 줍니다.Location의 hasServicesEnabledAsync를 사용하면 알 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 앱 코드const checkLocation = async () => { // 위치 서비스 허용 여부 확인 const isEnabled = await Location.hasServicesEnabledAsync(); if (!isEnabled) { Alert.alert( "위치 서비스 사용", '위치 서비스를 사용할 수 없습니다. "기기의 설정 > 개인 정보 보호" 에서 위치서비스를 켜주세요.', [ { text: "취소", style: "cancel" }, { text: "설정으로 이동", onPress: () => { Linking.openSettings(); }, }, ], { cancelable: false } ); return false; } return true;};그다음 앱의 위치 정보 접근 권한을 요청하는 함수를 만들어 주고,마찬가지로 동의가 거부된 상태면 설정으로 이동할 수 있도록 합니다.Location의 requestForegroundPermissionsAsync를 사용해 요청할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 앱 코드const requestPermissions = async () => { // 앱의 위치 접근 권한 요청 const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== "granted") { Alert.alert( "위치 정보 접근 거부", "위치 권한이 필요합니다.", [ { text: "취소", style: "cancel" }, { text: "설정으로 이동", onPress: () => { Linking.openSettings(); }, }, ], { cancelable: false } ); return false; } return true;};마지막으로 Location을 사용하여 사용자의 위치를 가져오는 함수를 만들어 주고,위치 정보를 받은 후 webview의 postMessage로 웹에 위치 정보를 보내줍니다.저는 사용자의 위치를 실시간으로 1m 간격으로 확인하기 위해 다음과 같은 설정으로 만들었습니다.Location의 watchPositionAsync으로 사용자의 위치를 추적할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 앱 코드const getLocation = async () => { try { setGpsLoading(true); let location = await Location.watchPositionAsync( { distanceInterval: 1, }, (location) => { const { latitude, longitude } = location.coords; webViewRef.current?.postMessage( JSON.stringify({ latitude, longitude }) ); } ); } catch (error) { console.error(error); } finally { setGpsLoading(false); }};이제 해당 함수들을 아까 위에서 만든 handleMessage에서 실행시킵니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 앱 코드const handleMessage = async (event: WebViewMessageEvent) => { const message = event.nativeEvent.data; if (message.type === "GPS_PERMISSIONS") { // 기기 위치 서비스 허용 여부 확인 const servicesEnabled = await checkLocation(); if (!servicesEnabled) return; // 앱 위치 정보 접근 권한 요청, 확인 const permissionsGranted = await requestPermissions(); if (!permissionsGranted) return; // 사용자 위치 정보 수집 및 웹으로 전송 getLocation(); }};-> 참고https://reactnative.dev/docs/alert이제 웹 코드에서 이를 확인하고 원하는 로직을 만들어 주면 됩니다.앱에서 보낸 메시지를 웹에서 확인하기 위해최상위 객체에 message 이벤트를 달아주면 됩니다.message를 통해 가져온 데이터를 JSON.parse를 통해 다시 자바스크립트 객체로 만들어 주고,이를 확인하고 코드를 작성해 줍니다.여기서 주의할 점은 IOS와 Android의 최상위 객체가 다르기 때문에 각각 달아줘야 합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLuseEffect(() => { const handleMessage = (event: any) => { const data = JSON.parse(event.data); if (data.latitude && data.longitude) { ...여기에 코드 } }; // IOS window.addEventListener("message", handleMessage); // Android document.addEventListener("message", handleMessage); return () => { window.removeEventListener("message", handleMessage); document.removeEventListener("message", handleMessage); };}, []);이런 식으로 앱에서는 expo의 Location 라이브러리를 통해사용자의 위치 정보를 가져오도록 만들었습니다.expo의 Location에서는 위치 업데이트 빈도 세밀하게 설정, 배터리 절약, 백그라운드 동작 등다양한 기능을 제공하기 때문에 더 풍부한 기능을 가진 앱을 만들 수 있을 거 같습니다.

React, 추상화
[React] 리액트에서 컴포넌트 추상화에 대한 고민
추상화는 불필요한 세부 사항을 제거하고 중요한 부분만 남겨시스템을 더 쉽게 다룰 수 있게 만드는 과정입니다.프로그래밍에서는 인터페이스, 추상 클래스, 함수, 모듈 등을 통해 기능을 추상화할 수 있습니다.이를 통해 코드 재사용성, 유지보수성, 확장성을 높일 수 있습니다.React에서 컴포넌트를 설계할 때 이런 추성화 기법을 사용하여재사용성, 유지보수성, 유연성 등을 높일 수 있습니다.최근 리액트로 만들어진 다른 사람의 코드를 보던 중,문득 "이 부분을 좀 더 추상화하면 관리가 더 쉬워질까?"라는 생각이 들었습니다.코드를 처음 보면 간단해 보이지만,새로운 요구사항이 추가될 때마다 코드가 복잡해질 수 있지 않을까 고민했습니다.| 구현해 볼 컴포넌트제가 고민한 내용을 간단한 컴포넌트를 만들면서 공유해보려 합니다.아래 이미지와 같은 간단한 리스트 컴포넌트를 구현해 보겠습니다.해당 컴포넌트는 왼쪽 부분, 타이틀, 서브타이틀, 오른쪽 부분, 화살표를 통해재사용 가능하도록 만들어 보려 합니다.아래 예시로 구현하는 코드는 리액트, emotion을 사용하여 작성하였고,코드보다는 어떤 방식으로 컴포넌트를 추상화하려 했는지를 중점으로 봐주시면 좋을 거 같습니다.| 첫 번째 방법: 단일 컴포넌트에 여러 섹션 포함첫 번째 방법은 저에게 추상화에 대해 다시 생각해보게 한 코드입니다.하나의 컴포넌트 내부에 모든 섹션을 포함하여 관리하는 방식입니다.이 방식에서는 컴포넌트 내에 각각의 섹션(좌측, 내용, 우측)을 props로 전달하고,해당 컴포넌트 내에서 직접 섹션을 배치하고 렌더링 합니다. 구현우선 필요한 다른 파일들을 가져온 후 스타일을 지정해 주겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { css } from "@emotion/react";import Flex from "../flex";import Text from "../text";interface Props { left?: React.ReactNode; contents: React.ReactNode; right?: React.ReactNode; withArrow?: boolean; onClick?: VoidFunction;}const listRowContainerStyles = css` padding: 8px 24px;`;const listRowLeftStyles = css` margin-right: 14px;`;const listRowContentsStyles = css` flex: 1;`;위에서 가져온 Flex 컴포넌트는 간단하게 flex 구조를 제공하는 컴포넌트이고,Text 컴포넌트는 글자의 스타일을 간단하게 제공하는 컴포넌트입니다.다음으로 해당 스타일로 리스트 컴포넌트를 만들어 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst ListRow = ({ left, contents, right, withArrow, onClick }: Props) => { return ( <Flex as="li" css={listRowContainerStyles} onClick={onClick} align="center"> <Flex css={listRowLeftStyles}>{left}</Flex> <Flex css={listRowContentsStyles}>{contents}</Flex> <Flex>{right}</Flex> {withArrow && <IconArrowRight />} </Flex> );};const IconArrowRight = () => { return ( <svg> ... </svg> );};ListRow라는 컴포넌트를 만들고, 해당 컴포넌트에 왼쪽영역,가운데 내용 영역, 오른쪽 영역과 화살표를 포함시켜 줍니다.그다음PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst ListRowTexts = ({ title, subTitle,}: { title: string; subTitle: string;}) => { return ( <Flex direction="column"> <Text fontWeight="bold">{title}</Text> <Text typography="t7">{subTitle}</Text> </Flex> );};ListRow.Text = ListRowTexts;export default ListRow;해당 리스트에 내용에 타이틀과 서브 타이틀을 정해진 스타일로 제공하기 위해ListRowTexts라는 컴포넌트를 만들어주고ListRow의 Text에 해당 컴포넌트를 포함시켜 준 후ListRow를 export default로 내보내줍니다.이제 해당 컴포넌트를 사용할 파일에서PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML<ListRow withArrow left={<div>left</div>} contents={<ListRow.Text title="타이틀" subTitle="서브타이틀" />} right={<div>right</div>}/>이런 식으로 사용해 주면 완성입니다.장단점이 방법으로 구현했을 때 제가 생각한 장단점은 다음과 같았습니다.우선 장점으로는컴포넌트가 단일 구조로 되어 있어 읽기 쉬웠고, 사용이 간편했습니다.모든 섹션이 한 곳에 모여 있어 컴포넌트의 구조를 한눈에 파악할 수 있었습니다.다음으로 단점으로는개별 섹션을 세부적으로 조정하거나 독립적으로 관리하기 어려웠습니다.새로운 기능이나 요구사항이 추가될 시 컴포넌트가 복잡해지고, 수정이 어려워질 우려가 있었습니다.| 두 번째 방법: 컴포넌트 분리두 번째 방법은 첫 번째 방법에서 단점을 보완하고자 고민해 본 방식입니다.이 방법은 컴포넌트를 좀 더 깊게 추상화하며, 각 섹션을 독립적인 컴포넌트로 분리하는 구조입니다.이렇게 하면 각 부분을 개별적으로 관리할 수 있고,필요한 경우 독립적으로 재사용할 수 있다 생각했습니다.구현우선 필요한 다른 파일들을 가져온 후 스타일을 지정해 주겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { css } from "@emotion/react";import Flex from "./flex";import Text from "./text";interface Props { children: React.ReactNode; withArrow?: boolean; onClick?: VoidFunction;}const listRowContainerStyles = css` padding: 8px 24px;`;const listRowLeftStyles = css` margin-right: 14px;`;const listRowContentsStyles = css` flex: 1;`;다음으로 해당 스타일로 리스트 컴포넌트를 만들어 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLexport const ListRow = ({ children, withArrow, onClick }: Props) => { return ( <Flex as="li" css={listRowContainerStyles} onClick={onClick} align="center"> {children} {withArrow && <IconArrowRight />} </Flex> );};const IconArrowRight = () => { return ( <svg> ... </svg> );};이번에는 ListRow 컴포넌트를 만들고, 해당 컴포넌트에 여러 섹션을 포함하지 않고,children을 그대로 받아서 렌더링 하도록 만들어 줍니다.그리고 리스트의 왼쪽 부분, 내용 부분, 오른쪽 부분을 각각 독립된 컴포넌트로 만들어 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLexport const ListLeft = ({ children }: { children: React.ReactNode }) => { return <Flex css={listRowLeftStyles}>{children}</Flex>;};export const ListContents = ({ children, title, subTitle,}: { children?: React.ReactNode; title?: string; subTitle?: string;}) => { return ( <Flex css={listRowContentsStyles}> {children} <Flex direction="column"> <Text fontWeight="bold">{title}</Text> <Text typography="t7">{subTitle}</Text> </Flex> </Flex> );};export const ListRight = ({ children }: { children: React.ReactNode }) => { return <Flex>{children}</Flex>;};이제 해당 컴포넌트를 사용할 파일에서PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML<ListRow withArrow> <ListLeft>left</ListLeft> <ListContents title="타이틀" subTitle="서브타이틀" /> <ListRight>right</ListRight></ListRow>이런 식으로 사용해 주면 완성입니다.이 컴포넌트는 ListRow가 전체 레이아웃을 담당하고,각 섹션은 개별적으로 컴포넌트로 분리됩니다.장단점첫 번째 방법의 단점을 보완하고자 이 방법으로 구현해 봤는데역시 장단점이 존재했습니다.우선 장점으로는각 섹션을 독립적으로 관리할 수 있어 수정 및 확장이 용이하다고 생각했습니다.특정 섹션을 여러 곳에서 재사용할 수 있어 프로젝트가 커지면 코드 중복을 줄일 수 있다 생각했습니다.새로운 요구사항이 추가되어도 기존 구조를 쉽게 확장할 수 있습니다.다음으로 단점으로는여러 컴포넌트로 분리되면서 코드가 길어지고 관리가 다소 복잡해질 우려가 있었습니다.컴포넌트를 조합해서 사용해야 하므로 좀 더 구체적인 사용법에 대한 설명(문서화)이 필요할 수 있습니다. (이 부분이 가장 중요하다고 생각)| 결론컴포넌트를 추상화할 때 간결함과 유연성 사이의 균형을 맞추는 것은 매우 중요하지만,실제로는 쉽지 않은 작업입니다.위 예시 코드는 매우 간단한 리스트 컴포넌트를 구현한 것이며,이를 통해 두 가지 방법을 비교해 보았습니다.단순한 코드의 경우 첫 번째 방식이 간결하고 빠르며 코드 작성이 수월합니다.하지만, 프로젝트가 복잡해지고 요구사항이 늘어날 가능성을 고려한다면,두 번째 방식처럼 각 섹션을 독립적으로 관리하는 방법도 좋은 선택일 수 있습니다.또한, 코드의 추상화는 프로젝트의 성격과 팀의 개발 스타일에 따라 달라질 수 있습니다.단순함을 우선할지, 유연성을 추구할지 고민해 보고,그에 맞는 방법을 선택하는 것이 중요하다고 생각합니다.사실, 저 역시 개발 경험이 많지 않아 어떤 방법이 정답인지는 잘 모르겠습니다.다만, 이런 고민을 통해 앞으로 프로젝트의 특성에 맞게더 나은 컴포넌트를 작성할 수 있는 역량을 키울 수 있을 거라 생각합니다.
Next.js, 카카오 지도, 최적화, 클러스터링, 알고리즘
카카오 지도에 여러 개의 마커 표시하고 최적화하기 (Next.js, Grid-Based 알고리즘)
카카오 지도 API를 활용한 프로젝트를 진행하던 중,백엔드 서버에서 받아온 여러 위치 데이터를 바탕으로지도에 마커를 표시하는 기능을 구현해야 할 일이 있었습니다.이 과정에서 다수의 마커를 효율적으로 관리하고,지도를 최적화하는 방법에 대해 고민하게 되었습니다.이번 글에서 어떠한 과정으로 고민하고 문제를 해결했는지 작성해보려 합니다.-> 참고: 프로젝트는 Next.js, tailwind css, typescript를 사용하였습니다.| 지도 생성우선 카카오 지도 API를 사용하기 위해다음과 같이 next.js의 Script 태그를 사용해 스크립트를 포함시켜 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst KakaoMap = () => { return ( <> <Script src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_APP_KEY}&libraries=clusterer,services&autoload=false`} /> <div id="map" className="relative w-full h-dvh" /> </> );};export default KakaoMap;그다음 스크립트가 로드된 후 카카오 지도를 생성할 수 있도록Script태그의 onLoad 속성에 다음과 같이 카카오 지도 생성 함수를 넣어 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst KakaoMap = () => { const handleLoadMap = () => { window.kakao.maps.load(() => { const mapContainer = document.getElementById("map"); const mapOption = { center: new window.kakao.maps.LatLng(37.566535, 126.9779692), // 처음 위치 level: 3, // 처음 레벨 }; const map = new window.kakao.maps.Map(mapContainer, mapOption); setMap(map); // 지도 상태 저장 }); }; return ( <> <Script src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_APP_KEY}&libraries=clusterer,services&autoload=false`} onLoad={handleLoadMap} /> <div id="map" className="relative w-full h-dvh" /> </> );};export default KakaoMap;이제 서버에서 데이터를 가져와 준 후지도에 마커를 생성해 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML// 데이터 저장useEffect(() => { const fetch = async () => { const data = await getAllMarker(); setMarkers(data); // 데이터 상태 저장 }; fetch();}, []);PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLuseEffect(() => { if (!map || !markers) return; const imageSize = new window.kakao.maps.Size(44, 49); const imageOption = { offset: new window.kakao.maps.Point(21, 39) }; const imageUrl = "/pin-active.svg"; const pin = new window.kakao.maps.MarkerImage( imageUrl, imageSize, imageOption ); markers.forEach((marker) => { const position = new window.kakao.maps.LatLng(marker.latitude, marker.longitude); new window.kakao.maps.Marker({ map: map, position: position, image: pin, }); });}, [map, markers]);이런 식으로 하면 위치 데이터를 가지고 지도에 마커들을 표시할 수 있습니다.| 문제하지만 프로젝트에서 가져오는 위치 데이터가 5000개를 넘다 보니, 지도를 이동하거나 확대/축소할 때 과도한 연산이 발생했습니다.그 결과 아래와 같이 CPU 점유율이 수시로 100%까지 치솟고,화면이 멈추는 현상이 빈번하게 발생했습니다.이어 따라 화면에 표시되는 마커의 수를 줄이고, 그룹별로 묶어 표시하기 위해다음과 같이 카카오 지도의 클러스터러(clusterer)를 적용했습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLuseEffect(() => { if (!map || !markers) return; const imageSize = new window.kakao.maps.Size(44, 49); const imageOption = { offset: new window.kakao.maps.Point(21, 39) }; const imageUrl = "/pin-active.svg"; const pin = new window.kakao.maps.MarkerImage( imageUrl, imageSize, imageOption ); const newMarkers = markers.map((marker) => { const position = new window.kakao.maps.LatLng(marker.latitude, marker.longitude); const marker = new window.kakao.maps.Marker({ map: map, position: position, image: pin, }); return marker; }); const clusterer = new window.kakao.maps.MarkerClusterer({ map: map, // 클러스터러 적용할 지도 gridSize: 240, // 클러스터 포함 범위 }); clusterer.addMarkers(newMarkers);}, [map, markers]);그 결과, 지도의 이동은 확실히 부드러워졌지만,여전히 화면을 확대하거나 축소할 때 CPU 점유율이 100%까지 치솟으며 잠깐의 멈춤이 발생했습니다| 해결이에 저는 지도의 중심을 기준으로 범위를 설정하고,해당 범위 내의 마커들만 연산 및 표시하도록 하여,직접 클러스터링을 구현함으로써 성능을 최적화하기로 했습니다.클러스터링을 구현할 때 여러 가지 사용할 수 있는 알고리즘이 있습니다.보통 밀도 기반의 DBSCAN알고리즘,데이터 포인트들을 계층적으로 묶어 트리구조로 클러스터링 하는 Hierarchical Clustering 알고리즘 등을 사용할 수 있습니다.해당 프로젝트에서는 지도를 그리드 형태로 나누고, 각 그리드 셀에 있는 데이터를하나의 클러스터로 묶는 Grid-Based 클러스터링 알고리즘을 사용하려 합니다.해당 알고리즘은 계산 속도가 매우 빠르고, 클러스터링 구조가 간단하고 명확하다는 장점이 있습니다.-> 여러 가지 클러스터링 알고리즘의 구현 방법과 장단점에 대해서는 다음에 블로그 글로 작성해 보겠습니다.1. 마커 필터링 (Harversine 공식을 활용한 거리 계산)먼저 각 마커와 지도의 중심 사이의 거리를 계산하기 위해harversine 공식을 사용한 함수를 만들었습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst haversineDistance = ( lat1: number, lng1: number, lat2: number, lng2: number): number => { const R = 6371; const dLat = (lat2 - lat1) * (Math.PI / 180); const dLng = (lng2 - lng1) * (Math.PI / 180); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c;};또한 이를 바탕으로 특정 거리 이내에 있는 마커들만 필터링하는 함수를 하나 만들었습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLexport const findNearbyMarkers = ({ markers, latitude, longitude, maxDistance,}: { markers: MarkerRes[]; latitude: number; longitude: number; maxDistance: number;}): MarkerRes[] => { return markers.filter((marker) => { const distance = haversineDistance( latitude, longitude, marker.latitude, marker.longitude ); return distance <= maxDistance; });};2. 지도의 레벨에 따른 거리 및 셀 크기 설정지도 레벨에 따라 표시할 머커의 범위와 클러스터링 셀의 크기를 동적으로 조정했습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst getDistance = (level: number) => { switch (true) { case level <= 3: return 1; case level <= 5: return 2; case level <= 6: return 4; case level <= 7: return 7; case level <= 8: return 14; case level <= 9: return 21; case level <= 10: return 30; case level <= 11: return 40; default: return 120; }};const getCellSize = (level: number) => { switch (true) { case level === 6: return 0.02; case level === 7: return 0.04; case level === 8: return 0.08; case level === 9: return 0.2; case level === 10: return 0.5; case level === 11: return 0.8; default: return 1.6; }};여기서 getDistance 함수는 지도 레벨에 따라 마커를 표시할 최대 거리를 설정하고,getCellSize 함수는 클러스터링에 사용할 셀의 크기를 설정했습니다.-> 참고: 이 과정을 위해 카카오 지도에 거리 재기 기능을 사용하고, 수치를 계속 확인해 보며 하나하나 조정했습니다.3. 클러스터링 구현다음으로 그리드 기반 클러스터링 방식을 직접 구현했습니다.이 방법은 마커들의 위치를 그리드 셀로 변환 후,동일한 셀에 속한 마커들을 하나의 그룹으로 묶는 방식입니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLinterface MarkerRes { markerId: number; latitude: number; longitude: number; address?: string;}interface MarkerGroup { centerLatitude: number; centerLongitude: number; count: number;}const getGridCoordinates = ( lat: number, lng: number, cellSize: number): { x: number; y: number } => { const x = Math.floor(lng / cellSize); const y = Math.floor(lat / cellSize); return { x, y };};export const clusterMarkers = ( markers: MarkerRes[], cellSize: number): MarkerGroup[] => { const groups: { [key: string]: MarkerGroup } = {}; markers.forEach((marker) => { const { x, y } = getGridCoordinates( marker.latitude, marker.longitude, cellSize ); const key = `${x},${y}`; if (!groups[key]) { groups[key] = { centerLatitude: 0, centerLongitude: 0, count: 0 }; } groups[key].centerLatitude += marker.latitude; groups[key].centerLongitude += marker.longitude; groups[key].count += 1; }); return Object.values(groups).map((group) => ({ centerLatitude: group.centerLatitude / group.count, centerLongitude: group.centerLongitude / group.count, count: group.count, }));};이때 클러스터링 로직은 다음과 같습니다.그리드 좌표 계산: getGridCoordinates 함수를 통해 마커의 위도와 경도를 그리드 셀의 좌표로 변환합니다. 이 좌표를 바탕으로 마커들을 그룹화합니다.그룹 중심 계산: 각 그룹 내의 마커들을 평균 내에 클러스터의 중심 좌표를 계산합니다.그룹화된 마커 반환: 최종적으로 각 그룹의 중심 좌표와 해당 그룹에 속한 마커의 개수를 반환합니다.4. 마커와 오버레이 관리이제 마커를 생성하기 위한 함수를 따로 만들어 주겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLinterface CreateMarkerOption { image?: "pending" | "active" | "selected"; position?: any; markerId?: string | number;}interface CreateMarker { options: CreateMarkerOption; map: KakaoMap;}const createMarker = ({ options, map }: CreateMarker) => { const imageSize = new window.kakao.maps.Size(44, 49); const imageOption = { offset: new window.kakao.maps.Point(21, 39) }; const imageUrl = options.image === "selected" ? "/pin-selected.svg" : "/pin-active.svg"; const pin = new window.kakao.maps.MarkerImage( imageUrl, imageSize, imageOption ); const marker = new window.kakao.maps.Marker({ map: map, position: options.position, image: pin, }); setMarkers([marker]);};다음으로 각 그룹을 커스텀 오버레이로 표시해 주기 위해 오버레이 생성을 위한 함수도 만들어 주겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLinterface CreateOverlayOption { position?: any; title: string;}interface CreateOverlay { options: CreateOverlayOption; map: KakaoMap;}const createOverlay = ({ map, options }: CreateOverlay) => { const overlayDiv = document.createElement("div"); const root = createRoot(overlayDiv); const overlay = new window.kakao.maps.CustomOverlay({ position: options.position, content: overlayDiv, clickable: true, }); // Overlay 컴포넌트는 따로 만들어 줘야 합니다. root.render(<Overlay title={options.title} position={options.position} />); overlay.setMap(map); setOverlays(overlay);};이제 지도 중심 이동과 확대/축소 레벨에 따라 표시할 마커들을 다시 로드하기 위한 함수를 만들어 줍니다.지도의 레벨이 낮아 마커가 많아질 때는 직접 구현한 클러스터링 기능을 사용해 그룹화된 오버레이로 표시하고,확대된 상태에서는 개별 마커를 다시 그립니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLinterface ReloadMarkersOprion { maxLevel: number; selectId?: number;}interface ReloadMarkers { options: ReloadMarkersOprion; map: KakaoMap;}const reloadMarkers = ({ map, options }: ReloadMarkers) => { // 지도에 표시된 모든 마커 삭제 함수 (상태 null로) deleteAllMarker(); // 지도에 표시된 모든 오버레이 삭제 함수 (상태 null로) deleteOverlays(); const position = map.getCenter(); const level = map.getLevel(); const distance = getDistance(level); const nearbyMarker = findNearbyMarkers({ markers: marker, latitude: position.getLat(), longitude: position.getLng(), maxDistance: distance, }); if (level >= options.maxLevel) { const group = clusterMarkers(nearbyMarker, getCellSize(level)); for (let i = 0; i < group.length; i++) { createOverlay({ map, options: { position: new window.kakao.maps.LatLng( group[i].centerLatitude, group[i].centerLongitude ), title: `${group[i].count} 개`, }, }); } } else { for (let i = 0; i < nearbyMarker.length; i++) { if (options.selectId) { let image: "pending" | "active" | "selected"; if (nearbyMarker[i].markerId === options.selectId) { image = "selected"; } else { image = "active"; } createMarker({ map, options: { image: image, markerId: nearbyMarker[i].markerId, position: new window.kakao.maps.LatLng( nearbyMarker[i].latitude, nearbyMarker[i].longitude ), }, }); } else { createMarker({ map, options: { image: "active", markerId: nearbyMarker[i].markerId, position: new window.kakao.maps.LatLng( nearbyMarker[i].latitude, nearbyMarker[i].longitude ), }, }); } } }};해당 함수들을 재사용하기 쉽게 커스텀 훅으로 만들어줬습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst useMarkerControl = () => { // ...위에 함수들 return { createMarker, createOverlay, reloadMarkers };};5. 지도에 마커 그리기지도에 중심 좌표나 확대 수준이 변경되면계산된 그룹과 마커를 그리기 위해 카카오 지도 API의 idle 이벤트를 사용하여마커들을 다시 그려줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLuseEffect(() => { if (!map) return; const handleIdle = () => { // selectedId는 선택된 마커를 구분하기 위함 if (selectedId) { reloadMarkers({ map, options: { maxLevel: 6, selectId: selectedId } }); } else { reloadMarkers({ map, options: { maxLevel: 6 } }); } }; window.kakao.maps.event.addListener(map, "idle", handleIdle); return () => { window.kakao.maps.event.removeListener(map, "idle", handleIdle); };}, [map, selectedId]);| 끝이와 같은 최적화를 통해 아래 이미지와 같이 지도의 이동과 확대/축소 시 성능을 크게 개선할 수 있었습니다.CPU 점유율 또한 최대 30%로 훨씬 안정적으로 유지되었고,많은 마커를 동시에 표시하더라고 화면이 멈추는 현상이 사라졌습니다.사용자 경험도 한층 개선되었으며, 지도상의 마커를 효율적으로 관리할 수 있는 체계가 마련되었습니다.-> 참고: 모든 성능 측정은 동일한 환경에서 측정되었습니다.

Yarn Berry, React
[vite.js / react] yarn berry 사용하기 (vscode 타입스크립트 오류)
yarn berry는 yarn의 최신 버전(yarn 2와 그 이후의 버전)을 지칭합니다.PnP(Plug 'n' Play) 방식, Zero Install 방식을 지원하며 인기가 많은 패키지 관리 도구입니다.| yarn berry 특징Cache와 Zero-Installyarn berry는 설치된 패키지를 글로벌 캐시에 저장하여,같은 패키지를 여러 프로젝트에서 사용할 때 다운로드 시간을 절약합니다.패키지는 zip 파일 형태로 .yarn/cache에 저장되며,이를 통해 네트워크 요청 없이 빠르게 패키지를 불러올 수 있고,디스크 공간을 효율적으로 사용할 수 있습니다.또한, Zero Install 기능을 통해, 패키지들이 리포지토리에 함께 저장하며프로젝트를 클론 하는 것만으로 필요한 모든 패키지를 바로 사용할 수 있습니다.따라서 개발 환경 설정 시간을 크게 단축시킬 수 있습니다. Plug 'n' Play (PnP)PnP는 node_modules 폴더를 사용하지 않고, 대신 모든 의존성 정보를 .pnp.cjs 파일에 기록합니다.yarn은 이 파일을 통해 모듈을 해석하고 로드합니다.이로 인해 의존성 해석에 걸리는 시간이 줄어들게 되고, 유령 의존성 문제를 해결할 수 있습니다.| 사용하기 with React.js / Vite.js우선 vite를 사용하여 리액트 프로젝트를 현제 폴더에 만들어 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLnpm create vite@latest ./oryarn create vite ./만약 yarn create로 vite를 설치할 때vite은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는 배치 파일이 아닙니다.라는 에러가 뜨면 create-vite 바이너리가 설치되지 않아서 그럴 가능성이 있습니다.따라서 create-vite를 전역으로 설치한 다음, 프로젝트 디렉터리를 초기화해 줘야 합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLyarn global add create-viteyarn create vite ./참고: https://classic.yarnpkg.com/en/docs/cli/create다음으로 yarn을 사용할 때 berry 버전을 사용하기 위해 버전을 저장해 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLyarn set version berry 또한 저희는 node_modules가 없는 PnP 방식을 사용하기 위해.yarnrc.yml에 다음 텍스트를 추가하여 pnp 방식으로 사용하겠다 명시해 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLnodeLinker: pnp 이제 패키지를 다운로드해 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLyarn install 그럼 .pnp.cjs 파일이 생성되고, 해당 파일에서 패키지들의 위치와 의존성을 관리하게 됩니다.| 타입스크립트 오류만약 타입스크립트를 사용해 프로젝트를 초기화하면파일을 열어보면 타입 오류가 발생하는 경우가 있습니다.이유는 패키지들이 .yarn/cache에 zip 파일로 관리되는데이때 타입스크립트도 zip 파일로 관리가 되고, 이를 vscode가 해석을 못 해 발생하는 오류입니다.따라서 vscode가 타입스크립트에 대한 정보를 알 수 있도록연결시켜 주기 위해 다음 명령어를 입력합니다.(eslint나 프리티어 사용 시에도 해당 명령어로 연결시켜 줘야 합니다.)PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLyarn dlx @yarnpkg/sdks vscode 그럼 vscode에서 아래와 같은 메시지가 뜨는데 Allow를 눌러주면 문제가 해결됩니다.만약 여기서 allow를 못 눌렀다면컨트롤 + 시프트 + p를 누른 후 "select typescript version"을 입력 후 use workspace version을 클릭하면 해결됩니다.참고: https://yarnpkg.com/getting-started/editor-sdks그리고 Zero Install을 사용하기 위해 캐시파일을 깃허브에 다 올리게 되는데이때 불필요한 파일과 필요한 파일을 구분해서 올리기 위해 .gitignore에 다음을 추가해 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML# Zero-Installs.yarn/*!.yarn/cache!.yarn/patches!.yarn/plugins!.yarn/releases!.yarn/sdks!.yarn/versions참고: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored| node_modules 폴더가 생기는 문제이제 yarn dev를 실행시켜 보면 아래와 같이 node_modules 폴더가 생깁니다.이때 생기는 node_modules 폴더는 vite.js에서 성능을 위해 미리 번들된 의존 파일이거나vite에 의해 생성된 어떤 다른 파일의 캐시 파일을 저장해 놓는 폴더입니다.기본적으로 vite는 해당 캐시 파일을 node_modules/.vite에 저장해 두기 때문에node_modules 폴더가 생기게 됩니다. 따라서 해당 이유로 생기는 node_modules 폴더는 그냥 신경 쓰지 않고 프로젝트를 진행하면 됩니다.만약 그래도 해당 폴더가 생기는 자체가 신경 쓰이고 불편하다면vite.config 파일에 캐시 파일들이 저장될 경로를 따로 설정해 줄 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { defineConfig } from "vite";import react from "@vitejs/plugin-react";// https://vitejs.dev/config/export default defineConfig({ plugins: [react()], cacheDir: "vite_cache",});cacheDir 속성에 저장할 폴더 이름을 넣어주면이런 식으로 해당 폴더에 캐시 파일이 저장됩니다.마지막으로 해당 폴더를 .gitignore에 추가해 주면 됩니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML# Vite cachevite_cache참고: https://ko.vitejs.dev/config/shared-options.html#cachedir
JavaScript, 무한스크롤, 최적화
무한 스크롤을 구현하기 위한 몇 가지 방법들 (Intersection Observer API를 사용하는 이유)
무한 스크롤은 사용자가 페이지를 스크롤할 때 콘텐츠를 동적으로 로드하는 기법입니다.무한 스크롤을 구현하는 방식은 몇 가지가 있는데 그중 몇 가지 방법을 사용해 보고,어떤 방식이 가장 좋은지 확인해 보려 합니다.| json-sever해당 글에서 기본적으로 예시 코드를 구현할 때 json-server라이브러리를 사용하여 구현하였습니다.https://www.npmjs.com/package/json-server이후 나올 예시 코드에서 나오는 '/items?_page=${page}&_per_page=8' 해당 주소로 요청 보내면아래와 같이 데이터가 온다는 것을 알고 보시면 이해하기 더 쉬울 거 같습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML{ "first": 1, "prev": 1, "next": 3, "last": 5, "pages": 5, "items": 40, "data": 데이터 배열}| 기본 원리무한 스크롤의 기본 원리는 콘텐츠의 마지막 부분이 보일 때 클라이언트는 서버에 다음 데이터를 요청하고,데이터를 받으면 추가된 콘텐츠를 화면에 보여주는 방식입니다.| 스크롤 이벤트기본적으로 스크롤 이벤트를 사용하여 구현할 때는 매 스크롤 이벤트에 대해현재 document의 위치를 파악하여 데이터를 fetch 할지 여부를 결정하게 됩니다.먼저 무한 스크롤을 위한 상태들을 초기화해 주겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst [items, setItems] = useState<Item[]>([]); // fetch요청으로 가져 올 데이터const [page, setPage] = useState(1); // 현재 페이지 번호const [lastPage, setLastPage] = useState(null); // 마지막 페이지 번호const [loading, setLoading] = useState(false); // 로딩 상태다음으로 page가 변하면 새로운 데이터를 받아와 items 배열에 추가해 주기 위해useEffect에 page의존성을 추가해 주고 데이터를 받아오는 함수를 작성해 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLuseEffect(() => { const fetchItems = async () => { setLoading(true); const res = await fetch( `http://localhost:3001/items?_page=${page}&_per_page=8` ).then((res) => res.json()); const data = res.data; setLastPage(res.last); setItems((prev) => [...prev, ...data]); setLoading(false); }; fetchItems();}, [page]);다음으로 스크롤 이벤트를 작성해 줍니다.해당 이벤트에서 document의 위치를 파악하고,로딩 상태인지, 마지막 페이지인지 여부를 확인하여 데이터를 가져올지를 결정합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLuseEffect(() => { const handleScroll = () => { const { innerHeight } = window; const { scrollTop, offsetHeight } = document.documentElement; // 스크롤이 완전 끝까지 스크롤 된 후 데이터를 가져오는 것이 아닌, // 좀 더 위에서 미리 가져오기 위해 offsetHeight 에서 100을 뺀 후 비교합니다. if ( loading || (lastPage !== null && page >= lastPage) || innerHeight + scrollTop < offsetHeight - 100 ) return; setPage((prevPage) => prevPage + 1); }; window.addEventListener("scroll", handleScroll); return () => { window.removeEventListener("scroll", handleScroll); };}, [loading, lastPage]);이런 방식으로 하면 무한 스크롤을 구현할 수 있습니다.| 문제이런 식으로 스크롤 이벤트를 사용하여 무한 스크롤을 구현하면 이벤트가 너무 빈번하게 발생한다는 문제가 있습니다.또한 해당 이벤트에서 계속해서 document.documentElement를 참조하며리플로우가 빈번하게 발생할 수 있다는 문제점도 있습니다.따라서 이를 계선하기 위해서 몇 가지 방법을 도입할 수 있습니다.| Throttle가장 우선적으로 고려할 수 있는 방법은 throttle입니다.일정 주기마다 1번의 이벤트만 발생하도록 하며스크롤 이벤트가 계속해서 발생하는 것을 막으면 됩니다.이런 throttle 함수를 직접 구현해 도 되지만,편의를 위해 lodash의 throttle을 사용하여 구현할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { throttle } from "lodash";const handleScroll = throttle(() => { console.log(1); const { innerHeight } = window; const { scrollTop, offsetHeight } = document.documentElement; if ( loading || (lastPage !== null && page >= lastPage) || innerHeight + scrollTop < offsetHeight - 100 ) return; setPage((prevPage) => prevPage + 1);}, 200);이런 식으로 기존의 이벤트 헨들러 함수를 lodash의 throttle의 첫 번째 인자로 주고,2번째 인자로 스크롤 이벤트를 처리하고 싶은 간격을 주면 됩니다.그럼 기존 코드와 throttle을 적용한 코드를 스크롤 이벤트를 콘솔에 출력해 비교해 보면기존 코드throttle 적용이벤트 호출 횟수가 확연하게 차이 나는 게 보입니다.하지만 이런 throttle도 한계가 있습니다.기본적으로 throttle은 setTimeout을 사용하기 때문에 콜 스택이 꽊 차있으면,타이머가 지연되며, 이벤트가 예상과 다른 타이밍에 동작될 수 있습니다.이를 위해 다른 방안을 생각해 볼 필요가 있습니다.| requestAnimationFrame대안으로 도입할 수 있는 방법으로 requestAnimationFrame을 사용하는 방법이 있습니다.requestAnimationFrame은 Animation Frames에서 처리되기 때문에 Task Queue보다 우선순위가 높고,브라우저가 화면을 다시 그릴 때마다 호출되므로, 주로 60 fps로 실행됩니다.따라서 기존 setTimeout을 사용한 throttle보다 실행 시간을 더 보장할 수 있습니다.일반적으로 lodash에서 throttle을 사용하면 2번째 인자의 timeout값을 주지 않으면requestAnimationFrame 기반으로 동작하도록 되어있다고 합니다.하지만 이번에는 함수를 직접 구현해 보도록 하겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst throttleWithRAF = (callback: VoidFunction) => { if (typeof callback !== "function") { throw new Error("Invalid required arguments"); } let isThrottled = false; return () => { if (isThrottled) return; isThrottled = true; requestAnimationFrame(() => { isThrottled = false; callback(); }); };};해당 함수는 이벤트가 발생할 때 requestAnimationFrame을 사용하여 콜백이 애니메이션 프레임으로 들어가도록 합니다. 콜백이 실행되기 전까지는 isThrottled가 true이므로 이벤트가 다시 발생하더라도 무시됩니다.애니메이션 프레임이 처리되면 콜백이 실행되고 isThrottled를 false로 바꿔줍니다.이 과정을 반복하여 이벤트 발생을 제어하게 됩니다.그리고 스크롤 함수를 해당 함수의 콜백으로 준 후 이벤트를 등록하면 됩니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst handleScroll = throttleWithRAF(() => { const { innerHeight } = window; const { scrollTop, offsetHeight } = document.documentElement; if ( loading || (lastPage !== null && page >= lastPage) || innerHeight + scrollTop < offsetHeight - 100 ) return; setPage((prevPage) => prevPage + 1);});window.addEventListener("scroll", handleScroll);하지만 실질적으로 이벤트가 동작하는 것을 보면 큰 차이가 없는 것을 확인할 수 있습니다.다만 requestAnimationFrame는 브라우저의 리페인트 주기와 동기화되면서 애니메이션을 부드럽게 만들 수 있고,브라우저가 프레임을 렌더링 할 준비가 되었을 때 실행되므로,리플로우와 리페인트를 최소화하여 렌더링 성능을 최적화할 수 있습니다.| Intersection ObserverrequestAnimationFrame의 성능 상 제한을 극복할만한 또 다른 방법은Intersection Observer API를 사용하는 것입니다.이는 브라우저 viewport와 targert element의 교차점을 관찰하며target이 화면에 포함되는지 구별할 수 있는 기능을 제공합니다.먼저 화면에 보이면 다음 데이터를 가져올 요소 와,옵저버 요소를 위한 Ref를 만들어 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst loadMoreRef = useRef<HTMLDivElement | null>(null);const observerRef = useRef<IntersectionObserver | null>(null);그 후 기존 코드에서 스크롤 이벤트 대신 다음과 같은 Intersection Observer를 이용한 코드를 작성해 줍니다.그리고 rootMargin 옵션을 사용하여 데이터를 좀 더 빠른 시점에 가져올 수 있도록 해줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLuseEffect(() => { if (loading || lastPage === page || !loadMoreRef.current) return; if (observerRef.current) observerRef.current.disconnect(); observerRef.current = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { setPage((prevPage) => prevPage + 1); } }, { rootMargin: "100px", } ); observerRef.current.observe(loadMoreRef.current); return () => { if (observerRef.current) observerRef.current.disconnect(); };}, [loading, lastPage, page]);이제 다음 데이터를 가져오기 위해 Intersection Observer로 감시하고 있는 loadMoreRef 요소를 만들어 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLreturn ( <div> <h1 className="mb-6">Items</h1> <ul> {items.map((item) => ( <li key={item.id} className="border border-red-300 border-solid p-10 mb-3" > {item.name} </li> ))} </ul> {loading && <p className="text-center">Loading...</p>} {items.length > 0 && ( <div ref={loadMoreRef} className="h-5 bg-transparent" /> )} </div>);이런 식으로 하면 성능적으로 다른 방법보다 좋게 무한 스크롤을 구현할 수 있습니다.| 끝보통 무한 스크롤을 구현할 때 저는 아무 이유 없이 Intersection Observer API를 사용해 왔습니다.하지만 실제로 공부해 보니 다양한 방법들이 있었고, 왜 사람들이 이런 다양한 방법 중에 Intersection Observer API를 사용하는지 알 수 있었습니다.이렇게 자주 사용하는 기술들이 어떤 방식으로 최적화되는지 공부하는 것이 더 좋은 사용자 경험을 제공하는 데 매우 유익하다는 것을 깨달았습니다. 앞으로도 기술에 대해 깊이 이해하고 최적의 방법을 찾아가는 노력을 계속해야 할 것 같습니다.

JavaScript, User Agent
[Javascript] (User Agent) 사용자가 접속한 브라우저, 기기 확인하기
프론트엔드 개발을 진행하다 보면 사용자가 데스크톱으로 접속했는지, 모바일 기기로 접속했는지,어떤 브라우저를 사용해서 접속했는지 등을 확인하고 이에 따라 다른 UI를 제공해야 하는 경우가 있습니다.이때 웹 브라우저의 User Agent를 활용하여 이를 알 수 있습니다.| User AgentUser Agent는 사용자가 웹사이트에 접속할 때 브라우저가 서버로 보내는 문자열입니다.이 문자열에는 브라우저의 정보, 운영 체제, 기기 정보 등이 포함되어 있어이를 파싱 하면 사용자의 접속 환경을 알 수 있습니다.자바스크립트에서는 브라우저의 navigator객체를 통하여해당 문자열을 확인할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconsole.log(navigator.userAgen);또한 기기나 브라우저로 출력되는 User Agent는 다음과 같습니다.iOS ChromePlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLMozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/125.0.6422.80 Mobile/15E148 Safari/604.1iOS SafariPlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLMozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1IOS KakaoPlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLMozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Safari/604.1 KAKAOTALK/10.8.0Android ChromePlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLMozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Mobile Safari/537.36Android KakaoPlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLMozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Mobile Safari/537.36 KAKAOTALK/10.8.0이런 식으로 출력되는 문자열을 확인하고, 정규식을 검사하여사용자가 어떤 기기의 어떤 브라우저로 접속했는지 확인할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLconst getDeviceType = () => { const userAgent = navigator.userAgent; // iOS 기기 판별 if (/iPad|iPhone|iPod/.test(userAgent)) { if (/CriOS/.test(userAgent)) { return "iOS Chrome"; } else if (/Safari/.test(userAgent) && !/CriOS/.test(userAgent)) { return "iOS Safari"; } else if (/KAKAOTALK/.test(userAgent)) { return "iOS Kakao"; } else { return "iOS"; } } // Android 기기 판별 if (/android/i.test(userAgent)) { if (/Chrome/.test(userAgent)) { return "Android Chrome"; } else if (/SamsungBrowser/.test(userAgent)) { return "Android Samsung Browser"; } else if (/KAKAOTALK/.test(userAgent)) { return "Android Kakao"; } else { return "Android"; } } // 웹 브라우저 판별 return "Web";};console.log(getDeviceType());

Next.js, PWA
[Next.js] 프로젝트에 PWA 적용하기
최근에 지도 서비스를 개발하면서 사용자들이 해당 서비스를모바일에서 보다 편리하게 사용할 수 있도록 PWA를 적용하기로 했습니다.오늘 포스팅에서 Next.js에서 PWA를 적용했던 과정을 적어보려 합니다.| manifest.ts먼저 pwa를 적용하기 위해서는 manifest라는 json 파일을 통해서 앱의 이름, 아이콘,기타 설명 등등을 설정해 줘야 사용할 수 있습니다.따라서 우선적으로 해야 할 부분은 manifest파일을 만들고, 설정을 입력하고,html의 head태그 안에 아래와 같이 link태드를 넣어주면 됩니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML<link rel="manifest" href="/manifest.json">하지만 Next.js에서는 직접 manifest.json을 만들고 link를 걸어줄 필요가 없고,공식문서를 보면 manifest.ts파일을 통해 자동으로 manifest를 생성하고,자동으로 link태그로 추가해 준다고 합니다.https://nextjs.org/docs/app/api-reference/file-conventions/metadata/manifest따라서 우선 프로젝트의 app폴더 내에 manifest.ts 파일을 만들고만들고 있는 앱의 설정을 넣어주겠습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport { MetadataRoute } from "next";const manifest = (): MetadataRoute.Manifest => { return { theme_color: "#222", background_color: "#222", display: "standalone", scope: "/", start_url: "/", name: "철봉 지도", short_name: "철봉 지도", description: "주변 철봉 위치를 확인하세요", lang: "ko-KR", icons: [ { src: "/logo192.png", sizes: "192x192", type: "image/png", purpose: "maskable", }, { src: "/logo512.png", sizes: "512x512", type: "image/png", }, ], };};export default manifest;여기서 display속성으로 스마트폰에서 앱을 받았을 때 어떤 식으로 보여줄지를 정할 수 있습니다.자세한 내용은 아래 링크를 참고하면 되고, 일반적으로 standalone을 사용한다고 하여저도 standalone을 사용하였습니다.https://developer.mozilla.org/en-US/docs/Web/Manifest/display이때 standalone을 사용하면 아이폰에서 볼 때 화면 위쪽에 카메라 때문에 약간의 여백이 생깁니다.해당 여백의 색상은 manifest의 theme_color를 통하여 지정할 수 있습니다.그리고 icons에 purpose에 maskable 속성이 있는데 이는 아이콘을 자르거나모양을 변경하지 않고도 다양한 크기와 모양의 디바이스에서 잘 보일 수 있도록 디자인된아이콘을 말합니다.그럼 이제 로컬 환경에서 프로젝트를 실행시켜 보면상단에 이런 식으로 다운로드할 수 있는 아이콘이 생기게 됩니다.이런 식으로 pwa 적용이 완료되었습니다.| manifest 401 에러pwa적용을 완료하고 실제 배포된 주소에 적용을 했을 때다음과 같은 401 에러가 발생하였습니다.찾아보니 link태그의 crossorigin 속성을 use-cresdentials로 줘서자격증명을 포함시키도록 하면 해결이 된다고 하여 이를 적용해보려 했습니다.next.js에서는 head태그내부의 태그들에 crossOrigin 속성을 next.config.ts 파일에서 설정할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLmodule.exports = { crossOrigin: 'use-credentials',}이런 식으로 설정을 주고 다시 실행시켜 봤는데link태그로 연결되어 있는 manifest만 use-credentials가 적용이 안 되는 문제가 발생했습니다. 따라서 다른 방법을 찾아보기로 했습니다.| next-pwa다시 원점으로 돌아가서 next.js에서 pwa를 어떻게 적용하는 게 좋을지 찾아보다가next-pwa라는 라이브러리가 있다는 것을 알았습니다.https://github.com/shadowwalker/next-pwa이번에는 이 라이브러리를 사용하여 pwa를 적용해보려 합니다.우선 next.config.ts 파일에서 해당 라이브러리를 import해온다음설정을 작성해 주고 리턴합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML/** @type {import('next').NextConfig} */import withPWA from "next-pwa";const nextPWA = withPWA({ dest: "public",});const nextConfig = { ...설정들};export default nextPWA(nextConfig);이런 식으로 한 후 public 폴더에 manifest.json 파일을 하나 만들어 줍니다.다음, manifest 설정을 입력합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML{ "theme_color": "#222", "background_color": "#222", "display": "standalone", "scope": "/", "start_url": "/", "name": "철봉 지도", "short_name": "철봉 지도", "description": "주변 철봉 위치를 확인하세요", "lang": "ko-KR", "icons": [ { "src": "/logo192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/logo512.png", "sizes": "512x512", "type": "image/png" } ]}그리고 마지막으로 layout에 head태그를 포함시키고아래와 같이 link태그로 연결해 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">이런 식으로 코드를 작성한 후다시 배포해 보니까 이 번에는 pwa가 잘 적용이 되었습니다.| No theme-color tag found이제 사이트를 lighthouse를 통해 검사해 보면 다음과 같이 설치 가능이라고 나옵니다.하지만 아래로 좀 내려보면이런 문구가 보이는 것을 확인할 수 있습니다.이는 간단하게 해당 태그를 추가해 주면 해결됩니다.next.js14 버전 이후 기준으로메타 태그를 설정할 때PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLexport const metadata: Metadata = { ...};다음과 같은 함수를 통하여 생성하게 됩니다.하지만 next.js의 공식문서를 보면 theme-color는 해당 함수 내부에서 설정하는 것이 아닌따로 viewport 객체를 만들어서 생성해 줘야 합니다.https://nextjs.org/docs/app/api-reference/functions/generate-viewport따라서 layout 파일에 아래와 같이 코드를 작성해 주어서 해결하였습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLexport const viewport: Viewport = { themeColor: "#222",};