React를 잘근잘근 씹으며 만드는 Twittler(트0터 짭...)
본격 리액트에 들어온지도 사흘이 지났습니다. 그리고.....리액트 쉽다고 생각했던 과거의 나를 묻어버리고 싶네요.
'애걔걔~리액트 그거 뭐 변수 좀 만지고 컴포넌트 넣고 props로 내려보내면 되는 거 아니냐?'
무슨 깡으로 그렇게 생각했던 건지... ...? 좀 물어보고 싶습니다.
물론 알고 있습니다. 낯설어서 그런거지 적응하면 어떻게든 할 것 같아요. 근데 적응이 잘 안 된다는 게 문제죠. 얄팍하게 올라 앉아 삐걱거리던 지식들이 props와 state와 함께 몰락했습니다. 하산은 멀었다... 가서 개념 더 잡고 와라.....
리액트 문법들을 익힌다고 익혔는데도 실습과 마주하니 머리가 하얗게 손이 덜덜 떨리는 경험을 할 수 있었습니다. 한번에 문제들이 몰아쳐서 그럴까요. 어쨌든. 마냥 울면서 손을 놓고 있을 수는 없으니, 구조부터 문법까지 감.자.탕.뼈 훑듯이 날름날름 핥아보려고 합니다. 보다보면 되겠죠. 포스팅 하면 어떻게든 더 자세히 보게 되더라고요(...) VS Code와 티스토리를 나란히 켜두고 앉아있는 이유입니다.
Twittler 만들기 🐥<- 이놈이 이번의 주제입니다. 짠짜란!
트위틀러. 트0터의 짝퉁, 대충 sns 비슷한 걸 만들어보는 모양입니다. sns의 기능이란 뭐다? 메세지를 작성해서 올린다.
어쨌든 주어진 파일을 구조부터 한번 살펴보기로 합니다. 리액트는 컴포넌트들을 조립하는 레고와 같기 때문에 구조 이해가 무엇보다도 중요합니다.
├── /React Twittler State Props
│ ├── README.md
│ ├── /public # create-react-app이 만들어낸 파일, yarn/npm start로 실행할 시에 쓰입니다
│ └── /src # React 컴포넌트가 들어가는 폴더
│ ├── static # dummyData가 들어가는 폴더
│ │ └── dummyData.js
│ ├── Pages # 페이지를 표시하는 컴포넌트가 들어가는 폴더
│ │ ├── About.css
│ │ ├── About.js
│ │ ├── Mypage.css
│ │ ├── Mypage.js
│ │ ├── Tweets.css
│ │ └── Tweets.js
│ ├── Components # 단일 컴포넌트가 들어가는 폴더
│ │ ├── Tweet.css
│ │ └── Tweet.js
│ ├── App.css
│ ├── App.js
│ ├── Footer.js
│ ├── index.js
│ └── Sidebar.js
├ package.json
└ .gitignore
src라고 하는 폴더 내부에는 중요한 파일들이 담겨 있습니다. 대개 코드는 여기서 작성하게 되죠.
지금은 완성되어 있어 딱히 건드릴 필요는 없지만, index.js 파일부터 한번 살펴보고자 합니다.
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import dummyTweets from './static/dummyData';
ReactDOM.render(
<App dummyTweets={dummyTweets} />,
document.getElementById('root')
);
이 파일은 리액트를 불러오고, 곧 만질 App.js에서 App을 불러오고 있습니다. dummyTweets = {dummyTweets}를 App 내부에 넣으면서 App에 Props로 전달하고 있는 것 같습니다.
또한 미리 주어진 dummyData들도 import로 가져오고 있군요.
ReactDOM.render은 React 코드를 DOM (Document Object Model)에 붙이는 역할을 합니다. id="root"인 곳에 붙인다는 뜻인데, 저게 어디 있는지 찾아보니 public 폴더 내부 index.html 파일에 div가 root라는 이름으로 들어가 있었군요. index.js에 위와 같은 코드가 있기 때문에, 앞으로 리액트를 통해 만든 것들은 index.html의 해당 div 내부에 들어갑니다.
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
//index.html에 root가 들어가 있는 걸 볼 수 있다.
//noscript란? 스크립트가 무효한 브라우저에서 나오는 메시지
트위틀러 페이지의 기본이 되는 App.js부터 들여다볼까요? 제일 먼저 주어진 테스트는 다음과 같습니다.
-
React Router DOM을 설치 후, import 구문을 이용하여 BrowserRouter, Routes, Route 컴포넌트를 불러옵니다.
React Router Dom을 먼저 설치해줍니다. 다음과 같은 명령어를 터미널에 입력하면 설치가 됩니다.
npm install react-router-dom@6.3.0
이후 App.js의 상단에 import를 사용해서 필요한 컴포넌트들을 불러옵니다.
import { BrowserRouter, Routes, Route } from 'react-router-dom';
불러올 땐 {} 내부에 하나씩 넣어주면 됩니다. BrowserRouter만 입력해도 어디서 가져오는지는 자동으로 생겨나니 참 좋죠. 이 문제는 통과한 것 같습니다.
- MyPage, About 컴포넌트를 import 합니다.
다음 문제입니다. Mypage.js와 About.js에는 각각의 함수로 만든 컴포넌트들이 존재합니다.
import Mypage from './Pages/MyPage';
import About from './Pages/About';
각각의 파일에서 불러와주면 될 것 같습니다. App 컴포넌트 내부에서 저것들을 다룰 것이니까요. 이제 BrowserRouter의 주석을 해제해줍니다. <BrowserRouter>는 HTML5의 history API를 활용하여 UI를 업데이트한다고 하는데요. 한마디로 동적인 페이지를 만들기 위해서 사용합니다. 정적인 페이지를 만들기 위해서는 <HashRouter>이 존재한다고 하는데, 보통은 <BrowserRouter>을 쓰는 것 같습니다.
<Link>는 링크를 생성합니다. html에서는 a href로 이동했지만 리액트의 문법에서는 다릅니다. <Link to="이동하고 싶은 주소">와 같이 쓰이고, 라우터가 설정해둔 동일한 이름의 path로 링크가 생깁니다. Link를 사용하면 브라우저 주소만 바꿀 뿐, 페이지를 다시 불러오지는 않는다는 점이 중요합니다. 컴포넌트만 바뀌는데 매번 페이지를 전부 다 불러오는 에너지를 쓰지 않기 위해서 리액트를 사용하기로 한 거니까요.
<Switch>는 종종 쓰이는데, path 충돌이 일어나지 않게 <Route>를 관리해주는 역할입니다. <Switch>내부에 <Route>가 있다면 조건에 맞는 Route들이 여럿일 때 가장 처음 매칭되는 <Route>만 실행해줍니다. 또한 <Route>끼리 이동시 혹시 모를 충돌도 방지해줍니다.
//설명 보강할 것
<Route>는 요청받은 경로 이름을 가진 컴포넌트를 rendering합니다. 리액트는 페이지(URL)을 이동할 때마다 서버에서 받아오는 것이 아니라, 자바스크립트가 대신 출력을 해주는데요. 이때 경로를 나눠주는 녀석이 라우터라고 불리죠. <Route path="">로 주소를 연결하고, element={}로 컴포넌트를 받아 넣습니다.
이때 Route들은 <Routes></Routes> 내부에 들어가 있어야 합니다. 아래와 같이 <Routes> 내부에 <Route>로 만든 요소들을 넣어 진열해줍니다. 경로가 "/"라면 현재의 경로를 말하는데, 여기에서는 곧바로 Tweets를 보여달라는 뜻이겠군요. /mypage로 이동하면 Mypage가 보이고, /about로 이동하면 About를 보여줄 겁니다. 라우터로 그렇게 설정해뒀으니까요.
설정은 이렇게 해뒀으니 Sidebar에 있는 Link로 눌러 주소대로 이동하게 될 겁니다.
const App = (props) => {
return (
<BrowserRouter>
<div className="App">
<main>
<Sidebar />
<section className="features">
<Routes>
<Route path="/" element={<Tweets/>}></Route>
<Route path="/mypage" element={<Mypage/>}></Route>
<Route path="/about" element={<About/>}></Route>
</Routes>
</section>
</main>
</div>
</BrowserRouter>
);
};
그렇다면 Sidebar.js로 이동해서 Link를 조금 만져보기로 합시다.
import { Link } from 'react-router-dom';
꼭대기에는 이렇게 Link를 불러와두고요.
- Link 컴포넌트를 작성하고, to 속성을 이용하여 경로(path)를 연결합니다.
Link를 사용하여 주어진 이모티콘들을 각각의 페이지와 연결시켜주기로 합니다. 이미 라우터에서 주소 이름은 정해뒀습니다. 그 주소들을 쓰면 되겠군요.
const Sidebar = () => {
return (
<section className="sidebar">
<Link to="/"><i className="far fa-comment-dots"></i></Link>
<Link to="/about"><i className="far fa-question-circle"></i></Link>
<Link to="/mypage"><i className="far fa-user"></i></Link>
</section>
);
};
이렇게 만들어주면 될 것 같습니다. Link to를 사용해서 링크를 이어 주었습니다. 이제 주어진 이모티콘을 누르면 각각의 주소로 넘어가며 이전 라우터에서 element로 보여주기로 한 요소들을 보여주게 될 겁니다.
막간을 틈타 잠깐 Footer.js로 넘어갔다가 오겠습니다. 여기도 문제가 하나 있으니까요.
- Footer 함수 컴포넌트를 작성합니다. 시멘틱 요소 footer가 포함되어야 합니다.
시멘틱 요소란, div를 써도 되지만 footer 태그를 써서 작성해달라는 것 같군요. 더 의미있는 코딩을 위해서겠죠.
const Footer = () => {
return <div></div>;
};
이렇게 되어 있는 부분을
const Footer = () => {
return <footer>copyrights.</footer>;
};
이렇게 바꾸면 어떨까요? 조금 더 시멘틱해진 것 같습니다. About.js, Tweets.js, MyPAge.js마다 <Footer />을 불러오는 것을 볼 수 있겠네요. 뭐라도 있는 게 좋을 것 같아서 내부엔 카피라이트를 하나 넣어줬습니다.
이쯤해서 잠시 멈추어 내부 구조를 파악해보기로 합니다. 지금부터 더 세밀한 작업에 들어가야 하거든요.
페이지로 보여주는 컴포넌트는 현재 세 개입니다.
1. 나에 대한 정보를 보여주는 About.
2. 내가 쓴 글을 보여주는 MyPage. 또한 Tweets의 하위 컴포넌트로 Tweet가 들어갑니다.
3. 전체 글들을 보여주는 Tweets. 또한 Tweets의 하위 컴포넌트로 Tweet가 들어갑니다.
서로 다른 파일인 세 컴포넌트의 상위 컴포넌트는 App.js입니다. 서로 다른 두 컴포넌트의 자식 컴포넌트로 Tweet이 존재한다는 사실을 눈여겨보고 진행하겠습니다.
About.js는 당장 건드릴 부분이 없으니, 내가 쓴 글을 보여주는 MyPage부터 살펴보기로 합시다.
MyPage.js를 들여다보면 다음과 같은 두 가지 조건이 보입니다.
- 주어진 트윗 목록(dummyTweets)중 현재 유져인 parkhacker의 트윗만 보여줘야 합니다.
- MyPage 컴포넌트의 자식 Tweet 컴포넌트에게 props로 각 트윗의 정보(dummyTweets의 요소)가 전달되어야 합니다.
const filteredTweets = dummyTweets.filter((tweet)=> tweet.username ==='parkhacker');
username이 parkhacker과 일치하는 녀석만 걸러주었습니다. 고차함수를 쓰면 어렵지 않네요.
이후 filteredTweets에 들어온 것이라 기존의 부분들을 filteredTweets[0]에서 뽑은 정보들로 바꿔주었습니다.
const MyPage = () => {
const filteredTweets = dummyTweets.filter(
(tweet) => tweet.username === 'parkhacker'
);
return (
<section className="myInfo">
<div className="myInfo__container">
<div className="myInfo__wrapper">
<div className="myInfo__profile">
<img src={filteredTweets[0].picture} />
</div>
<div className="myInfo__detail">
<p className="myInfo__detailName">
{filteredTweets[0].username} Profile
</p>
<p>28 팔로워 100 팔로잉</p>
</div>
</div>
</div>
<ul className="tweets__mypage">
{filteredTweets.map((tweet) => {
return <Tweet key={tweet.id} tweet={tweet} />;
})}
</ul>
<Footer />
</section>
);
};
<ul>내부에는 map()를 사용하여 Tweet 컴포넌트에 각 트윗마다 props를 내려줍니다. 이때, key는 되도록 포함해야 합니다. map()을 써서 엘리먼트 리스트를 만들 때는 위처럼 내부 요소로 key를 따로 포함해줘야 소소하게 에러가 나지 않는데요. 형제 사이에서 고유하고 식별 가능한 key가 있어야, 쓸데없이 전체 키를 다 불러오지 않고 안정적으로 추가되는 키만 수정해준다고 합니다.
보통은 id를 key로 둡니다. 아니면 index를 쓸 수 있겠지요. key를 지정하지 않으면 React는 기본적으로 key로 index를 사용하는데, 순서가 바뀔 때는 인덱스를 키로 사용하는 것을 지양해야 합니다. 다만 이 방법은 성능 저하를 일으킬 수 있으니 기왕이면 key를 지정해주는 게 좋습니다.
이제 Tweets.js로 이동해보겠습니다.
- 새로 트윗을 작성하고 전송할 수 있게 useState를 적절히 활용하세요.
드디어 나왔습니다. '트윗을 작성해서 전송할 수 있게'.....어떻게 하면 될까요?
현재 구현해야 하는 것은 다음과 같습니다.
1) 작성자명
2) 트윗 내용
3) 전송 버튼을 누르면, 위 두 가지 내용이 기존 트윗 전체객체에 포함되어 리스트로 같이 출력된다.
가볍게 작성자 명과 내용부터 살펴보기로 합시다.
작성자명과 내용은, 입력마다 바뀌는 부분입니다. React에서 '변화가 발생하는 부분'은 useState로 쓰라고 배웠습니다. 상태 변화를 바로바로 반영해줘야 하기 때문에 지금부터 useState를 써서 위쪽에 적어줄 겁니다.
const [username, setUsername] = useState("");
const [msg, setMsg] = useState("");
[]내부에서도 앞쪽은 상태 변수, 뒤쪽은 상태 갱신 함수입니다. ""으로 초기값으론 빈 여백을 주었습니다. 이름, 메세지의 변화를 다룰 수 있는 두 가지 state를 만들었으니, 이제 이것을 직접 적용해봅시다.
먼저 트윗과 작성자명을 입력할 수 있는 input과 textarea을 만들겁니다. 뭔가를 적으려면 입력칸이 있어야 하니까요. 또, 이것들을 제출할 수 있는 button도 하나 있으면 좋겠군요. 전송은 버튼을 눌러야 이루어질 테니까요.
<div className="tweetForm__input">
<input
type="text"
defaultValue="parkhacker"
placeholder="your username here..."
className="tweetForm__input--username"
onChange={handleChangeUser}
value={username}>
</input>
<textarea
placeholder='트윗 내용을 입력하세요'
className="tweetForm__input--message"
onChange={handleChangeMsg}
value={msg}>
</textarea>
</div>
우선 버튼은 제외하고 이렇게만 작성을 해봤습니다. 위는 작성자명을 입력할 수 있는 input, 아래는 트윗 내용을 입력할 textarea입니다. 이 두 칸은 사용자가 적은 것을 입력받는다는 특징을 가집니다.
특히 주목할 부분은 onChange와 value입니다. onChange는 input이나 textarea같은 것과 자주 쓰이는데, 무언가 입력이 될 때마다 그 변화를 반영하여 event를 발생시킵니다. 한 마디로 정리하자면 사용자가 '입력하면', 이벤트가 발생하면서 {} 내부의 함수 handleChangeUser과 handleChangeMsg를 실행하라고 명령해두었습니다. 이때 실행되는 함수는 아까 useState에서 만든 상태 갱신 함수를 건드려, 그 결과로 상태 변수까지 바꾸도록 만들어줄 것입니다. 함수명은 임의로 정해준 거니 다른 이름으로 써도 되고요.
이 함수를 만드는 건 잠시 보류하고, value 로 넘어가 보겠습니다. value에는 useState에서 만든 상태 변수를 담아두었습니다. 상태 변경 함수가 바뀔 때마다 상태 변수가 바뀌니, input과 textarea 내부의 값(value)도 상태 변수로 바뀌게 될 것입니다.
즉, 정리하자면 아래와 같은 순서대로 작동합니다.(이걸 보는 게 이해하기 편합니다.)
사용자가 input이나 textarea에 뭔가를 입력한다 → 타자를 입력할 때마다 onChange가 이벤트를 발생시키고, 이때 {}내부의 함수가 실행된다. 이 실행 함수는 아직 정의하지 않았지만, useState에서 만든 상태 변경 함수를 건드려줄 것이다. 그렇게 만들어야 한다. → 상태 변경 함수가 포함된 함수가 실행될 때마다 useState에서 만든 상태 변수가 바뀔 것이다. 이 변수를 그대로 가져와 value에 담아두었으니 value는 최종적으로 변경된 상태 변수로 바뀌게 될 것이다.
과정자체는 복잡하게 보이지만, 돌고 도는 원리를 이해하고 나면 조금 해볼만 합니다. 중요한 건 하나죠. 리액트 내부에서 상태 변경이 일어나려면 무조건 useState의 상태 변경 함수를 거쳐야 한다. 그냥 막 바꿀 수가 없다.
이제 상태 변경 함수를 건드려 상태를 바꾸어 줄 함수들을 만들러 가보겠습니다.
const handleChangeUser = (event) => {
//이름
setUsername(event.target.value)
};
const handleChangeMsg = (event) => {
//메세지
setMsg(event.target.value)
};
이렇게 만들면 어떨까요?
두 개의 함수를 각각 만들어서, 이벤트가 발생하면 입력칸의 값(event.target.value)을 가져온 뒤 useState의 상태 변경 함수로 전달해 주게 하였습니다. 상태 변경 함수가 바뀌면 상태 변수도 함께 바뀝니다. 그러라고 있는 useState이니까...
근데 위에서 이미 value = {상태 변수}로 설정해두었죠? 그러니 돌고 돌아 빈칸에 사용자가 입력한 값을 자신의 value로 갖게 되는 것입니다.
많이 쓰이는 방식이니 외우기라도 해서 숙지하는 편이 좋겠습니다.
이제 다음 구현을 해봅시다. 전체 트윗의 개수를 셀 수 있는 카운터와, 제출용 버튼을 구현해봤습니다.
<div className="tweetForm__count" role="status">
<span className="tweetForm__count__text">
{'total: '+ tweets.length}
</span>
</div>
</div>
<div className="tweetForm__submit">
<div className="tweetForm__submitIcon"></div>
<button
type='button'
className="tweetForm__submitButton"
onClick={handleButtonClick}>트윗 전송
</button>
</div>
카운터는 tweets의 길이만 세주면 됩니다.
버튼으로 넘어가 볼까요? 버튼이 클릭할 시점에 이벤트가 발생하도록 onClick을 달아주었고, 이때 {}내의 함수가 발생하도록 적어주었습니다. 이 함수도 이제 정의를 해야겠죠?
또 useState를 사용해서 버튼이 클릭될 때마다 기존 들어온 더미 데이터 객체에 새로운 정보가 하나하나 '추가'되도록 해줘야 할 것 같습니다. 이것 또한 상태 변경이니까요. 위쪽에 useState를 하나 더 작성해줍니다.
const [tweets, setTweets] = useState(dummyTweets);
이제 알겠지만 setTweets는 상태변경 함수, tweets는 변수입니다. 이걸 머리에 잘 박아두고 바로 버튼 클릭 이벤트가 발생할 때 실행되는 함수를 새로 만들러 가봅니다.
함수 handleButtonClick는 어떤 기능을 갖춰야 할까요? 우선 입력된 value들을 객체로 만든 다음, 기존 더미 데이터 객체에 추가까지 해줘야 합니다. 그 뒤에 input와 textarea에 입력된 값들을 처음 상태로 없애줘야 할 것 같습니다.
더미 데이터의 객체 정보를 보고 그와 비슷하게 만들어주었습니다. 대신 id는 +1을 해주고, username과 content에 아까 만든 useState의 변수들을 담아줍니다. 날짜는 new Date()로 실시간 반영하면 딱 좋겠군요.
객체 모양을 잡아줬으니 이것을 기존 자료에 추가를 해줘야 하는데... 리액트는 가능한 원본 데이터를 변경시키지 않아야 합니다. 따라서 트윗 추가용 상태 변경 함수와, 복사하는 용도의 ...를 사용해서 앞에 새 tweet를 넣어줍니다. 위치를 바꾸면 뒤쪽에 넣어줄 수도 있겠군요.
마지막으로 input과 textarea의 칸 내부 값을 상태 변경 함수를 갖고 와서 초기의 빈칸으로 되돌려주면 끝입니다.
const handleButtonClick = () => {
const tweet = {
id: tweets.length + 1,
username: username,
picture: `https://randomuser.me/api/portraits/men/98.jpg`,
content: msg,
createdAt: new Date(),
updatedAt: new Date()
};
setTweets([tweet, ...tweets]);
//const newTweets=[tweet, ...tweets];
//setTweets(newTweets);
//이렇게 작성할 수도 있습니다.
setUsername('')
setMsg('');
// TODO : Tweet button 엘리먼트 클릭시 작동하는 함수를 완성하세요.
// 트윗 전송이 가능하게 작성해야 합니다.
};
const Tweet = ({ tweet }) => {
const parsedDate = new Date(tweet.createdAt).toLocaleDateString('ko-kr');
이렇게 먼저 날짜를 조금 다듬어주고 이것을 아래에서 활용해보기로 합니다.
<div className='tweet__username'>
{tweet.username}
</div>
tweet의 유저명을 적어줍니다. 대괄호 내부에 넣는 것을 잊지 말고, props를 왜 앞에 쓰지 않아도 되는지 다시 한번 자세히 보고 확인해봅시다.
<div className='tweet__createdAt'>
{parsedDate}
</div>
마지막으로 예쁘게 위에 변수로 다듬어둔 생성 날짜까지 넣어주면 완성입니다.
원리가 이해가지 않는 사람(나...)도 보고 이해할 수 있도록 하나하나 설명하느라 길어졌습니다. 포스팅을 쓰면서 스스로도 조금 더 이해한 느낌이 드네요.
이벤트 발생, useStates, props에 대해 추가로 공부하면 좋을 것 같습니다.