상태 관리
고친 사람 github-actions[bot]
상태 관리는 화면이 기억하는 값을 어디에 두고 어떻게 바꿀지 정하는 일입니다. 장바구니에 담긴 상품 수가 그런 값입니다. 값이 바뀌면 그 값을 쓰는 화면이 모두 함께 바뀌게 합니다. 이 말은 주로 프론트엔드에서 씁니다.
쉽고 빠른 이해
상태 관리는 여러 화면 조각이 함께 쓰는 값을 한 곳에 두고 정해진 길로만 바꾸는 일입니다. 쇼핑몰에서 상품을 담으면 머리글의 장바구니 숫자와 아래쪽 합계 금액이 한꺼번에 바뀌어야 합니다.
값을 조각마다 따로 들고 있으면 한 곳만 바뀌고 나머지는 옛 값을 보여 줍니다. 머리글에는 3개, 합계에는 2개 값이 찍히는 식입니다.
- 여러 조각이 함께 쓰는 값을 한 곳에 모읍니다
- 값은 「상품을 담았다」처럼 일어난 일을 적어 정해 둔 함수에 넘겨서만 바꿉니다
- 값이 바뀌면 그 값을 쓰는 조각만 다시 그립니다
대가는 거쳐야 하는 코드입니다. 숫자 하나를 바꾸는 데도 일어난 일을 적어 함수에 넘겨야 합니다. 화면이 작으면 이 수고가 얻는 것보다 큽니다.
상세
사무실 벽에 회의실 예약표가 한 장 붙어 있습니다. 예약을 바꾸고 싶은 사람은 그 표에 고쳐 적습니다. 다들 그 한 장만 보니 누구도 엉뚱한 시간에 회의실로 가지 않습니다.
이 절은 화면이 기억하는 값이 무엇인지에서 시작합니다. 그 값이 흩어지면 무엇이 곤란한지 보고, 값을 둘 곳과 바꾸는 길, 바뀐 값을 화면에 알리는 방법으로 넘어갑니다. 예는 쇼핑몰 장바구니 화면 하나로 끝까지 갑니다.
화면이 기억하는 값
웹 화면은 사용자가 페이지에 머무는 동안 계속 살아 있습니다. 그동안 여러 값을 기억합니다. 입력칸에 친 글자, 펼쳐 둔 메뉴, 로그인한 사용자 이름, 장바구니에 담은 상품이 그렇습니다.
이처럼 시간이 지나며 바뀌고 화면의 모습을 정하는 값을 상태라고 부릅니다. 상태가 바뀌면 화면도 따라 바뀌어야 합니다. 장바구니에 상품을 담으면 머리글의 숫자가 하나 늘어야 합니다.
백엔드 서버와 견주면 차이가 보입니다. 서버는 요청 하나를 받으면 데이터베이스에서 읽어 응답합니다. 응답한 뒤에는 그 요청을 잊는 경우가 많습니다. 요청 사이에 값을 들고 있지 않는 이런 방식을 무상태라고 합니다.
화면은 반대로 사용자 곁에 오래 떠 있습니다. 떠 있는 내내 값을 쥐고 있습니다.
상태를 받아 그리는 화면
많은 화면 라이브러리는 화면을 컴포넌트라는 조각으로 나눕니다. 컴포넌트는 값을 받아 「화면이 이렇게 생겨야 한다」를 돌려주는 함수입니다. 상품 카드 하나, 머리글 하나가 각각 컴포넌트입니다.
컴포넌트는 다른 컴포넌트를 품습니다. 품는 쪽을 부모, 들어가는 쪽을 자식이라고 부릅니다. 화면 전체는 부모와 자식이 가지를 치며 이어진 트리 하나가 됩니다.
이런 함수는 화면이 어떻게 생겨야 하는지만 적습니다. 지금 화면에서 무엇을 지우고 무엇을 새로 붙일지는 라이브러리가 맡습니다. 이 방식을 선언형이라고 합니다. 개발자는 상태에 맞는 모습만 생각하면 됩니다.
상태가 바뀌면 라이브러리가 함수를 다시 불러 새 모습을 받습니다. 그 모습대로 실제 화면을 고칩니다. 이 과정을 다시 그리기, 또는 렌더링이라고 부릅니다.
그래서 상태 관리의 물음은 둘로 모입니다. 값을 어디에 두느냐, 그리고 누가 어떻게 바꾸느냐입니다. 둘이 정해지면 화면을 언제 다시 그릴지도 정해집니다.
값이 흩어질 때 생기는 일
장바구니 화면을 떠올려 봅시다. 상품 카드의 담기 버튼, 머리글의 장바구니 숫자, 아래쪽 합계 금액이 같은 값을 씁니다. 장바구니에 담긴 상품 목록입니다.
세 컴포넌트가 이 값을 저마다 복사해 들고 있다고 합시다. 담기 버튼은 자기 복사본만 고칩니다. 머리글과 합계는 고쳐진 줄 모르고 옛 값을 계속 보여 줍니다.
이를 막으려면 같은 값은 한 곳에만 둡니다. 나머지는 그 한 곳을 읽기만 합니다. 상세 첫머리의 회의실 예약표 한 장이 이렇게 쓰입니다.
어떤 값의 정본을 하나로 정하는 이 원칙을 단일 진실 원천이라고 부릅니다. 상태 관리는 화면 안에서 이 원칙을 지키는 일입니다.
상태를 두는 세 단계
상태를 둘 곳은 그 값을 쓰는 범위를 보고 고릅니다. 좁은 범위부터 셋으로 나눠 봅니다. 뒤로 갈수록 여러 곳에서 쓸 수 있는 대신 값의 흐름을 따라가기가 어려워집니다.
첫째는 컴포넌트 안입니다. 상품 카드의 「자세히 보기」가 펼쳐졌는지는 그 카드만 알면 됩니다. 다른 컴포넌트가 알 필요가 없으니 카드가 직접 들고 있습니다.
둘째는 공통 부모입니다. 목록 화면 안에 정렬 버튼과 상품 목록이 형제로 있다고 합시다. 둘 다 정렬 순서를 알아야 하니 그 값을 둘의 부모인 목록 화면으로 옮깁니다. 이 일을 상태 끌어올리기라고 합니다.
부모는 옮겨 온 값을 자식에게 props(properties, 속성)로 내려 줍니다. props 는 부모가 자식에게 건네는 값입니다. 함수의 인자와 같습니다.
값은 위에서 아래로만 흐릅니다. 자식은 받은 값을 고치지 않습니다. 이 규칙을 단방향 데이터 흐름이라고 부릅니다. 값을 고치는 곳이 부모 하나라서 누가 바꿨는지 찾기 쉽습니다.
끌어올리기에는 한계가 있습니다. 머리글과 상품 카드처럼 멀리 떨어진 두 조각은 공통 부모가 화면 맨 위입니다. 그러면 사이의 컴포넌트가 전부 쓰지도 않는 값을 받아 넘겨야 합니다. 이 문제를 prop drilling이라고 부릅니다.
React 같은 라이브러리에는 트리 중간을 건너뛰어 값을 건네는 장치가 있습니다. 이 장치를 컨텍스트라고 부릅니다. 위에서 넣은 값을 아래 어느 자식이든 바로 꺼내 씁니다.
컨텍스트는 둘째 단계에 붙는 장치입니다. 값은 여전히 트리 안의 부모가 들고 있습니다. 사이 컴포넌트를 거치는 수고만 덜어 줍니다.
셋째는 컴포넌트 트리 밖입니다. 화면 전체가 함께 쓰는 값을 트리 밖 한 곳에 모읍니다. 이 한 곳을 스토어(store)라고 부릅니다. 값이 필요한 컴포넌트는 사이를 거치지 않고 스토어에서 바로 읽습니다.
아래 그림은 쇼핑몰 화면에서 세 값이 어디에 사는지를 보입니다. 펼침 여부는 상품 카드 안에, 정렬 순서는 목록 화면에 있습니다. 담긴 상품은 트리 밖 스토어에 있습니다. 머리글과 상품 카드, 합계 금액이 점선으로 이 값을 읽어 갑니다.
flowchart TD
subgraph S["스토어 · 트리 밖"]
CART["담긴 상품"]
end
subgraph T["컴포넌트 트리"]
APP["쇼핑몰 화면"] --> HEAD["머리글"]
APP --> PAGE["목록 화면 · 정렬 순서"]
APP --> SUM["합계 금액"]
PAGE --> SORT["정렬 버튼"]
PAGE --> LIST["상품 목록"]
LIST --> CARD["상품 카드 · 펼침 여부"]
end
CART -.-> HEAD
CART -.-> CARD
CART -.-> SUM
값을 바꾸는 길을 하나로 좁히기
스토어에 값을 모아도 아무 코드나 값을 고칠 수 있으면 문제가 남습니다. 숫자가 이상할 때 누가 언제 바꿨는지 찾을 길이 없습니다. 그래서 많은 스토어는 값을 바꾸는 길을 하나로 좁힙니다.
컴포넌트는 값을 직접 고치지 않습니다. 대신 「무슨 일이 있었다」만 적어 스토어에 보냅니다. 「상품을 담았다」, 「장바구니를 비웠다」 같은 내용입니다. 이 내용을 담은 객체를 액션(action)이라고 부릅니다.
스토어는 액션을 받아 새 상태를 만듭니다. 이 일을 맡는 함수가 리듀서(reducer)입니다. 리듀서는 지금 상태와 액션을 받아 다음 상태를 돌려줍니다.
리듀서는 같은 입력이면 언제나 같은 결과를 냅니다. 서버를 부르거나 화면을 고치는 일도 하지 않습니다. 이런 함수를 순수 함수라고 합니다. 그래서 상태가 왜 이렇게 됐는지는 들어온 액션만 보면 알 수 있습니다.
아래는 장바구니 숫자를 다루는 리듀서입니다. 아래쪽 세 줄의 주석은 그 줄을 지난 뒤의 count 값입니다.
function cart(state, action) {
if (action.type === 'add') {
return { count: state.count + 1 };
}
if (action.type === 'clear') {
return { count: 0 };
}
return state;
}
let s = { count: 0 };
s = cart(s, { type: 'add' }); // 1
s = cart(s, { type: 'add' }); // 2
s = cart(s, { type: 'clear' }); // 0
리듀서는 받은 state 를 고치지 않습니다. 매번 새 객체를 만들어 돌려줍니다. 모르는 액션이 오면 받은 상태를 손대지 않고 돌려줍니다.
이 흐름을 장바구니 화면에 놓으면 아래와 같습니다. 담기 버튼은 액션만 보냅니다. 머리글은 스토어가 알려 줄 때 다시 그립니다. 스토어가 머리글을 어떻게 아는지는 아래 「바뀐 값을 화면에 알리기」에서 봅니다.
sequenceDiagram
participant 버튼 as 담기 버튼
participant 스토어
participant 머리글
버튼->>스토어: 액션 「상품을 담았다」
Note over 스토어: 리듀서가 새 상태를 만듦
스토어->>머리글: 값이 바뀌었다고 알림
Note over 머리글: 새 숫자로 다시 그림
이 구조는 백엔드의 이벤트 소싱과 닮았습니다. 둘 다 일어난 일을 차례로 쌓습니다. 그 일을 하나씩 적용해 지금 상태를 만듭니다. 액션 기록을 남겨 두면 지난 상태로 되돌려 보는 일도 됩니다.
새 객체를 만드는 까닭
리듀서가 상태를 고치지 않고 새 객체를 만드는 데는 까닭이 있습니다. 한번 만든 값을 고치지 않는 이 성질을 불변성이라고 합니다.
첫째, 바뀌었는지 빨리 압니다. 객체를 고쳐 쓰면 안의 값을 하나하나 견줘야 바뀐 것을 압니다. 새 객체를 만들면 「같은 객체인가」만 보면 됩니다. 다른 객체면 바뀐 것입니다.
둘째, 앞 상태가 남습니다. 고쳐 쓰지 않았으니 이전 객체가 멀쩡히 남아 있습니다. 되돌리기나 앞뒤 비교를 이 객체로 합니다.
대가는 메모리입니다. 값이 바뀔 때마다 새 객체가 생깁니다. 바뀌지 않은 안쪽 값은 앞 객체와 나눠 써서 이 부담을 줄입니다.
바뀐 값을 화면에 알리기
스토어가 상태를 바꿨으면 그 값을 쓰는 컴포넌트가 알아야 합니다. 컴포넌트는 스토어에 「이 값이 바뀌면 나를 불러 달라」고 미리 등록합니다. 이 등록을 구독이라고 합니다.
값이 바뀌면 스토어가 구독한 컴포넌트를 차례로 부릅니다. 바뀌는 쪽이 지켜보는 쪽 목록을 들고 있다가 알립니다. 이 구조는 옵저버 패턴과 같습니다.
구독을 스토어 전체에 걸면 낭비가 생깁니다. 로그인 이름만 바뀌어도 장바구니 숫자까지 다시 그려집니다.
그래서 컴포넌트는 상태에서 필요한 부분만 골라 구독합니다. 고르는 함수를 셀렉터(selector)라고 부릅니다. 고른 값이 앞과 같으면 다시 그리지 않습니다.
다른 길도 있습니다. 값 하나하나가 자기를 읽은 곳을 기억해 두는 방식입니다. 값이 바뀌면 그 값을 읽은 화면 조각만 바로 고칩니다. 이런 방식을 세밀한 반응성이라고 부릅니다.
서버에서 가져온 값
장바구니 숫자는 화면이 직접 만든 값입니다. 상품 목록은 다릅니다. 서버에 있는 데이터를 받아 와 화면에 잠시 둔 복사본입니다.
이런 값을 서버 상태라고 부릅니다. 주인이 서버라서 화면이 들고 있는 동안에도 서버 쪽 값이 바뀔 수 있습니다.
판매자가 그 사이 상품 가격을 바꿨다고 합시다. 화면은 여전히 옛 가격을 보여 줍니다. 이렇게 서버와 어긋난 채 화면에 남은 값을 낡은 데이터라고 부릅니다.
서버 상태는 화면이 만든 값과 다른 일을 요구합니다. 언제 다시 받아 올지, 받아 오는 동안 무엇을 보여 줄지, 실패하면 어떻게 할지를 정해야 합니다. 이것은 캐싱의 문제입니다.
그래서 서버 상태는 TanStack Query 처럼 이 일만 맡는 라이브러리에 따로 맡기기도 합니다. 스토어에는 화면이 직접 만든 값만 남깁니다.
상태가 사는 곳과 수명
상태는 메모리 말고도 둘 곳이 있습니다. 어디에 두느냐에 따라 언제 사라지는지가 갈립니다.
그중 하나가 웹 주소입니다. 주소 끝의 ?sort=price 같은 부분을 쿼리 문자열이라고 합니다. 정렬 순서를 여기 적으면 새로고침해도 남습니다. 그 주소를 남에게 보내면 같은 정렬로 열립니다.
브라우저도 값을 담아 두는 저장소를 줍니다. 그 하나가 로컬 스토리지입니다. 탭을 닫았다 다시 열어도 값이 남습니다.
아래 표는 네 곳을 장바구니 화면의 예와 함께 견줍니다.
| 두는 곳 | 언제 사라지나 | 예 |
|---|---|---|
| 컴포넌트·스토어의 메모리 | 새로고침하면 | 펼침 여부 · 입력하던 글자 |
| 주소의 쿼리 문자열 | 주소를 바꾸면 | 정렬 순서 · 검색어 |
| 로컬 스토리지 | 사용자나 코드가 지울 때까지 | 최근 본 상품 |
| 서버 | 서버가 지울 때까지 | 주문 내역 |
스토어가 늘리는 코드
스토어를 두면 값 하나를 바꾸는 데 거치는 코드가 늘어납니다. 액션을 정해야 합니다. 리듀서에 갈래도 더해야 합니다. 컴포넌트에서는 구독을 걸어야 합니다.
뜻보다 형식 때문에 쓰는 이런 코드를 보일러플레이트라고 부릅니다. 장바구니 숫자 하나를 늘리는 데도 이 세 곳을 모두 손봐야 합니다.
모든 값을 스토어에 넣으면 다른 문제도 생깁니다. 어느 코드든 읽을 수 있는 값은 전역 변수를 닮아 갑니다. 어떤 컴포넌트가 어떤 값에 기대는지 코드만 봐서는 알기 어려워집니다.
그래서 기준은 그 값을 쓰는 범위입니다. 한 컴포넌트만 쓰면 그 안에 둡니다. 형제 몇이 쓰면 부모로 올립니다. 화면 여러 곳이 멀리 떨어져 함께 쓸 때 스토어를 씁니다.
상태 관리 라이브러리의 갈래
스토어와 액션으로 짜는 방식은 Flux에서 나왔습니다. Flux 는 값이 한 방향으로만 흐르게 하는 화면 설계 방식입니다.
Redux 는 Flux 를 단순하게 다듬은 라이브러리입니다. 스토어 하나에 상태를 모읍니다. 그 상태는 리듀서로만 바꿉니다.
앞에서 본 세밀한 반응성 쪽으로 간 라이브러리도 있습니다. MobX 에서는 상태 객체를 고치면 그 값을 읽은 컴포넌트가 알아서 다시 그려집니다. 리듀서를 두지 않습니다.
화면 라이브러리도 상태를 다루는 기본 장치를 갖습니다. React 는 컴포넌트 안의 상태와 컨텍스트, 리듀서를 쓰는 장치를 기본으로 줍니다.
Vue.js 도 널리 쓰는 화면 라이브러리입니다. Vue.js 에서 스토어를 맡는 라이브러리로는 Pinia 가 있습니다.
화면 밖에서 쓰는 상태 관리
상태 관리라는 말은 화면 밖에서도 씁니다. 「바뀌는 값을 어디에 두고 누가 바꾸나」를 정한다는 뜻은 같습니다. 다루는 값이 다릅니다.
코드형 인프라는 서버와 네트워크를 손으로 만들지 않고 코드로 적어 만드는 방식입니다. Terraform 같은 도구는 지금 만들어 둔 서버와 네트워크의 목록을 파일 하나에 적어 둡니다. 이 파일을 지키는 일도 상태 관리라고 부릅니다. 여럿이 한꺼번에 고쳐 파일이 깨지지 않게 막는 일이 여기 듭니다.
백엔드에서는 로그인 정보 같은 세션 값을 어디에 둘지 정하는 일을 그렇게 부르기도 합니다. 서버를 여러 대 띄우면 세션을 어느 서버에 둘지가 바로 이 물음이 됩니다.
관련 항목
상태 관리가 다루는 값의 종류
UI 상태 · 서버 상태 · 전역 상태 · 파생 상태 · 폼 상태 · URL 상태
상태 관리를 이루는 구성 요소
스토어 · 액션 · 리듀서 · 셀렉터 · 디스패치 · 미들웨어
상태 관리가 지키는 원칙
단일 진실 원천 · 단방향 데이터 흐름 · 불변성 · 순수 함수 · 상태 끌어올리기
상태를 컴포넌트에 건네는 수단
상태 관리가 막으려는 문제
prop drilling · 낡은 데이터 · 경쟁 상태 · 불필요한 리렌더링 · 전역 변수
바뀐 상태를 화면에 알리는 방식
옵저버 패턴 · 구독 · 세밀한 반응성 · 시그널 · 반응형 프로그래밍
상태 관리를 구현한 라이브러리
Redux · Redux Toolkit · MobX · Zustand · Recoil · Jotai · Pinia · Vuex · NgRx
서버 상태를 맡는 캐싱 라이브러리
TanStack Query · SWR · Apollo Client · RTK Query · 캐싱
상태 관리 방식의 바탕이 된 설계
Flux · 이벤트 소싱 · 상태 기계 · Elm 아키텍처 · CQRS
상태를 오래 남기는 브라우저 저장소
쿼리 문자열 · 로컬 스토리지 · 세션 스토리지 · IndexedDB · 쿠키
상태 관리를 떠받치는 화면 개발 기술
프론트엔드 · 컴포넌트 · 가상 DOM · 선언형 · 렌더링 · 트리 · React · Vue.js
화면 밖에서 상태 관리라 부르는 대상
다른 이름: state management · 프론트엔드 상태 관리 · 클라이언트 상태 관리