Record의 고급활용법
먼저 Record란 무엇인가
- 객체의 타입을 정의하는 타입스크립트 유틸리티 타입
- 키와 값의 타입을 명시적으로 지정
- 일반 객체보다 더 엄격하고 안전한 타입체크
// Record의 기본형태
Record<Keys, Type>
// 실제 예시
Record<string, number> // 키는 string, 값은 number인 객체
Record<'a'|'b',boolean> // 키는 'a','b' 중 하나 값은 boolean
일반객체 vs Record
// 일반객체 - 타입 안전성 부족
const normalObject : { [key:string] : any } = {
name : '김철수',
age : 30,
city : '서울'
};
// 이런 실수가 가능하다.
normalObject.agee = 31; // 오타를 했는데도 에러가 나지 않는다.
normalObject.random = "아무거나"; // 예상치 못한 속성 추가
// Record를 쓰면 타입 안전성이 보장된다.
const userInfo : Record<'name' | 'age' | 'city', string|number> = {
name : '김철수',
age : 30,
city : '서울'
}
// 다음과 같은 실수를 방지할 수 있다.
userInfo.agee = 31; // 컴파일 에러
userInfo.random = '아무거나' // 컴파일 에러
상태관리에서의 Record 활용
패턴1 _ 폼 에러 상태 관리
// 기존 방식 - 타입 안전성이 부족
const [errors, setErrors] = useState<any>({});
// Record 방식 - 타입 안전성 보장
interface FormFields{
title : string;
content : string;
category : string;
}
// 이렇게 되면 keyof FormFields는 "title" | "content" | "category" 가 된다.
const [formErrors, setFormErrors] = useState<Record<keyof FormFields, string>>({
title : "",
content : "",
category : ""
});
// 에러 설정함수 - 타입 안전
const setFieldError = (field : keyof FormFields, error : string) => {
setFormErrors(prev => ({
...prev,
[field]:error // 이렇게 되면 field는 반드시 formField의 키여야 한다.
}));
}
// 사용 예시
const validateForm = () => {
if(!formData.title){
setFieldError('title', '제목을 입력해주세요') // 안전
// setFieldError('titlee', '에러'); // 컴파일 에러
}
}
패턴 2 : 동적 로딩 상태 관리
// 여러 api의 로딩 상태를 한번에 관리
type ApiEndpoints = 'announcements' | 'users' | 'courses' | 'videos';
const [loadingStates, setLoadingStates] = useState<Record<ApiEndpoints, boolean>>(
{
announcements : false,
users : false,
courses : false,
videos : false
});
// 타입 안전한 로딩 상태 변경
const setLoading = (endpoint : ApiEndpoints, isLoading : boolean) => {
setLoadingStates(prev => ({
...prev,
[endpoint]:isLoading
}));
};
// 사용 예시
const loadAnnouncements = async() => {
setLoading('announcements', true); // 타입안전하다.
try{
const data = await getAnnouncements();
// 처리...
}finally{
setLoading('announcements', false);
}
}
// 컴포넌트에서 다음과 같이 사용
{loadingStates.announcements && <div>공지사항 로딩 중 ... </div>}
패턴 3 : 복잡한 필터상태
// 다양한 타입의 필터를 안전하게 관리
interface FilterConfig{
search : string;
category : 'all' | 'notice' | 'event';
isTop : boolean;
dateRange : [Date, Date] | null;
}
const [filters, setFilters] = useState<Record<keyof FilterConfig, FilterConfig[keyof Filterconfig]>>({
search : "",
category : "all",
isTop : false,
dateRange : null,
page : 1
});
// 타입 안전한 필터 업데이트
const updateFilter = <K extends keyof FilterConfig>(
key : K,
value : FilterConfig[K]
)=>{
setFilters(prev => ({
...prev,
[key]:value,
// 필터 변경시 페이지는 항상 1로 리셋
...(key !== 'page' && { page : 1 }
}))
}
// 사용예시 - 모든 타입이 정확히 체크됨
updateFilter('search', '검색어');
updateFilter('category', 'notice');
updateFilter('isTop', true);
// updateFilter('category', 'invalid'); // 컴파일 안됨
* <K extends keyof FilterConfig>
- K는 제네릭을 의미, 함수나 클래스를 정의할 때, 타입을 확정하지 않고, 실제로 사용하는 시점에 타입을 지정할 수 있게 해주는 문법이다. 마치 함수의 매개변수처럼 타입을 파라미터로 받는다.
예를 들어서, updateFilter('search', '검색어') 라고 함수를 호출하면 이 때 k는 'search'라는 타입으로 확정된다.
keyof : keyof FilterConfig는 filterConfig인터페이스가 가진 모든 키들의 타입을 유니온 타입으로 만들어준다.
( "~~"|"~~" )
FilterConfig는 search, category, isTop, dateRange, page 라는 키를 가지고 있으므로, keyof FilterConfig는 다음과 같은 타입이 된다.
type FilterKeys = key of FilterConfig;
// Filter Keys는 'search' | 'category' | 'isTop' | 'dateRange' | 'page'와 같다.
* extends 제약조건
- extends는 제네릭 타입 K가 가질 수 있는 타입을 제한하는 역할을 한다.
- K extends keyof FilterConfig를 직역하면 K는 keyof FilterConfig 타입을 확장(상속)한다 이지만, 제네릭에서는 K는 반드시 keyof FilterConfig 타입에 포함되는 타입이어야 한다 라는 제약 조건으로 해석하는 것이 더 쉽다.
** 종합해서 이해하면,
<K extends keyof FilterConfig> 를 한문장으로 풀어서 설명하면 다음과 같다.
'updateFilter 함수는 제네릭 타입 K를 사용하는데, 이 K는 반드시 FilterConfig의 키 중 하나여야만 한다.
이 제네릭 덕분에 key와 value 파라미터의 타입이 서로 완벽하게 연결될 수 있다.
- key : K _ 함수의 첫번째 인자인 key는 K 타입을 가진다.
- value : FilterConfig[K] _ 두번째 인자인 value는 FilterConfig에서 K라는 키를 가진 값의 타입을 가진다.
// 예시
updateFilter('category', 'notice')를 호출하면
- K는 'category' 타입으로 추론된다.
- key의 타입은 'category'가 된다.
- value의 타입은 FilterConfig['category'] 즉 'all'|'notice'|'event가 된다.
- 따라서 'notice'는 유효한 값이고, 'invalid' 같은 값을 넣으면 컴파일 에러가 발생하여 타입 안전성이 확보되는 것이다.
* 그렇다면 ...(key !== 'page' && { page : 1}) 의 기능과 page 설정 방법은 무엇일까?
- 이 방법은 자바스크립트의 단축 평가와 전개구문을 활용한 매우 세련된 코드이다.
- 이 코드는 '만약 지금 변경하는 필터가 page가 아니라면, 페이지 번호를 1로 리셋해라'라는 의미를 가진다.
- 게시판에서 검색어나 카테고리를 바꾸면 보통 첫 페이지부터 다시 보게되는 것과 같은 UX를 구현하는 것이다.
ㄴ 이제 명확한 이해를 위해서 단축 평가를 알아야 한다고 한다.
사례 1 : key가 'search' 일 경우
- 'search' !== 'page'는 true이다.
- true && {page : 1}이 실행된다.
- && 연산자는 오른쪽 값인 {page:1} 객체를 반환한다.
사례 2 : key가 'page'일 경우
- 'page' !== 'page'는 false이다.
- false && {page : 1}이 실행된다.
- && 연산자는 왼쪽 값인 false를 반환하고, 오른쪽은 아예 실행하지 않는다.
... (전개구문) : 이 연산자는 객체나 false 같은 값을 펼쳐준다.
- 사례 1의 결과 ({page:1})가 들어왔을 때
- ...{page:1}은 page : 1이라는 속성을 바깥 객체에 추가한다.
- 결과적으로 setFilters는 {...prev, search : '새검색어', page : 1}과 같이 업데이트 된다.
- (search도 바뀌고 page도 1로 리셋됨 )
- 사례 2의 결과 false가 들어왔을 때
- ...false는 아무일도 하지 않는다. ...false, ...null, ...undefined는 그냥 무시된다.
- 결과적으로 setFilters는 {...prev, page : 3 }와 같이 page 값만 업데이트 한다.
ㄴ Truthy 와 Falsy
1. Falsy : false, 0, "", null, undefined, NaN은 모두 falsy
2. Truthy : 일반 문자열, 숫자, 객체, 배열, 등
패턴4 : API 응답캐싱
interface CacheEntry<T>{
data : T;
timestamp : number;
expires : number;
}
// 다양한 api 응답을 타입별로 캐싱
const [apiCache, setApiCache] = useState<Record<string, CacheEntry<any>>>({});
// 제네릭을 활용한 타입 안전한 캐시함수
const setCacheData = <T>(key : string, data : T, ttl : number = 5*60*1000)=>{
const now = Date.now();
setApiCache(prev => ({
...prev,
[key] : {
data,
timestamp : now,
expires : now + ttl
}
}));
};
위의 코드는 리액트 환경에서 API 응답을 효율적으로 관리하기 위한 클라이언트 측 캐시를 구현한 것이다.
* CacheEntry<T> : 캐시 데이터의 설계도이다. 어떤 데이터든 캐시에 넣으려면 이 규칙을 따라야 한다.
interface CacheEntry<T>{
data : T;
timestamp : number;
expires : number;
}
- data : T;
ㄴ 실제 데이터 그 자체이다. 사용자 정보 User가 될 수도, 상품 목록 Product[]이 될 수도 있다.
- timestamp : number;
ㄴ 이 데이터가 언제 저장되었는지 기록
- expires : number;
ㄴ 이 데이터가 언제까지 유효한지...이 시간이 지나면 데이터는 신선하지 않다고 판단한다.
* apiCache : 캐시 저장공간이다.
const [apiCache, setApiCache] = useState<Record<string, CacheEntry<any>>>({});
- 저장소의 상태가 바뀌면 화면을 다시 그릴 수 있도록 리액트의 상태로 관리한다.
- Record<string, CacheEntry<any>> : 저장소의 타입
ㄴ string : 데이터를 찾기위한 열쇠 key 이다. 예를 들어 'user-123'이나 'product-all' 처럼 고유한 이름을 붙인다.
* setCacheData<T> : 캐시에 데이터 저장
const setCacheData = <T>(key : string, data : T, ttl : number = 5*60*1000) => {
// ...
}
- 이 함수는 이름 그대로 캐시에 데이터를 저장하는 역할을 한다.
ㄴ <T> _ 저장하려는 data의 타입을 정확히 기억하기 위해 여기서도 제네릭을 사용한다.
ㄴ key : string _ 데이터를 저장할 열쇠
ㄴ data : T _ 저장할 실제 데이터
ㄴ ttl : number _ 'time to live'의 약자로 데이터의 수명을 밀리초 단위로 받는다. 기본값은 5분
* loadUserData : 캐시 시스템 사용
const loadUserData = async(userId : string) => {
// ...
}
1. cacheKey 만들기 : 'user-123' 처럼 고유한 키를 생성
2. 캐시 확인
3. Cache hit : cached에 데이터가 있다면 서버에 요청을 보낼 필요없이 즉시 그 데이터를 반환한다.
4. Cache miss : 만약 cached가 null이라면 ( 데이터가 없거나 유통기한이 지났다면 ) fetchUser(userId)를 호출하여 서버에 데이터를 실제로 요청한다.
5. 새 데이터 캐싱 : 서버에서 받아온 따끈따끈한 userData를 다음을 위해서 setCacheData(cacheKey, userData)를 통해 캐시에 저장해 둔다.
6. 그리고 userData를 반환한다.
** Record 디버깅 팁
// Record 상태를 쉽게 디버깅
const debugRecord = (record: Record<string, any>, name: string) => {
console.group(`📊 ${name} Record State`);
Object.entries(record).forEach(([key, value]) => {
console.log(`${key}:`, value);
});
console.groupEnd();
};
// 사용 예시
useEffect(() => {
debugRecord(formData, 'Form Data');
debugRecord(formValidation, 'Form Validation');
}, [formData, formValidation]);