Nextjs를 활용한 블로그 형식의 포트폴리오 제작을 진행하며 개인적인 관점에서 느낀점, 기술 기록용으로 작성될 예정입니다:). 흥미 위주로 서술한 만큼 오류가 있을 수 있습니다. 가벼운 마음으로 읽고 지적 해 주실 부분이 있으시다면 언제나 환영입니다! :)
오늘은 기타 개인적으로 필요한 설정하고 리덕스 툴킷을 적용하겠습니다.
github : https://github.com/dvisign/dv-blog-portfolio
작성한 _app.js
에서 글로벌스타일을 가져온 import GlobalStyle from "../src/styles/globalStyle";
에서 해당파일의 경로를 찾아가기 위해 ../ 을 사용하였다. 단순 두어번의 루트이동쯤이야 그러려니 하겠지만 프로젝트 규모가 커지면 한개 파일을 불러오기 위해 수도없는 루트를 이동해야한다. 그러므로 절대경로를 설정해보자. 기존에 내가 쓰던 방식은 바벨에서 설정하였으나, 이번엔 tsconfig.json에서 설정해보기로 했다.
(babelrc에서 설정하는 코드와 라이브러리는 맨 하단에 추가로 정리해보자)
// tsconfig.json
{
...(생략)
"compilerOptions" : {
...(생략),
"baseUrl": ".", // baseUrl
"paths": { // path alias 설정
"@src/*" : ["src/*"], // src path 설정
"@styles/*": ["src/styles/*"], // src/styles path 설정
"@utils/*": ["src/utils/*"], // src/utils path 설정
"@hooks/*": ["src/hooks/*"], // src/hooks path 설정
"@components/*": ["src/components/*"], // src/components path 설정
}
},
...(생략)
}
그리고 _app.tsx로 가서 import GlobalStyle from "../src/styles/globalStyle";
을 절대경로 설정에 맞게 수정해보았다.
import { AppProps } from "next/app";
import { NextPage } from "next";
import Head from "next/head";
import GlobalStyle from "@styles/globalStyle";
const Reactproject: NextPage<AppProps> = ({
Component,
pageProps,
}: AppProps) => {
return (
<>
<Head>
<meta charSet="utf-8" />
<title>DVISIGN :: 웹 퍼블리셔 정문채 포트폴리오</title>
<link href="https://fonts.googleapis.com/css?family=Noto+Sans+TC:100,300,400,500,700,900&display=swap" />
<link href="https://fonts.googleapis.com/css?family=Noto+Sans+KR:100,300,400,500,700,900&display=swap&subset=korean" />
</Head>
<Component {...pageProps} />
<GlobalStyle />
</>
);
};
export default Reactproject;
아니나 다를까 구동은 되었지만 eslint에서 경고음을 알려왔다(이쯤되면 너무한거 아닌가..싶다..)
원인은 eslint에서 tsconfig.json에서 설정한 paths를 인식 하지 못하는 것이 원인이었다.
그래서 eslint에 추가 셋팅을 해주었다.
npm i -D eslint-import-resolver-typescript
eslint-import-resolver-typescript
은 tsconfig.json에 정의된 paths를 사용하게 해주는 패키지다.
인스톨이 끝나고 .eslintrc.js로 가서 모듈을 셋팅해주자.
{
// ...생략
settings: {
// ...생략
"import/resolver" : {
// ...생략
typescript: {} // 프로젝트의 최상위 루트에서 tsconfig를 찾는다.
}
}
// ...생략
}
이제 절대경로 셋팅의 경고가 사라졌다.
일단 리덕스 툴킷을 셋팅함에 있어 리덕스를 포함한 여러 상태관리툴이 왜 필요한지 이유를 먼저 정리해본다.
리액트와 뷰는 비슷하면서도 살짝 다른부분이 많은데 대표적으로 props의 관리이다.
뷰는 부모컴포넌트에서 props를 던져주고 자식컴포넌트에서 직접적으로 업데이트가 가능하다. 또한 상하관계가 아니라도 직접적으로 업데이트가 가능하다.
리액트는 그 반대로 단방향데이터 흐름을 보인다. 부모컴포넌트에서 자식컴포넌트에게만 내려줄수 있으며, 자식컴포넌트에서 prop를 업데이트 할 방법은 이른바 state 끌어올리기
라는 방식으로 업데이트를 할 수 있다. props로 데이터와 데이터를 수정 할 setState함수를 같이 내려보내어 자식컴포넌트에서 setState를 시키는것이다. 하지만 리액트에서는 그다지 추천하지 않는 방식이다. 불필요한 렌더링이 있기 때문이다.
그리고 뷰처럼 직접적인 상하 관계에서만 props를 전달하고 받아서 사용 할 수 있다. 어떻게 보면 뷰보다는 관리하기는 쉽지만 반대로 원하는 순간에 업데이트를 하기엔 다소 까다롭다고 할 수 있다.
그래서 전역상태관리 툴인 redux를 사용하는 것이다. 물론 상태관리 툴이 redux말고도 많다. 대표적으로 Mobx, Recoil, Zustand, Apollo 등이 있다.
전부 장단점도 있고, GraphQL과 연동하여 써야하는 라이브러리도 있어, 이 부분은 나중에 따로 정리해보자.
리덕스 툴킷 없이 리덕스와 리덕스 미들웨어를 각자 설치 하려면 부가 패키지들을 많이 설치해야겠지만, 이미 모든것이 이미 포함되어있는 툴킷을 사용 할것이므로 3가지정도만 설치하자.
$ npm i react-redux next-redux-wrapper @reduxjs/toolkit
// 설치후
$ npm i -D @types/react-redux
react-redux : 리덕스를 리액트 환경에서 사용하기 편하게 해주는 라이브러리
next-redux-wrapper : next에서 제공하는 getInitialProps, getServerSideProps 등에서 redux store에 접근할 수 있도록 도와주는 라이브러리
@reduxjs/toolkit : 리덕스셋팅에 필요한 툴들(ex 리덕스 미들웨어)등의 패키지
@types/react-redux : 개발모드에서 react-redux typescript용 라이브러리
그리고 typescript에서는 type을 지정해야 하므로 interface를 절대경로와 redux store의 절대경로, 각 액션들을 모아놓을 slice 절대경로를 추가해주자.
// tsconfig.json
{
"compilerOptions": {
//생략
"paths": {
// 생략
"@interface/*": ["src/interface/*"], // src/interface path 설정
"@store/*": ["src/redux/store/*"], // src/redux/store path 설정
"@slice/*": ["src/redux/slice/*"] // src/redux/slice path 설정
}
},
// 생략
}
절대경로를 추가 해주고 src루트 안에 interface루트와 리덕스를 연결해줄 store와 각 액션들을 관리할 slice루트를 추가해주자.
추가 절대경로 생성했을 경우 항상 서버를 재실행 시켜주자.. 계속 에러나서 왜 절대경로가 설정이 안되는지 헷갈렸다.
그리고 interface에 테스트 용도로 interface를 하나 작성해보자.
// src/interface/user.ts
export interface User {
id: number;
name: string;
}
그리고 store를 생성해보자.
// /src/redux/store/storeConfig.ts
import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
const store = configureStore({
reducer: {},
devTools: process.env.NODE_ENV !== "production",
});
const wrapper = createWrapper(() => store);
export default wrapper;
export type RootState = ReturnType<typeof store.getState>;
reducer안에 객체로 만들어줄 reducer들을 추가해 나갈것이다.
리듀서도 테스트용으로 하나 만들어보자. 리듀어에서는 createSlice를 활용할것이다.
createSlice란 리덕스툴킷의 장점중 하나로 기존의 리덕스는 action type을 지정하고 action creator를 만들어야 했으며 미들웨어를 사용하는 경우 reducer까지 만들어야 했으나 리덕스 툴킷은 createSlice로 한번에 처리가 가능하다.
// /src/redux/slice/userSlice.ts
import { createSlice } from "@reduxjs/toolkit";
import { User } from "@interface/user";
const initialState: Array<User> = [
{
id: 1,
name: "divisign",
},
];
const users = createSlice({
name: "users",
initialState,
reducers: {
userAction: (state, action: PayloadAction<User>) => {
const { id, name } = action.payload;
state.push({ id, name });
},
},
});
const { actions, reducer } = userSlice;
export const { userAction } = actions;
export default reducer;
const initialState는 최초 스테이트를 지정한다. 일단 api연결이 안되있고 store가 제대로 붙었는지 확인 하기위해 User타입에 맞춰서 데이터를 집어넣어보자.
그리고 다시 store.js로 가서 reducer를 연결해보자.
// /src/redux/store/storeConfig.ts
import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
// reducer import
import userReducer from "@slice/userSlice";
const store = configureStore({
reducer: {
// reducer 추가
user: userReducer,
},
devTools: process.env.NODE_ENV !== "production",
});
const wrapper = createWrapper(() => store);
export default wrapper;
export type RootState = ReturnType<typeof store.getState>;
그리고 이제 직접 store를 붙이고 정상적으로 연결이 되었는지 확인해보자.
// pages/_app.tsx
import { AppProps } from "next/app";
import { NextPage } from "next";
import Head from "next/head";
import GlobalStyle from "@styles/globalStyle";
// store wrapper 추가
import wrapper from "@store/storeConfig";
const Reactproject: NextPage<AppProps> = ({
Component,
pageProps,
}: AppProps) => {
return (
<>
<Head>
<meta charSet="utf-8" />
<title>DVISIGN :: 웹 퍼블리셔 정문채 포트폴리오</title>
<link href="https://fonts.googleapis.com/css?family=Noto+Sans+TC:100,300,400,500,700,900&display=swap" />
<link href="https://fonts.googleapis.com/css?family=Noto+Sans+KR:100,300,400,500,700,900&display=swap&subset=korean" />
</Head>
<Component {...pageProps} />
<GlobalStyle />
</>
);
};
// wrapper로 감싸줌
export default wrapper.withRedux(Reactproject);
storeConfig.ts에서 devTools를 개발모드에서 true로 해주었기 때문에 서버 실행을 하게 되면 개발자도구에 redux탭에서 확인이 가능하다.
개발자도구의 redux탭을 설치하기 위해 크롬웹스토어로 가서 redux-dev-tools를 검색해 설치하자.
설치가 되었다면 이제 서버를 실행하고 개발자 도구에서 redux탭으로 가서 확인해보자.
정상적으로 redux가 연결 되었다.
그럼 이번엔 userSlice.ts에서 등록한 action을 테스트해보겠다.
기존 styled components 테스트한 코드를 지우고 버튼을 클릭하여 action을 실행해보자.
// /pages/index.tsx
import React, { useState } from "react";
import type { NextPage } from "next";
import styled from "styled-components";
import { RootState } from "@store/storeConfig";
import { userAction } from "@slice/userSlice";
import { User } from "@interface/user";
import { useDispatch, useSelector } from "react-redux";
const ListStyle = styled.ul`
border-top: 1px solid #ccc;
> li {
border-bottom: 1px solid #ccc;
}
`;
const InputStyle = styled.div`
> input[type="text"] {
border: 1px solid #ccc;
}
`;
const Index: NextPage = () => {
const users = useSelector<RootState, User[]>((state) => state.user);
const [id, setId] = useState<number | null>(users[users.length - 1].id + 1);
const [name, setName] = useState<string | null>("");
const dispatch = useDispatch();
const onClicks = () => {
dispatch(
userAction({
id,
name,
}),
);
};
return (
<div>
<ListStyle>
{users.map((v, i: number) => {
return (
<li key={i}>
<span>id : {v.id}</span>
<span>name : {v.name}</span>
</li>
);
})}
</ListStyle>
<InputStyle>
<label htmlFor="user_id">유저 id</label>
<input
type="text"
value={id}
id="user_id"
onChange={(e: React.FormEvent<HTMLInputElement>) => {
const { value } = e.currentTarget;
setId(Number(value));
}}
/>
</InputStyle>
<InputStyle>
<label htmlFor="user_name">유저 name</label>
<input
type="text"
value={name}
id="user_name"
onChange={(e: React.FormEvent<HTMLInputElement>) => {
const { value } = e.currentTarget;
setName(value);
}}
/>
</InputStyle>
<button onClick={() => onClicks()} type="button">
action test
</button>
</div>
);
};
export default Index;
했는데 갑자기 스타일이 먹히지 않는다. 개발자 도구를 열어보니 에러가 발생했다.
해당 에러를 찾아보니 첫 페이지는 server side rendering으로 작동하며 이후 client side rendering으로 화면을 렌더링하게 되는데, 이때 서버에서 받은 해시+클래스명과 이후 클라이언트에서 작동하는 해시+클래스 명이 달라지면서 스타일을 불러올수 없는 문제가 발생한다. 쉽게 말해서 styled-components에서 생성하는 고유의 클래스 네이밍이 달라서 라고 할수 있다.
이때 바벨 플러그인으로 해결하는데 이참에 styled-components의 단점으로 소개했던 ssr도 같이 설정해보자.
플러그인 babel-plugin-styled-components를 설치해주자.
$ npm i babel-plugin-styled-components
설치가 완료되면 프로젝트 최상위 루트에 .babelrc파일을 생성해주자.
그리고 아래코드를 넣자
{
// nextjs에서 바벨 설정을 추가할때는 next/bable 프리셋을 항상 추가해야함
"presets": [
"next/babel"
],
"plugins": [
// styled-components의 고유 클래스 네이밍 셋팅 및 ssr셋팅.
[
"babel-plugin-styled-components",
// 아래 디테일한 설정은 굳이 필요는 없지만 한번 적어보자.
{
"fileName": true, // fileName: 코드가 작성된 파일명을 알려줌
"displayName": true, // displayName : 클래스명에 해당 스타일 정보 추가
"pure": true, // 사용하지 않은 속성 제거
"ssr": true // server side rendering
}
]
]
}
이제 스타일이 정상적으로 먹히는 것이 보인다.
하지만 eslint에서 Do not use Array index in keys
와 A form label must be associated with a control.
경고가 발생하고 있었다.
Do not use Array index in keys
에러먼저 해결하자.
리액트에서 반복문 맵핑을 돌려 렌더링 시에는 반드시 키값을 추가하기를 권장하고 있다.
그래서 index값을 추가해 주었는데 리액트 eslint에서는 react/no-array-index-key 권장에 의해 배열에서 인덱스값을 사용하지 않기를 권장하고 있다. 위 사항도 지난 시간에 tsconfig.json셋팅등 처럼 제외를 시킬 수 있는데, 해당 사용코드에서 주석처리로 제외를 하거나, .eslintrc.js에서 셋팅이 가능하다. 하지만 맵핑에서 굳이 index를 쓸 필요는 없기에 index값대신에 value의 id값을 키값으로 사용하기로 했다.
import React, { useState } from "react";
import type { NextPage } from "next";
import styled from "styled-components";
import { RootState } from "@store/storeConfig";
import { userAction } from "@slice/userSlice";
import { User } from "@interface/user";
import { useDispatch, useSelector } from "react-redux";
const ListStyle = styled.ul`
border-top: 1px solid #ccc;
> li {
border-bottom: 1px solid #ccc;
}
`;
const InputStyle = styled.div`
> input[type="text"] {
border: 1px solid #ccc;
}
`;
const Index: NextPage = () => {
const users = useSelector<RootState, User[]>((state) => state.user);
const [id, setId] = useState<number | null>(users[users.length - 1].id + 1);
const [name, setName] = useState<string | null>("");
const dispatch = useDispatch();
const onClicks = () => {
dispatch(
userAction({
id,
name,
}),
);
};
return (
<div>
<ListStyle>
// index값 대신 value의 id를 key값으로 사용
{users.map((v) => {
return (
<li key={v.id}>
<span>id : {v.id}</span> / <span>name : {v.name}</span>
</li>
);
})}
</ListStyle>
<InputStyle>
<label htmlFor="user_id">유저 id</label>
<input
type="text"
value={id}
id="user_id"
onChange={(e: React.FormEvent<HTMLInputElement>) => {
const { value } = e.currentTarget;
setId(Number(value));
}}
/>
</InputStyle>
<InputStyle>
<label htmlFor="user_name">유저 name</label>
<input
type="text"
value={name}
id="user_name"
onChange={(e: React.FormEvent<HTMLInputElement>) => {
const { value } = e.currentTarget;
setName(value);
}}
/>
</InputStyle>
<button onClick={() => onClicks()} type="button">
action test
</button>
</div>
);
};
export default Index;
그리고 다음 A form label must be associated with a control.
에러이다. eslint에서는 jsx에서 라벨과 폼의 컨트롤 연결이 off로 기본셋팅이 되어있는것 같다.
이 부분은 폼에 라벨작성하는데 많이 사용될것 같아 라벨의 htmlFor을 통해 id값으로 해당 폼과 자동으로 연결하도록 해당기능을 살려보자.
// .eslintrc.js
module.exports = {
// 생략
rules: {
// 생략
"jsx-a11y/label-has-associated-control": [
"error",
{
required: {
some: ["nesting", "id"],
},
},
],
"jsx-a11y/label-has-for": [
"error",
{
required: {
some: ["nesting", "id"],
},
},
],
},
// 생략
};
그럼 이제 화면이 제대로 그려지고 라벨도 인풋과 정상적으로 연결 되었다.
그럼 이제 유저id인풋과 유저name인풋을 작성하고 클릭이벤트에 걸린 액션을 실행했을때 리스트가 제대로 업데이트 되는지 확인해보자.
생각보다 eslint에서 권고하는 사항이 많습니다.. 매 작업시간마다 eslint설정을 건드는것 같네요.. 그리고 리덕스도 기존에 리덕스와 리듀서를 작성하는데 있어서 코드량이 확 짧아진 것이 느껴지네요. 그리고 오늘 제로초님의 인프런 지식공유 소식에 의하면 리덕스에서도 이제 리덕스만 따로 쓰는것이 아닌 리덕스 툴킷 사용권장을 하는것으로 보아 앞으로의 리덕스 업데이트는 리덕스툴킷이 중심이 될 것으로 보입니다.
당장에 필요한 셋팅들은 끝났으니, 다음 시간부터 페이지들을 작업해 보겠습니다.