금요일 퇴근 버스는 지옥이었다.
지옥이라고 표현하면 좀 과한 게 아닌가 싶겠지만, 그 버스에 타본 사람이라면 수긍하리라 확신한다. 일단 화장실이 급했다. 근본적인 생리적 욕구가 해결되지 않은 상태에서 만원 버스에 올라탄 것이다. 신호등 앞에서 멈출 때마다 복부에서 올라오는 압력은 내가 인간임을 절절히 상기시켰다. 사람들은 빈틈없이 구겨져 있었고, 커브길을 돌 때마다 그 인간 덩어리가 우르르 한쪽으로 쏠렸다. 나는 손잡이를 잡은 채 고통에 시달렸다.
환승하면 나아지겠거니 했다. 틀렸다. 두 번째 버스도 상황은 비슷했다. 금요일 저녁의 대중교통이란, 퇴근하는 직장인들의 집단적 귀소본능이 물리적으로 응축된 현상이다. 버스는 느렸고, 사람들은 많았고, 나의 방광은 한계에 가까워지고 있었다.
어찌어찌 살아서 자취방 문을 열었을 때, 나는 신발도 벗지 않고 화장실로 직행했다. 인간의 존엄이 회복되는 순간이었다. 그리고 문득 깨달았다. 오늘이 첫 주의 마지막 날이라는 것을. 5일. 고작 5일이 지났을 뿐인데 마치 5주가 지난 것 같다. 이게 사회인의 시간 감각인 것일까.
1주차 근무 결산, 혹은 자기 변호

5일 동안 내가 한 것을 정리해보면 이렇다.
- 개발 환경 세팅
- 자기소개
- 코드 분석
- 노션 팀페이지 제작
- 코드 분석
- 코드 분석
- 코드 분석...?
리스트의 절반 이상이 코드 분석이다. 내가 만든 코드가 아닌, 남이 만든 코드를 읽는 것. 5일 중 4일을 남의 코드를 읽는 데 썼다고 해도 과언이 아니다. 생산적인 활동이라고 하기엔 뭔가 찜찜하고, 비생산적이라고 하기엔 분명 배운 것은 있다. 이 애매한 상태가 나를 불안하게 만든다.
1달 뒤에 있을 성과 발표에서 나는 대체 뭘 발표할 것인가. "저는 1주차에 남의 코드를 열심히 읽었습니다?" 박수가 나올까? 아마 어색한 침묵이 흐르겠지. 차라리 닉슨처럼 기자회견을 열어서 "전 사기꾼이 아닙니다!"를 외치는 게 더 반응이 좋을지도 모른다.
하지만 변명을 하자면─ 아니, 변명이 아니라 사실을 말하자면, 남의 코드를 읽는 것은 생각보다 많은 것을 가르쳐준다. 특히 실무 코드는 지망생들이 짜던 토이/사이드 프로젝트와 차원이 다르다. 왜 이런 구조를 썼는지, 왜 이 라이브러리를 선택했는지, 왜 이 패턴을 적용했는지. 코드 한 줄 한 줄에 이유가 있고, 그 이유를 추적하는 과정에서 배움이 발생한다. 적어도 나는 그렇게 믿고 있다. 그렇게 믿지 않으면 이번 주가 너무 허무해지니까.
그래서 오늘은 이번 주 코드 분석을 통해 배운 것들을 남김없이 정리해보려 한다. 회사의 기밀에 해당하는 구체적인 코드는 당연히 공개할 수 없지만, 그 코드에서 사용된 기술과 패턴, 그리고 그것들이 왜 존재하는지에 대해서는 충분히 이야기할 수 있다. 남의 코드를 읽었으니, 이제는 그 독서록이라도 제대로 써보자.
공항의 보안검색대: Axios 인터셉터

