0%

Diary App 만들기 - 1. Main 구현

Diary App

0️⃣

data.json 파일

  • 이 mock data 파일은 npx create-react-app 으로 생성된 리액트 폴더의 root에 저장해야 한다. ( src 폴더 밖에 )
  • 해당 파일을 읽기 위해
    1. npm i json-server --save-dev 을 통해 json-server을 설치하고
    2. npx json-server --watch data.json --port port# 을 통해 json-server을 실행하여 읽을 data 파일과 서버 포트 번호를 지정해준다.
1
2
3
4
{
"posts": {
"data": {
"2021": [

여기서 posts는 /posts url을 의미하고, data는 그 페이지 안에 들어있는 data 묶음을 의미한다.

data 안에는 2021년에 대한 data 배열이 있는데, 이를 자세히 보면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"2021": [
[],
[],
[],
[],
[
{
"id": 1,
"date": 20210501,
"title": "다이어리 제목 어쩌고 저쩌고",
"image": "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/a1108dc0-bf35-418b-956d-338d154a5911/image1.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAT73L2G45O3KS52Y5%2F20210508%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20210508T050603Z&X-Amz-Expires=86400&X-Amz-Signature=4c752ad0fba5a98411eafd63ba9c406626003981a362eb9b675739cd386846e0&X-Amz-SignedHeaders=host&response-content-disposition=filename%20%3D%22image1.jpg%22",
"weather": "맑음",
"tags": ["태그1", "태그2"]
},
.
.
.
],
[],
[],
[],
[],
[],
[],
[]
]

이렇게 12개의 배열이 담겨있다. 즉, 월별 데이터가 각 배열에 들어있다는 의미.


1️⃣

component 구조

  1. /components

Main 페이지를 보면, 크게

1
2
3
4
5
6
7
├ MainHeader
├ Calendar
├ Title
├ Main
├ Card
├ NewCard
├ Footer

이렇게 나뉜다.

여기서 공통적으로 적용되는 부분은

1
2
3
4
5
/common
├ MainHeader
├ Calendar
├ Title
├ Footer

이다.

이 외에 페이지 (Main / Diary) 에 따라 달라지는 부분은

1
2
3
4
5
/main
├ Card
├ NewCard
/diary
├ ...

가 있을 것이다.

  1. pages

위의 컴포넌트를 모두 적용시킨 페이지의 경우 따로 폴더를 만들어 페이지별로 컴포넌트를 생성한다.

1
2
3
/pages
├ Main
├ Diary
  1. 그 외…
  • assets
  • lib

2️⃣

App.js

1
2
3
4
5
6
7
8
9
import React from "react";
import { BrowserRouter, Route, Switch } from "react-router-dom";
import MainHeader from "./components/common/MainHeader";
import Calendar from "./components/common/Calendar";
import Title from "./components/common/Title";
import Footer from "./components/common/Footer";
import Main from "./pages/Main";
import Diary from "./pages/Diary";
// Card와 NewCard를 제외하고 모두 불러와주세요!

root ( App.js ) 에서 부를 모든 컴포넌트 import 하기

데이터 이동이 있는 등 리렌더링이 진행되는 컴포넌트는 모두 BrowserRouter 에 넣는다. main, diary 페이지가 모두 사용하는 MainHeader , Calendar , Title 가 이에 해당하며, 특히 컴포넌트 클릭시 url이 바뀌고 렌더링 되는 컴포넌트가 달라져야 하는 경우 Switch 안에 넣는다. Switch 내에는 Route 로 url 별 컴포넌트 렌더링을 달리 정의한다.

여기서 main은 path="/" 일 때이므로 exact를 설정해 Main을 넘겨주고, /diary 일 때는 Diary 컴포넌트를, 그 외에는 Page Not Found 를 띄워준다. Route 는 위에서부터 차례로 내려가며 path를 비교해 처음으로 매칭되는 컴포넌트를 렌더링한다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// BrowserRouter 내부에 모두 넣어줍시다
// Switch를 사용하는 이유 -> URL이 path에 없으면 Page Not Found를 불러오기 위해서
function App() {
return (
<>
<BrowserRouter>
<MainHeader />
<Calendar />
<Title />
<Switch>
<Route exact path="/" component={Main} />
<Route path="/diary/:id" component={Diary} />
<Route component={() => <div>Page Not Found</div>} />
</Switch>
</BrowserRouter>
<Footer />
</>
);
}

exact에 대한 자세한 설명은 다음을 참고하자.

For example, imagine we had a Users component that displayed a list of users. We also have a CreateUser component that is used to create users. The url for CreateUsers should be nested under Users. So our setup could look something like this:

1
2
3
4
<Switch>
<Route path="/users" component={Users} />
<Route path="/users/create" component={CreateUser} />
</Switch>

Now the problem here, when we go to http://app.com/users the router will go through all of our defined routes and return the FIRST match it finds. So in this case, it would find the Users route first and then return it. All good.

But, if we went to http://app.com/users/create, it would again go through all of our defined routes and return the FIRST match it finds. React router does partial matching, so /users partially matches /users/create, so it would incorrectly return the Users route again!

The exact param disables the partial matching for a route and makes sure that it only returns the route if the path is an EXACT match to the current url.

So in this case, we should add exact to our Users route so that it will only match on /users:

1
2
3
4
<Switch>
<Route exact path="/users" component={Users} />
<Route path="/users/create" component={CreateUser} />
</Switch>

3️⃣

공통 컴포넌트 update!

  1. MainHeader.js

Router Props

브라우저와 리액트앱의 라우터를 연결하게 되면 그 결과 라우터가 history api에 접근할 수 있게 되며 각각의 Route와 연결된 컴포넌트에 props로 match, location, history라는 객체를 전달하게 된다.

Match

match 객체에는 와 URL이 매칭된 대한 정보가 담겨져있다. 대표적으로 match.params로 path에 설정한 파라미터값을 가져올 수 있다.

Location

location 객체에는 현재 페이지의 정보를 가지고 있다. 대표적으로 location.search로 현재 url의 쿼리 스트링을 가져올 수 있다.

👆 요청 url

History

history 객체는 브라우저의 history와 유사하다. 스택(stack)에 현재까지 이동한 url 경로들이 담겨있는 형태로 주소를 임의로 변경하거나 되돌아갈 수 있도록 해준다.

withRouter 을 이용해 컴포넌트를 감싸면 컴포넌트 자체에서도 location , history 와 같이 url을 활용할 수 있도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Route가 아닌 요소에서 location을 사용하기 위해서 withRouter를 사용합니다
import { withRouter } from "react-router-dom";
import styled from "styled-components";
import MenuIcon from "../../assets/MenuIcon.svg";
import ProfileIcon from "../../assets/ProfileIcon.svg";

export default withRouter(MainHeader);

const MainHeaderWrap = styled.div`
.header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 91px;

&__title {
font-size: 36px;
font-weight: bold;
font-style: italic;
color: #cea0e3;
&:hover {
cursor: pointer;
}
}

&__profile {
margin-right: 10px;
}

&__hr {
width: 1200px;
height: 13px;
background: linear-gradient(90deg, white, #cea0e3);
}
}
`;

props에 history 을 받아와 컴포넌트 간 이동을 컨트롤한다. history.push() 를 통해 url 이동이 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const MainHeader = ({ history }) => {
// history를 props로 가져옵니다.
return (
<MainHeaderWrap>
<div className="header">
{/* header는 menu, title, profile의 flex-container 입니다. */}
<img className="header__menu" src={MenuIcon} alt="" />
<div className="header__title" onClick={() => history.push("/")}>
{/* title을 누르면 path="/", 즉 Main 페이지로 이동 */}
Diary App
</div>
<img className="header__profile" src={ProfileIcon} alt="" />
</div>
<div className="header__hr"></div>
{/* hr은 위의 menu, title, profile과 수직상에 있기 때문에 flex를 적용시키지 않기 위해 밖으로 뺐습니다 */}
{/* hr을 header div 안으로 넣으면 menu, title, profile과 동일 선상에서 가로로 정렬됩니다. 궁금하면 넣어보세요 ㅎㅅㅎ */}
</MainHeaderWrap>
);
};
  1. Title.js
1
2
3
4
5
6
7
8
9
10
11
const Title = ({ location }) => {
// location을 props로 가져옵니다 (현재 페이지 정보를 담고 있는 객체)
// location.pathname이 "/"이면 "이번 달 일기"를 저장하고, 아니면 "오늘의 일기"를 저장합니다.
const title = location.pathname === "/" ? "이번 달 일기" : "오늘의 일기";

return (
<TitleWrap>
<div className="title">{title}</div>
</TitleWrap>
);
};
  1. Footer.js
1
2
3
4
5
6
7
8
9
const Footer = () => {
return (
<FooterWrap>
<div className="footer">
Copyright&copy; 2021. BE SOPT Web part. All rights reserved.
</div>
</FooterWrap>
);
};

&copy 는 Copyright 표시인 © 를 보여주는 html 요소이다 👾

  1. Calendar.js

Calendar 컴포넌트 및 다른 컴포넌트에도 공통된 state를 내려주기 위해 App.js에서 공통된 state를 생성 및 설정하고, props로 내려준다.

App.js에서 년도와 월을 저장한 후, 데이터를 Calendar.js로 전달해주는 방식입니다!

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React, { useState } from 'react'; // state를 사용하기 위해 useState를 불러옵니다

// Default 값으로 현재 연도와 월을 저장하기 위해, 현재 연도와 월을 불러오는 함수입니다
const getCurrDate = () => {
const now = new Date();
const currYear = now.getFullYear();
const currMonth = now.getMonth();
return { year: currYear, month: currMonth }; // 객체로 한 번에 리턴해줍시다
};

function App() {
// state를 각각 선언해주세요~ useState()의 인자로 각각 현재 연도와 월을 전달합니다
const [year, setYear] = useState(getCurrDate().year);
const [month, setMonth] = useState(getCurrDate().month);
return (
<>
<BrowserRouter>
<MainHeader />
<Calendar
currYear={year}
setCurrYear={setYear}
currMonth={month}
setCurrMonth={setMonth}
/> {// Calendar 컴포넌트에 state로 선언한 모든 것들을 props로 전달합니다 }

Calendar.js

App.js에서 넘겨준 props를 받아준다.

1
const Calendar = ({ currYear, setCurrYear, currMonth, setCurrMonth }) => {

useRef는 DOM 요소를 조작하기 위해 생성하는 상응하는 리액트 내 DOM 객체이다.

1
2
3
// useRef를 사용하여 "DOM 요소를 가져올 수 있는 변수"를 선언합니다
const leftButton = useRef();
const rightButton = useRef();
1
const isMain = location.pathname === "/" ? true : false;
1
2
3
4
5
6
7
8
9
<img
src={LeftOff}
alt=""
onClick={() => isMain && setCurrYear(currYear - 1)}
onMouseEnter={() => (leftButton.current.src = LeftOn)}
onMouseLeave={() => (leftButton.current.src = LeftOff)}
ref={leftButton}
className="calendar__year--left"
/>

onClick 시에는 year를 다시 세팅해야 하므로 props로 받은 setCurrYear 함수를 이용해 year를 알맞게 설정한다. 이 때, url이 “/“ 인 경우에만 월 이동이 가능하게 설정하기 위해 isMain && 조건을 적용한다.

onMouseEnteronMouseLeave 의 경우에는 마우스를 DOM 요소에 호버 온/오프 했을 때의 변화를 설정하는 것이므로, ref를 전달하고 해당 ref의 current.src 에 알맞은 svg 을 전달하여 알맞은 아이콘을 렌더링하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div className="calendar__month">
{monthList.map((month) => {
return (
<div
key={month}
onClick={() => setCurrMonth(month)}
style={
month === currMonth
? { fontSize: "22px", fontWeight: "bold" }
: {}
}
className="calendar__month--button"
>
{month}월
</div>
);
})}
</div>

map 함수를 사용할 시, 되도록 key props를 설정하도록 습관을 들이자! 또한, map 사용 시 꼭 return 문을 사용해야하는 것은 아니지만, 만약 사용한다면 {} 로 return 문을 감싸야 한다!

여기서는 월을 선택했을 때 currMonth 을 다시 세팅하고, 그에 맞는 스타일을 적용해야 하므로 style 객체에 조건문을 설정하여 알맞게 렌더링되도록 한다. 여기서 jsx 의 특성을 엿볼 수 있는데, 일단 javascript 문법이 들어가기 때문에 style={} 로 문법임을 선언해주고, 이 안에 조건문을 설정한 후 실제 스타일 객체는 {} 로 전달해준다!


4️⃣

Card 설정

Main.js

Main 페이지는 Card를 포함하는 최상단 페이지이므로, 자식 컴포넌트로 Card를 렌더링하고 props로 userData를 전달한다.

1
2
3
4
5
return (
<MainWrap>
<Card userData={userData} />
</MainWrap>
);

Card.js

1
2
3
4
5
6
// 서버에 date가 20200509 형식으로 저장되어있기 때문에, 이를 "5월 9일" 형태로 반환하는 함수입니다
const getDateFormat = (date) => {
const month = parseInt((date % 10000) / 100);
const day = date % 100;
return `${month}월 ${day}일`;
};

props로 받은 userData 객체를 구조분해할당하여 개별적인 변수에 담는다.

1
2
const Card = ({ userData }) => {
const { date, title, image, weather, tags } = userData;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// text 길이가 길어지면 ... 로 생략하는 방법(아래의 3가지 속성을 세트로 사용합니다)
// white-space: nowrap; -> text가 길면 보통 다음 줄로 넘어가는데, "한 줄에만; nowrap" 작성하여 넘치게 합니다
// overflow: hidden; -> 넘친 텍스트를 숨깁니다
// text-overflow: ellipsis; -> 넘친 텍스트를 ... 로 표시합니다

&__title {
font-size: 18px;
height: 25px;
margin: 0 12px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

5️⃣

API 설정

Main 컴포넌트 자체만 넘기지 않고 props도 함께 전달하기 위해 Route 내 component props을 변경한다.

1
2
3
4
5
6
7
8
// Main 컴포넌트에서는 "해당 월의 데이터만" 가지고 있어야하므로 Main에 현재 연도와 월을 넘겨줍니다
// ex) 2021년 5월에 해당하는 데이터만 가지고 오기 위함! 연도나 월이 변경되면 자동으로 API를 다시 요청합니다
<Switch>
<Route
exact
path="/"
component={<Main year={year} month={month} />}
/>

GetUserData API를 작성하여 json-server에 있는 data를 가져옵니다. 실제로 가져오기 전에 Postman으로 테스트 해보면 좋겠죠?

api 요청 함수는 무조건 async , await 으로 작성하여 비동기 처리한다! 데이터를 받지 않으면 진행되지 않아야 버그를 방지한다.

get url 의 경우, 앞서 data.json 에서 설명했듯이 설정한 서버 포트 넘버 3001번에서 /posts url 에서 data를 가져온다. 이 때, api로 가져오는 data는 객체 내에 data에 대한 metadata와 함께 data를 가져오므로 기본적으로 다음과 같은 구조를 가지고 있다.

1
2
3
4
5
6
7
8
9
rawData {
...metadata,
data: {
.
.
.
data: {}
}
}

여기서는 data 라고 명명하였으므로, data 내 data에 접근하여 data를 가져온다.

1
2
3
4
5
export const getCardData = async () => {
try {
const rawData = await axios.get("http://localhost:3001/posts");
return rawData.data.data;
}

App.js에서 내려준 year , month 를 props로 받아 data의 property로서 접근하여 알맞은 데이터를 가져온다. api에서 data를 받아올 것이므로 초기값은 null 로 설정하고, useEffect 를 이용해 setState한다. 이 때, useEffect 에 전달하는 callback 함수의 경우 밖에서 정의 후 전달하는 것이 아닌 함수 내에서 정의하는 것이므로, IFFY 를 위해 기본적인 함수 생성문 () => {} 정의 후 내부에 IFFY를 위한 (() => {})() 를 정의한다. 이 때, api를 이용해 data를 호출하므로 async, await로 data를 받아오고 설정해야 한다. (api 함수 자체가 async, await 함수이므로) 또한, year과 month가 바뀔 때에만 리렌더링을 해야하므로 dependency 배열에 이 두 변수를 설정한다. 즉, dependencies가 year, month 이므로 year와 month가 변경되면 api를 다시 요청하게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
const Main = ({ year, month }) => {
// useState를 사용하여 전체 데이터 값을 저장합니다. 여기서는 테스트를 위해 초기값으로 더미 데이터를 넣었습니다!
// 이후에는 서버에서 API 요청 결과값을 받으면 userData에 저장합니다.
const [userData, setUserData] = useState(null);

useEffect(() => {
(async () => {
const data = await getCardData();
data[year] && setUserData(data[year][month]);
})();
}, [year, month]);

Reference

Router : https://gongbu-ing.tistory.com/45