이미지 로딩 중...
AI Generated
2025. 11. 11. · 3 Views
타입스크립트로 비트코인 클론하기 React 웹 UI 만들기
비트코인 블록체인의 핵심 개념을 학습하면서 TypeScript와 React로 실제 작동하는 웹 인터페이스를 구축하는 방법을 배웁니다. 블록체인 데이터를 시각화하고, 트랜잭션을 생성하며, 지갑 기능을 구현하는 실전 프로젝트입니다.
목차
- React 프로젝트 초기 설정 - TypeScript와 함께 시작하기
- 블록체인 타입 정의 - TypeScript로 데이터 구조 안전하게 관리하기
- 블록체인 데이터 페칭 - Axios로 API 통신하기
- React 컴포넌트 구조 설계 - 재사용 가능한 UI 만들기
- useState와 useEffect - 상태 관리와 라이프사이클
- 트랜잭션 폼 구현 - 사용자 입력 처리하기
- useCallback으로 함수 최적화 - 불필요한 리렌더링 방지하기
- Custom Hook 만들기 - 로직 재사용하기
- React Context로 전역 상태 관리하기 - Prop Drilling 해결
- CSS-in-JS로 스타일링하기 - 동적 스타일과 타입 안정성
- 환경 변수로 설정 관리하기 - 개발/프로덕션 환경 분리
- 빌드와 배포 최적화 - 프로덕션 준비하기
1. React 프로젝트 초기 설정 - TypeScript와 함께 시작하기
시작하며
여러분이 블록체인 프로젝트를 만들고 싶은데, 어떻게 시작해야 할지 막막하셨나요? 특히 TypeScript를 사용하면서 React 환경을 구축하는 것은 처음에는 복잡해 보일 수 있습니다.
많은 개발자들이 프로젝트 초기 설정에서 시간을 많이 소비합니다. 의존성 충돌, 타입 설정 오류, 빌드 도구 선택 등 고려해야 할 것들이 너무 많기 때문입니다.
하지만 Vite를 사용하면 이 모든 과정이 몇 분 안에 끝납니다. Vite는 빠른 개발 서버와 최적화된 빌드를 제공하여, 여러분이 설정보다는 코드 작성에 집중할 수 있게 해줍니다.
개요
간단히 말해서, Vite는 차세대 프론트엔드 빌드 도구로서 기존의 Create React App보다 훨씬 빠른 개발 경험을 제공합니다. 실무에서는 개발 서버의 시작 속도와 HMR(Hot Module Replacement) 속도가 생산성에 직접적인 영향을 미칩니다.
Vite는 ESM(ES Modules)을 기반으로 동작하여 프로젝트 크기가 커져도 빠른 속도를 유지합니다. 특히 블록체인 프로젝트처럼 많은 모듈과 복잡한 로직을 다루는 경우에 매우 유용합니다.
기존의 Webpack 기반 도구는 전체 번들을 다시 빌드해야 했다면, Vite는 변경된 모듈만 즉시 업데이트합니다. 이로 인해 코드 수정 후 브라우저에 반영되는 시간이 몇 초에서 밀리초 단위로 줄어듭니다.
Vite의 핵심 특징은 첫째, 네이티브 ESM을 활용한 초고속 개발 서버, 둘째, TypeScript를 기본적으로 지원하여 별도 설정 없이 바로 사용 가능, 셋째, Rollup 기반의 최적화된 프로덕션 빌드입니다. 이러한 특징들이 현대적인 웹 개발 워크플로우를 완성합니다.
코드 예제
// Vite로 React + TypeScript 프로젝트 생성
npm create vite@latest bitcoin-ui -- --template react-ts
cd bitcoin-ui
npm install
// 블록체인 관련 의존성 추가
npm install axios crypto-js
// package.json에 추가될 스크립트
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
}
설명
이것이 하는 일: Vite CLI를 통해 React와 TypeScript가 사전 설정된 프로젝트를 생성하고, 블록체인 UI에 필요한 핵심 라이브러리를 설치합니다. 첫 번째로, npm create vite@latest 명령어는 Vite의 최신 버전을 사용하여 새 프로젝트를 생성합니다.
--template react-ts 옵션은 React와 TypeScript가 함께 설정된 템플릿을 선택하는데, 이를 통해 tsconfig.json, vite.config.ts 등 모든 필수 설정 파일이 자동으로 생성됩니다. 이렇게 하면 수동으로 TypeScript 컴파일러 옵션을 설정하거나 타입 정의 파일을 찾는 번거로움이 사라집니다.
그 다음으로, npm install을 실행하면 React, ReactDOM, TypeScript 컴파일러 등 기본 의존성이 설치됩니다. 추가로 axios는 블록체인 노드와 HTTP 통신을 위해, crypto-js는 해시 함수와 암호화 기능을 위해 설치합니다.
실무에서는 이러한 라이브러리들이 블록 검증, 트랜잭션 서명 등 블록체인 핵심 기능을 구현하는 데 필수적입니다. 마지막으로, package.json의 scripts 섹션을 보면 개발 워크플로우를 이해할 수 있습니다.
dev 스크립트는 개발 서버를 시작하고, build는 TypeScript를 컴파일한 후 프로덕션 빌드를 생성하며, preview는 빌드된 결과물을 로컬에서 미리 확인할 수 있게 합니다. 여러분이 이 설정을 사용하면 몇 분 안에 개발을 시작할 수 있고, 코드 변경사항이 즉시 브라우저에 반영되는 쾌적한 개발 환경을 얻게 됩니다.
TypeScript의 타입 안정성과 Vite의 속도가 결합되어 버그는 줄이고 생산성은 높이는 이상적인 환경이 완성됩니다.
실전 팁
💡 Vite 개발 서버는 기본적으로 5173 포트를 사용하는데, vite.config.ts에서 server: { port: 3000 }으로 포트를 변경할 수 있습니다
💡 TypeScript 컴파일 오류가 빌드를 막지 않게 하려면 vite.config.ts에 build: { rollupOptions: { onwarn: 'ignore' } }를 추가하세요. 하지만 프로덕션에서는 모든 타입 오류를 수정하는 것이 좋습니다
💡 환경변수는 .env 파일에 VITE_ 접두사를 붙여 정의하면 import.meta.env.VITE_변수명으로 접근할 수 있습니다. API 엔드포인트 URL 같은 설정값을 관리하기 좋습니다
💡 개발 중에는 npm run dev -- --host를 실행하면 네트워크의 다른 기기에서도 접속할 수 있어 모바일 테스트가 편리합니다
💡 빌드 결과물의 크기를 분석하려면 rollup-plugin-visualizer를 설치하여 어떤 패키지가 번들 크기를 키우는지 시각적으로 확인할 수 있습니다
2. 블록체인 타입 정의 - TypeScript로 데이터 구조 안전하게 관리하기
시작하며
여러분이 블록체인 애플리케이션을 개발하면서 "이 객체에 어떤 속성이 있었지?", "이 함수는 무엇을 반환하지?" 같은 질문에 계속 코드를 뒤적이며 확인해본 경험이 있나요? JavaScript로 개발하면 런타임에 가서야 오타나 잘못된 속성 접근을 발견하게 됩니다.
특히 블록체인처럼 복잡한 데이터 구조를 다룰 때는 작은 실수가 큰 버그로 이어질 수 있습니다. 바로 이럴 때 필요한 것이 TypeScript의 인터페이스와 타입 정의입니다.
컴파일 타임에 모든 타입 오류를 잡아내고, IDE의 자동완성 기능을 최대한 활용하여 개발 속도와 안정성을 동시에 높일 수 있습니다.
개요
간단히 말해서, TypeScript의 타입 시스템은 코드에 명확한 계약(contract)을 정의하여 예상치 못한 데이터 형태로 인한 버그를 사전에 방지합니다. 실무에서는 여러 개발자가 협업하거나 프로젝트가 커질수록 타입 정의의 중요성이 커집니다.
블록체인 프로젝트에서는 Block, Transaction, Wallet 같은 핵심 데이터 구조가 여러 컴포넌트와 함수에서 반복적으로 사용됩니다. 이때 명확한 타입 정의가 없으면 각 개발자가 다르게 이해하여 일관성이 깨질 수 있습니다.
기존 JavaScript에서는 JSDoc 주석으로 타입을 표현했다면, TypeScript는 언어 차원에서 타입을 강제하고 검증합니다. 이는 단순한 문서화를 넘어 실제로 실행 가능한 타입 체크로 작동합니다.
타입 정의의 핵심 특징은 첫째, 인터페이스를 통한 객체 구조의 명확한 정의, 둘째, 유니온 타입과 제네릭을 활용한 유연성, 셋째, 타입 추론을 통한 중복 제거입니다. 이러한 특징들이 대규모 애플리케이션의 유지보수성을 크게 향상시킵니다.
코드 예제
// types/blockchain.ts
export interface Transaction {
from: string;
to: string;
amount: number;
timestamp: number;
signature?: string;
}
export interface Block {
index: number;
timestamp: number;
transactions: Transaction[];
previousHash: string;
hash: string;
nonce: number;
}
export interface Wallet {
address: string;
balance: number;
publicKey: string;
privateKey?: string; // 선택적 속성
}
// API 응답 타입
export type BlockchainResponse = {
chain: Block[];
pendingTransactions: Transaction[];
}
설명
이것이 하는 일: 블록체인 애플리케이션에서 사용되는 모든 데이터 구조를 TypeScript 인터페이스로 정의하여 타입 안정성을 확보하고 IDE의 지원을 최대화합니다. 첫 번째로, Transaction 인터페이스는 블록체인의 가장 기본 단위인 거래를 정의합니다.
from과 to는 송신자와 수신자의 주소를, amount는 전송량을, timestamp는 생성 시각을 나타냅니다. signature?의 물음표는 선택적 속성을 의미하는데, 아직 서명되지 않은 트랜잭션도 다룰 수 있게 해줍니다.
이렇게 정의하면 transaction.amount를 입력할 때 IDE가 자동완성을 제공하고, 잘못된 속성명을 입력하면 즉시 빨간 줄로 표시됩니다. 그 다음으로, Block 인터페이스는 여러 트랜잭션을 담는 블록을 정의합니다.
transactions: Transaction[]은 트랜잭션 배열을 나타내는데, 이미 정의한 Transaction 타입을 재사용함으로써 일관성을 유지합니다. previousHash와 hash는 블록체인의 연결 고리를 나타내고, nonce는 작업증명(PoW)에 사용되는 값입니다.
이러한 구조화된 정의 덕분에 블록 검증 로직을 작성할 때 실수로 속성을 빠뜨리는 일이 없어집니다. 마지막으로, type 키워드를 사용한 BlockchainResponse는 API 응답 형태를 정의합니다.
interface와 달리 type은 유니온 타입이나 인터섹션 타입 같은 고급 타입 조합에 유용합니다. 실무에서는 API 통신 시 이런 타입을 정의해두면 axios.get<BlockchainResponse>()처럼 제네릭과 함께 사용하여 응답 데이터의 타입을 보장할 수 있습니다.
여러분이 이 타입 정의들을 사용하면 코드 작성 중에 실시간으로 오류를 발견할 수 있고, 리팩토링할 때도 타입스크립트가 영향받는 모든 코드를 알려줘서 안전하게 수정할 수 있습니다. 또한 새로운 팀원이 합류했을 때 코드를 읽는 것만으로도 데이터 구조를 명확히 이해할 수 있어 온보딩 시간이 크게 단축됩니다.
실전 팁
💡 interface는 선언 병합(declaration merging)이 가능하여 확장에 유리하고, type은 유니온/인터섹션 타입에 유리합니다. 객체 구조 정의에는 interface를, 복잡한 타입 조합에는 type을 사용하세요
💡 선택적 속성(?)과 undefined 유니온 타입(string | undefined)은 다릅니다. 전자는 속성 자체가 없어도 되지만, 후자는 속성이 반드시 있어야 하며 값이 undefined일 수 있습니다
💡 Readonly<T>나 Partial<T> 같은 유틸리티 타입을 활용하면 기존 타입을 재사용하면서 새로운 변형을 쉽게 만들 수 있습니다. 예: Readonly<Block>은 불변 블록을 나타냅니다
💡 복잡한 타입은 별도의 types/ 디렉토리에 모아두고 필요한 곳에서 import하여 사용하면 중복을 줄이고 일관성을 유지할 수 있습니다
💡 API 응답 타입은 백엔드와 계약을 맺는 것과 같으므로, 변경 시 백엔드 개발자와 협의하고 버전 관리를 철저히 해야 합니다
3. 블록체인 데이터 페칭 - Axios로 API 통신하기
시작하며
여러분이 웹 UI를 만들었는데 실제 블록체인 데이터를 어떻게 가져와야 할지 고민되나요? 프론트엔드와 백엔드를 연결하는 것은 풀스택 개발의 핵심 단계입니다.
많은 초보 개발자들이 fetch API를 사용하다가 에러 핸들링, 타임아웃 설정, 요청 취소 등의 복잡한 상황에서 어려움을 겪습니다. 특히 블록체인 노드와 통신할 때는 네트워크 지연이나 노드 다운 상황을 고려해야 합니다.
바로 이럴 때 필요한 것이 Axios입니다. Promise 기반의 HTTP 클라이언트로서 직관적인 API와 강력한 기능을 제공하여, 여러분이 복잡한 네트워크 로직보다는 비즈니스 로직에 집중할 수 있게 해줍니다.
개요
간단히 말해서, Axios는 브라우저와 Node.js에서 모두 사용 가능한 HTTP 클라이언트로, fetch API보다 편리한 기능과 더 나은 에러 핸들링을 제공합니다. 실무에서는 API 통신이 애플리케이션의 핵심입니다.
블록체인 노드에서 최신 블록을 가져오고, 트랜잭션을 전송하며, 지갑 잔액을 조회하는 모든 작업이 HTTP 통신을 통해 이루어집니다. Axios를 사용하면 이러한 작업을 일관된 방식으로 처리할 수 있습니다.
기존 fetch API는 JSON 파싱을 수동으로 해야 하고, 에러 핸들링이 까다로우며, 요청 취소 기능이 복잡했다면, Axios는 자동 JSON 변환, 인터셉터를 통한 중앙화된 에러 핸들링, 요청 취소 토큰 등을 기본 제공합니다. Axios의 핵심 특징은 첫째, 자동 JSON 변환으로 response.data에 바로 접근 가능, 둘째, 인터셉터를 통한 요청/응답 전처리, 셋째, TypeScript 제네릭 지원으로 응답 타입 보장입니다.
이러한 특징들이 견고한 API 레이어를 구축하는 데 필수적입니다.
코드 예제
// services/blockchainApi.ts
import axios from 'axios';
import type { Block, Transaction, BlockchainResponse } from '../types/blockchain';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
// Axios 인스턴스 생성
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 5000,
headers: { 'Content-Type': 'application/json' }
});
// 전체 블록체인 조회
export const getBlockchain = async (): Promise<Block[]> => {
const response = await api.get<BlockchainResponse>('/blockchain');
return response.data.chain;
};
// 새 트랜잭션 생성
export const createTransaction = async (transaction: Transaction): Promise<void> => {
await api.post('/transactions', transaction);
};
// 특정 블록 조회
export const getBlock = async (index: number): Promise<Block> => {
const response = await api.get<Block>(`/blocks/${index}`);
return response.data;
};
설명
이것이 하는 일: Axios 인스턴스를 설정하고 TypeScript 제네릭을 활용하여 타입 안전한 API 통신 함수들을 구현합니다. 첫 번째로, axios.create()로 커스텀 인스턴스를 생성합니다.
이렇게 하면 baseURL, timeout, headers 같은 공통 설정을 한 곳에서 관리할 수 있습니다. import.meta.env.VITE_API_URL은 Vite의 환경변수로, 개발/프로덕션 환경에 따라 다른 API 서버를 사용할 수 있게 해줍니다.
timeout: 5000은 5초 안에 응답이 없으면 자동으로 에러를 발생시켜 무한 대기를 방지합니다. 그 다음으로, getBlockchain 함수는 제네릭 <BlockchainResponse>를 사용하여 응답 타입을 지정합니다.
이렇게 하면 response.data가 BlockchainResponse 타입으로 추론되어, response.data.chain에 안전하게 접근할 수 있습니다. async/await 문법을 사용하면 Promise 체이닝보다 가독성이 좋고 에러 핸들링도 try-catch로 간단히 할 수 있습니다.
마지막으로, createTransaction과 getBlock 함수는 각각 POST와 동적 경로 GET 요청을 보여줍니다. createTransaction은 Promise<void>를 반환하는데, 이는 성공/실패만 중요하고 반환값이 없음을 명시합니다.
getBlock은 템플릿 리터럴로 동적 경로를 구성하는데, 이는 RESTful API의 일반적인 패턴입니다. 여러분이 이 API 레이어를 사용하면 컴포넌트에서는 데이터 페칭 로직을 몰라도 되고, 단순히 함수를 호출하면 됩니다.
만약 API 엔드포인트가 변경되거나 인증 헤더를 추가해야 할 때도 이 파일만 수정하면 전체 애플리케이션에 반영됩니다. 또한 TypeScript 덕분에 API 응답 구조가 변경되면 컴파일 타임에 모든 영향받는 코드를 찾아낼 수 있습니다.
실전 팁
💡 Axios 인터셉터를 사용하면 모든 요청에 자동으로 인증 토큰을 추가하거나, 401 에러 시 자동 로그아웃 처리 같은 공통 로직을 구현할 수 있습니다. api.interceptors.request.use()로 설정하세요
💡 요청 취소가 필요한 경우(예: 사용자가 페이지를 떠날 때) AbortController를 사용하세요. React의 useEffect cleanup 함수에서 취소하면 메모리 누수를 방지할 수 있습니다
💡 에러 응답도 타입을 정의하면 좋습니다. catch 블록에서 error.response?.data의 타입을 명시하면 에러 메시지를 안전하게 처리할 수 있습니다
💡 개발 중에는 api.interceptors.response.use()에서 모든 응답을 콘솔에 로깅하면 디버깅이 편리합니다. 프로덕션에서는 조건부로 비활성화하세요
💡 재시도 로직이 필요하다면 axios-retry 라이브러리를 추가하여 네트워크 일시 장애에 자동으로 대응할 수 있습니다
4. React 컴포넌트 구조 설계 - 재사용 가능한 UI 만들기
시작하며
여러분이 블록체인 UI를 만들 때 모든 코드를 한 파일에 작성하다가 금방 복잡해져서 관리하기 어려워진 경험이 있나요? 코드가 길어질수록 버그를 찾기도 어렵고 수정하기도 두려워집니다.
실무에서는 하나의 컴포넌트가 수백 줄을 넘어가면 유지보수가 거의 불가능해집니다. 특히 블록 리스트, 트랜잭션 폼, 지갑 정보 같은 독립적인 UI 요소들이 뒤섞이면 어디서부터 손대야 할지 막막합니다.
바로 이럴 때 필요한 것이 컴포넌트 기반 아키텍처입니다. 각 UI 요소를 독립적인 컴포넌트로 분리하면 재사용성이 높아지고, 테스트가 쉬워지며, 여러 개발자가 동시에 작업할 수 있게 됩니다.
개요
간단히 말해서, 컴포넌트 기반 설계는 UI를 작은 독립적인 조각들로 나누어 각각을 재사용 가능하고 관리하기 쉬운 단위로 만드는 것입니다. 실무에서는 Atomic Design 패턴을 많이 사용합니다.
가장 작은 단위인 원자(Atoms)부터 시작해서, 분자(Molecules), 유기체(Organisms), 템플릿, 페이지 순으로 조합해 나갑니다. 블록체인 UI의 경우, 버튼과 입력 필드가 원자, 트랜잭션 폼이 분자, 블록 리스트가 유기체가 될 수 있습니다.
기존에는 하나의 거대한 컴포넌트에 모든 로직과 UI가 들어있었다면, 이제는 각 컴포넌트가 단일 책임을 가지고 props를 통해 데이터를 주고받습니다. 이를 통해 컴포넌트를 다른 프로젝트에서도 그대로 가져다 쓸 수 있습니다.
컴포넌트 설계의 핵심 특징은 첫째, 명확한 props 인터페이스로 사용법을 명시, 둘째, 단일 책임 원칙을 따라 하나의 일만 수행, 셋째, 상태 관리 최소화로 예측 가능성 향상입니다. 이러한 특징들이 확장 가능한 애플리케이션의 기반이 됩니다.
코드 예제
// components/BlockCard.tsx
import React from 'react';
import type { Block } from '../types/blockchain';
interface BlockCardProps {
block: Block;
onClick?: (block: Block) => void;
}
export const BlockCard: React.FC<BlockCardProps> = ({ block, onClick }) => {
return (
<div
className="block-card"
onClick={() => onClick?.(block)}
style={{ cursor: onClick ? 'pointer' : 'default' }}
>
<h3>Block #{block.index}</h3>
<p>Hash: {block.hash.substring(0, 16)}...</p>
<p>Transactions: {block.transactions.length}</p>
<p>Timestamp: {new Date(block.timestamp).toLocaleString()}</p>
</div>
);
};
// components/BlockList.tsx
interface BlockListProps {
blocks: Block[];
onBlockClick?: (block: Block) => void;
}
export const BlockList: React.FC<BlockListProps> = ({ blocks, onBlockClick }) => {
return (
<div className="block-list">
{blocks.map(block => (
<BlockCard key={block.index} block={block} onClick={onBlockClick} />
))}
</div>
);
};
설명
이것이 하는 일: 블록 정보를 표시하는 재사용 가능한 컴포넌트들을 만들고, TypeScript 인터페이스로 props 계약을 명확히 정의합니다. 첫 번째로, BlockCard 컴포넌트는 단일 블록을 카드 형태로 표시하는 원자적 컴포넌트입니다.
BlockCardProps 인터페이스는 필수 props인 block과 선택적 props인 onClick을 정의합니다. onClick?의 물음표는 이 prop이 없어도 컴포넌트가 작동함을 의미하는데, 이렇게 하면 클릭 가능한 카드와 정적 카드 모두에 같은 컴포넌트를 사용할 수 있습니다.
React.FC<BlockCardProps> 타입 표기는 함수 컴포넌트임을 명시하고, children props도 자동으로 포함시킵니다. 그 다음으로, 컴포넌트 내부의 onClick?.(block) 문법은 optional chaining으로, onClick이 제공되었을 때만 호출합니다.
이는 onClick && onClick(block)보다 간결하고 안전합니다. block.hash.substring(0, 16)은 긴 해시값을 축약하여 UI를 깔끔하게 유지하는데, 실무에서는 이런 작은 디테일이 사용자 경험에 큰 영향을 줍니다.
new Date(block.timestamp).toLocaleString()은 타임스탬프를 사용자 친화적인 날짜 문자열로 변환합니다. 마지막으로, BlockList 컴포넌트는 여러 BlockCard를 조합한 상위 컴포넌트입니다.
blocks.map()으로 배열을 렌더링할 때 key={block.index}를 제공하는 것은 React가 효율적으로 리렌더링하기 위해 필수입니다. 각 카드에 동일한 onBlockClick 핸들러를 전달하여, 어떤 블록이 클릭되어도 상위 컴포넌트에서 일관되게 처리할 수 있습니다.
여러분이 이런 식으로 컴포넌트를 설계하면 나중에 "트랜잭션 내역도 카드로 보여주자"고 결정했을 때 BlockCard의 구조를 참고하여 TransactionCard를 쉽게 만들 수 있습니다. 또한 BlockCard를 다른 프로젝트의 블록 탐색기에서도 그대로 사용할 수 있고, Storybook 같은 도구로 독립적으로 개발하고 테스트할 수 있습니다.
실전 팁
💡 컴포넌트 파일명은 PascalCase(BlockCard.tsx)를 사용하고, 폴더 구조는 components/ 아래 기능별로 나누세요. 예: components/blocks/, components/transactions/
💡 props가 3개 이상이면 객체 구조 분해를 사용하되, 필수와 선택적 props를 주석으로 구분하면 가독성이 좋습니다
💡 스타일은 CSS Modules(BlockCard.module.css)나 styled-components를 사용하면 스타일 충돌을 방지할 수 있습니다. 전역 클래스명은 피하세요
💡 컴포넌트가 5개 이상의 props를 받는다면 너무 많은 책임을 가진 것일 수 있습니다. 더 작은 컴포넌트로 쪼개는 것을 고려하세요
💡 React.memo()로 컴포넌트를 감싸면 props가 변경되지 않을 때 리렌더링을 건너뛰어 성능을 최적화할 수 있습니다. 하지만 모든 컴포넌트에 적용하기보다는 프로파일링 후 병목 지점에만 사용하세요
5. useState와 useEffect - 상태 관리와 라이프사이클
시작하며
여러분이 React 컴포넌트를 만들었는데 버튼을 클릭해도 UI가 업데이트되지 않거나, 데이터를 언제 가져와야 할지 모르겠다는 경험이 있나요? 이는 React 초보자들이 가장 흔히 겪는 문제입니다.
React는 선언적 UI 라이브러리로, 상태가 변경되면 자동으로 UI를 업데이트합니다. 하지만 그 상태를 어떻게 관리하고, 컴포넌트가 마운트될 때 어떤 작업을 수행해야 하는지는 Hooks를 통해 명시해야 합니다.
바로 이럴 때 필요한 것이 useState와 useEffect입니다. 이 두 Hook은 React의 가장 기본적이면서도 강력한 도구로, 함수형 컴포넌트에서 상태와 사이드 이펙트를 다룰 수 있게 해줍니다.
개요
간단히 말해서, useState는 컴포넌트 내부에 상태를 저장하고 업데이트하는 Hook이고, useEffect는 컴포넌트 마운트/업데이트/언마운트 시점에 사이드 이펙트를 실행하는 Hook입니다. 실무에서는 거의 모든 컴포넌트가 이 두 Hook을 사용합니다.
블록체인 UI의 경우, useState로 현재 선택된 블록, 입력 중인 트랜잭션 금액, 로딩 상태 등을 관리하고, useEffect로 컴포넌트가 화면에 나타날 때 API에서 데이터를 가져옵니다. 이 두 Hook의 조합이 동적이고 반응적인 UI의 핵심입니다.
기존 클래스 컴포넌트에서는 this.state, componentDidMount, componentDidUpdate 등으로 분산되어 있던 로직이, Hooks에서는 관련된 로직끼리 묶여서 가독성과 재사용성이 크게 향상되었습니다. 이 Hooks의 핵심 특징은 첫째, useState의 setter 함수를 호출하면 자동으로 리렌더링 트리거, 둘째, useEffect의 dependency array로 정확한 실행 시점 제어, 셋째, cleanup 함수로 메모리 누수 방지입니다.
이러한 특징들이 견고한 React 애플리케이션의 기반이 됩니다.
코드 예제
// pages/BlockchainPage.tsx
import React, { useState, useEffect } from 'react';
import { getBlockchain } from '../services/blockchainApi';
import { BlockList } from '../components/BlockList';
import type { Block } from '../types/blockchain';
export const BlockchainPage: React.FC = () => {
// 상태 정의
const [blocks, setBlocks] = useState<Block[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// 컴포넌트 마운트 시 데이터 페칭
useEffect(() => {
const fetchBlocks = async () => {
try {
setLoading(true);
const data = await getBlockchain();
setBlocks(data);
setError(null);
} catch (err) {
setError('Failed to fetch blockchain data');
console.error(err);
} finally {
setLoading(false);
}
};
fetchBlocks();
}, []); // 빈 배열: 마운트 시 한 번만 실행
if (loading) return <div>Loading blockchain...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>Blockchain Explorer</h1>
<p>Total Blocks: {blocks.length}</p>
<BlockList blocks={blocks} />
</div>
);
};
설명
이것이 하는 일: 컴포넌트가 화면에 나타날 때 블록체인 데이터를 가져와서 상태에 저장하고, 로딩과 에러 상태를 관리하여 사용자에게 적절한 피드백을 제공합니다. 첫 번째로, 세 개의 useState Hook을 사용하여 컴포넌트의 상태를 정의합니다.
useState<Block[]>([])는 제네릭으로 타입을 명시하고 빈 배열을 초기값으로 설정합니다. 이렇게 하면 blocks 변수는 항상 Block[] 타입으로 추론되어 안전합니다.
loading과 error 상태는 UI에서 로딩 스피너나 에러 메시지를 조건부로 렌더링하는 데 사용됩니다. 이런 패턴은 "loading-error-success" 상태 머신으로 알려져 있으며 거의 모든 데이터 페칭 시나리오에 적용됩니다.
그 다음으로, useEffect 내부의 fetchBlocks 함수는 비동기 데이터 페칭 로직을 캡슐화합니다. try-catch-finally 구조로 성공, 실패, 완료 시나리오를 모두 처리하는데, 이는 실무에서 필수적인 에러 핸들링 패턴입니다.
setLoading(true)를 먼저 호출하여 로딩 UI를 보여주고, 데이터를 성공적으로 가져오면 setBlocks(data)로 상태를 업데이트합니다. 에러가 발생하면 setError()로 에러 메시지를 저장하고, finally 블록에서 항상 로딩 상태를 false로 만듭니다.
마지막으로, useEffect의 dependency array []가 핵심입니다. 빈 배열은 "이 effect는 어떤 값에도 의존하지 않으므로 마운트 시 한 번만 실행하라"는 의미입니다.
만약 [someValue]처럼 값을 넣으면 그 값이 변경될 때마다 effect가 재실행됩니다. 실무에서는 이 dependency array를 정확히 설정하지 않으면 무한 루프나 stale closure 문제가 발생할 수 있습니다.
여러분이 이 패턴을 사용하면 컴포넌트가 마운트되자마자 자동으로 데이터를 가져오고, 로딩 중에는 스피너를, 에러 시에는 에러 메시지를, 성공 시에는 실제 데이터를 보여주는 완전한 UX를 구현할 수 있습니다. 이는 SPA(Single Page Application)의 표준 패턴이며, 대부분의 프로덕션 React 앱에서 볼 수 있는 구조입니다.
실전 팁
💡 useEffect 내부에서 async 함수를 직접 선언하지 말고, 내부에 별도 async 함수를 만들어 호출하세요. useEffect 콜백 자체를 async로 만들면 cleanup 함수를 반환할 수 없습니다
💡 dependency array를 생략하면 매 렌더링마다 effect가 실행되어 성능 문제가 발생할 수 있습니다. ESLint의 exhaustive-deps 규칙을 활성화하여 빠뜨린 dependency를 자동으로 찾으세요
💡 컴포넌트가 언마운트되기 전에 실행 중인 비동기 작업이 있다면, cleanup 함수에서 취소해야 합니다. return () => { /* cleanup */ }로 구현하세요
💡 여러 개의 독립적인 effect는 하나로 합치지 말고 각각 별도의 useEffect로 분리하면 관련 로직끼리 묶여서 가독성이 좋아집니다
💡 복잡한 상태 로직은 useReducer를 고려하세요. loading/error/data 상태를 하나의 reducer로 관리하면 상태 전환이 명확해집니다
6. 트랜잭션 폼 구현 - 사용자 입력 처리하기
시작하며
여러분이 블록체인에서 가장 중요한 기능인 트랜잭션 전송을 구현하려는데, 사용자 입력을 어떻게 검증하고 처리해야 할지 고민되나요? 잘못된 입력으로 인한 에러는 사용자 경험을 크게 해칩니다.
많은 개발자들이 폼 구현을 간단하게 생각하지만, 실시간 검증, 에러 메시지 표시, 제출 중 중복 방지 등 고려할 사항이 많습니다. 특히 블록체인 트랜잭션은 되돌릴 수 없으므로 사용자가 실수하지 않도록 철저한 검증이 필요합니다.
바로 이럴 때 필요한 것이 제어 컴포넌트(Controlled Component) 패턴입니다. React 상태로 입력값을 관리하면 실시간 검증, 조건부 UI, 동적 에러 메시지 등을 자연스럽게 구현할 수 있습니다.
개요
간단히 말해서, 제어 컴포넌트는 폼 입력의 값을 React 상태로 관리하여, React가 "single source of truth"가 되도록 하는 패턴입니다. 실무에서는 사용자가 입력하는 모든 값을 상태로 저장하고, onChange 이벤트로 상태를 업데이트하며, 그 상태를 다시 input의 value로 전달합니다.
이렇게 하면 입력값이 변경될 때마다 검증 로직을 실행하고, 조건에 맞지 않으면 제출 버튼을 비활성화하는 등의 세밀한 제어가 가능합니다. 기존의 비제어 컴포넌트는 DOM이 직접 입력값을 관리하여 React가 값을 모르는 상태였다면, 제어 컴포넌트는 모든 입력이 React 상태를 거쳐 흐르므로 완전한 제어권을 가집니다.
제어 컴포넌트의 핵심 특징은 첫째, 입력값의 즉각적인 검증과 변환 가능, 둘째, 여러 입력 필드 간의 의존성 처리 용이, 셋째, 폼 제출 시 이미 모든 값이 상태에 있어 간편합니다. 이러한 특징들이 복잡한 폼 로직을 관리 가능하게 만듭니다.
코드 예제
// components/TransactionForm.tsx
import React, { useState } from 'react';
import { createTransaction } from '../services/blockchainApi';
import type { Transaction } from '../types/blockchain';
export const TransactionForm: React.FC = () => {
const [from, setFrom] = useState<string>('');
const [to, setTo] = useState<string>('');
const [amount, setAmount] = useState<string>('');
const [submitting, setSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// 폼 검증
const isValid = from.length > 0 && to.length > 0 && parseFloat(amount) > 0;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); // 페이지 새로고침 방지
if (!isValid) return;
try {
setSubmitting(true);
setError(null);
const transaction: Transaction = {
from,
to,
amount: parseFloat(amount),
timestamp: Date.now()
};
await createTransaction(transaction);
// 성공 시 폼 초기화
setFrom('');
setTo('');
setAmount('');
alert('Transaction created successfully!');
} catch (err) {
setError('Failed to create transaction');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Create Transaction</h2>
<input
type="text"
placeholder="From address"
value={from}
onChange={(e) => setFrom(e.target.value)}
disabled={submitting}
/>
<input
type="text"
placeholder="To address"
value={to}
onChange={(e) => setTo(e.target.value)}
disabled={submitting}
/>
<input
type="number"
placeholder="Amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={submitting}
min="0"
step="0.01"
/>
<button type="submit" disabled={!isValid || submitting}>
{submitting ? 'Creating...' : 'Create Transaction'}
</button>
{error && <div className="error">{error}</div>}
</form>
);
};
설명
이것이 하는 일: 사용자가 입력하는 트랜잭션 정보를 React 상태로 관리하고, 실시간 검증, 제출 처리, 에러 핸들링을 구현합니다. 첫 번째로, 각 입력 필드마다 별도의 상태를 선언합니다.
from, to, amount는 사용자 입력값을, submitting은 제출 진행 상태를, error는 에러 메시지를 저장합니다. amount를 string으로 관리하는 이유는 사용자가 "0."처럼 불완전한 숫자를 입력 중일 수 있기 때문입니다.
실제 제출 시점에 parseFloat()로 변환합니다. isValid 변수는 파생 상태(derived state)로, 기존 상태들로부터 계산되므로 별도로 useState를 사용하지 않습니다.
그 다음으로, handleSubmit 함수는 폼 제출의 전체 흐름을 처리합니다. e.preventDefault()는 브라우저의 기본 폼 제출 동작(페이지 새로고침)을 막는 SPA의 필수 코드입니다.
setSubmitting(true)로 제출 중임을 표시하여 버튼을 비활성화하고, 이는 중복 제출을 방지합니다. Transaction 객체를 생성할 때 TypeScript 타입 덕분에 필수 필드를 빠뜨리면 컴파일 에러가 발생합니다.
성공 시 setFrom('') 등으로 폼을 초기화하여 다음 입력을 준비합니다. 마지막으로, JSX에서 각 <input>의 value와 onChange를 연결합니다.
value={from}은 React 상태를 input의 표시 값으로 설정하고, onChange={(e) => setFrom(e.target.value)}는 사용자 입력을 상태에 반영합니다. 이 양방향 바인딩이 제어 컴포넌트의 핵심입니다.
disabled={submitting}은 제출 중 추가 입력을 방지하고, <button>의 disabled={!isValid || submitting}은 폼이 유효하지 않거나 제출 중일 때 버튼을 비활성화합니다. 여러분이 이 패턴을 사용하면 입력값을 언제든지 검증하고 변환할 수 있으며, 비동기 제출 중 UI를 적절히 업데이트하여 사용자에게 명확한 피드백을 줄 수 있습니다.
또한 모든 입력이 상태로 관리되므로 나중에 "마지막 트랜잭션 기억하기" 같은 기능을 추가하기도 쉽습니다.
실전 팁
💡 많은 입력 필드가 있다면 객체 하나로 관리하세요. const [formData, setFormData] = useState({ from: '', to: '', amount: '' })로 묶으면 코드가 간결해집니다
💡 실시간 검증 메시지를 표시하려면 각 필드마다 에러 상태를 추가하고, onBlur 이벤트에서 검증하여 사용자가 입력을 마쳤을 때만 에러를 보여주세요
💡 복잡한 폼은 Formik이나 React Hook Form 같은 라이브러리를 사용하면 보일러플레이트 코드를 줄이고 고급 기능을 쉽게 구현할 수 있습니다
💡 숫자 입력은 type="number"보다 type="text"에 정규식 검증을 추가하는 것이 더 세밀한 제어가 가능합니다. type="number"는 브라우저마다 동작이 다를 수 있습니다
💡 제출 성공 시 사용자에게 피드백을 주는 방법은 alert 대신 토스트 알림 라이브러리(react-toastify 등)를 사용하면 더 나은 UX를 제공합니다
7. useCallback으로 함수 최적화 - 불필요한 리렌더링 방지하기
시작하며
여러분이 React 앱을 만들었는데 성능이 느리게 느껴지거나, 콘솔에 "함수가 매번 새로 생성되어 자식 컴포넌트가 계속 리렌더링됩니다"라는 경고를 본 적이 있나요? 이는 함수형 컴포넌트에서 흔히 발생하는 성능 이슈입니다.
함수형 컴포넌트는 렌더링될 때마다 내부의 모든 함수가 새로 생성됩니다. 이 새로운 함수를 props로 받는 자식 컴포넌트는 "props가 변경되었다"고 판단하여 불필요하게 리렌더링됩니다.
특히 블록 리스트처럼 많은 아이템을 렌더링하는 경우 성능에 큰 영향을 미칩니다. 바로 이럴 때 필요한 것이 useCallback Hook입니다.
함수를 메모이제이션하여 dependency가 변경되지 않는 한 같은 함수 인스턴스를 재사용하므로, 자식 컴포넌트의 불필요한 리렌더링을 방지할 수 있습니다.
개요
간단히 말해서, useCallback은 함수를 메모이제이션하는 Hook으로, dependency array에 명시된 값들이 변경될 때만 새로운 함수를 생성합니다. 실무에서는 특히 React.memo로 감싼 자식 컴포넌트에 함수를 props로 전달할 때 useCallback이 필수입니다.
React.memo는 props가 동일하면 리렌더링을 건너뛰는데, 함수가 매번 새로 생성되면 참조가 달라져서 최적화가 무용지물이 됩니다. 블록체인 UI에서 수백 개의 블록을 렌더링하면서 각 블록에 클릭 핸들러를 전달하는 경우, useCallback 없이는 성능 문제가 발생할 수 있습니다.
기존에는 클래스 컴포넌트의 메서드가 인스턴스에 바인딩되어 참조가 유지되었다면, 함수형 컴포넌트는 매 렌더링마다 모든 것이 새로 생성되므로 명시적으로 메모이제이션해야 합니다. useCallback의 핵심 특징은 첫째, 함수 참조 동일성 보장으로 자식 컴포넌트 최적화, 둘째, dependency array로 정확한 갱신 시점 제어, 셋째, useEffect의 dependency로 사용 시 무한 루프 방지입니다.
이러한 특징들이 대규모 React 애플리케이션의 성능을 유지하게 합니다.
코드 예제
// pages/BlockchainPage.tsx (최적화 버전)
import React, { useState, useEffect, useCallback } from 'react';
import { getBlockchain } from '../services/blockchainApi';
import { BlockList } from '../components/BlockList';
import type { Block } from '../types/blockchain';
export const BlockchainPage: React.FC = () => {
const [blocks, setBlocks] = useState<Block[]>([]);
const [selectedBlock, setSelectedBlock] = useState<Block | null>(null);
useEffect(() => {
getBlockchain().then(setBlocks);
}, []);
// useCallback으로 함수 메모이제이션
const handleBlockClick = useCallback((block: Block) => {
setSelectedBlock(block);
console.log('Block clicked:', block.index);
}, []); // 빈 배열: 이 함수는 마운트 시 한 번만 생성
// selectedBlock에 의존하는 함수
const handleCloseDetail = useCallback(() => {
setSelectedBlock(null);
}, []); // setSelectedBlock은 안정적이므로 dependency 불필요
return (
<div>
<h1>Blockchain Explorer</h1>
<BlockList blocks={blocks} onBlockClick={handleBlockClick} />
{selectedBlock && (
<div className="block-detail">
<h2>Block Details</h2>
<pre>{JSON.stringify(selectedBlock, null, 2)}</pre>
<button onClick={handleCloseDetail}>Close</button>
</div>
)}
</div>
);
};
설명
이것이 하는 일: 블록 클릭 핸들러를 useCallback으로 감싸서 함수 참조가 매 렌더링마다 변경되지 않도록 하여, BlockList 컴포넌트의 불필요한 리렌더링을 방지합니다. 첫 번째로, handleBlockClick 함수를 useCallback으로 감쌉니다.
이 함수는 setSelectedBlock(block)을 호출하는데, React의 setState 함수는 참조가 안정적으로 유지되므로 dependency array에 포함할 필요가 없습니다. 빈 배열 []을 전달하면 이 함수는 컴포넌트가 마운트될 때 한 번만 생성되고, 이후 렌더링에서는 같은 함수 인스턴스가 재사용됩니다.
만약 useCallback을 사용하지 않으면 blocks 상태가 업데이트될 때마다 handleBlockClick이 새로 생성되어, BlockList가 props 변경으로 인식하고 모든 BlockCard를 리렌더링합니다. 그 다음으로, handleCloseDetail 함수도 useCallback으로 메모이제이션합니다.
이 함수는 setSelectedBlock(null)만 호출하는 간단한 함수지만, <button onClick={handleCloseDetail}>처럼 JSX에서 직접 사용되므로 메모이제이션하면 버튼의 불필요한 리렌더링을 방지합니다. 실무에서는 이런 작은 최적화들이 모여 전체 성능에 영향을 미칩니다.
마지막으로, useCallback의 dependency array를 정확히 설정하는 것이 중요합니다. 만약 함수 내부에서 외부 변수를 참조한다면 반드시 dependency에 포함해야 합니다.
예를 들어, useCallback((block) => { console.log(someState, block) }, [])처럼 작성하면 someState는 항상 초기값을 참조하는 stale closure 문제가 발생합니다. 올바르게는 [someState]를 dependency에 포함해야 합니다.
여러분이 useCallback을 적절히 사용하면 특히 큰 리스트를 렌더링할 때 눈에 띄는 성능 향상을 경험할 수 있습니다. 하지만 모든 함수를 무분별하게 useCallback으로 감싸는 것은 오히려 메모리를 낭비하고 코드를 복잡하게 만듭니다.
프로파일링 도구로 실제 병목을 확인한 후, 자식 컴포넌트에 props로 전달되는 함수나 useEffect의 dependency로 사용되는 함수에 우선적으로 적용하세요.
실전 팁
💡 useCallback과 React.memo는 세트로 사용해야 효과가 있습니다. 자식 컴포넌트를 React.memo()로 감싸지 않으면 useCallback의 효과가 미미합니다
💡 dependency array에 객체나 배열을 넣을 때는 주의하세요. 매 렌더링마다 새로운 참조가 생성되므로 useCallback이 무용지물이 됩니다. useMemo로 객체를 메모이제이션하거나 원시 값만 dependency로 사용하세요
💡 ESLint의 exhaustive-deps 규칙을 활성화하면 빠뜨린 dependency를 자동으로 경고해줍니다. 하지만 경고를 무시하지 말고 왜 그런 경고가 나오는지 이해하고 수정하세요
💡 함수가 다른 함수를 반환하는 고차 함수의 경우, 반환되는 함수도 useCallback으로 감싸야 최적화가 유지됩니다
💡 성능 최적화는 측정 가능한 문제가 있을 때만 하세요. React DevTools Profiler로 실제 렌더링 시간을 측정한 후, 병목이 확인되면 그때 useCallback/useMemo를 적용하는 것이 좋습니다
8. Custom Hook 만들기 - 로직 재사용하기
시작하며
여러분이 여러 컴포넌트에서 비슷한 데이터 페칭 로직을 반복해서 작성하고 있나요? 코드 중복은 버그의 원인이 되고, 수정할 때도 여러 곳을 고쳐야 하는 번거로움이 있습니다.
많은 개발자들이 로직을 재사용하기 위해 유틸리티 함수를 만들지만, React의 Hook(useState, useEffect 등)은 일반 함수에서 호출할 수 없다는 제약이 있습니다. Hook은 반드시 React 함수 컴포넌트나 다른 Hook 내에서만 호출할 수 있습니다.
바로 이럴 때 필요한 것이 Custom Hook입니다. use로 시작하는 이름의 함수를 만들어 내부에서 다른 Hook들을 사용하면, 상태 로직을 깔끔하게 재사용할 수 있습니다.
개요
간단히 말해서, Custom Hook은 React의 Hook들을 조합하여 재사용 가능한 상태 로직을 만드는 함수로, 여러 컴포넌트에서 같은 로직을 공유할 수 있게 합니다. 실무에서는 데이터 페칭, 폼 관리, 인증 상태, 웹소켓 연결 등 반복되는 로직을 Custom Hook으로 추출합니다.
블록체인 UI의 경우, "블록체인 데이터 가져오기"는 여러 페이지에서 필요하므로 useBlockchain이라는 Custom Hook으로 만들면 코드 중복을 없애고 유지보수가 쉬워집니다. 기존에는 Higher-Order Component나 Render Props 패턴으로 로직을 재사용했다면, Hook은 더 간단하고 직관적인 방법을 제공합니다.
컴포넌트 계층 구조를 복잡하게 만들지 않고도 로직을 공유할 수 있습니다. Custom Hook의 핵심 특징은 첫째, use로 시작하는 네이밍 컨벤션으로 Hook 규칙 자동 검사, 둘째, 각 사용처마다 독립적인 상태 생성, 셋째, 컴포넌트처럼 다른 Hook을 자유롭게 조합 가능입니다.
이러한 특징들이 깔끔하고 재사용 가능한 코드베이스를 만듭니다.
코드 예제
// hooks/useBlockchain.ts
import { useState, useEffect } from 'react';
import { getBlockchain } from '../services/blockchainApi';
import type { Block } from '../types/blockchain';
interface UseBlockchainReturn {
blocks: Block[];
loading: boolean;
error: string | null;
refetch: () => void;
}
export const useBlockchain = (): UseBlockchainReturn => {
const [blocks, setBlocks] = useState<Block[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetchBlocks = async () => {
try {
setLoading(true);
setError(null);
const data = await getBlockchain();
setBlocks(data);
} catch (err) {
setError('Failed to fetch blockchain');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchBlocks();
}, []);
return {
blocks,
loading,
error,
refetch: fetchBlocks // 수동 재페칭 함수
};
};
// 사용 예시
export const BlockchainPage: React.FC = () => {
const { blocks, loading, error, refetch } = useBlockchain();
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<button onClick={refetch}>Refresh</button>
<BlockList blocks={blocks} />
</div>
);
};
설명
이것이 하는 일: 블록체인 데이터를 가져오는 모든 로직(상태 관리, API 호출, 에러 핸들링)을 하나의 재사용 가능한 Hook으로 캡슐화합니다. 첫 번째로, useBlockchain 함수는 use로 시작하여 React에게 이것이 Hook임을 알립니다.
이 네이밍 컨벤션 덕분에 ESLint가 Hook 규칙 위반을 자동으로 검사합니다. UseBlockchainReturn 인터페이스로 반환 타입을 명시하면, 이 Hook을 사용하는 쪽에서 자동완성과 타입 체크를 받을 수 있습니다.
내부적으로는 일반 컴포넌트와 동일하게 useState와 useEffect를 사용하는데, 차이점은 JSX를 반환하지 않고 데이터와 함수를 객체로 반환한다는 것입니다. 그 다음으로, fetchBlocks 함수를 별도로 정의하여 useEffect 내부뿐만 아니라 refetch로도 노출합니다.
이렇게 하면 컴포넌트에서 "새로고침" 버튼을 클릭했을 때 수동으로 데이터를 다시 가져올 수 있습니다. 이는 Custom Hook의 강력한 기능 중 하나로, 단순히 데이터를 제공하는 것을 넘어 데이터 조작 함수까지 함께 제공할 수 있습니다.
마지막으로, 사용 예시를 보면 useBlockchain()을 호출하는 것만으로 모든 데이터 페칭 로직이 처리됩니다. 컴포넌트는 blocks, loading, error만 신경 쓰면 되고, API 호출 방법이나 에러 핸들링 세부사항은 알 필요가 없습니다.
이를 "관심사의 분리(Separation of Concerns)"라고 하며, 컴포넌트는 UI 렌더링에만, Hook은 데이터 로직에만 집중할 수 있게 됩니다. 여러분이 이런 Custom Hook을 만들면 같은 데이터 페칭 로직을 여러 컴포넌트에서 일관되게 사용할 수 있습니다.
만약 API 엔드포인트가 변경되거나 캐싱 로직을 추가하고 싶다면 Hook 하나만 수정하면 모든 사용처에 자동으로 반영됩니다. 또한 Hook은 독립적으로 테스트할 수 있어, 컴포넌트 테스트와 분리하여 로직만 검증할 수 있습니다.
실전 팁
💡 Custom Hook의 이름은 반드시 use로 시작해야 React의 Hook 규칙 검사를 받을 수 있습니다. fetchBlockchain이 아니라 useBlockchain이어야 합니다
💡 Hook이 반환하는 값은 배열([blocks, loading])이나 객체({ blocks, loading }) 형태로 할 수 있습니다. 2-3개 값은 배열이, 4개 이상이거나 이름이 명확해야 하면 객체가 좋습니다
💡 여러 Hook이 비슷한 패턴을 공유한다면 더 일반화된 Hook을 만들 수 있습니다. 예: useFetch(url)처럼 URL을 파라미터로 받는 범용 Hook
💡 Custom Hook 내부에서 다른 Custom Hook을 사용할 수 있습니다. useBlockchain 내부에서 useApi나 useLocalStorage를 조합하여 더 복잡한 로직을 구성할 수 있습니다
💡 Hook을 테스트할 때는 @testing-library/react-hooks의 renderHook을 사용하면 실제 컴포넌트를 만들지 않고도 Hook만 독립적으로 테스트할 수 있습니다
9. React Context로 전역 상태 관리하기 - Prop Drilling 해결
시작하며
여러분이 사용자 지갑 정보를 여러 컴포넌트에서 사용하려고 하는데, 매번 props로 전달하다 보니 중간 컴포넌트들이 불필요한 props를 받고 있나요? 이를 "Prop Drilling"이라고 하며, 컴포넌트 계층이 깊어질수록 코드가 복잡해집니다.
많은 개발자들이 최상위 컴포넌트에서 하위로 props를 10단계 이상 전달하다가 유지보수에 어려움을 겪습니다. 특히 블록체인 앱에서는 지갑 주소, 잔액, 네트워크 정보 같은 전역 데이터가 많아 이 문제가 더 심각합니다.
바로 이럴 때 필요한 것이 React Context입니다. Context를 사용하면 컴포넌트 트리 전체에 데이터를 "방송"하여, 중간 컴포넌트를 거치지 않고도 필요한 곳에서 직접 접근할 수 있습니다.
개요
간단히 말해서, Context는 컴포넌트 트리 전체에 데이터를 공유하는 React의 내장 메커니즘으로, props를 일일이 전달하지 않고도 데이터에 접근할 수 있게 합니다. 실무에서는 인증 정보, 테마 설정, 언어 선택, 사용자 프로필 같은 앱 전역 데이터를 Context로 관리합니다.
블록체인 앱의 경우, 연결된 지갑 정보는 헤더의 잔액 표시, 트랜잭션 폼의 송신자 주소, 설정 페이지의 지갑 상세 정보 등 여러 곳에서 필요하므로 Context가 이상적입니다. 기존에는 최상위 컴포넌트에서 모든 상태를 관리하고 props로 전달했다면, Context는 Provider 컴포넌트가 상태를 관리하고 하위 어디서든 Consumer나 useContext Hook으로 접근할 수 있습니다.
Context의 핵심 특징은 첫째, Prop Drilling 없이 깊은 계층의 컴포넌트에 데이터 전달, 둘째, Provider 값이 변경되면 모든 Consumer가 자동 리렌더링, 셋째, 여러 Context를 중첩하여 사용 가능합니다. 이러한 특징들이 대규모 앱의 상태 관리를 단순화합니다.
코드 예제
// contexts/WalletContext.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface Wallet {
address: string;
balance: number;
}
interface WalletContextType {
wallet: Wallet | null;
connectWallet: (address: string) => void;
disconnectWallet: () => void;
}
// Context 생성
const WalletContext = createContext<WalletContextType | undefined>(undefined);
// Provider 컴포넌트
export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [wallet, setWallet] = useState<Wallet | null>(null);
const connectWallet = (address: string) => {
// 실제로는 여기서 블록체인 네트워크에 연결
setWallet({ address, balance: 100 });
};
const disconnectWallet = () => {
setWallet(null);
};
return (
<WalletContext.Provider value={{ wallet, connectWallet, disconnectWallet }}>
{children}
</WalletContext.Provider>
);
};
// Custom Hook으로 Context 사용 간소화
export const useWallet = (): WalletContextType => {
const context = useContext(WalletContext);
if (!context) {
throw new Error('useWallet must be used within WalletProvider');
}
return context;
};
// 사용 예시
export const Header: React.FC = () => {
const { wallet, disconnectWallet } = useWallet();
return (
<header>
{wallet ? (
<div>
<span>{wallet.address}</span>
<span>Balance: {wallet.balance}</span>
<button onClick={disconnectWallet}>Disconnect</button>
</div>
) : (
<span>No wallet connected</span>
)}
</header>
);
};
설명
이것이 하는 일: 지갑 정보를 전역 상태로 관리하는 Context를 만들고, 앱의 어느 컴포넌트에서든 지갑 연결/해제 기능과 현재 지갑 정보에 접근할 수 있게 합니다. 첫 번째로, createContext<WalletContextType | undefined>(undefined)로 Context를 생성합니다.
제네릭으로 타입을 지정하면 TypeScript가 Context 값의 타입을 검사합니다. 초기값을 undefined로 설정하는 이유는 Provider 외부에서 접근하려 할 때 에러를 발생시키기 위함입니다.
WalletContextType 인터페이스는 Context가 제공할 데이터와 함수들을 명시하는데, 이는 API 계약과 같아서 사용하는 쪽에서 무엇을 기대할 수 있는지 명확히 합니다. 그 다음으로, WalletProvider 컴포넌트는 실제로 상태를 관리합니다.
내부적으로 useState를 사용하여 wallet 상태를 저장하고, connectWallet과 disconnectWallet 함수를 정의합니다. 이 함수들은 Context value에 포함되어 모든 Consumer에게 제공됩니다.
<WalletContext.Provider value={...}>로 감싼 children은 이 Context 값에 접근할 수 있는 영역이 됩니다. 실무에서는 최상위 App 컴포넌트에서 이 Provider로 전체 앱을 감싸는 것이 일반적입니다.
마지막으로, useWallet Custom Hook은 Context 사용을 더 편리하게 만듭니다. useContext(WalletContext)를 직접 호출하는 대신, useWallet()을 호출하면 자동으로 Provider 외부에서 사용했는지 검사하고 명확한 에러 메시지를 제공합니다.
Header 컴포넌트 예시처럼 props 없이도 useWallet()만 호출하면 지갑 정보와 함수들에 접근할 수 있어 매우 간결합니다. 여러분이 이 패턴을 사용하면 지갑 정보가 필요한 10개의 컴포넌트가 있어도 각각 useWallet()만 호출하면 되고, 중간 컴포넌트들은 아무것도 몰라도 됩니다.
지갑 연결 로직을 수정하거나 새로운 기능을 추가할 때도 WalletProvider만 수정하면 모든 사용처에 자동으로 반영됩니다.
실전 팁
💡 Context는 자주 변경되는 값보다는 테마, 인증 정보처럼 비교적 정적인 값에 적합합니다. Context 값이 변경되면 모든 Consumer가 리렌더링되므로 성능에 영향을 줄 수 있습니다
💡 Context를 여러 개로 분리하세요. 하나의 거대한 Context보다는 WalletContext, ThemeContext, LocaleContext처럼 역할별로 나누면 불필요한 리렌더링을 줄일 수 있습니다
💡 Context 값으로 객체를 전달할 때는 useMemo로 메모이제이션하세요. value={{ wallet, connectWallet }}는 매 렌더링마다 새 객체를 생성하여 모든 Consumer를 리렌더링시킵니다
💡 Redux나 Zustand 같은 상태 관리 라이브러리는 Context보다 더 강력한 기능(미들웨어, DevTools, 시간 여행 디버깅)을 제공하므로, 복잡한 상태 로직에는 이들을 고려하세요
💡 Context는 컴포넌트 재사용성을 해칠 수 있습니다. 재사용 가능한 UI 컴포넌트(버튼, 입력 등)는 Context에 의존하지 말고 props를 사용하세요
10. CSS-in-JS로 스타일링하기 - 동적 스타일과 타입 안정성
시작하며
여러분이 블록체인 UI를 만들면서 블록의 상태(채굴됨, 대기중, 검증 실패)에 따라 다른 색상을 표시하고 싶은데, CSS 클래스를 여러 개 만들고 조건부로 적용하는 것이 번거롭게 느껴지나요? 전통적인 CSS 파일은 JavaScript와 분리되어 있어 동적 값을 전달하기 어렵고, 클래스명 충돌 문제도 있습니다.
특히 컴포넌트 기반 개발에서는 스타일도 컴포넌트와 함께 관리하는 것이 유지보수에 유리합니다. 바로 이럴 때 필요한 것이 CSS-in-JS입니다.
styled-components 같은 라이브러리를 사용하면 JavaScript 안에서 CSS를 작성하고, props를 기반으로 동적 스타일을 적용하며, TypeScript의 타입 체크까지 받을 수 있습니다.
개요
간단히 말해서, CSS-in-JS는 JavaScript 코드 안에서 CSS를 작성하는 방식으로, 컴포넌트와 스타일을 강하게 결합하여 재사용성과 유지보수성을 높입니다. 실무에서는 styled-components, Emotion, Stitches 같은 라이브러리를 사용하여 스타일드 컴포넌트를 만듭니다.
블록체인 UI의 경우, 블록 카드의 배경색을 해시값에 따라 동적으로 지정하거나, 트랜잭션 금액이 음수면 빨간색, 양수면 초록색으로 표시하는 등 로직과 스타일이 밀접하게 연관된 경우가 많습니다. 기존 CSS 파일은 전역 스코프에서 작동하여 클래스명 충돌 위험이 있었다면, CSS-in-JS는 각 컴포넌트에 고유한 클래스명을 자동 생성하여 완전히 격리된 스타일을 보장합니다.
또한 사용되지 않는 스타일은 번들에 포함되지 않아 최적화에도 유리합니다. CSS-in-JS의 핵심 특징은 첫째, props 기반의 동적 스타일링으로 조건부 렌더링 간소화, 둘째, 자동 벤더 프리픽스와 스코프 격리, 셋째, TypeScript와의 완벽한 통합으로 타입 안전성입니다.
이러한 특징들이 현대적인 컴포넌트 기반 UI 개발의 표준이 되고 있습니다.
코드 예제
// components/StyledBlockCard.tsx
import styled from 'styled-components';
import type { Block } from '../types/blockchain';
// Props 타입 정의
interface CardProps {
$isSelected?: boolean; // $ 접두사: DOM에 전달되지 않는 props
$validBlock?: boolean;
}
// 스타일드 컴포넌트 정의
const Card = styled.div<CardProps>`
padding: 20px;
margin: 10px 0;
border-radius: 8px;
background-color: ${props => props.$isSelected ? '#e3f2fd' : '#ffffff'};
border: 2px solid ${props => props.$validBlock ? '#4caf50' : '#f44336'};
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
`;
const Hash = styled.code`
font-family: 'Courier New', monospace;
font-size: 12px;
color: #666;
word-break: break-all;
`;
const TransactionCount = styled.span<{ $count: number }>`
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
background-color: ${props => props.$count > 5 ? '#ff9800' : '#4caf50'};
color: white;
font-size: 12px;
font-weight: bold;
`;
// 사용 예시
interface BlockCardProps {
block: Block;
isSelected: boolean;
onClick: () => void;
}
export const StyledBlockCard: React.FC<BlockCardProps> = ({ block, isSelected, onClick }) => {
const isValid = block.hash.startsWith('0000'); // 간단한 검증
return (
<Card $isSelected={isSelected} $validBlock={isValid} onClick={onClick}>
<h3>Block #{block.index}</h3>
<Hash>{block.hash}</Hash>
<p>Previous: {block.previousHash.substring(0, 10)}...</p>
<TransactionCount $count={block.transactions.length}>
{block.transactions.length} transactions
</TransactionCount>
</Card>
);
};
설명
이것이 하는 일: styled-components를 사용하여 블록의 상태(선택됨, 유효성)에 따라 동적으로 스타일이 변경되는 카드 컴포넌트를 만들고, 모든 스타일 props에 타입 안정성을 제공합니다. 첫 번째로, styled.div<CardProps>는 div 엘리먼트를 기반으로 한 스타일드 컴포넌트를 생성하고, 제네릭으로 props 타입을 지정합니다.
$isSelected처럼 $ 접두사를 붙이는 것은 styled-components v6의 권장 사항으로, 이 props가 실제 DOM 엘리먼트에 전달되지 않도록 합니다. 템플릿 리터럴 안에서 ${props => ...} 문법으로 JavaScript 표현식을 사용할 수 있는데, 이를 통해 props 값에 따라 CSS 속성을 동적으로 결정합니다.
예를 들어, $isSelected가 true면 파란색 배경, false면 흰색 배경이 적용됩니다. 그 다음으로, CSS의 고급 기능들도 그대로 사용할 수 있습니다.
&:hover는 해당 엘리먼트에 마우스가 올라갔을 때의 스타일이고, transition과 transform으로 부드러운 애니메이션 효과를 줍니다. 이런 스타일들이 컴포넌트와 함께 정의되어 있어, 다른 파일을 오가며 확인할 필요가 없고 컴포넌트를 다른 프로젝트에 가져갈 때도 스타일이 함께 따라옵니다.
마지막으로, TransactionCount 컴포넌트는 트랜잭션 개수에 따라 배경색이 달라지는 배지를 구현합니다. $count > 5라는 조건으로 많은 트랜잭션을 포함한 블록은 주황색, 적은 블록은 초록색으로 표시하여 시각적으로 구분할 수 있습니다.
이렇게 비즈니스 로직(트랜잭션 개수)과 스타일(배경색)을 직접 연결하면 코드의 의도가 명확해지고 유지보수가 쉬워집니다. 여러분이 CSS-in-JS를 사용하면 "이 스타일이 어디서 정의되었지?"라고 찾아 헤매는 시간이 사라지고, 컴포넌트 파일만 보면 구조와 스타일을 한눈에 파악할 수 있습니다.
또한 TypeScript 덕분에 스타일 props를 잘못 전달하면 컴파일 에러가 발생하여 런타임 버그를 사전에 방지할 수 있습니다.
실전 팁
💡 styled-components의 $ 접두사는 v6부터 도입되었으므로, 구버전 사용 시 transient props나 shouldForwardProp 설정이 필요합니다
💡 스타일드 컴포넌트는 렌더링마다 새로 생성되면 안 됩니다. 컴포넌트 외부나 상단에 정의하세요. 내부에 정의하면 매 렌더링마다 새 클래스가 생성되어 성능 문제가 발생합니다
💡 테마를 사용하려면 <ThemeProvider theme={...}>로 앱을 감싸고, 스타일드 컴포넌트에서 ${props => props.theme.primaryColor}로 접근할 수 있습니다
💡 전역 스타일은 createGlobalStyle로 정의하세요. 폰트, 리셋 CSS, body 스타일 등을 한 곳에서 관리할 수 있습니다
💡 스타일 코드가 너무 길어지면 별도 파일(BlockCard.styles.ts)로 분리하되, 컴포넌트와 같은 폴더에 두어 응집도를 유지하세요
11. 환경 변수로 설정 관리하기 - 개발/프로덕션 환경 분리
시작하며
여러분이 로컬에서는 http://localhost:3001의 블록체인 노드에 연결하고, 프로덕션에서는 https://api.myblockchain.com에 연결해야 하는데, 코드를 매번 수정하고 커밋하는 것이 번거롭나요? 하드코딩된 설정값은 환경을 전환할 때마다 코드를 수정해야 하고, 실수로 개발용 API 키를 프로덕션에 배포하는 등의 보안 문제를 일으킬 수 있습니다.
또한 민감한 정보가 코드에 포함되면 Git 히스토리에 영구히 남게 됩니다. 바로 이럴 때 필요한 것이 환경 변수입니다.
Vite의 환경 변수 시스템을 사용하면 개발/프로덕션 환경에 따라 다른 설정을 자동으로 적용하고, 민감한 정보는 Git에서 제외할 수 있습니다.
개요
간단히 말해서, 환경 변수는 코드 외부에서 설정값을 주입하는 메커니즘으로, 환경에 따라 다른 값을 사용하고 민감한 정보를 안전하게 관리할 수 있게 합니다. 실무에서는 API 엔드포인트, 데이터베이스 연결 문자열, API 키, 기능 플래그 등을 환경 변수로 관리합니다.
블록체인 앱의 경우, 네트워크 RPC URL, 체인 ID, 컨트랙트 주소 등이 환경마다 다를 수 있으므로 환경 변수로 분리하는 것이 필수입니다. 기존에는 설정 파일을 여러 개 만들고(config.dev.js, config.prod.js) 빌드 시 선택했다면, 환경 변수는 .env 파일과 시스템 환경 변수를 통해 더 표준화되고 유연한 방식을 제공합니다.
환경 변수의 핵심 특징은 첫째, 환경별로 다른 값을 코드 수정 없이 적용, 둘째, .env.local을 Git에서 제외하여 민감 정보 보호, 셋째, Vite의 VITE_ 접두사로 클라이언트 노출 변수 명시적 제어입니다. 이러한 특징들이 안전하고 관리 가능한 설정 시스템을 만듭니다.
코드 예제
# .env (기본값, Git에 커밋됨)
VITE_APP_NAME=Bitcoin Clone
VITE_API_URL=http://localhost:3001
VITE_NETWORK=testnet
VITE_ENABLE_DEBUG=true
# .env.production (프로덕션 빌드 시 자동 사용)
VITE_API_URL=https://api.myblockchain.com
VITE_NETWORK=mainnet
VITE_ENABLE_DEBUG=false
# .env.local (개발자 개인 설정, Git에서 제외)
VITE_API_URL=http://192.168.1.100:3001
VITE_DEV_WALLET_KEY=your-private-key-here
// src/config/env.ts
// 환경 변수 타입 정의 및 검증
interface EnvConfig {
appName: string;
apiUrl: string;
network: 'mainnet' | 'testnet';
enableDebug: boolean;
}
function getEnvVar(key: string, defaultValue?: string): string {
const value = import.meta.env[key];
if (!value && !defaultValue) {
throw new Error(`Environment variable ${key} is not defined`);
}
return value || defaultValue!;
}
export const config: EnvConfig = {
appName: getEnvVar('VITE_APP_NAME', 'Bitcoin Clone'),
apiUrl: getEnvVar('VITE_API_URL'),
network: getEnvVar('VITE_NETWORK') as 'mainnet' | 'testnet',
enableDebug: getEnvVar('VITE_ENABLE_DEBUG') === 'true'
};
// 사용 예시
import { config } from './config/env';
const api = axios.create({
baseURL: config.apiUrl
});
if (config.enableDebug) {
console.log('Debug mode enabled');
console.log('Connected to:', config.network);
}
설명
이것이 하는 일: Vite의 환경 변수 시스템을 활용하여 개발/프로덕션 환경에 따라 다른 API 엔드포인트와 설정을 자동으로 적용하고, 타입 안전성을 보장하는 설정 모듈을 만듭니다. 첫 번째로, .env 파일에 VITE_ 접두사를 붙여 환경 변수를 정의합니다.
Vite는 보안상의 이유로 VITE_로 시작하는 변수만 클라이언트 코드에 노출합니다. 일반 환경 변수는 서버 사이드에서만 접근 가능하므로, 프론트엔드에서 사용할 변수는 반드시 이 접두사를 붙여야 합니다.
.env 파일은 기본값을 정의하고 Git에 커밋하여 팀원들이 어떤 설정이 필요한지 알 수 있게 하고, .env.production은 프로덕션 빌드(npm run build) 시 자동으로 오버라이드됩니다. 그 다음으로, .env.local 파일은 개발자 개인의 로컬 설정을 저장하며, .gitignore에 추가하여 Git에 커밋되지 않도록 합니다.
이 파일에 개인 API 키나 로컬 네트워크 주소를 저장하면 다른 개발자나 프로덕션에 영향을 주지 않습니다. Vite는 파일 우선순위가 있어서 .env.local이 .env보다 우선 적용됩니다.
마지막으로, env.ts 모듈은 환경 변수를 타입 안전하게 사용할 수 있도록 래핑합니다. import.meta.env는 Vite가 제공하는 전역 객체로, 모든 VITE_ 환경 변수가 여기 포함됩니다.
getEnvVar 함수는 필수 환경 변수가 정의되지 않았을 때 명확한 에러를 발생시켜, 배포 후 "API URL이 없어서 앱이 안 돼요"라는 상황을 방지합니다. EnvConfig 인터페이스로 설정 객체의 타입을 명시하면, config.apiUrl을 사용할 때 자동완성과 타입 체크를 받을 수 있습니다.
여러분이 이 시스템을 사용하면 로컬에서는 개인 설정으로, 스테이징에서는 테스트 환경으로, 프로덕션에서는 실제 환경으로 자동 전환되어 코드를 단 한 줄도 수정하지 않아도 됩니다. 새로운 팀원이 합류했을 때도 .env.local을 만들고 자신의 로컬 설정만 입력하면 바로 개발을 시작할 수 있습니다.
실전 팁
💡 .env.local을 반드시 .gitignore에 추가하세요. git status로 확인하여 민감한 정보가 스테이징되지 않았는지 확인하세요
💡 환경 변수는 빌드 타임에 번들에 인라인됩니다. 따라서 런타임에 변경할 수 없으므로, 환경 변수를 바꾸려면 다시 빌드해야 합니다
💡 TypeScript에서 import.meta.env의 타입을 정의하려면 src/vite-env.d.ts에 interface ImportMetaEnv 를 확장하세요. IDE 자동완성이 작동합니다
💡 프로덕션 빌드 전에 필수 환경 변수를 검증하는 스크립트를 작성하면 배포 실패를 사전에 방지할 수 있습니다
💡 민감한 API 키는 클라이언트 코드에 절대 포함하지 마세요. 백엔드 서버에서 프록시하거나 서버리스 함수를 사용하여 키를 안전하게 보관하세요
12. 빌드와 배포 최적화 - 프로덕션 준비하기
시작하며
여러분이 블록체인 UI 개발을 완료하고 실제 사용자에게 배포하려는데, 빌드 파일이 너무 크거나 로딩이 느리다는 피드백을 받은 적이 있나요? 개발 환경에서는 문제없던 앱이 프로덕션에서 느려지는 것은 흔한 일입니다.
많은 개발자들이 npm run build만 실행하고 최적화를 소홀히 합니다. 하지만 번들 크기 최적화, 코드 스플리팅, 캐싱 전략 등을 적용하지 않으면 사용자 경험이 크게 저하될 수 있습니다.
특히 블록체인 앱은 암호화 라이브러리 등 무거운 의존성이 많아 최적화가 더욱 중요합니다. 바로 이럴 때 필요한 것이 프로덕션 빌드 최적화입니다.
Vite의 빌드 옵션과 React의 코드 스플리팅을 활용하면 초기 로딩 시간을 크게 줄이고 사용자 경험을 개선할 수 있습니다.
개요
간단히 말해서, 프로덕션 빌드 최적화는 애플리케이션의 번들 크기를 줄이고 로딩 속도를 높이기 위해 코드 스플리팅, 트리 쉐이킹, 압축 등의 기법을 적용하는 것입니다. 실무에서는 번들 분석기로 어떤 라이브러리가 용량을 많이 차지하는지 파악하고, React.lazy와 Suspense로 라우트별 코드 스플리팅을 적용하며, 이미지 최적화와 캐싱 헤더 설정으로 재방문 속도를 높입니다.
블록체인 앱의 경우, crypto 라이브러리가 크므로 이를 별도 청크로 분리하여 필요할 때만 로딩하는 것이 효과적입니다. 기존 개발 빌드는 소스맵과 디버깅 정보가 포함되어 크고 느렸다면, 프로덕션 빌드는 압축(minification), 난독화(obfuscation), 트리 쉐이킹(사용하지 않는 코드 제거)이 적용되어 훨씬 작고 빠릅니다.
프로덕션 최적화의 핵심 특징은 첫째, 코드 스플리팅으로 초기 로딩 파일 크기 최소화, 둘째, 트리 쉐이킹으로 미사용 코드 자동 제거, 셋째, 압축과 캐싱으로 전송 크기 및 재방문 속도 개선입니다. 이러한 특징들이 프로덕션 품질의 웹 애플리케이션을 완성합니다.
코드 예제
// vite.config.ts - 프로덕션 빌드 최적화
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
// 번들 분석 리포트 생성
reportCompressedSize: true,
// 청크 크기 경고 임계값 (KB)
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
// 라이브러리별로 청크 분리
manualChunks: {
'react-vendor': ['react', 'react-dom'],
'crypto-vendor': ['crypto-js'],
'axios-vendor': ['axios']
}
}
}
}
});
// App.tsx - 라우트 레벨 코드 스플리팅
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// 동적 import로 라우트 컴포넌트 로딩
const BlockchainPage = lazy(() => import('./pages/BlockchainPage'));
const TransactionPage = lazy(() => import('./pages/TransactionPage'));
const WalletPage = lazy(() => import('./pages/WalletPage'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<BlockchainPage />} />
<Route path="/transactions" element={<TransactionPage />} />
<Route path="/wallet" element={<WalletPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
export default App;
// package.json - 빌드 스크립트
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"analyze": "vite-bundle-visualizer"
},
"devDependencies": {
"vite-bundle-visualizer": "^0.6.0"
}
}
설명
이것이 하는 일: Vite의 빌드 설정으로 라이브러리별 청크 분리를 구성하고, React.lazy로 라우트별 코드 스플리팅을 적용하여 사용자가 접근하는 페이지의 코드만 로딩하도록 최적화합니다. 첫 번째로, vite.config.ts의 manualChunks 옵션은 특정 라이브러리들을 별도 청크로 분리합니다.
react-vendor 청크에는 React와 ReactDOM을, crypto-vendor에는 암호화 라이브러리를 묶습니다. 이렇게 하면 애플리케이션 코드가 변경되어도 라이브러리 청크는 캐시에 남아있어 재방문 시 다시 다운로드하지 않아도 됩니다.
chunkSizeWarningLimit은 청크가 너무 커지면 경고를 표시하여 최적화가 필요한지 알려줍니다. 그 다음으로, React.lazy(() => import('./pages/BlockchainPage'))는 동적 import를 사용하여 해당 컴포넌트를 별도 청크로 분리합니다.
이는 "라우트 기반 코드 스플리팅"이라 불리는 가장 효과적인 최적화 방법입니다. 사용자가 / 경로에 접근하면 BlockchainPage만 로딩되고, /wallet을 방문할 때 비로소 WalletPage가 로딩됩니다.
이로 인해 초기 번들 크기가 1/3로 줄어들 수 있습니다. 마지막으로, <Suspense fallback={<div>Loading...</div>}>는 lazy 컴포넌트가 로딩되는 동안 보여줄 UI를 지정합니다.
네트워크가 느린 환경에서는 청크 다운로드에 시간이 걸리므로, 로딩 스피너나 스켈레톤 UI를 표시하여 사용자에게 "앱이 작동 중"임을 알려야 합니다. vite-bundle-visualizer는 빌드 결과를 시각화하여 어떤 패키지가 번들의 대부분을 차지하는지 확인할 수 있게 해줍니다.
여러분이 이러한 최적화를 적용하면 초기 로딩 시간이 3초에서 1초 미만으로 단축될 수 있습니다. 특히 모바일 사용자나 느린 네트워크 환경에서 큰 차이를 체감할 수 있으며, 검색 엔진 최적화(SEO)에도 긍정적인 영향을 미칩니다.
프로덕션 배포 전에는 반드시 Lighthouse 점수를 확인하여 성능, 접근성, SEO 모든 측면을 점검하세요.
실전 팁
💡 npm run build를 실행한 후 dist/ 폴더의 크기를 확인하고, vite-bundle-visualizer로 어떤 패키지가 큰지 분석하세요. 예상보다 큰 패키지는 대체 라이브러리를 찾거나 트리 쉐이킹이 되도록 import 방식을 변경하세요
💡 이미지는 WebP 포맷으로 변환하고, loading="lazy" 속성을 추가하여 뷰포트에 들어올 때만 로딩하세요. vite-plugin-imagemin을 사용하면 빌드 시 자동으로 이미지를 압축할 수 있습니다
💡 React.lazy는 default export만 지원하므로, named export를 사용하는 컴포넌트는 중간에 default로 re-export하는 파일을 만들어야 합니다
💡 Service Worker를 등록하면 오프라인 지원과 더 공격적인 캐싱이 가능합니다. Vite의 PWA 플러그인을 사용하면 쉽게 구현할 수 있습니다
💡 CDN을 사용하여 정적 파일을 제공하면 전 세계 사용자에게 빠른 로딩 속도를 제공할 수 있습니다. Vercel, Netlify, Cloudflare Pages 같은 플랫폼은 자동으로 CDN을 구성해줍니다