대부분의 UI는 데이터를 처리한다. 그리고 데이터는 서버(백엔드), 다른 브라우저 요소에서 가져온다.
React에서 데이터를 다룰 때 아래와 같이 여러 방법을 고려할 수 있다.
MVC 계열의 프레임워크와 통합 : 단일 페이지 애플리케이션 개발에 MVC 계열의 라이브러리를 이미 사용 중이거나 사용할 계획이 있을 경우 좋은 방법이다.
직접 개발한 데이터 메서드 또는 라이브러리 사용 : 작은 UI 컴포넌트 개발에 적합한 방법이다.
React 스택 사용하기 : 큰 마찰 없이 React 코드와 통합할 수 있어 가장 호환성이 좋고, React의 철학을 잘 반영할 수 있는 방법이다.
이 중에서 Redux에 대해 설명하고자 하는데, 이는 3번째 방법에 소갛ㄴ다.
※ Flux 아키텍처와 페이스북에서 개발한 flux 라이브러리도 있다. 하지만 flux 라이브러리 보다 Redux가 더 많은 프로젝트에서 사용되고 있다. flux 라이브러리는 Flux 아키텍처의 개념 증명에 조금 더 가깝고, Redux는 Flux 아키텍처를 따라 구현되었다. Redux와 flux 라이브러리 모두 Flux 아키텍처의 구현체라고 볼 수도 있다.
React의 단방향 데이터 흐름 지원
React는 '단방향 데이터 흐름'에 따라 작동하도록 설계된 뷰 레이어이다. '단방향 바인딩' 이라고도 부르는데, '단방향 데이터 패턴'은 관심사 간에 변경 가능한 참조가 없는 경우이다. 즉, 뷰와 모델이 양방향으로 참조되지 않는다.
A모델과 B뷰가 있다고 했을 때, A모델에서 B뷰로 데이터가 흘러가고, 역방향으로는 전달되지 않는다. 즉, '모델의 변경'이 '뷰의 변경'을 일으키는 것이다. 뷰가 모델을 직접 변경할 수 없다는 점이 단방향 데이터 흐름의 핵심이라고 볼 수 있다.
React의 단방향 데이터 흐름 아래에서 컴포넌트에 어떤 내용이 입력되도 render() 메서드를 통해 항상 동일하고 예측 가능한 결과를 얻을 수 있다. React패턴은 양방향 바인딩 패턴과는 대조됨을 알 수 있다.
양방향 데이터 흐름에서는 모델의 변경이 뷰의 변경을 일으키고, 사용자 입력 같은 뷰의 변경도 모델의 변경을 일으킨다. 그래서 양방향 데이터 흐름에서는 뷰의 상태에 대한 예측 가능성이 떨어지고, 이해와 디버깅, 유지보수에 조금 더 어려움이 생기곤 한다. 여기서 알 수 있듯, 양방향 데이터 흐름의 핵심은 '뷰가 모델을 직접 수정할 수 있다는 점'이다.
물론 단점만 있는 것은 아니다. 양방향 데이터 흐름을 사용하면 작성하는 코드의 수가 줄어든다는 점은 장점이다.
양방향 데이터 흐름도 좋지만, 복잡한 UI를 개발하기에는 부족함이나 어려움이 있을 수 있다.
여기서 문제가 생기는 이유는, 여러 개의 뷰가 여러 개의 모델을 조작하거나, 반대의 경우가 있기 때문이다. 소수의 모데이나 뷰가 분리되어 있는 경우라면 괜찮겠으나, 규모가 큰 애플리케이션에서는 더 많은 모델과 뷰가 서로를 갱신한다. 갱신을 일으킨 모델과 뷰를 쉽게 찾을 수 없고 순서도 알기 어려워, 어떤 모델이나 어떤 뷰가 특정한 상태가 되는 이유를 알아내는 것이 어려워진다. 즉, 버그를 찾는게 문제가 아니라 추적하는 것도 문제가 되는 것이다.
이에 많은 개발자들은 양방향 데이터 흐름을 안티패턴이라 여기기도 한다.
하지만 단방향 데이터 흐름은 모델이 뷰를 갱신할 뿐이다. 그리고 뷰가 변경할 수 없는 상태 함수이므로 동형/유니버셜 자바스크립트를 적용한 서버 측 렌더링도 가능하다.
장점을 조금 정리해보면 아래와 같다.
단일 데이터 소스를 채택하여 코드 가독성도 좋고 이해하기 쉽다.
디버깅에 유리하고 시간 여행 디버깅도 가능하다. ex) 예외나 버그 발생 시 기록을 서버로 전송하는 것이 간단
헤드리스 브라우저를 사용하지 않는 서버 측 렌더링 : 동형 또는 유니버셜 자바스크립트라고도 한다.
Flux 데이터 아키텍처
Flux는 데이터 흐름을 위한 아키텍처 패턴으로, 페이스북이 React앱에 사용하기 위해 개발하였다. Flux의 핵심은 단방향 데이터 흐름을 적용하고, MVC 계열 패턴의 복잡도를 제거하는 것이다.
MVC계열의 패턴은 양방향으로 연결되어 복잡해서 방향을 잃기 쉽다. 또한 이해하기도 어렵고 디버깅도 힘든 아키텍처이다.
그래서 Flux는 단방향 데이터 흐름의 사용을 제안한다. Flux는 뷰에서 발생한 '액션'이 '디스패처'를 거쳐 '데이터 스토어'를 호출한다. 그리고 스토어는 데이터와 뷰의 표현을 책임진다. 뷰는 데이터를 변경하지 않고, 디스패처를 이용해 액션을 전달한다.
아래와 같다.
※ Flux 아키텍처는 데이터가 스토어에서 뷰로 가도록 단방향으로 처리하여 데이터 흐름을 단순화한다.
※ Flux 아키텍처는 액션에 의해 디스패처가 실행되고, 스토어에 전달되어 결과적으로 뷰를 렌더링한다.
Flux는 하나의 아키텍처로, 페이스북 팀이 flux 모듈을 배포해서 React와 함께 쓸 수 있는 Flux 아키텍처를 구현했지만, flux모듈은 Flux 아키텍처의 개념 증명으로, React 개발자들이 많이 사용하지는 않는다.
Flux의 구현체로는 Redux, Reflux 등 여러가지가 있다. 하지만 npm 다운로드 수치에서 Flux나 Reflux에 비해 Redux가 가장 인기 있는 라이브러리인 것을 확인할 수 있다.
Redux 데이터 라이브러리
Redux는 Flux 아키텍처의 구현체 중 가장 인기가 높다.
Redux의 특징 몇 가지는 아래와 같다.
훌륭한 개발 생태계 : Awesome Redux(https://github.com/xgrommx/awesome-redux)
간결성 : 디스패처나 스토어 등록이 필요하지 않고, 최소환된 버전은 99행 밖에 안된다.
훌륭한 개발자 경험 : 핫 리로딩과 시간 여행 디버깅을 할 수 있다.
리듀서 구성 : 예를 들어, 취소/다시하기 고차 컴포넌트를 사용하면 최소한의 코드만으로 기능을 구현할 수 있다.
서버 측 렌더링 지원
Redux는 상태 컨테이너를 구현한 독립적인 라이브러리이다. Redux는 런타임에서 애플리케이션이 처리하는 모든 데이터를 포함하고, 저장하고, 변경하는 커다란 변수이다. Redux를 독립적으로 사용하거나 서버에서 사용할 수도 있다. Redux를 React와 조합하여 사용하는 방법은 매우 인기 있다. 이 둘의 좋바은 다른 라이브러리인 react-redux에 의해 구현된다.
React 앱에 Redux를 사용하면 바뀌는 부분 몇 가지가 있는데, 아래와 같다.
스토어(store)는 모든 데이터를 저장하고, 이 데이터를 조작할 수 있는 메서드를 제공한다. 스토어를 생성할 때는 createStore() 메서드를 사용한다.
Provider 컴포넌트는 모든 컴포넌트가 스토어에서 데이터를 가져올 수 있게 만든다.
connect() 메서드는 컴포넌트를 감싸서 스토어에 있는 애플리케이션 상태의 일부를 컴포넌트의 속성으로 연결한다.
위에 그림도 직접 만들어 보여줬었는데, 그걸 다시 참고해보면,
내부 상태를 변경하는 유일한 방법은 디스패처를 이용해 액션을 보내는 것이고, 액션은 스토어에 있다.
스토어의 모든 변경은 '액션'에 의해 이뤄진다. 각 액션은 애플리케이션에 발생한 일과 이에 따라 스토어에서 변경되어야 할 부분을 아려준다. 그리고 액션은 데이터를 제공하기도 한다. 모든 애플리케이션에는 변경되는 데이터가 있어 이런 방식은 매우 유용하다.
스토어에서 데이터의 변경 방법은 순수함수인 '리듀서(reducer)'에 의해 명시된다. 리듀서는 (state, action) => state 서명을 가지고 있다. 즉, 현재 상태에 액션을 적용하여 새로운 상태를 얻게되는 것이다. 이것으로 애플리케이션 상태를 예측할 수 있고, 취소 또는 디버깅을 통해 이전상태로 되돌릴 수 있는 기능도 갖게 되는 것이다.
Redux 애플리케이션은 하나 또는 그 이상의 리듀서를 가질 수 있다. 액션을 호출할 때마다 모든 리듀서가 호출된다. 리듀서는 스토어의 데이터 변경에 대한 책임이 있어 특정한 형식의 액션을 다룰 때는 주의를 요한다.
일반적으로 리듀서는 '상태'와 '액션'을 인자로 받는 함수이다.
Redux 사용
Redux 애플리케이션에서 Redux를 작동시키려면 컴포넌트 계층의 최사우이에 Provider 컴포넌트를 추가해줘야 한다. Provider 컴포넌트는 react-redux 패키지의 일부로, 스토어의 데이터를 컴포넌트로 주입해준다. 즉, Provider 컴포넌트를 사용하면 모든 자식 컴포넌트가 스토어에 접근할 수 있게 되는 것이다.
Provider 컴포넌트를 사용하려면 store 속성으로 스토어를 전달해줘야 한다. 스토어는 애플리케이션 상태를 표현하는 객체이다.
Provider 컴포넌트와 하위 트리의 컴포넌트를 렌더링할 때는 react-dom의 render() 메서드를 사용한다. < Provier>를 첫 번째 인자로 받아 두 번째 인자로 전달한 요소(document.getElementById('app))안에 렌더링한다.
정리해보면, JSX 포맷을 이용해 Provider 컴포넌트를 정의하고 리듀서를 전달받은 스토어 인스턴스를 넘겨주는 것!
전체 애플리케이션이 Redux의 기능을 사용하려면, 스토어에 연결하는 코드처럼 자식 컴포넌트에서 구현해야 할 것이 있다. react-redux의 connect() 메서드는 몇 가지 인자를 전달받아 함수를 반환한다. connect() 메서드가 반환한 함수로 컴포넌트를 감싸서 스토어의 일부를 컴포넌트의 속성으로 전달받게 된다.
Provider 컴포넌트가 스토어로부터 연결된 컴포넌트로 데이터 전달을 처리하기 때문에 속성을 직접 전달할 필요는 없다.
리듀서 결합하기
리듀서를 결합하는 중간 단계를 거치면 더 나은 아키텍처를 만들 수 있다.
플러그인 Node.js 패턴을 사용해서 리듀서를 추가하면 어려움 없이 앱을 확장해 나갈 수 있다. 이 방식을 '리듀서 분리'라고 한다.
각 리듀서는 스토어의 데이터를 변경할 수 있다. 하지만 안전한 데이터 변경을 위해서는 애플리케이션 상태를 여러 개의 부분으로 분리한 후 하나의 스토어로 결합하는 것이 좋다. 이러한 분할 정복 방식은 리듀서와 액션이 계속해서 늘어나는 규모있는 앱을 개발할 때 좋다. redux의 combineReducers() 메서드를 사용하면 어러 개의 리듀서를 쉽게 결합할 수 있다.
예를 들면 아래와 같다.
const {combineReducers} = require('redux')
const{
reducer: movies
} = require('./movies')
module.exports = combineReduces({
movies
// 리듀서를 더 추가할 수 있다!
})
리듀서를 마음대로 추가해 스토어에 독립적인 부분을 생성할 수 있다. 이름도 마음대로 지을 수 있다.
Redux에서 리듀서는 액션이 스토어에 전달될 때마다 실행되는 함수이다. 그리고 리듀서는 인자를 아래와 같이 2개를 받는다.
첫 번째 인자인 state는, 전체 상태에서 해당 리듀서가 관리하는 일부분에 대한 참조이다
두 번째 인자인 action은, 스토어로 전달된 액션을 표현하는 객체이다.
즉, 리듀서의 입력은 이전에 발생한 액션의 결과인 현재 상태(state)와 현재 발생한 액션(action)이다. 리듀서는 현재 상태를 받아 액션을 적용한다. 리듀서의 실행 결과는 새로운 상태인 것이다.
※ 리듀서에서 API 호출은 피하는 것이 좋다. 리듀서는 부수효과가 없는 순수함수로 작성해야 한다. 리듀서는 상태 기계(state machine)이다. API에 대한 HTTP요청 같은 비동기 작업은 수행하면 안된다. 비동기 호출을 처리할 가장 적합한 위치는 미들웨어, dispatch() 액션 생성자이다.
일반적으로 리듀서는 switch/case 문을 포함하는 함수인데, 이 방법은 나쁜 방법이라고 '자바스크립트 핵심 가이드'에서는 말한다. 간단한 redux-actions 라이브러리를 사용하면 리듀서 함수를 좀 더 깔끔한 함수형 프로그래밍 형식에 따르게 만들 수 있다.
그러면, redux-actions의 handleActions를 사용할 수 있다. handleActions는 키는 액션, 값은 함수인 맵 같은 형태의 객체를 받는다. 이렇게 하면 액션 종류에 따라 하나의 함수만 호출되게 된다. 즉, 액션 종류에 따라 함수가 선택되는 것이다.
액션
스토어의 데이터를 변경할 때는 '액션'을 사용한다. 브라우저의 사용자 입력 뿐만 아니라 무엇이든 액션이 될 수 있다. 비동기 작업의 결과가 액션이 될 수도 있는 것이다. 기본적으로 어떤 코드도 액션이 될 수 있다. 액션은 스토어를 위한 정보의 원천일 뿐이다. 데이터는 앱에서 스토어로 전달된다. 액션은 store.dispatch() or connect() 메서드를 통해 실행된다.
그 전에, 모든 액션은 최소한 하나의 속성인 type을 가진 순수한 객체이다. 스토어에 데이터를 전달하기 위해 필요에 따라 많은 속성을 가질 수도 있다. 아래와 같이 모든 액션은 type 속성을 갖는다.
{
type: 'blog/VELOG_HWIBINISSUCCESS'
}
액션의 type은 문자열이다.
액션 이름을 지을 때 흔히 모듈 이름 뒤에 대문자로 작성한다. 같은 이름의 액션이 발생할 가능성이 없어면 모듈 이름을 생략해도 괜찮다.
최신 Redux 개발 방식을 보면, 액션의 type은 문자열 상수로 선언한다.
애플리케이션 상태를 변경하고 싶을 때마다 해당하는 액션을 스토어에 전달해야 한다. API에서 데이터를 전달받거나 사용자가 폼에 입력한 정보를 전달받는 경우를 생각해보면, 전달받은 데이터를 모두 스토어에 저장하고 갱신할 수 있다.
아래 예시 코드를 한번 보면,
this.props.dispatch({
type: FETCH_VELOG,
blog: {}
})
위 코드의 실행 과정을 순서대로 보면 아래와 같다.
1. 컴포넌트에서 type 속성과 필요한 데이터를 담은 액션 객체를 dispatch()에 전달해서 실행한다.
2. 리듀서 모듈에서 관련되어 있는 리듀서를 실행한다.
3. 스토어가 새로운 상태로 갱신되고 컴포넌트에서 새로운 상태를 전달받는다.
액션 생성자
스토어를 변경하려면 모든 리듀서에 액션을 전달해야 한다. '리듀서'는 액션의 type에 따라 애플리케이션 상태를 변경한다. 따라서 '항상 액션의 type을 알아야 한다.'. 하지만 액션 생성자를 이용하면 액션의 type을 감출 수 있다. 과정은 아래와 같다.
1. 필요한 데이터와 함께 액션 생성자를 실행한다. 액션 생성자는 리듀서 모듈에서 정의할 수 있다.
2. 컴포넌트에서 스토어로 액션을 전달한다. 액션의 type을 몰라도 실행할 수 있다.
3. 리듀서 모듈에서 관련된 리듀서를 실행한다.
4. 스토어가 새로운 상태로 갱신한다.
어려워 보이긴 하는데, 쉽게 말하면, 액션 생성자는 '액션을 반환하는 함수'이다.
아래와 같이 말이다.
function fetchVelogCreator(blog){
return {
type: FETCH_VELOG,
blogs
}
}
액션 생성자를 이용하면 복잡한 로직을 함수 호출 한 번으로 감출 수 있다. 물론 위 예시 코드같은 경우 복잡한 로직은 없다. 그저 함수가 수행하는 작업은 액션을 반환하는 것 뿐이다.
액션에 type 속성이 있어야 하는 것을 잊지말고, 액션을 전달하려면 컴포넌트를 스토어에 연결해야 한다.
컴포넌트 스토어에 연결
위에 설명한 부분은 데이터를 스토어에 추가하는 방법이다.
이번에는 컴포넌트에서 스토어의 데이터에 접근하는 방법을 알아보자. 다행히 Provider 컴포넌트에는 데이터를 컴포넌트의 속성으로 끌어올 수 있는 기능이 있다. 하지만 데이터에 접근하려면 명시적으로 컴포넌트를 스토어에 연결해야 한다.
기본적으로 컴포넌트는 데이터 스토어에 연결되어 있지 않다. 컴포넌트를 스토어에 연결하는 작업은 특정 컴포넌트를 위한 명시적인 선택사항인 것이다.
스토어에 연결된 컴포넌트는 속성을 통해 스토어의 어느 데이터에도 접근할 수 있다. 컴포넌트를 스토어에 연결하기 위해서는 connect() 메서드를 사용한다.
예시를 보여주면 아래와 같다.
const {connect} = require('react-redux')
class Velog extends React.Component{
~~
}
module.exports = connect()(Blogs)
connect() 메서드는 react-redux 패키지의 일부이며, 최대 4개의 인자를 전달할 수 있다. 위 코드에서는 1가지만 전달했다.
connect() 메서드는 함수를 반환하고, 반환된 함수를 Velog 컴포넌트에 적용한다. 결과적으로 Velog 컴포넌트가 아닌 connect()로 호출한 Blogs 컴포넌트를 내보내게 되고, 사우이에 Provider 컴포넌트가 있으므로 Blogs 컴포넌트가 스토어에 연결된다.
여기서, 데이터를 원하는 형태로 전달받으려면 간단한 맵핑 함수를 생성해서 애플리케이션 상태를 컴포넌트 속성을 연결해야 한다.
맵핑 함수를 반드시 선언해야 할 필요는 없지만, 여러 방법 중 mapStateToProps() 함수를 사용할 수도 있고 하지만, 익명 화살표 함수를 사용하는 방법이 깔끔하고 간단하다. 맵핑 함수를 react-redux의 connect() 메서드에 전달한다. 맵핑 함수의 첫 번째 인자는 state이다!!!
module.exports = connect(function(state){
return state
})(Blogs)
또 한 가지 방법은, ESNext 스타일의 React에 친근한 암묵적인 반환 형식을 사용할 수도 있다.
module.exports = connect(state => state)(Blogs)
위의 경우, Blogs 컴포넌트가 전체 애플리케이션 상태를 속성으로 전달받게 된다.
이렇게 컴포넌트를 스토어에 연결하면, 스토어의 일부를 갱신할 때, 해당 부분에 의존하는 컴포넌트는 새로운 속성을 전달받아 다시 렌더링된다. 액션을 전달할 때 이 작업이 발생하게 되고, 이는 곧 컴포넌트들이 서로 느슨하게 결합되어있고, 스토어가 갱신되었을 때만 컴포넌트가 갱신된다는 것을 말한다. 적절한 액션을 전달하면 어떤 컴포넌트라도 애플리케이션 상태를 갱신할 수 있다. 더이상 상위 컴포넌트에서 중첩된 컴포넌트까지 속성으로 콜백함수를 내려보내는 고전적인 방법이 필요없다. 스토어를 컴포넌트에 연결하는 것만으로 충분한 것이다.