프로젝트의 코드를 처음 열었을 때, API 통신 쪽에서 가장 먼저 눈에 들어온 것이 Axios 인터셉터였다. 인터셉터라는 이름부터가 뭔가 거창해 보이지만, 개념 자체는 생각보다 단순하다.
Axios가 뭔데
우선 Axios부터 짚고 넘어가자. Axios는 브라우저와 Node.js 환경에서 HTTP 요청을 보내기 위한 JavaScript 라이브러리다. 쉽게 말해서 프론트엔드에서 백엔드 서버에 "이 데이터 좀 줘" 하고 요청을 보내거나, "이 데이터 저장해줘" 하고 데이터를 보내는 역할을 한다.
브라우저에는 이미 fetch라는 내장 API가 있는데 굳이 Axios를 쓰는 이유가 뭘까. 여러 가지가 있지만, 핵심은 편의 기능이 많다는 것이다. JSON 데이터 자동 변환, 요청 취소, 타임아웃 설정, 그리고 지금 이야기할 인터셉터까지. fetch로도 이 모든 것을 구현할 수 있지만, 직접 구현해야 한다. Axios는 그걸 기본으로 제공한다.
import axios from 'axios';
// GET 요청: 데이터를 가져온다
const response = await axios.get('/api/users');
// POST 요청: 데이터를 보낸다
await axios.post('/api/users', {
name: '홍길동',
email: 'gildong@example.com'
});
여기까지는 별 것 없다. 진짜 Axios의 진가는 axios.create()로 인스턴스를 만들고, 거기에 인터셉터를 붙일 때 드러난다.
인터셉터란 무엇인가
인터셉터(Interceptor)는 비행기가 아니다. HTTP 요청이 서버로 떠나기 전, 그리고 서버로부터 응답이 도착한 직후에 가로채서 무언가를 할 수 있게 해주는 기능이다. 공항의 보안검색대를 떠올리면 이해가 빠르다.
- 출국 보안검색대(요청 인터셉터)
탑승객(요청)이 비행기(서버)에 타기 전에 검사한다. 여권 확인, 소지품 검사, 금지 물품 차단. 요청이 서버에 도달하기 전에 인증 토큰을 붙이거나, 데이터를 변환하거나, 로깅을 하는 것과 같다.
- 입국 보안검색대(응답 인터셉터)
탑승객(응답)이 도착한 후에 검사한다. 세관 신고, 검역 검사. 응답 데이터를 가공하거나, 에러를 일괄 처리하거나, 인증이 만료됐을 때 자동으로 토큰을 갱신하는 것과 같다.
import axios from 'axios';
// Axios 인스턴스 생성
const apiClient = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000, // 10초
});
// 요청 인터셉터: 서버로 떠나기 전에 가로챈다
apiClient.interceptors.request.use(
(config) => {
// 모든 요청에 인증 토큰을 자동으로 붙인다
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log(`[요청] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
// 요청 자체가 실패한 경우 (네트워크 에러 등)
return Promise.reject(error);
}
);
// 응답 인터셉터: 서버로부터 응답이 도착한 직후에 가로챈다
apiClient.interceptors.response.use(
(response) => {
// 2xx 범위의 상태 코드: 성공
console.log(`[응답] ${response.status} ${response.config.url}`);
return response;
},
(error) => {
// 2xx 범위 밖의 상태 코드: 실패
if (error.response?.status === 401) {
// 인증 만료, 로그인 페이지로 이동
window.location.href = '/login';
}
return Promise.reject(error);
}
);
이게 왜 좋으냐면, 인터셉터가 없으면 매번 API를 호출할 때마다 토큰을 꺼내서 붙이고, 에러를 처리하는 코드를 반복해야 한다. 10개의 API 호출이 있으면 10번 반복. 100개면 100번. 인터셉터는 이 반복을 한 곳에서 처리해준다.
// 인터셉터가 없다면...
const token = localStorage.getItem('accessToken');
try {
const res = await axios.get('/api/users', {
headers: { Authorization: `Bearer ${token}` }
});
} catch (error) {
if (error.response?.status === 401) {
window.location.href = '/login';
}
}
// 이걸 매번, 모든 API 호출마다 반복해야 한다.
// 상상만 해도 끔찍하다.
인터셉터를 쓰면 이런 보일러플레이트를 한 방에 날릴 수 있다. 모든 요청에 자동으로 토큰이 붙고, 모든 응답에서 401 에러가 자동으로 처리된다. API를 호출하는 쪽에서는 그냥 apiClient.get('/api/users')만 하면 된다. 깔끔하다.
실무에서 자주 쓰이는 패턴
인터셉터가 실무에서 쓰이는 대표적인 패턴 몇 가지를 정리하면 이렇다.
1. 인증 토큰 자동 첨부
위에서 본 것처럼, 모든 요청에 Authorization 헤더를 자동으로 붙인다. 이게 가장 흔한 용례다.
2. 토큰 자동 갱신 (리프레시)
액세스 토큰이 만료되어 401 응답이 오면, 리프레시 토큰으로 새 액세스 토큰을 발급받고, 원래 요청을 자동으로 재시도하는 패턴이다. 사용자 입장에서는 토큰이 만료되었다는 사실을 전혀 인지하지 못한다.
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 401 에러이고, 아직 재시도하지 않은 요청이라면
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 재시도 플래그
try {
// 리프레시 토큰으로 새 액세스 토큰 발급
const { data } = await axios.post('/api/auth/refresh', {
refreshToken: localStorage.getItem('refreshToken'),
});
// 새 토큰 저장
localStorage.setItem('accessToken', data.accessToken);
// 원래 요청에 새 토큰을 붙여서 재시도
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
// 리프레시마저 실패하면 로그인 페이지로
localStorage.clear();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
_retry 플래그가 핵심이다. 이게 없으면 401 → 재시도 → 또 401 → 재시도의 무한 루프에 빠질 수 있다. 한 번 재시도한 요청은 다시 재시도하지 않도록 막아주는 안전장치다.
3. 로딩 상태 관리
요청이 시작되면 로딩 스피너를 띄우고, 응답이 오면 끈다. 이것도 인터셉터로 깔끔하게 처리할 수 있다. 물론 이 패턴은 전역 로딩 상태가 필요한 경우에만 쓰고, 개별 컴포넌트의 로딩은 따로 관리하는 게 보통이다.
4. 에러 메시지 통합 관리
서버에서 내려주는 에러 형식이 제각각일 때, 응답 인터셉터에서 에러 메시지를 통일된 형태로 변환해서 내려줄 수 있다. 그러면 컴포넌트에서는 에러의 원래 형식이 어떻든 상관없이 일관된 방식으로 에러를 보여줄 수 있다.
그리고 하나 더.
5. 데이터 형식 변환
이것은 바로 다음 섹션에서 다룬다.
뱀과 낙타의 통역사: 케이스 변환

코드를 분석하다 보니 요청 인터셉터와 응답 인터셉터에 낯선 유틸 함수가 달려 있었다. camelToSnake, snakeToCamel 같은 이름이었다. 처음에는 왜 이런 변환이 필요한지 이해하지 못했다. 알고 보니 이건 프론트엔드와 백엔드 사이의 '언어 차이' 때문이었다.
카멜 케이스와 스네이크 케이스
프로그래밍에서 변수나 함수 이름을 지을 때, 여러 단어를 합치는 방식에 관례가 있다.
카멜 케이스(camelCase): 첫 단어는 소문자, 이후 단어의 첫 글자를 대문자로. 낙타의 혹처럼 중간중간 튀어나온다고 해서 카멜 케이스다. JavaScript와 TypeScript의 표준 컨벤션이다.
userName, createdAt, isLoggedIn, orderItemList
스네이크 케이스(snake_case): 모든 단어를 소문자로 쓰고, 언더스코어(_)로 연결한다. 밑줄이 뱀처럼 기어가는 모양이라 스네이크 케이스다. Python, Ruby, 그리고 많은 백엔드 프레임워크와 데이터베이스에서 표준으로 쓴다.
user_name, created_at, is_logged_in, order_item_list
문제는 이것이다. 프론트엔드(JavaScript/TypeScript)는 카멜 케이스를 많이 쓰고, 백엔드(Python/Django, Ruby/Rails 등)는 스네이크 케이스를 많이 쓴다. 같은 데이터인데 이름이 다르다. 프론트에서 userName이라고 부르는 것을 백엔드에서는 user_name이라고 부른다.
그래서 둘 사이에서 통역이 필요하다. 프론트엔드가 백엔드에 데이터를 보낼 때는 카멜 케이스를 스네이크 케이스로 바꿔야 하고, 백엔드에서 데이터를 받을 때는 스네이크 케이스를 카멜 케이스로 바꿔야 한다.
왜 통일하지 않나
"그러면 둘 다 같은 케이스를 쓰면 안 되나?"라고 생각할 수 있다. 이론적으로는 가능하다. 하지만 현실적으로는 힘들다. 각 언어와 프레임워크에는 커뮤니티에서 오랜 시간에 걸쳐 형성된 컨벤션이 있다. JavaScript에서 스네이크 케이스를 쓰면 린터(ESLint)가 경고를 띄우고, Python에서 카멜 케이스를 쓰면 PEP 8을 어기게 된다. 각자의 영역에서는 각자의 관례를 따르는 게 자연스럽고 유지보수에도 좋다.
그래서 경계면, 즉 프론트엔드와 백엔드가 통신하는 지점에서 변환을 하는 것이 일반적인 해결책이다. 그리고 이 변환을 Axios 인터셉터에서 처리하면, 개별 API 호출마다 변환 로직을 넣을 필요가 없어진다.
인터셉터에서의 변환
실제 구현은 대략 이런 식이다.
import axios from 'axios';
// 객체의 모든 키를 재귀적으로 변환하는 유틸 함수
function convertKeys(obj: any, converter: (key: string) => string): any {
if (Array.isArray(obj)) {
return obj.map(item => convertKeys(item, converter));
}
if (obj !== null && typeof obj === 'object') {
return Object.keys(obj).reduce((result, key) => {
result[converter(key)] = convertKeys(obj[key], converter);
return result;
}, {} as any);
}
return obj;
}
// 카멜 → 스네이크
function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
}
// 스네이크 → 카멜
function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
const apiClient = axios.create({ baseURL: '/api' });
// 요청 인터셉터: 카멜 → 스네이크 (보내기 전에 변환)
apiClient.interceptors.request.use((config) => {
if (config.data) {
config.data = convertKeys(config.data, camelToSnake);
}
if (config.params) {
config.params = convertKeys(config.params, camelToSnake);
}
return config;
});
// 응답 인터셉터: 스네이크 → 카멜 (받은 후에 변환)
apiClient.interceptors.response.use((response) => {
if (response.data) {
response.data = convertKeys(response.data, snakeToCamel);
}
return response;
});
이렇게 해두면 프론트엔드 코드에서는 모든 것을 카멜 케이스로 다룰 수 있다. 백엔드가 user_name을 보내든 created_at을 보내든, 프론트엔드에서는 userName과 createdAt으로 받게 된다. 반대로 프론트에서 { userName: '홍길동' }을 보내면 백엔드에는 { user_name: '홍길동' }이 도착한다. 양쪽 다 자기 관례를 따르면서도 문제없이 통신할 수 있는 것이다.
convertKeys 함수가 재귀적(recursive)으로 동작한다는 점이 중요하다. 객체 안에 객체가 있고, 그 안에 또 배열이 있는 중첩 구조에서도 모든 키를 빠짐없이 변환해야 하니까. 한 겹만 변환하면 안쪽의 키는 스네이크 케이스로 남아버린다.
실무에서는 직접 구현하지 않고 humps 같은 라이브러리를 쓰기도 한다. 하지만 원리를 알아두면 변환 로직을 커스터마이징해야 할 때 도움이 된다. 예를 들어 특정 키는 변환하고 싶지 않다거나, 특정 패턴은 다르게 처리해야 한다거나 하는 경우 말이다.
곰이 상태를 관리한다: Zustand

코드 분석 중에 상태 관리 부분을 살펴보게 됐는데, Redux도 아니고 Context API도 아닌 낯선 녀석이 자리를 차지하고 있었다. Zustand. 독일어로 '상태(State)'라는 뜻이란다. 그리고 이 라이브러리의 마스코트는 곰이다. 귀엽긴 한데, 왜 하필 곰인지는 모르겠다. 독일에 곰이 많은 건가.
왜 상태 관리가 필요한가
React에서 상태(state)란 화면에 보여지는 데이터를 말한다. 사용자의 로그인 여부, 장바구니에 담긴 상품 목록, 현재 선택된 탭, 다크 모드 여부. 이런 것들이 전부 상태다.
문제는 이 상태를 여러 컴포넌트에서 공유해야 할 때 발생한다. React에서 데이터는 기본적으로 부모에서 자식으로 흐른다. A라는 컴포넌트의 상태를 B라는 컴포넌트에서 쓰려면, A와 B의 공통 부모에 상태를 올리고 props로 내려줘야 한다. 이걸 '상태 끌어올리기(Lifting State Up)'라고 한다.
컴포넌트 트리가 깊어지면 이 props 전달이 끔찍해진다. A → B → C → D → E, 이런 식으로 중간 컴포넌트들이 자기는 쓰지도 않는 데이터를 그저 아래로 전달하기 위해 props를 받아야 한다. 이걸 Props Drilling이라고 부르는데, 드릴로 구멍을 뚫듯이 데이터를 아래로 관통시킨다는 뜻이다. 비유가 꽤 적절하다.
React에는 이를 해결하기 위한 Context API가 내장되어 있다. 하지만 Context API에는 성능 문제가 있다. Context의 값이 바뀌면, 그 Context를 구독하고 있는 모든 컴포넌트가 리렌더링된다. 값을 하나만 바꿨는데 관련 없는 컴포넌트까지 전부 다시 그려지는 것이다. 작은 앱에서는 눈에 안 띄지만, 앱이 커지면 성능에 영향을 준다.
그래서 외부 상태 관리 라이브러리가 존재한다. Redux, MobX, Recoil, Jotai, 그리고 Zustand. 이 중에서 Zustand는 가장 간결하고 가장 적은 보일러플레이트를 요구하는 녀석이다.
Zustand의 기본 사용법
Zustand의 철학은 단순하다. 스토어를 만들고, 훅으로 쓴다. 끝이다.
import { create } from 'zustand';
// 스토어 생성
const useBearStore = create((set) => ({
// 상태
bears: 0,
// 액션 (상태를 변경하는 함수)
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
이게 전부다. Redux였으면 액션 타입 정의, 액션 생성자, 리듀서, 스토어 설정, Provider 래핑까지 해야 했을 것이다. Zustand는 create 함수 하나로 끝난다.
쓰는 쪽도 간단하다.
function BearCounter() {
const bears = useBearStore((state) => state.bears);
return <h1>{bears}마리의 곰이 있습니다</h1>;
}
function Controls() {
const increasePopulation = useBearStore((state) => state.increasePopulation);
return <button onClick={increasePopulation}>곰 추가</button>;
}
useBearStore에 셀렉터 함수를 넘겨서 필요한 상태만 꺼내 쓴다. 여기서 핵심은 셀렉터다. BearCounter는 bears만 구독하고 있으므로, bears가 바뀔 때만 리렌더링된다. 다른 상태가 바뀌어도 이 컴포넌트는 영향을 받지 않는다. Context API의 '전부 리렌더링' 문제가 여기서 해결된다.
그리고 Provider가 필요 없다. Redux나 Context API는 컴포넌트 트리 최상단에 Provider를 감싸야 한다. Zustand는 그런 거 없이 그냥 아무 데서나 훅을 호출하면 된다. 이게 생각보다 편하다.
비동기 액션
Zustand는 비동기 작업도 자연스럽게 처리한다. set 함수를 async 함수 안에서 호출하면 된다.
const useFishStore = create((set) => ({
fishes: [],
isLoading: false,
fetchFishes: async () => {
set({ isLoading: true });
try {
const response = await fetch('/api/fishes');
const fishes = await response.json();
set({ fishes, isLoading: false });
} catch (error) {
set({ isLoading: false });
console.error('물고기를 불러오는 데 실패했습니다:', error);
}
},
}));
Redux에서는 비동기 작업을 위해 redux-thunk나 redux-saga 같은 미들웨어를 따로 설치해야 한다. Zustand는 그냥 async/await을 쓰면 된다. 별도의 미들웨어가 필요 없다. Zustand가 비동기든 동기든 상관하지 않기 때문이다.
get으로 외부에서 상태 읽기
set 외에 get이라는 두 번째 인자도 있다. 액션 내부에서 현재 상태를 읽을 때 쓴다.
const useSoundStore = create((set, get) => ({
sound: 'grunt',
volume: 50,
playSound: () => {
const { sound, volume } = get(); // 현재 상태를 읽는다
// sound와 volume을 사용해서 뭔가를 한다
console.log(`${sound}를 볼륨 ${volume}으로 재생`);
},
}));
set은 상태를 쓰고, get은 상태를 읽는다. 깔끔한 대칭이다.
슬라이스 패턴
앱이 커지면 하나의 거대한 스토어보다 기능별로 쪼개는 게 관리하기 편하다. Zustand에서는 이걸 슬라이스(Slice) 패턴으로 구현한다.
// 인증 관련 슬라이스
const createAuthSlice = (set) => ({
user: null,
isLoggedIn: false,
login: (userData) => set({ user: userData, isLoggedIn: true }),
logout: () => set({ user: null, isLoggedIn: false }),
});
// 테마 관련 슬라이스
const createThemeSlice = (set) => ({
isDarkMode: false,
toggleTheme: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
});
// 합치기
const useStore = create((...args) => ({
...createAuthSlice(...args),
...createThemeSlice(...args),
}));
각 슬라이스는 독립적으로 정의하고, 최종적으로 하나의 스토어에 합친다. 파일도 분리할 수 있으니 팀 작업에서도 충돌이 줄어든다. Redux의 combineReducers와 비슷한 개념이지만, 문법이 훨씬 간결하다.
미들웨어
Zustand에는 내장 미들웨어도 있다. 자주 쓰이는 것 몇 가지만 언급하면 다음과 같다.
- persist
상태를 localStorage나 sessionStorage에 자동으로 저장하고, 페이지를 새로고침해도 상태가 유지되게 해준다. 로그인 상태나 사용자 설정 같은 것에 유용하다.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useSettingsStore = create(
persist(
(set) => ({
language: 'ko',
setLanguage: (lang) => set({ language: lang }),
}),
{
name: 'settings-storage', // localStorage에 저장될 키 이름
}
)
);
- devtools
Redux DevTools와 연동해서 상태 변화를 시각적으로 추적할 수 있게 해준다. 디버깅할 때 유용하다.
- immer
불변성을 직접 관리하지 않아도 되게 해주는 미들웨어다. 기본적으로 Zustand의 set은 불변성을 유지해야 하는데(스프레드 연산자 등을 써서), immer를 쓰면 직접 상태를 수정하는 것처럼 코드를 짜도 내부적으로 불변성이 유지된다.
Zustand vs Redux vs Context
솔직히 말해서 Zustand가 Redux보다 모든 면에서 낫다고 하기는 힘들다. Redux는 대규모 팀 프로젝트에서 엄격한 패턴을 강제하는 장점이 있고, 디버깅 도구와 미들웨어 생태계가 훨씬 넓다. 하지만 중소 규모 프로젝트에서, 복잡한 설정 없이 빠르게 전역 상태를 관리하고 싶다면 Zustand가 상당히 매력적이다. 보일러플레이트가 거의 없고, 러닝커브가 낮고, 번들 사이즈도 작다.
Context API는 상태 관리 라이브러리라기보다는 의존성 주입(Dependency Injection) 도구에 가깝다. 테마나 언어 설정처럼 자주 바뀌지 않는 값을 전달하는 데는 적합하지만, 자주 업데이트되는 상태를 관리하는 데는 성능 이슈가 있다. 물론 Context를 여러 개로 쪼개면 완화할 수 있긴 한데, 그 시점에서 이미 Zustand를 쓰는 게 편하다.
우리 프로젝트에서 Zustand를 선택한 이유도 아마 이런 맥락이 아닐까 싶다. 과도한 보일러플레이트 없이, 필요한 곳에서 필요한 만큼만 상태를 관리한다. 곰이 나를 대신해서 상태를 관리하고 있다고 생각하면 왠지 마음이 놓인다. 곰은 듬직하니까.
길을 찾아주는 사람: React Router

SPA(Single Page Application)에서 라우팅이란 URL에 따라 어떤 화면을 보여줄지 결정하는 것이다. 브라우저의 주소창에 /home을 치면 홈 화면이, /about을 치면 소개 화면이, /users/123을 치면 123번 사용자의 프로필이 나타나야 한다. 전통적인 웹사이트에서는 서버가 각 URL에 대응하는 HTML을 보내줬지만, SPA에서는 JavaScript가 URL을 해석하고 적절한 컴포넌트를 렌더링한다. 이 역할을 하는 것이 React Router다.
React Router v7
우리 프로젝트는 React Router v7을 쓰고 있다. v7은 v6에서 큰 틀은 유지하면서 꽤 의미 있는 변화를 가져왔다. 가장 눈에 띄는 변화부터 말하자면, react-router-dom이 사라졌다. 정확히는 react-router 하나로 통합됐다.
// v6
import { BrowserRouter, Route } from 'react-router-dom';
// v7
import { BrowserRouter, Route } from 'react-router';
예전에는 react-router(코어)와 react-router-dom(웹용)이 따로 있었다. v7에서는 패키지가 하나로 합쳐졌으므로, import 경로만 바꾸면 된다. 작은 변화 같지만 의존성 관리가 깔끔해진다.
그리고 v7의 큰 틀은 v6.4에서 도입된 Data Router 패러다임을 이어받고 있다. createBrowserRouter로 라우터를 생성하고, RouterProvider로 앱에 제공하는 방식이다. 과거의 <BrowserRouter> 컴포넌트 방식도 여전히 작동하지만, 새로운 Data Router가 공식 권장 방식이다.
import { createBrowserRouter, RouterProvider } from 'react-router';
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'about', element: <AboutPage /> },
{ path: 'users/:userId', element: <UserProfilePage /> },
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
객체 기반으로 라우트를 정의하는 방식이 처음에는 낯설었다. JSX로 <Route path="/" element={<Home />} />라고 쓰는 게 더 직관적으로 느껴졌으니까. 하지만 객체 방식은 라우트 설정을 별도 파일로 분리하기 쉽고, loader와 action 같은 데이터 관련 기능을 자연스럽게 붙일 수 있다. 쓰다 보면 익숙해진다.
중첩 라우팅 (Nested Routing)
React Router의 가장 강력한 기능 중 하나가 중첩 라우팅이다. 페이지 안에 또 다른 페이지를 넣는 개념이라고 생각하면 된다.
예를 들어 대시보드 페이지를 만든다고 하자. 대시보드에는 항상 사이드바와 헤더가 있고, 가운데 콘텐츠 영역만 URL에 따라 바뀐다. /dashboard/analytics이면 통계 화면이, /dashboard/settings이면 설정 화면이 가운데에 렌더링된다. 사이드바와 헤더는 그대로 유지되면서.
const router = createBrowserRouter([
{
path: '/dashboard',
element: <DashboardLayout />, // 사이드바 + 헤더 + Outlet
children: [
{ index: true, element: <DashboardHome /> }, // /dashboard
{ path: 'analytics', element: <Analytics /> }, // /dashboard/analytics
{ path: 'settings', element: <Settings /> }, // /dashboard/settings
],
},
]);
DashboardLayout 컴포넌트는 이렇게 생겼다.
import { Outlet } from 'react-router';
function DashboardLayout() {
return (
<div className="dashboard">
<Sidebar />
<div className="content">
<Header />
<Outlet /> {/* 여기에 자식 라우트의 컴포넌트가 렌더링된다 */}
</div>
</div>
);
}
<Outlet />이 핵심이다. 이 컴포넌트는 현재 URL에 매칭되는 자식 라우트의 컴포넌트를 렌더링한다. URL이 /dashboard/analytics이면 <Outlet /> 자리에 <Analytics />가, /dashboard/settings이면 <Settings />가 들어간다. 부모 레이아웃은 그대로 유지되면서 내부 콘텐츠만 교체되는 것이다.
중첩은 여러 단계로 할 수 있다. 대시보드 안에 설정이 있고, 설정 안에 프로필 설정과 알림 설정이 있는 식으로.
{
path: '/dashboard',
element: <DashboardLayout />,
children: [
{
path: 'settings',
element: <SettingsLayout />, // 설정 탭 메뉴 + Outlet
children: [
{ path: 'profile', element: <ProfileSettings /> },
{ path: 'notifications', element: <NotificationSettings /> },
],
},
],
}
/dashboard/settings/profile로 접근하면 DashboardLayout 안에 SettingsLayout이 있고, 그 안에 ProfileSettings가 렌더링된다. 마트료시카 인형처럼 레이아웃이 중첩되는 셈이다.
동적 라우트 파라미터
URL에 동적인 값을 넣는 것도 가능하다. 사용자 프로필 페이지를 생각해보자. 사용자가 1000명이라면 1000개의 라우트를 정의할 수는 없다.
{
path: 'users/:userId', // :userId는 동적 파라미터
element: <UserProfile />,
}
아래의 :userId 부분이 동적 파라미터다. /users/123이든 /users/abc든, 이 패턴에 맞는 URL은 전부 <UserProfile />로 라우팅된다.
import { useParams } from 'react-router';
function UserProfile() {
const { userId } = useParams();
// userId는 '123' 또는 'abc' 등 URL에 입력된 값
return <div>{userId}번 사용자의 프로필</div>;
}
그리고 컴포넌트에서는 useParams 훅으로 값을 꺼낸다.
useNavigate와 프로그래매틱 네비게이션
사용자가 링크를 클릭해서 이동하는 것 외에, 코드에서 직접 페이지를 이동시켜야 할 때가 있다. 로그인 성공 후 대시보드로 이동, 폼 제출 후 목록 페이지로 이동 같은 경우.
import { useNavigate } from 'react-router';
function LoginForm() {
const navigate = useNavigate();
const handleLogin = async (formData) => {
const result = await login(formData);
if (result.success) {
navigate('/dashboard'); // 대시보드로 이동
}
};
// ...
}
navigate(-1)을 쓰면 뒤로 가기, navigate(1)은 앞으로 가기. 브라우저 히스토리를 직접 조작하는 것이다. navigate('/login', { replace: true })처럼 replace 옵션을 주면 현재 히스토리 항목을 대체한다. 로그아웃 후 뒤로 가기를 눌러도 이전 페이지로 돌아가지 않게 하고 싶을 때 쓴다.
v7에서 달라진 것들
v7은 Remix와의 통합이 큰 특징이다. Remix는 React Router를 만든 팀이 만든 풀스택 프레임워크인데, v7에서 Remix의 많은 기능이 React Router로 흡수되었다. loader와 action 함수를 이용한 서버 사이드 데이터 로딩, 스트리밍, 프리렌더링 같은 기능들이 그것이다.
물론 우리 프로젝트에서 이런 프레임워크 모드 기능을 쓰고 있는 것 같지는 않다. SPA로서의 React Router 기능, 그러니까 클라이언트 사이드 라우팅에 집중하고 있는 것으로 보인다. 이 부분은 좀 더 코드를 분석해봐야 확실해질 것 같다.
v7의 다른 개선점으로는 타입 안전성 강화가 있다. 라우트 파라미터의 타입을 자동으로 추론해주는 기능이 추가됐다고 한다. TypeScript를 쓰는 프로젝트에서는 꽤 유용하지 않을까.
문 앞의 경비원: 인증 시스템

로그인과 회원가입. 거의 모든 웹 애플리케이션에 존재하는 기능이다. 사용자가 누구인지 확인하고, 권한에 따라 접근을 허용하거나 차단한다. 코드를 분석하면서 이 부분의 흐름을 따라가봤는데, 생각보다 복잡하면서도 정교했다.
토큰 기반 인증의 기본 구조
웹에서의 인증은 크게 세션 기반과 토큰 기반으로 나뉜다. 우리 프로젝트는 토큰 기반 인증을 쓰고 있었다. 구체적으로는 JWT(JSON Web Token) 방식이다.
흐름은 이렇다.
1. 사용자가 아이디와 비밀번호를 입력해서 로그인 요청을 보낸다.
2. 서버가 자격 증명을 확인하고, 맞으면 두 개의 토큰을 발급한다.
- 액세스 토큰(Access Token): API 요청할 때 "나 인증된 사용자야"를 증명하는 토큰. 유효 기간이 짧다. (보통 15분~1시간)
- 리프레시 토큰(Refresh Token): 액세스 토큰이 만료됐을 때 새 액세스 토큰을 발급받기 위한 토큰. 유효 기간이 길다. (보통 며칠~몇 주)
3. 프론트엔드는 이 토큰들을 저장한다.
4. 이후 API 요청마다 액세스 토큰을 헤더에 실어 보낸다.
5. 서버는 토큰을 검증하고, 유효하면 요청을 처리한다.
왜 토큰이 두 개인지 의문이 들 수 있다. 하나만 쓰면 안 되나? 보안 때문이다.
액세스 토큰은 매 요청마다 네트워크를 타고 돌아다닌다. 만약 누군가 이 토큰을 탈취하면, 그 사람은 나인 척 API를 호출할 수 있다. 그래서 액세스 토큰의 수명을 짧게 만든다. 탈취당해도 금방 만료되니까.
하지만 수명이 짧으면 사용자가 15분마다 다시 로그인해야 하는 문제가 생긴다. 이걸 해결하는 게 리프레시 토큰이다. 액세스 토큰이 만료되면 리프레시 토큰으로 조용히 새 액세스 토큰을 받아오는 것이다. 사용자는 아무것도 모른다.
자동 로그인
앱을 껐다가 다시 켰을 때, 매번 로그인하라고 하면 사용자는 화가 날 것이다. 어쩌면 사이트에 다시는 안 올지도 모른다. 자동 로그인은 이 문제를 해결한다.
원리는 간단하다. 토큰을 브라우저의 저장소(localStorage, sessionStorage, 혹은 Secure Cookie)에 저장해두고, 앱이 시작될 때 저장소에 토큰이 있는지 확인한다. 있으면 그 토큰으로 사용자 정보를 불러오고, 로그인 상태로 진입한다.
// 앱 초기화 시 실행되는 로직 (예시)
async function initializeAuth() {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
// 리프레시 토큰이 없으면 로그인 필요
return { isLoggedIn: false, user: null };
}
if (!accessToken) {
// 액세스 토큰이 없지만 리프레시 토큰은 있다
// → 리프레시 토큰으로 새 액세스 토큰 발급 시도
try {
const newTokens = await refreshAccessToken(refreshToken);
localStorage.setItem('accessToken', newTokens.accessToken);
// 새 토큰으로 사용자 정보 조회
const user = await fetchUserInfo(newTokens.accessToken);
return { isLoggedIn: true, user };
} catch {
// 리프레시마저 만료됨
localStorage.clear();
return { isLoggedIn: false, user: null };
}
}
// 액세스 토큰이 있다 → 사용자 정보 조회 시도
try {
const user = await fetchUserInfo(accessToken);
return { isLoggedIn: true, user };
} catch {
// 액세스 토큰이 만료되었을 수 있음 → 리프레시 시도
// (위와 비슷한 로직)
}
}
이 초기화 로직이 앱의 최상단에서 실행된다. 보통은 React의 루트 컴포넌트나 라우터 설정 부분에서 앱이 마운트될 때 호출한다. 그리고 결과에 따라 로그인 페이지로 보내거나, 메인 페이지로 보내거나 결정한다.
Zustand의 persist 미들웨어가 여기서 빛을 발한다. 인증 상태를 Zustand 스토어에서 관리하면서 persist로 localStorage에 자동 동기화하면, 위의 초기화 로직을 더 깔끔하게 구성할 수 있다.
관리자 권한 감지
모든 사용자가 같은 권한을 가지지는 않는다. 일반 사용자와 관리자는 볼 수 있는 페이지가 다르다. 관리자 대시보드나 사용자 관리 페이지는 일반 사용자에게 보여서는 안 된다.
이걸 프론트엔드에서 처리하는 방법은 크게 두 가지다.
1. 라우트 가드(Route Guard)
특정 라우트에 접근하기 전에 권한을 확인하는 컴포넌트를 만든다.
function AdminRoute({ children }) {
const user = useAuthStore((state) => state.user);
if (!user) {
// 로그인 안 됨 → 로그인 페이지로
return <Navigate to="/login" replace />;
}
if (user.role !== 'admin') {
// 관리자가 아님 → 접근 거부
return <Navigate to="/unauthorized" replace />;
}
return children;
}
// 라우터 설정에서
{
path: '/admin',
element: (
<AdminRoute>
<AdminDashboard />
</AdminRoute>
),
}
2. 조건부 렌더링
컴포넌트 내부에서 권한에 따라 다른 UI를 보여준다.
function Navigation() {
const user = useAuthStore((state) => state.user);
return (
<nav>
<Link to="/">홈</Link>
<Link to="/profile">프로필</Link>
{user?.role === 'admin' && (
<Link to="/admin">관리자 페이지</Link>
)}
</nav>
);
}
중요한 점은, 프론트엔드의 권한 체크는 UX를 위한 것이지 보안을 위한 것이 아니라는 사실이다. 진짜 보안은 서버에서 해야 한다. 프론트엔드에서 관리자 페이지를 숨겨도, 누군가 URL을 직접 입력하거나 API를 직접 호출하면 우회할 수 있다. 그래서 서버 측에서도 반드시 토큰에 포함된 권한 정보를 검증해야 한다.
인증 흐름과 인터셉터의 만남
앞서 다룬 Axios 인터셉터가 여기서 다시 등장한다. 인증 시스템의 많은 부분이 인터셉터를 통해 자동화된다.
요청 인터셉터에서는 저장된 액세스 토큰을 자동으로 모든 요청의 헤더에 붙인다. 응답 인터셉터에서는 401(Unauthorized) 에러가 오면 리프레시 토큰으로 자동 갱신을 시도한다. 갱신에 성공하면 원래 요청을 재시도하고, 실패하면 로그인 페이지로 보낸다.
이 모든 것이 개별 API 호출 코드에서는 보이지 않는다. 컴포넌트에서는 그냥 apiClient.get('/api/data')만 호출하면 되고, 토큰 관리는 인터셉터가 알아서 한다. 깔끔한 관심사의 분리(Separation of Concerns)다.
우아한 퇴장: Motion

React에서 컴포넌트가 화면에 나타나는 것은 쉽다. {show && <Component />}로 끝이다. 하지만 사라지는 것은 어렵다. React는 조건이 false가 되는 순간 컴포넌트를 DOM에서 즉시 제거해버린다. 페이드 아웃 같은 퇴장 애니메이션을 넣고 싶어도, 컴포넌트가 이미 사라진 뒤라서 애니메이션을 적용할 대상이 없다. 존재하지 않는 것에 옷을 입힐 수는 없으니까.
이 문제를 해결하는 것이 Motion(이전 이름: Framer Motion) 라이브러리의 AnimatePresence 컴포넌트다.
Motion이란
Motion은 React를 위한 애니메이션 라이브러리다. 원래 Framer Motion이라는 이름으로 알려져 있었는데, 언젠가부터 그냥 Motion으로 리브랜딩됐다. import 경로도 framer-motion에서 motion/react로 바뀌었다.
Motion의 핵심은 <motion.div>, <motion.span> 같은 motion 컴포넌트다. 기존 HTML 요소를 motion. 접두사로 감싸면 애니메이션 props를 사용할 수 있게 된다.
import { motion } from 'motion/react';
function FadeInBox() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }} // 처음 상태
animate={{ opacity: 1, y: 0 }} // 최종 상태
transition={{ duration: 0.5 }} // 전환 설정
>
안녕하세요!
</motion.div>
);
}
initial은 컴포넌트가 마운트될 때의 초기 상태, animate는 도달해야 할 최종 상태, transition은 그 사이의 전환을 어떻게 할지 정의한다. CSS 애니메이션이나 @keyframes를 쓰는 것보다 직관적이다. 선언적으로 "이 상태에서 저 상태로 가라"고 말해주면 된다.
AnimatePresence: 퇴장의 미학
앞서 말한 '사라지는 애니메이션' 문제를 해결하는 것이 AnimatePresence다.
import { motion, AnimatePresence } from 'motion/react';
function Modal({ isOpen }) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} // 사라질 때의 애니메이션
transition={{ duration: 0.3 }}
>
모달 내용
</motion.div>
)}
</AnimatePresence>
);
}
exit prop이 핵심이다. AnimatePresence로 감싸진 자식 컴포넌트가 React 트리에서 제거될 때, 즉시 DOM에서 제거하지 않고 exit 애니메이션을 먼저 실행한다. 애니메이션이 끝난 후에야 비로소 DOM에서 제거된다.
AnimatePresence가 동작하려면 직속 자식(direct children)에 고유한 key prop이 있어야 한다. React가 어떤 요소가 추가되고 어떤 요소가 제거됐는지 추적하기 위해서다. 이건 React의 리스트 렌더링에서 key가 필요한 것과 같은 이유다.
페이지 전환 애니메이션
우리 프로젝트에서 AnimatePresence는 라우터 설정 부분에 달려 있었다. 페이지가 전환될 때 자연스러운 애니메이션을 적용하기 위해서다.
React Router의 <Outlet />과 조합하면 이런 식이 된다.
import { Outlet, useLocation } from 'react-router';
import { motion, AnimatePresence } from 'motion/react';
function AnimatedLayout() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname} // URL이 바뀔 때마다 새 컴포넌트로 인식
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}
key에 location.pathname을 주는 것이 포인트다. URL이 /home에서 /about으로 바뀌면, key가 바뀌므로 React는 이전 컴포넌트가 제거되고 새 컴포넌트가 추가된 것으로 인식한다. AnimatePresence는 이전 컴포넌트에 exit 애니메이션을 실행하고, 새 컴포넌트에 initial → animate 애니메이션을 실행한다.
mode="wait"는 이전 페이지의 퇴장 애니메이션이 완전히 끝난 후에야 새 페이지의 등장 애니메이션을 시작하라는 뜻이다. 이게 없으면 이전 페이지가 사라지는 동시에 새 페이지가 나타나서 두 페이지가 겹쳐 보일 수 있다.
이 레이아웃 컴포넌트를 라우터 설정에서 부모 라우트로 쓰면, 그 아래의 모든 자식 라우트 전환에 애니메이션이 적용된다. 모든 페이지에 일일이 애니메이션 코드를 넣을 필요 없이, 라우터 설정 한 곳에서 관리할 수 있는 것이다. 우리 프로젝트에서도 이 패턴을 쓰고 있었다.
Motion의 다른 기능들
AnimatePresence 외에도 Motion에는 유용한 기능이 많다. 간략하게만 언급하면 이런 것들이 있다.
- 레이아웃 애니메이션
layout prop을 붙이면 컴포넌트의 크기나 위치가 바뀔 때 자동으로 애니메이션이 적용된다. 리스트에서 아이템을 삭제했을 때 나머지 아이템들이 부드럽게 재배치되는 효과를 아주 쉽게 구현할 수 있다.
- 제스처 애니메이션
whileHover, whileTap, whileDrag 같은 prop으로 사용자 인터랙션에 반응하는 애니메이션을 만들 수 있다. 버튼을 누를 때 살짝 작아지거나, 카드 위에 마우스를 올리면 살짝 떠오르는 효과 같은 것.
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
클릭
</motion.button>
- Variants
여러 컴포넌트의 애니메이션을 동기화할 수 있다. 부모가 애니메이션 상태를 바꾸면 자식들도 따라서 변하는 식이다. 메뉴가 열릴 때 각 메뉴 아이템이 순서대로 나타나는 효과를 구현할 때 유용하다.
- 스프링 물리
단순한 선형 애니메이션이 아니라 실제 물리 시뮬레이션(스프링, 관성)을 적용할 수 있다. 요소를 드래그해서 놓았을 때 탄성 있게 원래 자리로 돌아가는 느낌 같은 것. 이런 물리 기반 애니메이션이 '자연스러운' 느낌을 만들어준다.
CSS 애니메이션으로도 물론 많은 것을 할 수 있다. 하지만 Motion은 React의 상태 변화와 자연스럽게 연동되고, 특히 exit 애니메이션처럼 CSS만으로는 다루기 어려운 영역을 깔끔하게 해결해준다. 라이브러리 하나 추가하는 대가치고는 얻는 게 꽤 크다.
데스크 위의 데스커

잡담에 가깝지만, 내일 저녁 즈음에 데스커(DESKER) 사의 책상이 도착한다. 자취방에 제대로 된 책상이 없어서 지금까지 바닥에 노트북을 놓고 작업하거나, 임시 테이블 위에 올려놓고 쓰고 있었다. 이래놓고 코드 분석을 한다는 것도 웃기기는 하지만 어쨌거나 내일부터는 달라진다. 책상이 오면 데스크탑도 설치할 수 있고, 모니터도 놓을 수 있고, 인간다운 작업 환경을 구축할 수 있다.
사실 책상이 온다는 게 이렇게 기쁜 일인 줄 몰랐다. 취준생 시절에는 책상이 있든 없든 큰 상관이 없었다. 어차피 할 일도 없었으니까. 하지만 매일 코드를 읽고, 글을 쓰고, 공부를 해야 하는 지금은 다르다. 제대로 된 작업 공간이 간절하다. 내일 도착하면 조립하고 짐 정리하고, 주말 내내 세팅에 매달릴 것 같다.
1주일 근무를 마치며

5일이 지났다. 코드를 읽고, 또 읽고, 또 읽었다. 내가 만든 것은 노션 팀페이지가 전부다. 생산적이었냐고 물으면 솔직히 자신이 없다.
하지만 이번 주에 읽은 코드들은 분명 나에게 무언가를 가르쳐줬다. Axios 인터셉터가 어떻게 반복을 줄여주는지, Zustand가 왜 Redux보다 간결한지, React Router의 중첩 라우팅이 어떻게 레이아웃을 구성하는지, 토큰 기반 인증이 어떤 흐름으로 동작하는지, Motion의 AnimatePresence가 왜 필요한지. 남의 코드를 읽는 것이 전부였지만, 그 과정에서 배운 것은 적지 않았다. 거기에 주말에도 이 작업은 계속될 예정이다. 지금까지의 내용은 결코 끝이 아니다.
다음 주에는 사수님이 돌아오신다. 그러면 본격적인 업무가 시작될 것이다. 코드를 읽기만 하던 시기가 끝나고, 직접 코드를 쓰는 시기가 올 것이다. 솔직히 두렵다. 읽는 것과 쓰는 것은 차원이 다른 문제니까. 남의 소설을 읽는 것과 내 소설을 쓰는 것이 전혀 다른 것처럼.
그래도 일단은 주말이다. 내일 책상이 오면 조립하고, 데스크탑 세팅하고, 짐 정리를 해야겠다. 그런 다음에는 롤체나 발로란트 좀 해보고. 추가 분석을 해도 할 건 하는 게 맞으리라. 그리고 월요일에는 조금 더 나은 신입 개발자가 되어 출근하자. 조금이라도.
금요일 밤, 자취방은 조용하다. 이 고요함이 이제는 익숙하다. 아니, 좋다. 1주차를 살아남았다는 안도감과 함께, 이 조용한 방에서 블로그를 쓰고 있으니까. 불금이라기에는 뭔가 부족하지만, 나쁘지 않은 금요일이다.

'📗 일지' 카테고리의 다른 글
| 개발자 일지 #8 - 건조주의보 (1) | 2026.02.09 |
|---|---|
| 개발자 일지 #7 - 우리 일지 정상 영업합니다 (0) | 2026.02.09 |
| 개발자 일지 #4 - Interlude (1) | 2026.02.05 |
| 개발자 일지 #3 - 기능미 (機能美) (5) | 2026.02.05 |
| 개발자 일지 #2 - 가지 않은 길 (1) | 2026.02.04 |