본문 바로가기
카테고리 없음

Record와 상태관리에서의 Record 활용 패턴들

by 잘먹는 개발자 에단 2025. 6. 30.

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]);