서버에서 받아온 JSON을 그대로 useState에 쑤셔넣고 화면을 그린다. 처음 며칠은 아무 문제 없이 잘 돌아간다. 사달은 그 데이터를 고치기 시작할 때 난다. 게시글 안에 댓글 배열이 박혀 있고, 댓글마다 작성자 객체가 또 박혀 있고, 같은 작성자가 게시글 세 개에 각각 복사돼 있다. 닉네임 하나 바꾸겠다고 배열 세 군데를 뒤지다 보면 벌써 손이 떨린다. 이 중첩 데이터를 다루는 표준 처방이 상태 정규화(normalization, 평탄화)다.
중첩이 왜 문제인가. 서버 응답은 보통 트리 모양이다. 게시글 아래 댓글, 댓글 아래 작성자. 이걸 그대로 상태에 넣으면 세 가지가 한꺼번에 무너진다. 첫째, 같은 데이터가 여러 곳에 복제된다(중복). 둘째, 그 복제본 중 하나만 고치면 나머지와 값이 어긋난다(불일치). 셋째, 깊이 박힌 값 하나를 바꾸려고 map과 스프레드를 몇 겹씩 쌓아야 한다(갱신 지옥). 아래가 그 흔한 모양이다.
const [state, setState] = useState({
posts: [
{
id: 1,
title: '정규화 입문',
author: { id: 10, name: '민수' },
comments: [
{ id: 100, text: '좋아요', author: { id: 11, name: '지연' } }
]
}
]
})
여기서 작성자 민수의 이름을 바꾸려면 posts를 순회하고, 각 post의 author를 확인하고, comments까지 다시 순회해야 한다. 민수가 댓글도 달았다면 그 안의 author도 따로 고쳐야 한다. 데이터가 커질수록 이 코드는 손댈 수 없는 물건이 된다.
정규화는 id로 평탄화하는 것. 발상은 단순하다. 데이터베이스 테이블처럼 종류별로 표를 따로 만들고, 서로를 id로만 참조하게 한다. 각 표는 두 부분으로 쪼갠다. byId는 id를 키로 실제 객체를 담는 사전(map, 딕셔너리)이고, allIds는 순서를 기억하는 id 배열이다. 위 데이터를 정규화하면 이렇게 바뀐다.
const state = {
posts: {
byId: {
1: { id: 1, title: '정규화 입문', author: 10, comments: [100] }
},
allIds: [1]
},
comments: {
byId: {
100: { id: 100, text: '좋아요', author: 11 }
},
allIds: [100]
},
users: {
byId: {
10: { id: 10, name: '민수' },
11: { id: 11, name: '지연' }
},
allIds: [10, 11]
}
}
이제 게시글은 작성자를 통째로 안고 있지 않다. author: 10처럼 id만 가리킨다. 댓글도 id 배열 [100]으로만 연결한다. 사람 정보는 users 표에 딱 한 벌만 존재한다. 중복이 사라졌으니 불일치가 생길 자리도 함께 사라진다.
갱신이 한 군데로 끝난다. 민수 이름 바꾸기를 다시 해보자. 정규화 전에는 배열 몇 겹을 헤집어야 했지만, 이제는 users.byId[10] 한 곳만 고치면 그를 참조하는 모든 화면이 같이 바뀐다.
setState(prev => ({
...prev,
users: {
...prev.users,
byId: {
...prev.users.byId,
10: { ...prev.users.byId[10], name: '민수2' }
}
}
}))
스프레드가 네 겹이라 여전히 길어 보이지만, 배열 순회는 완전히 사라졌다. 어떤 게시글에 민수가 몇 번 등장하든 상관없다. 참조는 전부 id 10이므로 원본 한 곳만 고치면 된다. 이 갱신 코드는 데이터가 백 배로 늘어도 길이가 그대로다. 중첩 방식은 데이터가 늘수록 순회 비용도 같이 불어났다. 스프레드를 겹치는 게 번거로우면 Immer(불변 갱신을 대신 처리해 주는 라이브러리) 같은 도구로 마치 값을 직접 바꾸듯 짜도 안전하게 새 상태를 만들어 준다.
관계는 id 배열로 잇는다. 게시글과 댓글처럼 일대다(one-to-many, 하나에 여럿이 딸린) 관계는 부모가 자식 id 목록을 들고 있으면 된다. post.comments는 [100, 103, 107]처럼 댓글 id만 담는다. 새 댓글이 달리면 두 가지만 한다. comments 표에 새 항목을 넣고, 해당 post의 comments 배열 끝에 id를 붙인다.
function addComment(state, postId, comment) {
return {
...state,
comments: {
byId: { ...state.comments.byId, [comment.id]: comment },
allIds: [...state.comments.allIds, comment.id]
},
posts: {
...state.posts,
byId: {
...state.posts.byId,
[postId]: {
...state.posts.byId[postId],
comments: [...state.posts.byId[postId].comments, comment.id]
}
}
}
}
}
화면에 뿌릴 땐 다시 조립한다. 정규화된 상태는 저장과 갱신엔 최고지만, 사람 눈엔 흩어져 있다. 그래서 화면에 넘기기 직전에 id를 따라가 원래 모양으로 되살린다(역정규화, denormalize). 이 조립을 맡는 함수를 선택자(selector, 셀렉터)라 부른다.
function selectPost(state, postId) {
const post = state.posts.byId[postId]
return {
...post,
author: state.users.byId[post.author],
comments: post.comments.map(cid => {
const c = state.comments.byId[cid]
return { ...c, author: state.users.byId[c.author] }
})
}
}
컴포넌트는 selectPost가 돌려준 완성된 객체만 받아 쓴다. 상태를 어떻게 쪼개 저장했는지는 몰라도 된다. 선택자가 저장 구조와 화면 구조 사이의 번역기 노릇을 한다. 저장은 평탄하게, 화면은 트리로. 관심사가 깔끔하게 갈린다. 나중에 저장 방식을 바꾸더라도 선택자 안쪽만 손보면 되고, 화면 코드는 한 줄도 건드릴 필요가 없다.
전부 정규화할 필요는 없다. 정규화는 공짜가 아니다. byId와 allIds를 만들고 선택자를 짜는 손품이 든다. 목록이 짧고, 다른 화면과 데이터를 공유하지 않고, 갱신도 거의 없다면 그냥 배열로 두는 편이 낫다. 드롭다운에 잠깐 쓰는 옵션 열 개를 정규화하는 건 낭비다. 반대로 같은 엔티티(entity, 개체)가 여러 화면에 걸쳐 있고, 자주 갱신되고, 관계가 얽혀 있다면 그때가 정규화를 꺼낼 때다. 서버 데이터 전용 라이브러리가 내부에서 이런 정규화 캐시를 알아서 관리해 주기도 한다.
정규화의 핵심은 하나다. 같은 데이터는 한 곳에만 둔다. 나머지는 전부 그 한 곳을 id로 가리킨다. 이 규칙 하나만 지켜도 상태를 고치다 값이 슬그머니 어긋나는 부류의 버그가 통째로 사라진다. 정규화는 화려한 기법이 아니라, 데이터가 커져도 무너지지 않게 미리 자리를 잡아 두는 밑작업에 가깝다.