리덕스 Normalizing State Shape :: 마이구미
이 글은 "리덕스를 어떻게 사용할 수 있는가?" 를 나타낸다.
여러 방법 중에 "Normalizing State Shape" 를 다룬다.
공식 문서에서 언급된 것으로, 리덕스를 활용하는 패턴의 하나라고 볼 수 있다.
전반적인 내용과 이를 프로젝트에 적용해본 후기를 작성하려한다.
관련 문서 - https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape
글을 다루기에 앞서, 개발 시나리오를 생각해보자.
프로젝트 진행에 앞서, 서버쪽 API 가 아직 개발되지 않은 상태라면 프론트는 사전 작업을 먼저 하면 된다.
우선 마크업을 미리 진행하면 된다.
그리고 더 나아가 대략 추측 가능한 구조를 기반으로 더미 데이터를 API 응답으로 활용할 수 있다.
관련 작업에 있어, 이 글에서는 redux 를 이용한다.
즉, Redux 에서는 API 에 관련된 로직 및 상태를 관리하게된다.
API 응답이 어떻게 내려올지도 모르겠지만, 대부분 중첩된 구조로 내려올 것이다.
예를 들면 블로그 포스트들의 리스트가 존재한다면, 다음과 같이 내려올 것이다.
const blogPosts = [
{
id: 'post1',
author: { username: 'user1', name: 'User 1' },
body: '......',
comments: [
{
id: 'comment1',
author: { username: 'user2', name: 'User 2' },
comment: '.....'
},
{
id: 'comment2',
author: { username: 'user3', name: 'User 3' },
comment: '.....'
}
]
},
{
id: 'post2',
author: { username: 'user2', name: 'User 2' },
body: '......',
comments: [
{
id: 'comment3',
author: { username: 'user3', name: 'User 3' },
comment: '.....'
},
{
id: 'comment4',
author: { username: 'user1', name: 'User 1' },
comment: '.....'
},
{
id: 'comment5',
author: { username: 'user3', name: 'User 3' },
comment: '.....'
}
]
}
// and repeat many times
]
중첩된 구조는 댑스가 깊어질수록 상태 관리가 까다롭다는 것을 우리는 알고 있다.
예를 들어, username: 'user1' 을 값을 업데이트한다면, 어떻게 될까?
user1 을 가지고 있는 다른 요소를 찾아서 같이 업데이트를 해줘야한다.
결과적으로는 {post1}, {post2-comment4} 두 요소를 찾아서 똑같이 업데이트 해주어야한다.
즉, 다른 객체나 배열을 파헤치는 검색을 통해 동기를 맞춰줘야한다.
또 하나 더, 위 구조에서 id 값이 "comment4" 인 객체의 author 를 업데이트 할 경우는 어떻게 될까?
불변성을 위해 comment => comments => post => posts 모든 부모와 조상을 복사 및 업데이트를 새롭게 해줘야한다.
이로 인해, UI 컴포넌트는 비효율적인 리랜더링을 초래하게 된다.
이것만으로도 다른 잠재적인 것들을 대해 짐작할 수 있다.
이렇한 구조는 복잡해질수록 빠르게 어글리한 코드와 흐름을 맞이한다.
이를 위해 조금 더 효율적으로 state 를 리덕스에서 관리하는 방법을 고민해볼 수 있다.
공식문서에서 제공하는 것이 “Normalizing State Shape” 이다.
용어 그대로 데이터 구조를 노멀라이징하는 것이다.
핵심은 "구조를 어떻게 노멀라이징하는가?" 이다.
리덕스 스토어를 데이터베이스처럼 생각하고 테이블 구조를 갖추게 된다.
위 예제를 기반으로는 posts, comments, users 로 분류할 수 있다.
각 테이블의 아이템들은 key - value 구조의 형태를 가지게 된다.
{
posts : {
byId : {
"post1" : {
id : "post1",
author : "user1",
body : "......",
comments : ["comment1", "comment2"]
},
"post2" : {
id : "post2",
author : "user2",
body : "......",
comments : ["comment3", "comment4", "comment5"]
}
},
allIds : ["post1", "post2"]
},
comments : {
byId : {
"comment1" : {
id : "comment1",
author : "user2",
comment : ".....",
},
"comment2" : {
id : "comment2",
author : "user3",
comment : ".....",
},
"comment3" : {
id : "comment3",
author : "user3",
comment : ".....",
},
"comment4" : {
id : "comment4",
author : "user1",
comment : ".....",
},
"comment5" : {
id : "comment5",
author : "user3",
comment : ".....",
},
},
allIds : ["comment1", "comment2", "comment3", "commment4", "comment5"]
},
users : {
byId : {
"user1" : {
username : "user1",
name : "User 1",
},
"user2" : {
username : "user2",
name : "User 2",
},
"user3" : {
username : "user3",
name : "User 3",
}
},
allIds : ["user1", "user2", "user3"]
}
}
각 테이블은 byId, allIds 키를 가진다.
byId 는 각 아이템의 고유 id 를 key 로 삼아 객체로 관리되어진다.
allIds 는 아이템들의 순서로 사용될 수 있다.
계층적보다는 평평한 구조를 가진다.
이 구조는 처음에 언급한 중첩된 구조와 비교하면 어떤 개선점이 있는가?
post, comment, user 는 각각 서로 다른 장소에 존재한다.
위에서 언급한대로 id 가 "user1" 인 아이템을 수정하는 경우가 발생한다면 어떻게 했었는가?
user1 을 가지는 모든 아이템들을 찾아서 각각 업데이트를 해야한다.
하지만 노멀라이징한 구조에서는 user 데이터는 user 에서만 관리하고 있다.
user 가 필요한 곳은 user 의 고유 id 만을 가진다. (마치 주소값만 들고 있는 형태)
결과적으로 user 에서 key 가 "user1" 인 아이템을 찾아서 업데이트만 하면 된다.
// 중첩된 구조
blogPosts.map((post) => {
// post.author
post.comments.map((comment) => {
// comment.author
}
})
// 노멀라이징
users.byId['user1'] = newUserDatas;
중첩된 구조에서 탐색은 루프가 필수적이고, 구조의 뎁스에 따라서도 탐색 형태가 변하게 된다.
반대로 노멀라이징된 구조는 탐색이 굉장히 심플해지고, 일관성 있는 형태를 가질 수 있게 된다.
서로 다른 테이블의 아이템을 가지고 싶다면, 그 아이템의 고유 id 를 가지면서 이를 주소값을 참조하는 것처럼 활용한다.
중첩된 구조에서는 비록 author 만 업데이트했지만 불변성 유지를 위해 조상까지 모두 업데이트 해줘야했다.
노멀라이징된 구조를 보면, 각 post 는 author id 와 comment id 를 가지고, comment 는 author id 를 가진다.
서로 다른 타입이라면, 주소값을 참조하는 형태로 연결 관계를 만들어준다.
post, comment, author 는 같은 레벨로 여겨지고, 실제 데이터는 각자 다른 장소에서 관리되기 때문에 위 문제를 개선할 수 있다.
이를 기반으로 만들어진 라이브러리도 존재한다.