Types
이 가이드는 TypeScript 같은 정적 타입 언어에서 데이터를 어떻게 정의하고 활용할지,
그리고 FSD 구조 안에서 각 타입을 어디에 배치하는 것이 좋은지를 설명합니다.
더 궁금한 점이 있나요?
페이지 우측의 피드백 버튼을 눌러 의견을 남겨 주세요.
여러분의 제안은 이 문서를 개선하는 데 큰 도움이 됩니다!
유틸리티 타입
유틸리티 타입은 그 자체로 큰 의미를 가지기보다는, 다른 타입과 함께 자주 사용되는 보조 타입을 말합니다.
예를 들어, 배열에서 요소 타입만 추출하는 ArrayValues 같은 타입을 아래와 같이 정의할 수 있습니다.
type ArrayValues<T extends readonly unknown[]> = T[number];
Source: https://github.com/sindresorhus/type-fest/blob/main/source/array-values.d.ts
프로젝트 전체에서 유틸리티 타입을 사용하려면 두 가지 접근이 있습니다.
-
외부 라이브러리 설치
대표적으로type-fest를 설치해서 사용합니다. -
내부 유틸리티 타입 라이브러리 구축
shared/lib/utility-types폴더를 만들고, README에 다음 내용을 명확히 적어 두세요.- 우리 팀에서 유틸리티 타입이라고 부르는 기준
- 어떤 타입을 추가/제외할지에 대한 규칙
유틸리티 타입의 재사용 가능성을 과대평가하지 마세요.
재사용 가능하다는 이유만으로 꼭 전역(shared)에 둘 필요는 없습니다.
유틸리티 타입은 아래처럼 실제 사용되는 위치 근처에 두는 것이 오히려 유지보수에 유리한 경우가 많습니다.
- 📂 pages
- 📂 home
- 📂 api
- 📄 ArrayValues.ts (유틸리티 타입)
- 📄 getMemoryUsageMetrics.ts (유틸리티 타입을 사용하는 코드)
shared/types 폴더를 만들거나, 각 slice 안에 types segment를 따로 두고 싶을 수 있습니다.
하지만 types라는 이름만으로는 해당 코드의 “목적”이 드러나지 않습니다.
segment나 폴더는 “무엇을 담는지”가 아니라 왜 존재하는지(어떤 책임을 가지는지) 를 보여 줘야 합니다.
비즈니스 entity와 상호 참조
앱에서 가장 중요한 타입은 비즈니스 entity, 즉 도메인 객체 타입입니다.
예를 들어, 음악 스트리밍 서비스를 만든다고 하면 Song, Album 같은 타입이 entity에 해당합니다.
1. 백엔드 Response 타입
먼저 백엔드에서 내려오는 데이터를 기준으로 타입을 정의합니다.
필요하다면 Zod 같은 schema 기반 유효성 검사 라이브러리를 사용해 추가적인 타입 안전성을 확보할 수도 있습니다.
import type { Artist } from "./artists";
interface Song {
id: number;
title: string;
artists: Array<Artist>;
}
export function listSongs() {
return fetch("/api/songs").then(
(res) => res.json() as Promise<Array<Song>>,
);
}
예를 들어, Song 타입이 다른 entity인 Artist를 참조한다고 가정해 봅시다.
이때 Request/Response 관련 코드를 Shared layer에 두면,
이러한 상호 참조 관계를 한곳에서 관리할 수 있어서 유지보수가 훨씬 쉬워집니다.
반대로 이 Request 함수를 entities/song/api 내부에 두면 다음과 같은 문제가 생깁니다.
entities/artist slice에서 Song 타입을 참조하고 싶어도,
FSD의 layer별 import 규칙 때문에 동일 layer 간(import) 의존은 금지됩니다.
- 규칙 요약:
“한 slice의 모듈은 자신보다 아래 layer에 있는 slice만 import할 수 있다.”
즉, 같은 layer에 있는 entity끼리는 직접 cross-import 할 수 없기 때문에 Artist → Song 의존을 바로 연결하기가 어렵습니다.
이런 경우에는 제네릭 타입 매개변수를 사용하거나, @x Public API 같은 패턴을 사용해 우회하는 전략이 필요합니다.
2. 상호 참조 해결 전략
entity끼리 서로를 참조해야 할 때 사용할 수 있는 대표적인 전략은 다음 두 가지입니다.
1. 제네릭 타입 매개변수화
entity 간에 연결이 필요한 타입에 제네릭 타입 매개변수를 선언하고, 필요한 제약 조건을 부여합니다.
예를 들어, Song 타입에 ArtistType이라는 제네릭을 두고 제약을 걸 수 있습니다.
interface Song<ArtistType extends { id: string }> {
id: number;
title: string;
artists: Array<ArtistType>;
}
이 방식은 Cart = { items: Product[] }처럼 구조가 비교적 단순한 타입과 잘 어울립니다.
반면, Country-City처럼 서로 강하게 결합된 구조는 깔끔하게 분리하기 어려울 수 있습니다.
2. Cross-import (Public API(@x) 활용)
FSD에서 entity 간 의존을 허용하려면,
참조 대상 entity 내부에 다른 entity 전용 Public API를 @x 디렉터리에 둡니다.
예를 들어 artist와 playlist가 모두 song을 참조해야 한다면,
다음과 같은 구조를 만들 수 있습니다.
- 📂 entities
- 📂 song
- 📂 @x
- 📄 artist.ts (artist entity용 public API)
- 📄 playlist.ts (playlist entity용 public API)
- 📄 index.ts (기본 public API)
📄 entities/song/@x/artist.ts 파일의 내용은
📄 entities/song/index.ts와 매우 비슷하지만,
artist에서 사용할 수 있는 부분만 노출하는 역할을 합니다.
export type { Song } from "../model/song.ts";
이렇게 분리해 두면 📄 entities/artist/model/artist.ts에서 Song을 가져올 때,
다음과 같이 의존 대상이 명확한 import를 사용할 수 있습니다.
이 방식은 entity들의 의존 관계를 코드 구조 상에서 명확하게 보여 주고, 도메인 간 분리를 유지하는 데 도움이 됩니다.
import type { Song } from "entities/song/@x/artist";
export interface Artist {
name: string;
songs: Array<Song>;
}
데이터 전송 객체와 mappers
데이터 전송 객체(Data Transfer Object, DTO)는 백엔드에서 전달되는 데이터 구조 그대로를 표현한 타입입니다.
간단한 경우에는 DTO를 프론트엔드에서 그대로 사용해도 되지만,
실제 UI나 도메인 로직에서는 다루기 불편한 경우도 많습니다.
이럴 때 mapper를 사용해 DTO를 프론트엔드 친화적인 형태로 변환합니다.
DTO 배치 위치
DTO를 어디에 둘지는 백엔드와의 코드 공유 방식에 따라 달라집니다.
- 백엔드 타입을 별도 패키지로 공유하고 있다면 → 해당 패키지에서 DTO를 가져와서 사용하면 됩니다.
- 코드 공유가 없다면 → 프론트엔드 코드베이스 안 어딘가에 DTO를 정의해야 합니다.
Request 함수가 shared/api에 있다면,
DTO도 가능한 한 바로 옆에 두는 것을 권장합니다.
import type { ArtistDTO } from "./artists";
interface SongDTO {
id: number;
title: string;
artist_ids: Array<ArtistDTO["id"]>;
}
export function listSongs() {
return fetch("/api/songs").then(
(res) => res.json() as Promise<Array<SongDTO>>,
);
}
mapper 배치 위치
mapper는 DTO를 인자로 받아 변환하는 함수이므로, DTO 정의와 최대한 가까운 위치에 두는 것이 좋습니다.
import type { ArtistDTO } from "./artists";
interface SongDTO {
id: number;
title: string;
disc_no: number;
artist_ids: Array<ArtistDTO["id"]>;
}
interface Song {
id: string;
title: string;
/** 디스크 번호까지 포함한 전체 제목 */
fullTitle: string;
artistIds: Array<string>;
}
function adaptSongDTO(dto: SongDTO): Song {
return {
id: String(dto.id),
title: dto.title,
fullTitle: `${dto.disc_no} / ${dto.title}`,
artistIds: dto.artist_ids.map(String),
};
}
export function listSongs() {
return fetch("/api/songs").then(async (res) =>
(await res.json()).map(adaptSongDTO),
);
}
Request와 DTO가 shared/api에 있다면 → mapper도 shared/api에 둡니다.
Request와 store가 entity slice 내부에 있다면 → mapper도 해당 slice 안에 두되,
layer 간 cross-import 제한을 반드시 고려해야 합니다.
import type { ArtistDTO } from "entities/artist/@x/song";
export interface SongDTO {
id: number;
title: string;
disc_no: number;
artist_ids: Array<ArtistDTO["id"]>;
}
import type { SongDTO } from "./dto";
export interface Song {
id: string;
title: string;
/** 노래의 전체 제목, 디스크 번호까지 포함된 제목입니다. */
fullTitle: string;
artistIds: Array<string>;
}
export function adaptSongDTO(dto: SongDTO): Song {
return {
id: String(dto.id),
title: dto.title,
fullTitle: `${dto.disc_no} / ${dto.title}`,
artistIds: dto.artist_ids.map(String),
};
}
import { adaptSongDTO } from "./mapper";
export function listSongs() {
return fetch("/api/songs").then(async (res) =>
(await res.json()).map(adaptSongDTO),
);
}
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
import { listSongs } from "../api/listSongs";
export const fetchSongs = createAsyncThunk("songs/fetchSongs", listSongs);
const songAdapter = createEntityAdapter();
const songsSlice = createSlice({
name: "songs",
initialState: songAdapter.getInitialState(),
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchSongs.fulfilled, (state, action) => {
songAdapter.upsertMany(state, action.payload);
});
},
});
중첩 DTO 처리
하나의 백엔드 Response 안에 여러 entity가 함께 포함되는 경우도 있습니다.
예를 들어 곡 정보에 저자(Author) 객체 전체가 포함되는 식입니다.
이럴 때 entity들끼리는 서로의 존재를 완전히 모른 채 DTO 안에서만 연결될 수도 있습니다.
이 경우 간접 연결(middleware 등)로 우회하는 것보다,
@x 표기법을 활용해 명시적으로 cross-import를 허용하는 편이 나을 때가 많습니다.
(예: Redux Toolkit + Normalizr를 조합해 사용하는 패턴)
import {
createSlice,
createEntityAdapter,
createAsyncThunk,
createSelector,
} from "@reduxjs/toolkit";
import { normalize, schema } from "normalizr";
import { getSong } from "../api/getSong";
// Normalizr entity schema
export const artistEntity = new schema.Entity("artists");
export const songEntity = new schema.Entity("songs", {
artists: [artistEntity],
});
const songAdapter = createEntityAdapter();
export const fetchSong = createAsyncThunk(
"songs/fetchSong",
async (id: string) => {
const data = await getSong(id);
// 데이터를 정규화하여 리듀서가 예측 가능한 payload를 로드할 수 있도록 합니다:
const normalized = normalize(data, songEntity); // `action.payload = { songs: {}, artists: {} }`
return normalized.entities;
},
);
export const slice = createSlice({
name: "songs",
initialState: songAdapter.getInitialState(),
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchSong.fulfilled, (state, action) => {
songAdapter.upsertMany(state, action.payload.songs);
});
},
});
const reducer = slice.reducer;
export default reducer;
export { fetchSong } from "../model/songs";
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
import { fetchSong } from "entities/song/@x/artist";
const artistAdapter = createEntityAdapter();
export const slice = createSlice({
name: "users",
initialState: artistAdapter.getInitialState(),
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchSong.fulfilled, (state, action) => {
// 같은 fetch 결과를 처리하며, 여기서 artists를 삽입합니다.
artistAdapter.upsertMany(state, action.payload.artists);
});
},
});
const reducer = slice.reducer;
export default reducer;
이 방법을 사용하면 slice 간 완전한 독립성은 다소 줄어들지만,
어차피 강하게 묶여 있는 두 entity의 관계를
코드 상에서 명확하게 드러낼 수 있다는 장점이 있습니다.
즉, 나중에 둘 중 하나를 수정할 때 연결된 entity까지 함께 리팩토링해야 한다는 사실을 더 쉽게 인지할 수 있습니다.
Global 타입과 Redux
Global 타입은 애플리케이션 전역에서 사용되는 타입을 말하며, 크게 두 가지 종류로 나눌 수 있습니다.
- 애플리케이션에 특화되지 않은 제너릭 타입
- 애플리케이션 전체가 알고 있어야 하는 전역 도메인 타입
1) 제너릭 타입
첫 번째 경우(특정 도메인에 묶이지 않은 제너릭 타입)는 Shared 폴더 안의 적절한 segment에 배치하면 됩니다.
예를 들어, 분석(analytics) 관련 전역 인터페이스라면 shared/analytics에 두는 식입니다.
shared/types 폴더는 만들지 않는 것을 권장합니다.
타입이기 때문이라는 이유 하나로 서로 무관한 타입들을 모아두면, 나중에 어떤 타입이 어디에 속하는지 찾기 어렵고,
구조도 쉽게 흐트러집니다.
2) 애플리케이션 Global 타입
이 부분은 특히 Redux(순수 Redux + RTK 미사용) 프로젝트에서 자주 등장합니다.
모든 reducer를 합쳐야 비로소 store 타입이 완성되는데, 이 타입은 애플리케이션 전역에서 selector에 필요하게 됩니다.
import { combineReducers, createStore } from "redux";
import { songReducer } from "entities/song";
import { artistReducer } from "entities/artist";
const rootReducer = combineReducers(songReducer, artistReducer);
const store = createStore(rootReducer);
type RootState = ReturnType<typeof rootReducer>;
type AppDispatch = typeof store.dispatch;
이때, shared/store에서 useAppDispatch, useAppSelector 같은
커스텀 훅을 만들고 싶어도,
import 규칙에 의해
App layer에 있는 RootState, AppDispatch 타입을 바로 가져올 수 없습니다.
한 slice의 module은 자신보다 하위 layer에 있는 slice만 import할 수 있습니다.
권장 해결책
이 경우에는 Shared ↔ App layer 간에 한정된 암묵적 의존성을 허용하는 것이 현실적인 해결책입니다.
RootState, AppDispatch 타입은 자주 바뀌지 않고, Redux 사용 경험이 있는 개발자에게는 매우 익숙한 개념이기 때문에,
이 정도의 의존성은 유지보수 부담이 크지 않습니다.
/* 이전 코드 블록과 동일한 내용입니다… */
declare type RootState = ReturnType<typeof rootReducer>;
declare type AppDispatch = typeof store.dispatch;
import {
useDispatch,
useSelector,
type TypedUseSelectorHook,
} from "react-redux";
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
열거형(enum)
enum 타입은 다음 원칙에 따라 배치하는 것을 권장합니다.
- 가능한 한 가장 가까운 사용 위치에 정의합니다.
- 어떤 segment에 둘지는 용도 기준으로 결정합니다.
- UI toast 상태를 표현하는 enum →
uisegment - 백엔드 Response 상태를 표현하는 enum →
apisegment
- UI toast 상태를 표현하는 enum →
프로젝트 전역에서 공통으로 쓰는 값(예: Response 상태, 디자인 토큰 등)은
Shared layer에 두고,
역할에 따라 api, ui 등 적절한 segment를 선택합니다.
타입 검증 Schema와 Zod
데이터의 형태와 제약 조건을 검증하려면 Zod 같은 라이브러리로 validation schema를 정의합니다.
schema의 위치는 어디에서 쓰이는 데이터인지에 따라 결정합니다.
- 백엔드 Response 검증 →
apisegment 근처 - 폼 입력 값 검증 →
uisegment (또는 복잡한 경우modelsegment)
검증 schema는 DTO를 받아 파싱하고,
schema와 맞지 않으면 즉시 에러를 던집니다.
(Data transfer objects and mappers 섹션도 참고하세요.)
특히 백엔드 Response가 예상한 schema와 일치하지 않을 때
request를 실패시키도록 구현하면,
버그를 비교적 이른 시점에 발견할 수 있습니다.
이 때문에 검증 schema는 보통 api segment에 두는 편이 일반적입니다.
Component props, context 타입
일반적으로 Component의 props 타입과 context 타입은 해당 Component/Context를 정의한 파일과 같은 파일에 둡니다.
만약 단일 파일(Vue·Svelte 등)에서
여러 Component가 같은 Interface를 공유해야 한다면,
같은 폴더(보통 ui segment)에 별도의 타입 파일을 만드는 방식도 사용할 수 있습니다.
interface RecentActionsProps {
actions: Array<{ id: string; text: string }>;
}
export function RecentActions({ actions }: RecentActionsProps) {
/* … */
}
Vue에서 Interface를 별도 파일에 저장하는 패턴이 대표적인 예입니다.
export interface RecentActionsProps {
actions: Array<{ id: string; text: string }>;
}
<script setup lang="ts">
import type { RecentActionsProps } from "./RecentActionsProps";
const props = defineProps<RecentActionsProps>();
</script>
Ambient 선언 파일(*.d.ts)
Vite나 ts-reset 같은 일부 패키지는
전역 Ambient 선언이 필요합니다.
내용이 단순하다면 src/에 바로 두어도 괜찮습니다.
디렉터리 구조를 더 명확히 하고 싶다면 app/ambient/에 두는 것도 좋습니다.
타입 정의가 없는 외부 패키지에 대해서는
shared/lib/untyped-packages/%LIB%.d.ts 파일을 만들고,
그 안에 직접 타입을 선언합니다.
타입이 없는 외부 패키지
타입 정의가 없는 외부 라이브러리는 declare module을 사용해 미타입으로 선언하거나 직접 타입을 정의해야 합니다.
이때 권장 위치는 shared/lib/untyped-packages입니다.
이 폴더 안에 %LIBRARY_NAME%.d.ts 파일을 만들고,
해당 라이브러리에 필요한 타입들을 선언하세요.
// 공식 타입 정의가 없는 라이브러리 예시
declare module "use-react-screenshot";
타입 자동 생성
외부 schema(OpenAPI 등)로부터 타입을 자동 생성하는 경우에는 전용 디렉터리를 두는 것이 좋습니다.
예를 들어 shared/api/openapi와 같은 폴더를 만들고, README.md에 다음 내용을 함께 기록해 두는 것을 추천합니다.
- 이 폴더에 있는 파일들의 용도
- 타입을 재생성하는 방법 (스크립트 명령어 등)