Nextjs를 활용한 블로그 형식의 포트폴리오 제작을 진행하며 개인적인 관점에서 느낀점, 기술 기록용으로 작성될 예정입니다:). 흥미 위주로 서술한 만큼 오류가 있을 수 있습니다. 가벼운 마음으로 읽고 지적 해 주실 부분이 있으시다면 언제나 환영입니다! :)
오늘은 전체적인 레이아웃과 디자인툴을 설치하고 페이지 작업을 해보겠습니다.
github : https://github.com/dvisign/dv-blog-portfolio
ui디자인 툴로는 가장 익숙한 bootstrap을 활용하기로 했다. 리액트에서 더 쓰기 편하게 해주는 패키지까지 함께 설치하자.
공식문서의 시작 가이드를 따라 설치를 진행해본다.
$ npm i react-bootstrap bootstrap
설치가 완료 되었다면 _app.tsx에 css를 추가해주자.
// /pages/_app.tsx
// ...생략
import "bootstrap/dist/css/bootstrap.min.css";
// ...생략
그리고 개인적으로 가장 중요하게 생각하는 그리드 시스템이 잘 적용 되는지 확인해보자.
// /pages/index.tsx
import type { NextPage } from "next";
import AppLayout from "@components/AppLayout";
import { Container, Row, Col } from "react-bootstrap";
const Index: NextPage = () => {
return (
<AppLayout>
<Container>
<Row>
<Col sm={12} md={12} lg={4}>
1
</Col>
<Col sm={12} md={6} lg={4}>
2
</Col>
<Col sm={12} md={6} lg={4}>
3
</Col>
</Row>
</Container>
</AppLayout>
);
};
export default Index;
Col은 각 해당 기기 넓이에 따라 사이즈를 반응형으로 얼마나 가져갈지 작성한것이다.
각 브레이크포인트는 이곳에서 확인하자.
사이즈에 따라 잘 나오고 있다.
일단 레이아웃을 만들기 앞서 이제 컴포넌트를 모아둘 루트를 생성하자. src루트 안에 components루트를 생성하자. 앞으로 모든 컴포넌트는 이곳에 생성할 것이다.
이제 공통 레이아웃을 만들자. 공통 레이아웃은 html문서의 헤더 컨텐츠 푸터를 만들고 각 page들에서 공통 레이아웃으로 덮어 헤더와 푸터는 그대로 가져오고 각 페이지들에서 작성한 양식을 컨텐츠에서 보여주게 할것이다. 그렇게 하면 매 페이지마다 헤더와 푸터를 불러와야하는 번거로움이 없어진다.
// /src/components/AppLayout.tsx
import React from "react";
const AppLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<header>헤더영역</header>
<div>{children}</div>
<footer>푸터영역</footer>
</>
);
};
export default AppLayout;
그리고 pages/index.tsx로 가서 지난번에 리덕스툴킷을 테스트 했던 코드를 지우고 아래 코드를 작성해보자.
import type { NextPage } from "next";
import AppLayout from "@components/AppLayout";
const Index: NextPage = () => {
return (
<AppLayout>
<div>컨텐츠</div>
</AppLayout>
);
};
export default Index;
서버를 가동 시키면 헤더영역, 컨텐츠영역, 푸터영역이 AppLayout 컴포넌트에서 나눈대로 보여질것이다.
네비게이션등의 웹페이지 문서의 header부분을 작업하자.
아까 만들어둔 Header.tsx에 부트스트랩을 씌울 예정이다. 물론 styled-components로 커스텀도 할 것 이다.
그리고 헤더를 작업 시작하려다 느낀것인데 로고와 같은 이미지를 관리할 루트가 필요하다는 것이 생각이 들었다. 그래서 프로젝트 루트에 public 폴더를 생성하였다. nextjs에서 static assets파일들은 전부 public 폴더 안에서 관리된다.
그리고 이곳에서 이미지를 관리할 폴더를 만들고 그 안에서 이미지들을 관리하자.
일단 파비콘과 로고 이미지 파일을 넣었다 이제 파비콘은 head안에, 로고는 header레이아웃에서 사용하자.
// /pages/_app.tsx
import { AppProps } from "next/app";
import { NextPage } from "next";
import Head from "next/head";
import GlobalStyle from "@styles/globalStyle";
import wrapper from "@store/storeConfig";
import "bootstrap/dist/css/bootstrap.min.css";
const Reactproject: NextPage<AppProps> = ({
Component,
pageProps,
}: AppProps) => {
return (
<>
<Head>
<meta charSet="utf-8" />
<title>DVISIGN :: 웹 퍼블리셔 정문채 포트폴리오</title>
// 파비콘 추가
<link rel="shortcut icon" href="/img/common/favicon.png" />
<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 wrapper.withRedux(Reactproject);
이제 네비게이션 헤더바를 작업해보자. src/components에 공통으로 사용할 컴포넌트들을 모아둘 폴더를 하나 생성하고 그곳에 Header컴포넌트를 생성하자.
// /src/components/common/Header.tsx
import { Container, Navbar, Nav, NavDropdown } from "react-bootstrap";
import styled from "styled-components";
const HeaderStyle = styled.header`
width: 100%;
`;
const Header = () => {
return <HeaderStyle>헤더영역</HeaderStyle>
};
export default Header;
파일 생성 후에 AppLayout컴포넌트로 가서 Header 컴포넌트를 공통영역으로 넣어주자.
// /src/components/AppLayout.tsx
import React from "react";
import Header from "@components/common/Header";
const AppLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<Header />
<main>{children}</main>
<footer>푸터영역</footer>
</>
);
};
export default AppLayout;
그럼 이제 본격적으로 Header영역을 작업해보자. 리액트-부트스트랩 예제코드는 이곳에서 확인하자.
네비게이션을 만드는데 무난한 디자인의 레이아웃을 선택했다.
반응형 지원도 잘된다. 이제 필요한 부분들을 각각 커스터마이징 해보자.
import Head from "next/head";
import { Container, Navbar, Nav, NavDropdown } from "react-bootstrap";
import styled from "styled-components";
const HeaderStyle = styled.header`
width: 100%;
background-color: #3c3c3c;
#logos {
#logo-text {
font-family: "Pacifico";
font-size: 1rem;
}
}
#header-navs {
* {
font-size: 0.8rem;
}
}
`;
const Header = () => {
return (
<HeaderStyle>
<Head>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Pacifico"
/>
</Head>
<Navbar variant="dark" expand="lg">
<Container>
<Navbar.Brand id="logos" href="#home">
<img
alt="퍼블리셔 dvisign의 포트폴리오 입니다."
src="/img/common/nav_logo.png"
className="d-inline-block align-center"
/>{" "}
<span id="logo-text">dvisign</span>
</Navbar.Brand>
<Navbar.Toggle aria-controls="header-navs" />
<Navbar.Collapse id="header-navs" className="justify-content-end">
<Nav>
<Nav.Link href="#home">Home</Nav.Link>
<Nav.Link href="#link">About</Nav.Link>
<Nav.Link href="#link">Aptitute</Nav.Link>
<Nav.Link href="#link">Work</Nav.Link>
<NavDropdown title="Board" id="basic-nav-dropdown">
<NavDropdown.Item href="#action/3.2">
Reference
</NavDropdown.Item>
<NavDropdown.Item href="#action/3.3">Log</NavDropdown.Item>
<NavDropdown.Divider />
</NavDropdown>
<Nav.Link href="#link">Contact</Nav.Link>
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
</HeaderStyle>
);
};
export default Header;
부트스트랩에서 지원하는 대략적인 윤곽은 잡혔다. 반응형도 햄버거버튼으로 교체는 되지만 나는 사이드 네비게이션을 원했지만 아쉽게도 지원이 되지는 않는다. 추가 라이브러리를 설치한다면 가능하다고 스택오버플로우에 나와있긴 하지만 직접 만들어보자, 그리고 네비게이션 메뉴들은 관리자에서 직접 생성하게 만들기 위해서 DB에서 받아와서 처리를 하려한다. 하지만 현재 DB는 구축되어 있지 않기때문에 리덕스에 더미데이터를 생성하고 api요청을해 더미데이터로 뿌려주도록 바꿔보자. react에서만 작업을 하려면 useEffect로 최초렌더링에 api요청을 했겠지만 nextjs는getserversideprops를 활용해 렌더 이전에 데이터를 패치 할 수 있다. 그렇다면 모든페이지에서 렌더링 이전에 getserversideprops로 api요청을 하고 결과를 받아서 dispatch해주자.
하지만 getInitailProps, getserversideprops등을 사용 하려면 지난 시간에 작업했던 리덕스 툴킷의 내용을 약간 수정해야 할것같다. storeConfig작업 시에 리듀서를 그냥 객체로 선언하게 끔 만들어 주었으나, nextjs는 특성상 서버측 스토어와 클라이언트측 스토어가 공존하기 때문에 두개의 스토어를 합쳐주도록 HYDRATE설정을 해주어야 한다.
// /src/redux/slice/reducers.ts 파일을 생성
import { combineReducers } from "@reduxjs/toolkit";
import { HYDRATE } from "next-redux-wrapper";
import userReducer from "@slice/userSlice";
const reducer = (state, action) => {
if (action.type === HYDRATE) {
return {
...state,
...action.payload,
};
}
return combineReducers({
user: userReducer,
})(state, action);
};
export default reducer;
// /src/redux/store/storeConfig.ts
import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
import reducer from "@slice/reducers";
const store = configureStore({
reducer,
devTools: process.env.NODE_ENV !== "production",
});
const wrapper = createWrapper(() => store);
export default wrapper;
export type RootState = ReturnType<typeof store.getState>;
리듀서를 따로 분리하여 reducers.ts에서 따로 관리하며 액션타입이 HYDRATE일 경우 클라이언트 스토어와 서버 스토어를 합쳐 주었다.
이제 각 페이지들이 열리기전에 getServerSidePorps를 활용해 action을 dispatch 하여 네비게이션 리스트를 불러오도록 해보자.
// index.tsx
import type { NextPage } from "next";
import AppLayout from "@components/AppLayout";
import { Container, Row, Col } from "react-bootstrap";
import wrapper from "@store/storeConfig";
const Index: NextPage = () => {
return (
<AppLayout>
<Container>
<Row>
<Col sm={12} md={12} lg={4}>
1
</Col>
<Col sm={12} md={6} lg={4}>
2
</Col>
<Col sm={12} md={6} lg={4}>
3
</Col>
</Row>
</Container>
</AppLayout>
);
};
export const getServerSideProps = wrapper.getServerSideProps(() => async () => {
return {
props: {},
};
});
export default Index;
기본적인 getServerSideProps를 셋팅해 주었다. getServerSideProps와 활용방법에 대해 간단히 정리하고 가자.
위에서 간단하게 언급했듯이 페이지빌드와는 상관없이 데이터를 미리 Fetch해주기 위한 것이다.
사용 가능한 매개변수로는 context가 있으며, contaxt는 자바스크립트 객체로 되어있으며, 그 안에는 서버단에서 사용하는 request정보나 response정보등 많은 정보들이 담겨있다.
그리고 현재 셋팅 처럼 next-redux-wrapper를 통해 getServerSideProps를 사용시 리덕스의 store도 받아서 처리해줄수 있다. 즉 빌드와는 상관없이 redux action을 dispatch해서 전역상태를 미리 업데이트 시켜 ssr 해주는 것이다. 그럼 이제 네비게이션 관리에 대한 리듀서와 액션을 정의 하고 타입스크립트 이기때문에 interface도 생성해주고 가자.
// /src/interface/common.ts
export interface DrowDownList {
url: string;
name: string;
}
export interface NavList {
url: string;
name: string;
dropDownList?: Array<DrowDownList> | [];
}
export interface Common {
navList?: Array<NavList> | [];
}
타입먼저 지정해 주었다. 내가 생각한 네비게이션 설정은 1차 depth의 네비게이션 리스트와 그안에 2차 depth까지만 만들어 주려 한다. 여기서 dropDownList?: Array<DrowDownList> | null;
문법은 es2020에서 새롭게 추가된 옵셔널체이닝(Optional Chaining)
문법이다. 쉽게 이해하자면 삼항연산자의 확장된 문법이라고 이해하면 편하다.(개인적인생각) 아래 코드에서 간단한 예시를 들어보자.
interface Obj {
text: string;
text2: string;
}
const obj = {
text: 'test',
}
const objFuc = (object: Obj) => {
console.log(object)
};
objFuc(obj);
타입스크립트 테스트는 이곳에서 진행해 보자.
위 코드를 집어넣으면 에러가 발생한다.
text2에 대한 타입이 지정되어 있으나, 매개변수 object로 받은 obj에는 text2가 없기 때문에 undefined타입이라 타입 에러가 발생하는 것이다. 이때 타입도 선택적으로 받을 수있도록 옵셔녈 체이닝을 사용하는 것이다.
interface Obj {
text: string;
text2?: string | null;
}
const obj = {
text: 'test',
}
const objFuc = (object: Obj) => {
console.log(object)
};
objFuc(obj);
이처럼 타입스크립트에서 없는 속성값에 따라 타입에러가 아닌 없을 경우의 타입을 미리 지정해 줄수 있다.
이제 네비게이션 리스트에 대한 타입을 지정해 주었다면 이제 리듀서도 정의해보자.
// /src/redux/slice/commonSlice.ts
// commonSlice생성
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Common } from "@interface/common";
const initialState: Common = {
navList: [],
};
const commonSlice = createSlice({
name: "common",
initialState,
reducers: {
navListAction: (state, action: PayloadAction<Common>) => {
console.log(state);
console.log(action);
},
},
});
const { actions, reducer } = commonSlice;
export const { navListAction } = actions;
export default reducer;
// /src/slice/reducers.ts
import { combineReducers } from "@reduxjs/toolkit";
import { HYDRATE } from "next-redux-wrapper";
import userReducer from "@slice/userSlice";
// reducer에 등록
import commonSlice from "@slice/commonSlice";
const reducer = (state, action) => {
if (action.type === HYDRATE) {
return {
...state,
...action.payload,
};
}
return combineReducers({
user: userReducer,
// reducer에 등록
common: commonSlice,
})(state, action);
};
export default reducer;
export type RootState = ReturnType<typeof reducer>;
그리고 slice에서 기존에 테스트를 위해 reducers를 등록했지만 이제 api연결을 하고 그에 대한 미들웨어를 사용해야 하기 때문에 extraReducers로 교체 하도록 하겠다. 두개의 차이점은 아래에 정리해보자.
reducers는 리듀서의 키값으로 액션함수가 자동으로 생성한다. 반면 extraReducers는 액션함수가 자동으로 생성되지 않는 별도의 함수를 사용할 수 있도록 한다. 즉, reducer 내부 액션함수가 아닌, 외부의 액션을 참조하기 위해 사용된다.
그렇다면 api연결과 리듀서를 등록하는것이랑 무슨 연관이기에 교체를 하는가하면 위에 언급했듯이 외부의 액션을 참조하기 위함이다. 외부 액션이라는것이 api연결을 하기위한 비동기처리액션을 말하는것이며, 리덕스툴킷에 기본탑재 되어있는 thunk의 createAsyncThunk를 이용하기 위해서이다.
import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit";
import { CommonType } from "@interface/common";
const dummyData = {
navList: [
{
url: "/",
name: "Home",
},
{
url: "/about",
name: "About",
},
{
url: "/aptitute",
name: "Aptitute",
},
{
url: "/work",
name: "Work",
},
{
name: "board",
dropDownList: [
{
url: "/reference",
name: "Reference",
},
{
url: "/log",
name: "Log",
},
],
},
{
url: "/contact",
name: "Contact",
},
],
};
export const getCommonData = createAsyncThunk(
"GET_COMMON",
async (_, thunkAPI) => {
try {
const value = await new Promise((resolve) => {
resolve(dummyData);
});
return value;
} catch (e) {
return thunkAPI.rejectWithValue("error");
}
},
);
const initialState: CommonType = {
navList: [],
};
const commonSlice = createSlice({
name: "common",
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(getCommonData.pending, () => {
console.log("pending");
})
.addCase(
getCommonData.fulfilled,
(state, { payload }: PayloadAction<CommonType>) => {
return {
...state,
navList: payload.navList,
};
},
)
.addCase(getCommonData.rejected, () => {
console.log("reject");
});
},
});
export default commonSlice.reducer;
createAsyncThunk : 액션 타입 문자열과 프로미스를 반환하는 콜백 함수를 매개변수로 받아 자동으로 promise생명주기 액션타입을 생성해줍니다.(pending:요청, fulfilled:성공, rejected:실패)
createAsyncThunk에서 반환하는 콜백함수는 2가지 매개변수를 받으며, 1번째 인자는 함수에 전달되는 변수이다. 사용하지를 원하지 않는 경우엔 별 다른 네이밍 없이 _로 지정 해주면 된다., 두번째 매개변수는 thunkAPI로 Redux-thunk함수에 전달되는 모든 매개변수와 추가 옵션을 포함하는 객체로 대표적으로 생명주기의 성공이나 실패등을 알리는 유틸함수들을 포함하고 있다.
builder: Case Reducer로 액션별로 나눠서 액션을 처리할 수 있으며, createAsyncThunk에서 생성한 생명주기들을 등록해 줄 수 있다. (pending:요청, fulfilled:성공, rejected:실패)
그럼 이제 index.tsx에서 getServerSideProps로 네비게이션 리스트를 요청해보겠다.
// /pages/index.tsx
// ...생략
import { getCommonData } from "@slice/commonSlice";
// ...생략
export const getServerSideProps = wrapper.getServerSideProps(
(store) => async () => {
await store.dispatch(getCommonData());
console.log(store.getState().common);
return {
props: {},
};
},
);
// ...생략
extraReducers에서 요청중(pending)에서는 콘솔로 pending을 찍었고, 성공했다면(fulfilled) navList state를 업데이트 할것이다.console.log(store.getState().common);
이 부분을 통해 잘 업데이트 되었는지 확인해보자.
요청(pending)시에 콘솔로 찍었던 pending 텍스트와 업데이트 되고나서의 navList가 빈 배열에서 업데이트된 객체들로 채워진것이 보인다.
그럼 이제 Header.tsx로 가서 기존에 넣어두엇던 네비게이션 리스트를 삭제하고 리덕스에 저장된 스테이트를 받아 뿌려주도록 바꾸어보자.
// /src/components/common/Header.tsx
import Head from "next/head";
import { Container, Navbar, Nav, NavDropdown } from "react-bootstrap";
import styled from "styled-components";
import { useSelector } from "react-redux";
import { RootState } from "@slice/reducers";
import { CommonType, NavList } from "@interface/common";
const HeaderStyle = styled.header`
width: 100%;
background-color: #3c3c3c;
#logos {
#logo-text {
font-family: "Pacifico";
font-size: 1rem;
}
}
#header-navs {
* {
font-size: 0.8rem;
}
}
`;
const Header = () => {
const { navList } = useSelector<RootState, CommonType>(
(state) => state.common,
);
console.log(navList);
return (
<HeaderStyle>
<Head>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Pacifico"
/>
</Head>
<Navbar variant="dark" expand="lg">
<Container>
<Navbar.Brand id="logos" href="#home">
<img
alt="퍼블리셔 dvisign의 포트폴리오 입니다."
src="/img/common/nav_logo.png"
className="d-inline-block align-center"
/>{" "}
<span id="logo-text">dvisign</span>
</Navbar.Brand>
<Navbar.Toggle aria-controls="header-navs" />
<Navbar.Collapse id="header-navs" className="justify-content-end">
<Nav>
{navList.map((v: NavList) => {
if (!v.dropDownList) {
return (
<Nav.Link key={v.name} href={v.url}>
{v.name}
</Nav.Link>
);
}
const lists = v.dropDownList;
return (
<NavDropdown
key={v.name}
title={v.name}
id="basic-nav-dropdown"
>
{lists.map((value) => (
<NavDropdown.Item href={value.url} key={value.name}>
{value.name}
</NavDropdown.Item>
))}
<NavDropdown.Divider />
</NavDropdown>
);
})}
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
</HeaderStyle>
);
};
export default Header;
기존에 픽스했던거랑 같은 결과물이 나오고 있다.
타입스크립트를 단순히 개념만 자바스크립트에 타입 지정을 해주는거다. 라고 막연하게 생각했었는데 처음다루다 보니 생각보다 많이 헤맸던거 같네요. 타입 오류 지정에 대해 엄청 많이 검색했던거 같아요.. 하지만 앞으로도 타입스크립트 뿐만 아니라 개발트렌드 자체가 계속 발전할텐데 따라 가려면 어쩔수 없이 공부해야겠죠. 다음 시간에는 메인페이지의 레이아웃 구성을 하고, 헤더처럼 더미데이터를 연결해 보겠습니다.