Yonghun 개발 블로그 글 목록
Posts
95개의 글

img-toolkit: TypeScript에서 Rust + WebAssembly로 (+성능 비교)
| img-toolkit 개발 이유img-toolkit은 Canvas API를 기반으로 하는 매우 간단한 이미지 리사이징 라이브러리입니다.사실 처음에는 기능 구현보다는 "간단한 기능이라도 패키지로 배포해 보고, 다른 프로젝트에서 가져와 사용해 보고 싶다"는 학습 목적이 컸습니다.시간이 지나면서 단순히 배포 연습용으로 만들어진 라이브러리를 기능적으로 좀 더 완성도 있는 형태로 발전시키고 싶었습니다.이를 위해 Rust와 WebAssembly(WASM)를 활용해 기존 기능을 재작성하기로 결정했습니다.| Rust를 사용하는 이유개인적으로 Rust는 제가 가장 좋아하는 언어 중 하나입니다.러스트의 빡빡한 컴파일러와 싸우면서 자연스럽게 좋은 코드를 작성하게 되는 구조도 마음에 들고,WASM 관련 커뮤니티와 기술적 지원도 활발해 프론트엔드 개발자로서도 매력적인 언어라고 생각합니다.실제로 구글에서 만든 이미지 압축 사이트인 Squoosh의 이미지 처리 기능 대부분이C++와 Rust 기반의 WASM으로 작성되어 있습니다.이러한 사례를 통해 Rust를 이용한 WASM이 이미지 처리에 뛰어난 강점을 가지고 있음을 알게 되었고,저 역시 img-toolkit의 Rust 재작성을 통해 이를 직접 경험해보고자 했습니다.squoosh의 코드는 아래 링크를 통해 확인해볼 수 있습니다.https://github.com/GoogleChromeLabs/squoosh/tree/dev/codecs| Rust로 함수 구현기존 img-toolkit에서 제공하는 resizeImage 함수는파일과 압축 옵션을 받아 압축된 파일을 반환하는 구조입니다.Rust로 재작성할 때 기존의 인터페이스를 그대로 유지해사용자가 변경 없이 기존 방식대로 사용할 수 있도록 구현하였습니다.wrapper 함수 필요성JavaScript에서는 File 객체를 통해 이미지 처리를 수행하지만,Rust는 JavaScript의 File 객체를 직접 사용할 수 없습니다.따라서 이미지를 바이트 배열로 변환하여 Rust의 이미지 처리 함수에 전달해야 합니다.이를 해결하기 위해 JavaScript와 Rust 간의 변환 과정을 처리하는 Wrapper 함수를 만들어 제공했습니다.wrapper 함수는 다음과 같이 구현하였습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLimport init, { resize_image } from "../pkg/img_toolkit_rust.js";export type ResizeOptions = { width?: number; height?: number; quality?: number; format: "png" | "jpg" | "webp"; brightness?: number; resampling?: number;};export async function resizeImage( file: File, options: ResizeOptions): Promise<File> { // WASM 바이너리를 로드 await init(); // clamp 함수를 사용하여 각 옵션 값의 범위 제한 const sanitizedOptions = { ...options, quality: clamp(options.quality ?? 0.7, 0, 1), brightness: clamp(options.brightness ?? 0.5, 0, 1), resampling: clamp(options.resampling ?? 4, 0, 10), }; // 이미지 파일을 바이트 배열로 변환 const buffer = await file.arrayBuffer(); const uint8 = new Uint8Array(buffer); // Rust 함수를 호출하고 리사이징 된 이미지의 바이트 배열 저장 const result = resize_image(uint8, sanitizedOptions); const mime = options.format === "jpg" ? "image/jpeg" : `image/${options.format}`; return new File([result], `resized.${options.format}`, { type: mime });}function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max);}Rust 구현 시 사용된 의존성사용하는 의존성들은 다음과 같습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLuse wasm_bindgen::prelude::*;use wasm_bindgen::JsValue;use image::codecs::webp::WebPEncoder;use image::{ imageops::resize, imageops::FilterType, GenericImageView, ImageReader, DynamicImage, ImageFormat, ExtendedColorType, ImageEncoder,};use image::codecs::jpeg::JpegEncoder;use image::codecs::png::{CompressionType, PngEncoder};use std::io::Cursor;use serde::Deserialize;use serde_wasm_bindgen::from_value;wasm_bindgen: JS와 WASM의 바인등을 위해 사용합니다.image: 이미지 디코딩, 리사이징, 인코딩 기능을 위해 사용합니다.Cursor: 바이트 버퍼를 파일처럼 읽기 위해 사용합니다.serde + serde_wasm_bindgen: JS 객체를 Rust struct로 디시리얼라이즈 하기 위해 사용합니다.ResizeOptions 구조체 정의다음과 같이 자바스크립트에서 넘겨 받을 옵션을 Rust에서 struct로 정의합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML#[derive(Deserialize)]struct ResizeOptions { width: Option<u32>, height: Option<u32>, quality: Option<f32>, format: String, brightness: f32, resampling: u32,}resize_image 함수 시그니처resize_image 함수의 시그니처는 다음과 같습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XML#[wasm_bindgen]pub fn resize_image(data: &[u8], options: JsValue) -> Result<Box<[u8]>, JsValue> { // ...코드}data: &[u8]: JavaScript에서 전달된 이미지의 바이너리 데이터입니다.options: JsValue: JavaScript 객체 형태로 전달된 옵션입니다.반환값 Box<[u8]>: 인코딩된 이미지의 바이트 배열로, JavaScript에서 Uint8Array로 변환하여 사용됩니다.옵션 파싱serde_wasm_bindgen의 from_value 함수를 사용하여 전달받은 JavaScript 객체 형태의 옵션을Rust의 ResizeOptions 구조체로 변환합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLlet options: ResizeOptions = from_value(options) .map_err(|e| JsValue::from_str(&format!("Invalid options: {}", e)))?;이미지 디코딩전달된 이미지 데이터를 ImageReader 구조체의 new 메서드로 읽어 DynamicImage를 생성합니다.이 과정에서 밝기 조정 값을 반영하기 위해 별도의 처리를 추가로 수행합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLlet img = ImageReader::new(Cursor::new(data)) .with_guessed_format() .map_err(|e| JsValue::from_str(&format!("Format guess failed: {}", e)))? .decode() .map_err(|e| JsValue::from_str(&format!("Decode failed: {}", e)))? .brighten(value);이때, DynamicImage의 brighten 메서드는 -255에서 +255까지의 값을 받으므로,기존 JavaScript 인터페이스인 0.0~1.0의 범위를 맞추기 위해 map_brightness 함수를 통해 값을 변환합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLfn map_brightness(x: f32) -> i32 { let x = x.clamp(0.0, 1.0); let v = x * 510.0 - 255.0; v.round() as i32}let value = map_brightness(options.brightness);이미지 필터기존 자바스크립트 버전의 resampling 옵션은 이미지를 좀 더 부드럽게 처리하는 옵션이었으며,Rust에서는 이를 이미지 처리 필터로 대응시켰습니다.Rust에서의 필터 선택은 옵션의 resampling 값에 따라 다음과 같이 구현했습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLfn get_filter_type(level: u32) -> FilterType { match level.clamp(0, 10) { 0 | 1 => FilterType::Nearest, 2 | 3 => FilterType::Triangle, 4 | 5 => FilterType::CatmullRom, 6 | 7 => FilterType::Gaussian, _ => FilterType::Lanczos3, }}let filter = get_filter_type(options.resampling);각 필터가 어떤 필터인지는 아래 링크를 참고하면 알 수 있습니다.https://docs.rs/image/latest/image/imageops/enum.FilterType.html현재는 기존과 호환성을 유지하기 위해 resampling 옵션을 필터로 대응했으나,앞으로는 더 명확한 filter 옵션으로 변경하여 제공할 예정입니다.이미지 리사이즈이제 필터를 설정한 후, 옵션으로 받은 너비와 높이를 사용하여 이미지 크기를 변경해 줍니다.크기 변경은 image::imageops::resize 함수를 통해 진행하며,옵션으로 주어진 너비와 높이의 존재 여부에 따라 match 문으로 적절하게 처리해 줍니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLlet (orig_w, orig_h) = img.dimensions();let filter = get_filter_type(options.resampling);let width = options.width.filter(|&w| w > 0);let height = options.height.filter(|&h| h > 0);let resized = match (width, height) { (Some(w), Some(h)) => { let buf = resize(&img.to_rgba8(), w, h, filter); DynamicImage::ImageRgba8(buf) } (Some(w), None) => img.resize(w, ((w as f32)*(orig_h as f32)/(orig_w as f32)) as u32, filter), (None, Some(h)) => img.resize(((h as f32)*(orig_w as f32)/(orig_h as f32)) as u32, h, filter), (None, None) => img,};위 코드에서 너비와 높이 중 하나만 지정되면 원본 이미지의 비율에 맞춰 나머지 한쪽의 크기를 자동으로 계산하여 변경하고,둘 다 지정하지 않으면 원본 크기 그대로 유지됩니다.이미지 포맷 파싱옵션으로 전달받은 이미지 포맷 문자열을 Rust의 image::ImageFormat 열거형에 맞추기 위해,다음과 같이 parse_format라는 함수를 작성하였습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLfn parse_format(fmt: &str) -> Option<ImageFormat> { match fmt.to_lowercase().as_str() { "png" => Some(ImageFormat::Png), "jpeg" | "jpg" => Some(ImageFormat::Jpeg), "webp" => Some(ImageFormat::WebP), _ => None, }}let format = parse_format(&options.format) .ok_or_else(|| JsValue::from_str("Unsupported format"))?;Rust를 사용하는 장점 중 하나는 다양한 이미지 포맷을 지원한다는 점입니다.하지만 이번에는 기존의 JavaScript로 작성한 함수의 인터페이스와 동일하게 유지하는 것을 목표로 하였기 때문에,일단은 PNG, JPEG, WEBP 포맷만을 지원하도록 구현했습니다.이후에는 추가적인 포맷 지원도 고려하고 있습니다.지원 가능한 이미지 포맷에 대한 더 자세한 정보는 아래 링크에서 확인할 수 있습니다.https://docs.rs/image/latest/image/enum.ImageFormat.html이미지 인코딩이제 앞서 리사이징한 이미지(resized), 지정된 포맷(format), 그리고 옵션(options)을 이용하여이미지를 인코딩하는 encode_image 함수를 아래와 같이 작성했습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLfn encode_image( image: &DynamicImage, format: &ImageFormat, options: &ResizeOptions) -> Result<Vec<u8>, JsValue> { let mut buffer = Vec::new(); // 품질 값을 1~100 범위로 변환 let quality = (options.quality.unwrap_or(0.8) * 100.0) .round() .clamp(1.0, 100.0) as u8; // 포맷별로 다른 인코딩 로직 실행 match format { ImageFormat::Jpeg => { // JPEG은 JpegEncoder로 직접 인코딩 let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality); encoder .encode_image(image) .map_err(|e| JsValue::from_str(&format!("JPEG encode failed: {}", e)))?; } ImageFormat::Png => { // PNG는 JPEG으로 중간 압축 후 다시 PNG로 변환 let recompressed = compress_to_jpeg(image, quality)?; encode_as_png(&recompressed, &mut buffer)?; } ImageFormat::WebP => { // WebP 역시 JPEG 중간 압축 후 WebP로 변환 let recompressed = compress_to_jpeg(image, quality)?; encode_as_webp(&recompressed, &mut buffer)?; } _ => { // 기타 포맷은 image 크레이트의 기본 write_to로 처리 image .write_to(&mut Cursor::new(&mut buffer), *format) .map_err(|e| JsValue::from_str(&format!("Write failed: {}", e)))?; } } // 완성된 바이트 벡터 반환 Ok(buffer)}PNG와 WebP 포맷의 경우 image 크레이트가 직접 품질 설정을 지원하지 않기 때문에,먼저 JPEG으로 품질 조정을 한 후 원하는 포맷으로 변환하는 방식으로 처리했습니다.이제 위 코드에서 사용하는 JPEG 중간 압축을 위한 compress_to_jpeg,PNG 변환을 위한 encode_as_png, 그리고 WebP 변환을 위한 encode_as_webp 함수를 작성하겠습니다.● JPEG 중간 압축 (compress_to_jpeg)PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLfn compress_to_jpeg(image: &DynamicImage, quality: u8) -> Result<DynamicImage, JsValue> { let mut temp_jpeg = Vec::new(); let mut jpeg_encoder = JpegEncoder::new_with_quality(&mut temp_jpeg, quality); jpeg_encoder .encode_image(image) .map_err(|e| JsValue::from_str(&format!("Interim JPEG encode failed: {}", e)))?; image ::load_from_memory(&temp_jpeg) .map_err(|e| JsValue::from_str(&format!("JPEG decode failed: {}", e)))} ● PNG 변환 (encode_as_png)현재 옵션으로 압축 강도와 필터를 설정하지 않지만, 추후 옵션으로 제공할 수 있습니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLfn encode_as_png(image: &DynamicImage, buffer: &mut Vec<u8>) -> Result<(), JsValue> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); let encoder = PngEncoder::new_with_quality( buffer, // 최고 압축 강도 CompressionType::Best, // 픽셀 특성에 따라 필터 자동 적용 image::codecs::png::FilterType::Adaptive ); encoder .write_image(&rgba, w, h, ExtendedColorType::Rgba8) .map_err(|e| JsValue::from_str(&format!("PNG encode failed: {}", e)))}자세한 압축 및 필터 타입에 대해서는 아래 문서를 참고하세요.https://docs.rs/image/latest/image/codecs/png/enum.CompressionType.html https://docs.rs/image/latest/image/codecs/png/enum.FilterType.html● WebP 변환 (encode_as_webp)현재 image 크레이트에서는 WebP의 손실 압축을 지원하지 않으므로 무손실 압축만 사용합니다.JPEG 중간 압축 단계에서 이미 손실 압축이 이루어졌기 때문에 실제 결과는 품질이 낮아지고 용량도 줄어듭니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLfn encode_as_webp(image: &DynamicImage, buffer: &mut Vec<u8>) -> Result<(), JsValue> { let rgba = image.to_rgba8(); let (width, height) = rgba.dimensions(); let encoder = WebPEncoder::new_lossless(buffer); encoder .encode(&rgba, width, height, ExtendedColorType::Rgba8) .map_err(|e| JsValue::from_str(&format!("WebP encode failed: {}", e)))}이렇게 작성한 Rust 코드를 WASM으로 컴파일한 후 JavaScript에서 불러와 사용하면 됩니다.| wasm빌드 및 사용이제 구현한 Rust 코드를 WebAssembly(WASM)으로 컴파일하여 사용할 차례입니다.이를 쉽게 처리하기 위해 wasm-pack이라는 CLI 도구를 사용합니다.먼저 wasm-pack을 설치합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLcargo install wasm-pack설치 후, 프로젝트 디렉터리에서 다음 명령어를 실행해 WASM으로 컴파일합니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLwasm-pack build이렇게 하면 프로젝트 내에 pkg 폴더가 생성되고, 다음과 같은 파일들이 만들어집니다.PlainJavaScriptTypeScriptJSXTSXRustBashC++C#CSSHTML/XMLpkg/├── package.json # npm 패키지로 사용할 수 있도록 정의된 파일├── your_lib_bg.wasm # 컴파일된 WebAssembly 바이너리 파일├── your_lib.js # wasm-bindgen이 생성한 JavaScript wrapper 파일└── your_lib.d.ts # 타입스크립트 타입 정의 파일 (선택적 사용)위에서 생성된 JavaScript wrapper 파일(your_lib.js)을프로젝트 내에서 import 하여 바로 사용할 수 있습니다.이 블로그에서 구현한 프로젝트의 경우,Rust에서 생성된 wrapper 파일(your_lib.js)을 한 번 더 감싸주는 JavaScript wrapper 함수를 추가로 작성했습니다.사용자는 직접 이 추가 wrapper 함수를 import 하여 기존과 동일한 인터페이스로 사용할 수 있습니다.따라서 실제로 패키지를 배포할 때는 pkg 폴더 내의 파일들과 별도의 wrapper 함수를 함께 배포하면 되며,사용자는 최종적으로 제공된 wrapper 함수를 직접 호출하여 사용하는 형태가 됩니다.| 성능 비교마지막으로 가장 중요한 성능 비교를 진행해 보겠습니다.기존의 JavaScript(Canvas API 기반) 버전과 새롭게 Rust(WASM 기반)로 재작성한 버전에서동일한 이미지를 대상으로 다음 조건을 고정하여 비교하였습니다.너비: 550px로 고정품질 설정: 두 버전 모두 최대 품질로 설정비교 포맷: JPG, PNG, WebP 각각의 포맷으로 변환비교의 중점은 이미지 변환 시 결과 이미지의 용량, 처리 속도, 그리고 최종 이미지 품질입니다.JPG변환1. 자바스크립트 버전용량: 170kb속도: 40ms이미지: 2. Rust 버전용량: 114kb속도: 700ms이미지: PNG 변환1. 자바스크립트 버전용량: 278kb속도: 150ms이미지:2. Rust 버전용량: 225kb속도: 1000ms이미지: WEBP변환1. 자바스크립트 버전용량: 187kb속도: 160ms이미지: 2. Rust 버전용량: 174kb속도: 700ms이미지: | 끝이렇게 기존의 타입스크립트(JavaScript) 기반으로 만들어진 img-toolkit 라이브러리를Rust와 WASM을 이용해 다시 작성하는 경험을 했습니다.이번 글에서는 단순한 성능 비교 결과만 소개했지만,실제로 여러 세부 옵션을 조합하여 다양한 조건에서 테스트해 본 결과를 추가적으로 정리해 보면다음과 같은 경향성을 발견할 수 있었습니다.속도: 대부분의 상황에서 JavaScript 버전이 처리 속도 측면에서 훨씬 빨랐습니다.용량 대비 이미지 품질: Rust 버전이 JavaScript 버전보다 동일 용량 대비 이미지 품질이 눈에 띄게 좋았습니다.이미지 처리 작업에 있어서는 속도보다 이미지의 품질과 용량이 최우선적으로 고려되어야 하는 경우가 많다고 생각합니다.따라서 이번 Rust 기반의 WASM 구현이 실제 이미지 처리 용도로도 충분히 유용하고 좋은 결과를 가져왔다고 판단됩니다.현재는 두 버전 간 속도 차이가 꽤 크게 나타났지만,이후 더 복잡하고 고도화된 이미지 처리 연산이 추가될 경우 이 격차는 좁혀질 가능성이 높습니다.앞으로도 지속적으로 Rust와 WASM에 대해 공부하고img-toolkit 라이브러리의 기능과 성능을 개선하면서 더욱 발전시켜 나갈 계획입니다.| 저장소타입스크립트 버전: https://github.com/2YH02/img-toolkit러스트 버전: https://github.com/2YH02/img-toolkit-rust
![[React] 리액트에서 Virtual DOM과 diffing 알고리즘 썸네일](/_next/image?url=https%3A%2F%2Fevcsbwqeetfvegvrtbny.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2Fblog-img%2Fyonghunblog%2Freact.png&w=3840&q=45)
[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에서는 기본적으로 페이지 이동 시 애니메이션이 없지만,이를 직접 구현하여 자연스러운 페이지 전환을 만들 수 있습니다.이번 글에서는 좌우 슬라이드 전환 애니메이션을 적용하는 방법을 공유하겠습니다.이를 위해 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] 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

디바운스와 쓰로틀을 사용한 최적화
자바스크립트 애프리케이션에서는 사용자가 웹페이지와 상호작용하는 동안 발생하는 다양한 이벤트들을 다루게 됩니다.특히, 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(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 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] 리액트에서 컴포넌트 추상화에 대한 고민 썸네일](/_next/image?url=https%3A%2F%2Fevcsbwqeetfvegvrtbny.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2Fblog-img%2Fyonghunblog%2Freact.png&w=3840&q=45)
[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가 전체 레이아웃을 담당하고,각 섹션은 개별적으로 컴포넌트로 분리됩니다.장단점첫 번째 방법의 단점을 보완하고자 이 방법으로 구현해 봤는데역시 장단점이 존재했습니다.우선 장점으로는각 섹션을 독립적으로 관리할 수 있어 수정 및 확장이 용이하다고 생각했습니다.특정 섹션을 여러 곳에서 재사용할 수 있어 프로젝트가 커지면 코드 중복을 줄일 수 있다 생각했습니다.새로운 요구사항이 추가되어도 기존 구조를 쉽게 확장할 수 있습니다.다음으로 단점으로는여러 컴포넌트로 분리되면서 코드가 길어지고 관리가 다소 복잡해질 우려가 있었습니다.컴포넌트를 조합해서 사용해야 하므로 좀 더 구체적인 사용법에 대한 설명(문서화)이 필요할 수 있습니다. (이 부분이 가장 중요하다고 생각)| 결론컴포넌트를 추상화할 때 간결함과 유연성 사이의 균형을 맞추는 것은 매우 중요하지만,실제로는 쉽지 않은 작업입니다.위 예시 코드는 매우 간단한 리스트 컴포넌트를 구현한 것이며,이를 통해 두 가지 방법을 비교해 보았습니다.단순한 코드의 경우 첫 번째 방식이 간결하고 빠르며 코드 작성이 수월합니다.하지만, 프로젝트가 복잡해지고 요구사항이 늘어날 가능성을 고려한다면,두 번째 방식처럼 각 섹션을 독립적으로 관리하는 방법도 좋은 선택일 수 있습니다.또한, 코드의 추상화는 프로젝트의 성격과 팀의 개발 스타일에 따라 달라질 수 있습니다.단순함을 우선할지, 유연성을 추구할지 고민해 보고,그에 맞는 방법을 선택하는 것이 중요하다고 생각합니다.사실, 저 역시 개발 경험이 많지 않아 어떤 방법이 정답인지는 잘 모르겠습니다.다만, 이런 고민을 통해 앞으로 프로젝트의 특성에 맞게더 나은 컴포넌트를 작성할 수 있는 역량을 키울 수 있을 거라 생각합니다.