ํํ ๋ฆฌ์ผ
Part 1. ์ค๊ณโ
์ด ํํ ๋ฆฌ์ผ์์๋ Real World App์ด๋ผ๊ณ ๋ ์๋ ค์ง Conduit๋ฅผ ์ดํด๋ณด๊ฒ ์ต๋๋ค. Conduit๋ ๊ธฐ๋ณธ์ ์ธ Medium ํด๋ก ์ ๋๋ค - ๊ธ์ ์ฝ๊ณ ์ธ ์ ์์ผ๋ฉฐ ๋ค๋ฅธ ์ฌ๋์ ๊ธ์ ๋๊ธ์ ๋ฌ ์ ์์ต๋๋ค.
์ด ์ ํ๋ฆฌ์ผ์ด์ ์ ๋งค์ฐ ์์ ์ ํ๋ฆฌ์ผ์ด์ ์ด๋ฏ๋ก ๊ณผ๋ํ ๋ถํด๋ฅผ ํผํ๊ณ ๊ฐ๋จํ๊ฒ ์ ์งํ ๊ฒ์ ๋๋ค. ์ ์ฒด ์ ํ๋ฆฌ์ผ์ด์ ์ด ์ธ ๊ฐ์ ๋ ์ด์ด์ธ App, Pages, ๊ทธ๋ฆฌ๊ณ Shared์ ๋ง์ถฐ ๋ค์ด๊ฐ ๊ฒ์ ๋๋ค. ๊ทธ๋ ์ง ์๋ค๋ฉด ์ฐ๋ฆฌ๋ ๊ณ์ํด์ ์ถ๊ฐ์ ์ธ ๋ ์ด์ด๋ฅผ ๋์ ํ ๊ฒ์ ๋๋ค. ์ค๋น๋์ จ๋์?
๋จผ์ ํ์ด์ง๋ฅผ ๋์ดํด ๋ด ์๋ค.โ
์์ ์คํฌ๋ฆฐ์ท์ ๋ณด๋ฉด ์ต์ํ ๋ค์๊ณผ ๊ฐ์ ํ์ด์ง๋ค์ด ์๋ค๊ณ ๊ฐ์ ํ ์ ์์ต๋๋ค:
- ํ (๊ธ ํผ๋)
- ๋ก๊ทธ์ธ ๋ฐ ํ์๊ฐ์
- ๊ธ ์ฝ๊ธฐ
- ๊ธ ํธ์ง๊ธฐ
- ์ฌ์ฉ์ ํ๋กํ ๋ณด๊ธฐ
- ์ฌ์ฉ์ ํ๋กํ ํธ์ง (์ฌ์ฉ์ ์ค์ )
์ด ํ์ด์ง๋ค ๊ฐ๊ฐ์ Pages ๋ ์ด์ด์ ๋
๋ฆฝ๋ ์ฌ๋ผ์ด์ค๊ฐ ๋ ๊ฒ์
๋๋ค. ๊ฐ์์์ ์ธ๊ธํ๋ฏ์ด ์ฌ๋ผ์ด์ค๋ ๋จ์ํ ๋ ์ด์ด ๋ด์ ํด๋์ด๊ณ , ๋ ์ด์ด๋ pages
์ ๊ฐ์ ๋ฏธ๋ฆฌ ์ ์๋ ์ด๋ฆ์ ๊ฐ์ง ํด๋์ผ ๋ฟ์
๋๋ค.
๋ฐ๋ผ์ ์ฐ๋ฆฌ์ Pages ํด๋๋ ๋ค์๊ณผ ๊ฐ์ด ๋ณด์ผ ๊ฒ์ ๋๋ค.
๐ pages/
๐ feed/
๐ sign-in/
๐ article-read/
๐ article-edit/
๐ profile/
๐ settings/
Feature-Sliced Design์ด ๊ท์ ๋์ง ์์ ์ฝ๋ ๊ตฌ์กฐ์ ๋ค๋ฅธ ์ฃผ์ ์ฐจ์ด์ ์ ํ์ด์ง๋ค์ด ์๋ก๋ฅผ ์ฐธ์กฐํ ์ ์๋ค๋ ๊ฒ์ ๋๋ค. ์ฆ, ํ ํ์ด์ง๊ฐ ๋ค๋ฅธ ํ์ด์ง์ ์ฝ๋๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค. ์ด๋ ๋ ์ด์ด์ import ๊ท์น ๋๋ฌธ์ ๋๋ค.
์ฌ๋ผ์ด์ค์ ๋ชจ๋์ ์๊ฒฉํ ์๋์ ์๋ ๋ ์ด์ด์ ์์นํ ๋ค๋ฅธ ์ฌ๋ผ์ด์ค๋ง ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.
์ด ๊ฒฝ์ฐ ํ์ด์ง๋ ์ฌ๋ผ์ด์ค์ด๋ฏ๋ก, ์ด ํ์ด์ง ๋ด์ ๋ชจ๋(ํ์ผ)์ ๊ฐ์ ๋ ์ด์ด์ธ Pages๊ฐ ์๋ ์๋ ๋ ์ด์ด์ ์ฝ๋๋ง ์ฐธ์กฐํ ์ ์์ต๋๋ค.
ํผ๋ ์์ธํ ๋ณด๊ธฐโ
ํผ๋ ํ์ด์ง์๋ ์ธ ๊ฐ์ง ๋์ ์์ญ์ด ์์ต๋๋ค.
- ๋ก๊ทธ์ธ ์ฌ๋ถ๋ฅผ ๋ํ๋ด๋ ๋ก๊ทธ์ธ ๋งํฌ
- ํผ๋์์ ํํฐ๋ง์ ํธ๋ฆฌ๊ฑฐํ๋ ํ๊ทธ ๋ชฉ๋ก
- ์ข์์ ๋ฒํผ์ด ์๋ ํ๋/๋ ๊ฐ์ ๊ธ ํผ๋
๋ก๊ทธ์ธ ๋งํฌ๋ ๋ชจ๋ ํ์ด์ง์ ๊ณตํต์ ์ธ ํค๋์ ์ผ๋ถ์ด๋ฏ๋ก ๋์ค์ ๋ฐ๋ก ๋ค๋ฃจ๊ฒ ์ต๋๋ค.
ํ๊ทธ ๋ชฉ๋กโ
ํ๊ทธ ๋ชฉ๋ก์ ๋ง๋ค๊ธฐ ์ํด์๋ ์ฌ์ฉ ๊ฐ๋ฅํ ํ๊ทธ๋ฅผ ๊ฐ์ ธ์ค๊ณ , ๊ฐ ํ๊ทธ๋ฅผ ์นฉ์ผ๋ก ๋ ๋๋งํ๊ณ , ์ ํ๋ ํ๊ทธ๋ฅผ ํด๋ผ์ด์ธํธ ์ธก ์ ์ฅ์์ ์ ์ฅํด์ผ ํฉ๋๋ค. ์ด๋ฌํ ์์ ๋ค์ ๊ฐ๊ฐ "API ์ํธ์์ฉ", "์ฌ์ฉ์ ์ธํฐํ์ด์ค", "์ ์ฅ์" ์นดํ ๊ณ ๋ฆฌ์ ์ํฉ๋๋ค. Feature-Sliced Design์์๋ ์ฝ๋๋ฅผ ์ธ๊ทธ๋จผํธ๋ฅผ ์ฌ์ฉํ์ฌ ๋ชฉ์ ๋ณ๋ก ๋ถ๋ฆฌํฉ๋๋ค. ์ธ๊ทธ๋จผํธ๋ ์ฌ๋ผ์ด์ค ๋ด์ ํด๋์ด๋ฉฐ, ๋ชฉ์ ์ ์ค๋ช ํ๋ ์์์ ์ด๋ฆ์ ๊ฐ์ง ์ ์์ง๋ง, ์ผ๋ถ ๋ชฉ์ ์ ๋๋ฌด ์ผ๋ฐ์ ์ด์ด์ ํน์ ์ธ๊ทธ๋จผํธ ์ด๋ฆ์ ๋ํ ๊ท์น์ด ์์ต๋๋ค.
- ๐
api/
๋ฐฑ์๋ ์ํธ์์ฉ - ๐
ui/
๋ ๋๋ง๊ณผ ์ธ๊ด์ ๋ค๋ฃจ๋ ์ฝ๋ - ๐
model/
์ ์ฅ์์ ๋น์ฆ๋์ค ๋ก์ง - ๐
config/
๊ธฐ๋ฅ ํ๋๊ทธ, ํ๊ฒฝ ๋ณ์ ๋ฐ ๊ธฐํ ๊ตฌ์ฑ ํ์
ํ๊ทธ๋ฅผ ๊ฐ์ ธ์ค๋ ์ฝ๋๋ api
์, ํ๊ทธ ์ปดํฌ๋ํธ๋ ui
์, ์ ์ฅ์ ์ํธ์์ฉ์ model
์ ๋ฐฐ์นํ ๊ฒ์
๋๋ค.
๊ธโ
๊ฐ์ ๊ทธ๋ฃนํ ์์น์ ์ฌ์ฉํ์ฌ ๊ธ ํผ๋๋ฅผ ๊ฐ์ ์ธ ๊ฐ์ ์ธ๊ทธ๋จผํธ๋ก ๋ถํดํ ์ ์์ต๋๋ค.
- ๐
api/
: ์ข์์ ์๊ฐ ํฌํจ๋ ํ์ด์ง๋ค์ด์ ๋ ๊ธ ๊ฐ์ ธ์ค๊ธฐ - ๐
ui/
:- ํ๊ทธ๊ฐ ์ ํ๋ ๊ฒฝ์ฐ ์ถ๊ฐ ํญ์ ๋ ๋๋งํ ์ ์๋ ํญ ๋ชฉ๋ก
- ๊ฐ๋ณ ๊ธ
- ๊ธฐ๋ฅ์ ํ์ด์ง๋ค์ด์
- ๐
model/
: ํ์ฌ ๋ก๋๋ ๊ธ๊ณผ ํ์ฌ ํ์ด์ง์ ํด๋ผ์ด์ธํธ ์ธก ์ ์ฅ์ (ํ์ํ ๊ฒฝ์ฐ)
์ผ๋ฐ์ ์ธ ์ฝ๋ ์ฌ์ฌ์ฉโ
๋๋ถ๋ถ์ ํ์ด์ง๋ ์๋๊ฐ ๋งค์ฐ ๋ค๋ฅด์ง๋ง, ์ฑ ์ ์ฒด์ ๊ฑธ์ณ ์ผ๋ถ ์์๋ ๋์ผํ๊ฒ ์ ์ง๋ฉ๋๋ค. ์๋ฅผ ๋ค์ด, ๋์์ธ ์ธ์ด๋ฅผ ์ค์ํ๋ UI ํคํธ๋ ๋ชจ๋ ๊ฒ์ด ๋์ผํ ์ธ์ฆ ๋ฐฉ์์ผ๋ก REST API๋ฅผ ํตํด ์ํ๋๋ ๋ฐฑ์๋์ ๊ท์น ๋ฑ์ด ์์ต๋๋ค. ์ฌ๋ผ์ด์ค๋ ๊ฒฉ๋ฆฌ๋๋๋ก ์ค๊ณ๋์๊ธฐ ๋๋ฌธ์, ์ฝ๋ ์ฌ์ฌ์ฉ์ ๋ ๋ฎ์ ๊ณ์ธต์ธ Shared์ ์ํด ์ด์ง๋ฉ๋๋ค.
Shared๋ ์ฌ๋ผ์ด์ค๊ฐ ์๋ ์ธ๊ทธ๋จผํธ๋ฅผ ํฌํจํ๋ค๋ ์ ์์ ๋ค๋ฅธ ๊ณ์ธต๊ณผ ๋ค๋ฆ ๋๋ค. ์ด๋ฐ ๋ฉด์์ Shared ๊ณ์ธต์ ๊ณ์ธต๊ณผ ์ฌ๋ผ์ด์ค์ ํ์ด๋ธ๋ฆฌ๋๋ก ์๊ฐํ ์ ์์ต๋๋ค.
์ผ๋ฐ์ ์ผ๋ก Shared์ ์ฝ๋๋ ๋ฏธ๋ฆฌ ๊ณํ๋์ง ์๊ณ ๊ฐ๋ฐ ์ค์ ์ถ์ถ๋ฉ๋๋ค. ์ค์ ๋ก ์ด๋ค ์ฝ๋ ๋ถ๋ถ์ด ๊ณต์ ๋๋์ง๋ ๊ฐ๋ฐ ์ค์๋ง ๋ช ํํด์ง๊ธฐ ๋๋ฌธ์ ๋๋ค. ๊ทธ๋ฌ๋ ์ด๋ค ์ข ๋ฅ์ ์ฝ๋๊ฐ ์์ฐ์ค๋ฝ๊ฒ Shared์ ์ํ๋์ง ๋จธ๋ฆฟ์์ ๋ฉ๋ชจํด ๋๋ ๊ฒ์ ์ฌ์ ํ ๋์์ด ๋ฉ๋๋ค.
- ๐
ui/
โ UI ํคํธ, ๋น์ฆ๋์ค ๋ก์ง์ด ์๋ ์์ํ UI. ์: ๋ฒํผ, ๋ชจ๋ฌ ๋ํ ์์, ํผ ์ ๋ ฅ. - ๐
api/
โ ์์ฒญ ์์ฑ ๊ธฐ๋ณธ ์์(์: ์น์fetch()
)์ ๋ํ ํธ์ ๋ํผ ๋ฐ ์ ํ์ ์ผ๋ก ๋ฐฑ์๋ ์ฌ์์ ๋ฐ๋ผ ํน์ ์์ฒญ์ ํธ๋ฆฌ๊ฑฐํ๋ ํจ์. - ๐
config/
โ ํ๊ฒฝ ๋ณ์ ํ์ฑ - ๐
i18n/
โ ์ธ์ด ์ง์์ ๋ํ ๊ตฌ์ฑ - ๐
router/
โ ๋ผ์ฐํ ๊ธฐ๋ณธ ์์ ๋ฐ ๋ผ์ฐํธ ์์
์ด๋ Shared์ ์ธ๊ทธ๋จผํธ ์ด๋ฆ์ ๋ช ๊ฐ์ง ์์์ผ ๋ฟ์ด๋ฉฐ, ์ด ์ค ์ผ๋ถ๋ฅผ ์๋ตํ๊ฑฐ๋ ์์ ๋ง์ ์ธ๊ทธ๋จผํธ๋ฅผ ๋ง๋ค ์ ์์ต๋๋ค. ์๋ก์ด ์ธ๊ทธ๋จผํธ๋ฅผ ๋ง๋ค ๋ ๊ธฐ์ตํด์ผ ํ ์ ์ผํ ์ค์ํ ์ ์ ์ธ๊ทธ๋จผํธ ์ด๋ฆ์ด ๋ณธ์ง(๋ฌด์์ธ์ง)์ด ์๋ ๋ชฉ์ (์)์ ์ค๋ช ํด์ผ ํ๋ค๋ ๊ฒ์ ๋๋ค. "components", "hooks", "modals"๊ณผ ๊ฐ์ ์ด๋ฆ์ ์ด ํ์ผ๋ค์ด ๋ฌด์์ธ์ง๋ ์ค๋ช ํ์ง๋ง ๋ด๋ถ ์ฝ๋๋ฅผ ํ์ํ๋ ๋ฐ ๋์์ด ๋์ง ์๊ธฐ ๋๋ฌธ์ ์ฌ์ฉํด์๋ ์ ๋ฉ๋๋ค. ์ด๋ ํ์๋ค์ด ์ด๋ฌํ ํด๋์ ๋ชจ๋ ํ์ผ์ ํํค์ณ์ผ ํ๋ฉฐ, ๊ด๋ จ ์๋ ์ฝ๋๋ฅผ ๊ฐ๊น์ด ์ ์งํ๊ฒ ๋์ด ๋ฆฌํฉํ ๋ง์ ์ํฅ์ ๋ฐ๋ ์ฝ๋ ์์ญ์ด ๋์ด์ง๊ณ ๊ฒฐ๊ณผ์ ์ผ๋ก ์ฝ๋ ๋ฆฌ๋ทฐ์ ํ ์คํธ๋ฅผ ๋ ์ด๋ ต๊ฒ ๋ง๋ญ๋๋ค.
์๊ฒฉํ ๊ณต๊ฐ API ์ ์โ
Feature-Sliced Design์ ๋งฅ๋ฝ์์ ๊ณต๊ฐ API๋ผ๋ ์ฉ์ด๋ ์ฌ๋ผ์ด์ค๋ ์ธ๊ทธ๋จผํธ๊ฐ ํ๋ก์ ํธ์ ๋ค๋ฅธ ๋ชจ๋์์ ๊ฐ์ ธ์ฌ ์ ์๋ ๊ฒ์ ์ ์ธํ๋ ๊ฒ์ ์๋ฏธํฉ๋๋ค. ์๋ฅผ ๋ค์ด, JavaScript์์๋ ์ฌ๋ผ์ด์ค์ ๋ค๋ฅธ ํ์ผ์์ ๊ฐ์ฒด๋ฅผ ๋ค์ ๋ด๋ณด๋ด๋ index.js
ํ์ผ์ผ ์ ์์ต๋๋ค. ์ด๋ฅผ ํตํด ์ธ๋ถ ์ธ๊ณ์์ ๊ณ์ฝ(์ฆ, ๊ณต๊ฐ API)์ด ๋์ผํ๊ฒ ์ ์ง๋๋ ํ ์ฌ๋ผ์ด์ค ๋ด๋ถ์ ์ฝ๋๋ฅผ ์์ ๋กญ๊ฒ ๋ฆฌํฉํ ๋งํ ์ ์์ต๋๋ค.
์ฌ๋ผ์ด์ค๊ฐ ์๋ Shared ๊ณ์ธต์ ๊ฒฝ์ฐ, Shared์ ๋ชจ๋ ๊ฒ์ ๋ํ ๋จ์ผ ์ธ๋ฑ์ค๋ฅผ ์ ์ํ๋ ๊ฒ๊ณผ ๋ฐ๋๋ก ๊ฐ ์ธ๊ทธ๋จผํธ์ ๋ํด ๋ณ๋์ ๊ณต๊ฐ API๋ฅผ ์ ์ํ๋ ๊ฒ์ด ์ผ๋ฐ์ ์ผ๋ก ๋ ํธ๋ฆฌํฉ๋๋ค. ์ด๋ ๊ฒ ํ๋ฉด Shared์์์ ๊ฐ์ ธ์ค๊ธฐ๊ฐ ์์ฐ์ค๋ฝ๊ฒ ์๋๋ณ๋ก ๊ตฌ์ฑ๋ฉ๋๋ค. ์ฌ๋ผ์ด์ค๊ฐ ์๋ ๋ค๋ฅธ ๊ณ์ธต์ ๊ฒฝ์ฐ ๋ฐ๋๊ฐ ์ฌ์ค์ ๋๋ค โ ์ผ๋ฐ์ ์ผ๋ก ์ฌ๋ผ์ด์ค๋น ํ๋์ ์ธ๋ฑ์ค๋ฅผ ์ ์ํ๊ณ ์ฌ๋ผ์ด์ค๊ฐ ์ธ๋ถ ์ธ๊ณ์ ์๋ ค์ง์ง ์์ ์์ฒด ์ธ๊ทธ๋จผํธ ์ธํธ๋ฅผ ๊ฒฐ์ ํ๋๋ก ํ๋ ๊ฒ์ด ๋ ์ค์ฉ์ ์ ๋๋ค. ๋ค๋ฅธ ๊ณ์ธต์ ์ผ๋ฐ์ ์ผ๋ก ๋ด๋ณด๋ด๊ธฐ๊ฐ ํจ์ฌ ์ ๊ธฐ ๋๋ฌธ์ ๋๋ค.
์ฐ๋ฆฌ์ ์ฌ๋ผ์ด์ค/์ธ๊ทธ๋จผํธ๋ ์๋ก์๊ฒ ๋ค์๊ณผ ๊ฐ์ด ๋ํ๋ ๊ฒ์ ๋๋ค.
๐ pages/
๐ feed/
๐ index
๐ sign-in/
๐ index
๐ article-read/
๐ index
๐ โฆ
๐ shared/
๐ ui/
๐ index
๐ api/
๐ index
๐ โฆ
pages/feed
๋ shared/ui
์ ๊ฐ์ ํด๋ ๋ด๋ถ์ ๋ด์ฉ์ ํด๋น ํด๋์๋ง ์๋ ค์ ธ ์์ผ๋ฉฐ, ๋ค๋ฅธ ํ์ผ์ ์ด๋ฌํ ํด๋์ ๋ด๋ถ ๊ตฌ์กฐ์ ์์กดํด์๋ ์ ๋ฉ๋๋ค.
UI์ ํฐ ์ฌ์ฌ์ฉ ๋ธ๋กโ
์์ ๋ชจ๋ ํ์ด์ง์ ๋ํ๋๋ ํค๋๋ฅผ ๋ค์ ์ดํด๋ณด๊ธฐ๋ก ํ์ต๋๋ค. ๋ชจ๋ ํ์ด์ง์์ ์ฒ์๋ถํฐ ๋ค์ ๋ง๋๋ ๊ฒ์ ๋น์ค์ฉ์ ์ด๋ฏ๋ก ์ฌ์ฌ์ฉํ๊ณ ์ถ์ ๊ฒ์ ๋๋ค. ์ฐ๋ฆฌ๋ ์ด๋ฏธ ์ฝ๋ ์ฌ์ฌ์ฉ์ ์ฉ์ดํ๊ฒ ํ๋ Shared๋ฅผ ๊ฐ์ง๊ณ ์์ง๋ง, Shared์ ํฐ UI ๋ธ๋ก์ ๋ฃ๋ ๋ฐ๋ ์ฃผ์ํ ์ ์ด ์์ต๋๋ค โ Shared ๊ณ์ธต์ ์์ ๊ณ์ธต์ ๋ํด ์์ง ๋ชปํด์ผ ํฉ๋๋ค.
Shared์ Pages ์ฌ์ด์๋ Entities, Features, Widgets์ ์ธ ๊ฐ์ง ๋ค๋ฅธ ๊ณ์ธต์ด ์์ต๋๋ค. ์ผ๋ถ ํ๋ก์ ํธ๋ ์ด๋ฌํ ๊ณ์ธต์ ํฐ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ๋ธ๋ก์ ํ์ํ ๊ฒ์ด ์์ ์ ์์ผ๋ฉฐ, ์ด๋ ํด๋น ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ๋ธ๋ก์ Shared์ ๋ฃ์ ์ ์๋ค๋ ๊ฒ์ ์๋ฏธํฉ๋๋ค. ๊ทธ๋ ์ง ์์ผ๋ฉด ์์ ๊ณ์ธต์์ ๊ฐ์ ธ์ค๊ฒ ๋์ด ๊ธ์ง๋ฉ๋๋ค. ์ด๊ฒ์ด Widgets ๊ณ์ธต์ด ํ์ํ ์ด์ ์ ๋๋ค. Widgets๋ Shared, Entities, Features ์์ ์์นํ๋ฏ๋ก ์ด๋ค ๋ชจ๋๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค.
์ฐ๋ฆฌ์ ๊ฒฝ์ฐ, ํค๋๋ ๋งค์ฐ ๊ฐ๋จํฉ๋๋ค โ ์ ์ ๋ก๊ณ ์ ์ต์์ ํ์์
๋๋ค. ํ์์ ์ฌ์ฉ์๊ฐ ํ์ฌ ๋ก๊ทธ์ธํ๋์ง ์ฌ๋ถ๋ฅผ ํ์ธํ๊ธฐ ์ํด API์ ์์ฒญ์ ํด์ผ ํ์ง๋ง, ์ด๋ api
์ธ๊ทธ๋จผํธ์์ ๊ฐ๋จํ ๊ฐ์ ธ์ค๊ธฐ๋ก ์ฒ๋ฆฌํ ์ ์์ต๋๋ค. ๋ฐ๋ผ์ ์ฐ๋ฆฌ๋ ํค๋๋ฅผ Shared์ ์ ์งํ ๊ฒ์
๋๋ค.
ํผ์ด ์๋ ํ์ด์ง ์์ธํ ๋ณด๊ธฐโ
์ฝ๊ธฐ๊ฐ ์๋ ํธ์ง์ ์ํ ํ์ด์ง๋ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
๊ฐ๋จํด ๋ณด์ด์ง๋ง, ํผ ์ ํจ์ฑ ๊ฒ์ฌ, ์ค๋ฅ ์ํ, ๋ฐ์ดํฐ ์ง์์ฑ ๋ฑ ์์ง ํ๊ตฌํ์ง ์์ ์ ํ๋ฆฌ์ผ์ด์ ๊ฐ๋ฐ์ ์ฌ๋ฌ ์ธก๋ฉด์ ํฌํจํ๊ณ ์์ต๋๋ค.
์ด ํ์ด์ง๋ฅผ ๋ง๋ค๋ ค๋ฉด Shared์์ ์ผ๋ถ ์
๋ ฅ๊ณผ ๋ฒํผ์ ๊ฐ์ ธ์ ์ด ํ์ด์ง์ ui
์ธ๊ทธ๋จผํธ์์ ํผ์ ๊ตฌ์ฑํ ๊ฒ์
๋๋ค. ๊ทธ๋ฐ ๋ค์ api
์ธ๊ทธ๋จผํธ์์ ๋ฐฑ์๋์ ๊ธ์ ์์ฑํ๋ ๋ณ๊ฒฝ ์์ฒญ์ ์ ์ํ ๊ฒ์
๋๋ค.
์์ฒญ์ ๋ณด๋ด๊ธฐ ์ ์ ์ ํจ์ฑ์ ๊ฒ์ฌํ๋ ค๋ฉด ์ ํจ์ฑ ๊ฒ์ฌ ์คํค๋ง๊ฐ ํ์ํ๋ฉฐ, ์ด๋ฅผ ์ํ ์ข์ ์์น๋ ๋ฐ์ดํฐ ๋ชจ๋ธ์ด๊ธฐ ๋๋ฌธ์ model
์ธ๊ทธ๋จผํธ์
๋๋ค. ์ฌ๊ธฐ์ ์ค๋ฅ ๋ฉ์์ง๋ฅผ ์์ฑํ๊ณ ui
์ธ๊ทธ๋จผํธ์ ๋ค๋ฅธ ์ปดํฌ๋ํธ๋ฅผ ์ฌ์ฉํ์ฌ ํ์ํ ๊ฒ์
๋๋ค.
์ฌ์ฉ์ ๊ฒฝํ์ ๊ฐ์ ํ๊ธฐ ์ํด ์ฐ๋ฐ์ ์ธ ๋ฐ์ดํฐ ์์ค์ ๋ฐฉ์งํ๊ธฐ ์ํด ์
๋ ฅ์ ์ง์์ํฌ ์๋ ์์ต๋๋ค. ์ด๊ฒ๋ model
์ธ๊ทธ๋จผํธ์ ์์
์
๋๋ค.
์์ฝโ
์ฐ๋ฆฌ๋ ์ฌ๋ฌ ํ์ด์ง๋ฅผ ๊ฒํ ํ๊ณ ์ ํ๋ฆฌ์ผ์ด์ ์ ์๋น ๊ตฌ์กฐ๋ฅผ ๊ฐ๋ต์ ์ผ๋ก ์ค๋ช ํ์ต๋๋ค.
- Shared layer
ui
๋ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ UI ํคํธ๋ฅผ ํฌํจํ ๊ฒ์ ๋๋ค.api
๋ ๋ฐฑ์๋์์ ๊ธฐ๋ณธ์ ์ธ ์ํธ์์ฉ์ ํฌํจํ ๊ฒ์ ๋๋ค.- ๋๋จธ์ง๋ ํ์์ ๋ฐ๋ผ ์ ๋ฆฌ๋ ๊ฒ์ ๋๋ค.
- Pages layer โ ๊ฐ ํ์ด์ง๋ ๋ณ๋์ ์ฌ๋ผ์ด์ค์
๋๋ค.
ui
๋ ํ์ด์ง ์์ฒด์ ๋ชจ๋ ๋ถ๋ถ์ ํฌํจํ ๊ฒ์ ๋๋ค.api
๋shared/api
๋ฅผ ์ฌ์ฉํ์ฌ ๋ ํนํ๋ ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ๋ฅผ ํฌํจํ ๊ฒ์ ๋๋ค.model
์ ํ์ํ ๋ฐ์ดํฐ์ ํด๋ผ์ด์ธํธ ์ธก ์ ์ฅ์๋ฅผ ํฌํจํ ์ ์์ต๋๋ค.
์ด์ ์ฝ๋ ์์ฑ์ ์์ํด ๋ด ์๋ค!
Part 2. ์ฝ๋ ์์ฑโ
์ด์ ์ค๊ณ๋ฅผ ์๋ฃํ์ผ๋ ์ค์ ๋ก ์ฝ๋๋ฅผ ์์ฑํด ๋ด ์๋ค. React์ Remix๋ฅผ ์ฌ์ฉํ ๊ฒ์ ๋๋ค.
์ด ํ๋ก์ ํธ๋ฅผ ์ํ ํ ํ๋ฆฟ์ด ์ค๋น๋์ด ์์ต๋๋ค. GitHub์์ ํด๋ก ํ์ฌ ์์ํ์ธ์. https://github.com/feature-sliced/tutorial-conduit/tree/clean.
npm install
๋ก ์์กด์ฑ์ ์ค์นํ๊ณ npm run dev
๋ก ๊ฐ๋ฐ ์๋ฒ๋ฅผ ์์ํ์ธ์. http://localhost:3000์ ์ด๋ฉด ๋น ์ฑ์ด ๋ณด์ผ ๊ฒ์
๋๋ค.
ํ์ด์ง ๋ ์ด์์โ
๋ชจ๋ ํ์ด์ง์ ๋ํ ๋น ์ปดํฌ๋ํธ๋ฅผ ๋ง๋๋ ๊ฒ๋ถํฐ ์์ํ๊ฒ ์ต๋๋ค. ํ๋ก์ ํธ์์ ๋ค์ ๋ช ๋ น์ ์คํํ์ธ์.
npx fsd pages feed sign-in article-read article-edit profile settings --segments ui
์ด๋ ๊ฒ ํ๋ฉด pages/feed/ui/
์ ๊ฐ์ ํด๋์ ๋ชจ๋ ํ์ด์ง์ ๋ํ ์ธ๋ฑ์ค ํ์ผ์ธ pages/feed/index.ts
๊ฐ ์์ฑ๋ฉ๋๋ค.
ํผ๋ ํ์ด์ง ์ฐ๊ฒฐโ
์ ํ๋ฆฌ์ผ์ด์
์ ๋ฃจํธ ๊ฒฝ๋ก๋ฅผ ํผ๋ ํ์ด์ง์ ์ฐ๊ฒฐํด ๋ด
์๋ค. pages/feed/ui
์ FeedPage.tsx
์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค๊ณ ๋ค์ ๋ด์ฉ์ ๋ฃ์ผ์ธ์:
export function FeedPage() {
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
</div>
);
}
๊ทธ๋ฐ ๋ค์ ํผ๋ ํ์ด์ง์ ๊ณต๊ฐ API์ธ pages/feed/index.ts
ํ์ผ์์ ์ด ์ปดํฌ๋ํธ๋ฅผ ๋ค์ ๋ด๋ณด๋ด์ธ์.
export { FeedPage } from "./ui/FeedPage";
์ด์ ๋ฃจํธ ๊ฒฝ๋ก์ ์ฐ๊ฒฐํฉ๋๋ค. Remix์์ ๋ผ์ฐํ
์ ํ์ผ ๊ธฐ๋ฐ์ด๋ฉฐ, ๋ผ์ฐํธ ํ์ผ์ app/routes
ํด๋์ ์์ด Feature-Sliced Design๊ณผ ์ ๋ง์ต๋๋ค.
app/routes/_index.tsx
์์ FeedPage
์ปดํฌ๋ํธ๋ฅผ ์ฌ์ฉํ์ธ์.
import type { MetaFunction } from "@remix-run/node";
import { FeedPage } from "pages/feed";
export const meta: MetaFunction = () => {
return [{ title: "Conduit" }];
};
export default FeedPage;
๊ทธ๋ฐ ๋ค์ ๊ฐ๋ฐ ์๋ฒ๋ฅผ ์คํํ๊ณ ์ ํ๋ฆฌ์ผ์ด์ ์ ์ด๋ฉด Conduit ๋ฐฐ๋๊ฐ ๋ณด์ผ ๊ฒ์ ๋๋ค!
API ํด๋ผ์ด์ธํธโ
RealWorld ๋ฐฑ์๋์ ํต์ ํ๊ธฐ ์ํด Shared์ ํธ๋ฆฌํ API ํด๋ผ์ด์ธํธ๋ฅผ ๋ง๋ค์ด ๋ด
์๋ค. ํด๋ผ์ด์ธํธ๋ฅผ ์ํ api
์ ๋ฐฑ์๋ ๊ธฐ๋ณธ URL๊ณผ ๊ฐ์ ๋ณ์๋ฅผ ์ํ config
, ๋ ๊ฐ์ ์ธ๊ทธ๋จผํธ๋ฅผ ๋ง๋์ธ์.
npx fsd shared --segments api config
๊ทธ๋ฐ ๋ค์ shared/config/backend.ts
๋ฅผ ๋ง๋์ธ์.
export const backendBaseUrl = "https://api.realworld.io/api";
export { backendBaseUrl } from "./backend";
RealWorld ํ๋ก์ ํธ๋ ํธ๋ฆฌํ๊ฒ OpenAPI ์ฌ์์ ์ ๊ณตํ๋ฏ๋ก, ํด๋ผ์ด์ธํธ๋ฅผ ์ํ ์๋ ์์ฑ ํ์
์ ํ์ฉํ ์ ์์ต๋๋ค. ์ถ๊ฐ ํ์
์์ฑ๊ธฐ๊ฐ ํฌํจ๋ openapi-fetch
ํจํค์ง๋ฅผ ์ฌ์ฉํ ๊ฒ์
๋๋ค.
๋ค์ ๋ช ๋ น์ ์คํํ์ฌ ์ต์ API ํ์ ์ ์์ฑํ์ธ์.
npm run generate-api-types
์ด๋ ๊ฒ ํ๋ฉด shared/api/v1.d.ts
ํ์ผ์ด ์์ฑ๋ฉ๋๋ค. ์ด ํ์ผ์ ์ฌ์ฉํ์ฌ shared/api/client.ts
์ ํ์
์ด ์ง์ ๋ API ํด๋ผ์ด์ธํธ๋ฅผ ๋ง๋ค ๊ฒ์
๋๋ค.
import createClient from "openapi-fetch";
import { backendBaseUrl } from "shared/config";
import type { paths } from "./v1";
export const { GET, POST, PUT, DELETE } = createClient<paths>({ baseUrl: backendBaseUrl });
export { GET, POST, PUT, DELETE } from "./client";
ํผ๋์ ์ค์ ๋ฐ์ดํฐโ
์ด์ ๋ฐฑ์๋์์ ๊ฐ์ ธ์จ ๊ธ์ ํผ๋์ ์ถ๊ฐํ ์ ์์ต๋๋ค. ๊ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ปดํฌ๋ํธ๋ฅผ ๊ตฌํํ๋ ๊ฒ๋ถํฐ ์์ํ๊ฒ ์ต๋๋ค.
๋ค์ ๋ด์ฉ์ผ๋ก pages/feed/ui/ArticlePreview.tsx
๋ฅผ ๋ง๋์ธ์.
export function ArticlePreview({ article }) { /* TODO */ }
TypeScript๋ฅผ ์ฌ์ฉํ๊ณ ์์ผ๋ฏ๋ก ๊ธ ๊ฐ์ฒด์ ํ์
์ ์ง์ ํ๋ฉด ์ข์ ๊ฒ ๊ฐ์ต๋๋ค. ์์ฑ๋ v1.d.ts
๋ฅผ ์ดํด๋ณด๋ฉด ๊ธ ๊ฐ์ฒด๊ฐ components["schemas"]["Article"]
์ ํตํด ์ฌ์ฉ ๊ฐ๋ฅํ ๊ฒ์ ๋ณผ ์ ์์ต๋๋ค. ๊ทธ๋ผ Shared์ ๋ฐ์ดํฐ ๋ชจ๋ธ์ด ์๋ ํ์ผ์ ๋ง๋ค๊ณ ๋ชจ๋ธ์ ๋ด๋ณด๋ด๊ฒ ์ต๋๋ค.
import type { components } from "./v1";
export type Article = components["schemas"]["Article"];
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
์ด์ ๊ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ปดํฌ๋ํธ๋ก ๋์๊ฐ ๋ฐ์ดํฐ๋ก ๋งํฌ์ ์ ์ฑ์ธ ์ ์์ต๋๋ค. ์ปดํฌ๋ํธ๋ฅผ ๋ค์ ๋ด์ฉ์ผ๋ก ์ ๋ฐ์ดํธํ์ธ์.
import { Link } from "@remix-run/react";
import type { Article } from "shared/api";
interface ArticlePreviewProps {
article: Article;
}
export function ArticlePreview({ article }: ArticlePreviewProps) {
return (
<div className="article-preview">
<div className="article-meta">
<Link to={`/profile/${article.author.username}`} prefetch="intent">
<img src={article.author.image} alt="" />
</Link>
<div className="info">
<Link
to={`/profile/${article.author.username}`}
className="author"
prefetch="intent"
>
{article.author.username}
</Link>
<span className="date" suppressHydrationWarning>
{new Date(article.createdAt).toLocaleDateString(undefined, {
dateStyle: "long",
})}
</span>
</div>
<button className="btn btn-outline-primary btn-sm pull-xs-right">
<i className="ion-heart"></i> {article.favoritesCount}
</button>
</div>
<Link
to={`/article/${article.slug}`}
className="preview-link"
prefetch="intent"
>
<h1>{article.title}</h1>
<p>{article.description}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tagList.map((tag) => (
<li key={tag} className="tag-default tag-pill tag-outline">
{tag}
</li>
))}
</ul>
</Link>
</div>
);
}
์ข์์ ๋ฒํผ์ ์ง๊ธ์ ์๋ฌด ์์ ๋ ํ์ง ์์ต๋๋ค. ๊ธ ์ฝ๊ธฐ ํ์ด์ง๋ฅผ ๋ง๋ค๊ณ ์ข์์ ๊ธฐ๋ฅ์ ๊ตฌํํ ๋ ์์ ํ๊ฒ ์ต๋๋ค.
์ด์ ๊ธ์ ๊ฐ์ ธ์์ ์ด๋ฌํ ์นด๋๋ฅผ ์ฌ๋ฌ ๊ฐ ๋ ๋๋งํ ์ ์์ต๋๋ค. Remix์์ ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ๋ ๋ก๋ โ ํ์ด์ง๊ฐ ํ์๋ก ํ๋ ๊ฒ์ ์ ํํ ๊ฐ์ ธ์ค๋ ์๋ฒ ์ธก ํจ์ โ ๋ฅผ ํตํด ์ํ๋ฉ๋๋ค. ๋ก๋๋ ํ์ด์ง๋ฅผ ๋์ ํ์ฌ API์ ์ํธ ์์ฉํ๋ฏ๋ก ํ์ด์ง์ api
์ธ๊ทธ๋จผํธ์ ๋ฃ์ ๊ฒ์
๋๋ค:
import { json } from "@remix-run/node";
import { GET } from "shared/api";
export const loader = async () => {
const { data: articles, error, response } = await GET("/articles");
if (error !== undefined) {
throw json(error, { status: response.status });
}
return json({ articles });
};
ํ์ด์ง์ ์ฐ๊ฒฐํ๋ ค๋ฉด ๋ผ์ฐํธ ํ์ผ์์ loader
๋ผ๋ ์ด๋ฆ์ผ๋ก ๋ด๋ณด๋ด์ผ ํฉ๋๋ค.
export { FeedPage } from "./ui/FeedPage";
export { loader } from "./api/loader";
import type { MetaFunction } from "@remix-run/node";
import { FeedPage } from "pages/feed";
export { loader } from "pages/feed";
export const meta: MetaFunction = () => {
return [{ title: "Conduit" }];
};
export default FeedPage;
๋ง์ง๋ง ๋จ๊ณ๋ ํผ๋์ ์ด๋ฌํ ์นด๋๋ฅผ ๋ ๋๋งํ๋ ๊ฒ์
๋๋ค. FeedPage
๋ฅผ ๋ค์ ์ฝ๋๋ก ์
๋ฐ์ดํธํ์ธ์.
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const { articles } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
</div>
</div>
</div>
</div>
);
}
ํ๊ทธ๋ก ํํฐ๋งโ
ํ๊ทธ์ ๊ด๋ จํด์๋ ๋ฐฑ์๋์์ ํ๊ทธ๋ฅผ ๊ฐ์ ธ์ค๊ณ ํ์ฌ ์ ํ๋ ํ๊ทธ๋ฅผ ์ ์ฅํด์ผ ํฉ๋๋ค. ๊ฐ์ ธ์ค๊ธฐ ๋ฐฉ๋ฒ์ ์ด๋ฏธ ์๊ณ ์์ต๋๋ค โ ๋ก๋์์ ๋ ๋ค๋ฅธ ์์ฒญ์ ํ๋ฉด ๋ฉ๋๋ค. remix-utils
ํจํค์ง์์ promiseHash
๋ผ๋ ํธ๋ฆฌํ ํจ์๋ฅผ ์ฌ์ฉํ ๊ฒ์
๋๋ค. ์ด ํจํค์ง๋ ์ด๋ฏธ ์ค์น๋์ด ์์ต๋๋ค.
๋ก๋ ํ์ผ์ธ pages/feed/api/loader.ts
๋ฅผ ๋ค์ ์ฝ๋๋ก ์
๋ฐ์ดํธํ์ธ์.
import { json } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async () => {
return json(
await promiseHash({
articles: throwAnyErrors(GET("/articles")),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
์ค๋ฅ ์ฒ๋ฆฌ๋ฅผ ์ผ๋ฐ ํจ์ throwAnyErrors
๋ก ์ถ์ถํ๋ค๋ ์ ์ ์ฃผ๋ชฉํ์ธ์. ๊ฝค ์ ์ฉํด ๋ณด์ด๋ฏ๋ก ๋์ค์ ์ฌ์ฌ์ฉํ ์ ์์ ๊ฒ ๊ฐ์ต๋๋ค. ์ง๊ธ์ ๊ทธ๋ฅ ์ฃผ๋ชฉํด ๋๊ฒ ์ต๋๋ค.
์ด์ ํ๊ทธ ๋ชฉ๋ก์ผ๋ก ๋์ด๊ฐ์๋ค. ์ด๋ ์ํธ์์ฉ์ด ๊ฐ๋ฅํด์ผ ํฉ๋๋ค โ ํ๊ทธ๋ฅผ ํด๋ฆญํ๋ฉด ํด๋น ํ๊ทธ๊ฐ ์ ํ๋์ด์ผ ํฉ๋๋ค. Remix ๊ท์น์ ๋ฐ๋ผ URL ๊ฒ์ ๋งค๊ฐ๋ณ์๋ฅผ ์ ํ๋ ํ๊ทธ์ ์ ์ฅ์๋ก ์ฌ์ฉํ ๊ฒ์ ๋๋ค. ๋ธ๋ผ์ฐ์ ๊ฐ ์ ์ฅ์ ์ฒ๋ฆฌํ๊ฒ ํ๊ณ ์ฐ๋ฆฌ๋ ๋ ์ค์ํ ์ผ์ ์ง์คํ๊ฒ ์ต๋๋ค.
pages/feed/ui/FeedPage.tsx
๋ฅผ ๋ค์ ์ฝ๋๋ก ์
๋ฐ์ดํธํ์ธ์.
import { Form, useLoaderData } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const { articles, tags } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
</div>
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
</div>
</div>
</div>
</div>
);
}
๊ทธ๋ฐ ๋ค์ ๋ก๋์์ tag
๊ฒ์ ๋งค๊ฐ๋ณ์๋ฅผ ์ฌ์ฉํด์ผ ํฉ๋๋ค. pages/feed/api/loader.ts
์ loader
ํจ์๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ๋ณ๊ฒฝํ์ธ์.
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", { params: { query: { tag: selectedTag } } }),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
์ด๊ฒ ์ ๋ถ์
๋๋ค. model
์ธ๊ทธ๋จผํธ๊ฐ ํ์ํ์ง ์์ต๋๋ค. Remix๋ ๊ฝค ๊น๋ํ์ฃ .
ํ์ด์ง๋ค์ด์ โ
๋น์ทํ ๋ฐฉ์์ผ๋ก ํ์ด์ง๋ค์ด์ ์ ๊ตฌํํ ์ ์์ต๋๋ค. ์ง์ ์๋ํด ๋ณด๊ฑฐ๋ ์๋ ์ฝ๋๋ฅผ ๋ณต์ฌํ์ธ์. ์ด์ฐจํผ ๋น์ ์ ํ๋จํ ์ฌ๋์ ์์ต๋๋ค.
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
/** Amount of articles on one page. */
export const LIMIT = 20;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
const page = parseInt(url.searchParams.get("page") ?? "", 10);
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", {
params: {
query: {
tag: selectedTag,
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import { LIMIT, type loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const [searchParams] = useSearchParams();
const { articles, tags } = useLoaderData<typeof loader>();
const pageAmount = Math.ceil(articles.articlesCount / LIMIT);
const currentPage = parseInt(searchParams.get("page") ?? "1", 10);
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
<Form>
<ExistingSearchParams exclude={["page"]} />
<ul className="pagination">
{Array(pageAmount)
.fill(null)
.map((_, index) =>
index + 1 === currentPage ? (
<li key={index} className="page-item active">
<span className="page-link">{index + 1}</span>
</li>
) : (
<li key={index} className="page-item">
<button
className="page-link"
name="page"
value={index + 1}
>
{index + 1}
</button>
</li>
),
)}
</ul>
</Form>
</div>
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag", "page"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
</div>
</div>
</div>
</div>
);
}
์ด๊ฒ์ผ๋ก ์๋ฃ๋์์ต๋๋ค. ํญ ๋ชฉ๋ก๋ ๋น์ทํ๊ฒ ๊ตฌํํ ์ ์์ง๋ง, ์ธ์ฆ์ ๊ตฌํํ ๋๊น์ง ์ ์ ๋ณด๋ฅํ๊ฒ ์ต๋๋ค. ๊ทธ๋ฐ๋ฐ ๋ง์ด ๋์์ผ๋!
์ธ์ฆโ
์ธ์ฆ์๋ ๋ ๊ฐ์ ํ์ด์ง๊ฐ ๊ด๋ จ๋ฉ๋๋ค - ๋ก๊ทธ์ธ๊ณผ ํ์๊ฐ์
์
๋๋ค. ์ด๋ค์ ๋๋ถ๋ถ ๋์ผํ๋ฏ๋ก ํ์ํ ๊ฒฝ์ฐ ์ฝ๋๋ฅผ ์ฌ์ฌ์ฉํ ์ ์๋๋ก sign-in
์ด๋ผ๋ ๋์ผํ ์ฌ๋ผ์ด์ค์ ์ ์งํ๋ ๊ฒ์ด ํฉ๋ฆฌ์ ์
๋๋ค.
pages/sign-in
์ ui
์ธ๊ทธ๋จผํธ์ ๋ค์ ๋ด์ฉ์ผ๋ก RegisterPage.tsx
๋ฅผ ๋ง๋์ธ์.
import { Form, Link, useActionData } from "@remix-run/react";
import type { register } from "../api/register";
export function RegisterPage() {
const registerData = useActionData<typeof register>();
return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Sign up</h1>
<p className="text-xs-center">
<Link to="/login">Have an account?</Link>
</p>
{registerData?.error && (
<ul className="error-messages">
{registerData.error.errors.body.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<Form method="post">
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="text"
name="username"
placeholder="Username"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="text"
name="email"
placeholder="Email"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="password"
name="password"
placeholder="Password"
/>
</fieldset>
<button className="btn btn-lg btn-primary pull-xs-right">
Sign up
</button>
</Form>
</div>
</div>
</div>
</div>
);
}
์ด์ ๊ณ ์ณ์ผ ํ ๊นจ์ง import๊ฐ ์์ต๋๋ค. ์๋ก์ด ์ธ๊ทธ๋จผํธ๊ฐ ํ์ํ๋ฏ๋ก ๋ค์๊ณผ ๊ฐ์ด ๋ง๋์ธ์.
npx fsd pages sign-in -s api
๊ทธ๋ฌ๋ ๋ฑ๋ก์ ๋ฐฑ์๋ ๋ถ๋ถ์ ๊ตฌํํ๊ธฐ ์ ์ Remix๊ฐ ์ธ์ ์ ์ฒ๋ฆฌํ ์ ์๋๋ก ์ผ๋ถ ์ธํ๋ผ ์ฝ๋๊ฐ ํ์ํฉ๋๋ค. ๋ค๋ฅธ ํ์ด์ง์์๋ ํ์ํ ์ ์์ผ๋ฏ๋ก ์ด๋ Shared๋ก ๊ฐ๋๋ค.
๋ค์ ์ฝ๋๋ฅผ shared/api/auth.server.ts
์ ๋ฃ์ผ์ธ์. ์ด๋ Remix์ ๋งค์ฐ ํนํ๋ ๊ฒ์ด๋ฏ๋ก ๋๋ฌด ๊ฑฑ์ ํ์ง ๋ง์ธ์. ๊ทธ๋ฅ ๋ณต์ฌ-๋ถ์ฌ๋ฃ๊ธฐ ํ์ธ์.
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import type { User } from "./models";
invariant(
process.env.SESSION_SECRET,
"SESSION_SECRET must be set for authentication to work",
);
const sessionStorage = createCookieSessionStorage<{
user: User;
}>({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET],
secure: process.env.NODE_ENV === "production",
},
});
export async function createUserSession({
request,
user,
redirectTo,
}: {
request: Request;
user: User;
redirectTo: string;
}) {
const cookie = request.headers.get("Cookie");
const session = await sessionStorage.getSession(cookie);
session.set("user", user);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
maxAge: 60 * 60 * 24 * 7, // 7 days
}),
},
});
}
export async function getUserFromSession(request: Request) {
const cookie = request.headers.get("Cookie");
const session = await sessionStorage.getSession(cookie);
return session.get("user") ?? null;
}
export async function requireUser(request: Request) {
const user = await getUserFromSession(request);
if (user === null) {
throw redirect("/login");
}
return user;
}
๊ทธ๋ฆฌ๊ณ ๋ฐ๋ก ์์ ์๋ models.ts
ํ์ผ์์ User
๋ชจ๋ธ๋ ๋ด๋ณด๋ด์ธ์.
import type { components } from "./v1";
export type Article = components["schemas"]["Article"];
export type User = components["schemas"]["User"];
์ด ์ฝ๋๊ฐ ์๋ํ๋ ค๋ฉด SESSION_SECRET
ํ๊ฒฝ ๋ณ์๋ฅผ ์ค์ ํด์ผ ํฉ๋๋ค. ํ๋ก์ ํธ ๋ฃจํธ์ .env
ํ์ผ์ ๋ง๋ค๊ณ SESSION_SECRET=
์ ์์ฑํ ๋ค์ ํค๋ณด๋์์ ๋ฌด์์๋ก ํค๋ฅผ ๋๋ฌ ๊ธด ๋ฌด์์ ๋ฌธ์์ด์ ๋ง๋์ธ์. ๋ค์๊ณผ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ๋์์ผ ํฉ๋๋ค.
SESSION_SECRET=dontyoudarecopypastethis
๋ง์ง๋ง์ผ๋ก ์ด ์ฝ๋๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด ๊ณต๊ฐ API์ ์ผ๋ถ ๋ด๋ณด๋ด๊ธฐ๋ฅผ ์ถ๊ฐํ์ธ์.
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
export { createUserSession, getUserFromSession, requireUser } from "./auth.server";
์ด์ RealWorld ๋ฐฑ์๋์ ์ค์ ๋ก ํต์ ํ์ฌ ๋ฑ๋ก์ ์ํํ๋ ์ฝ๋๋ฅผ ์์ฑํ ์ ์์ต๋๋ค. ๊ทธ๊ฒ์ pages/sign-in/api
์ ์ ์งํ ๊ฒ์
๋๋ค. register.ts
๋ผ๋ ํ์ผ์ ๋ง๋ค๊ณ ๋ค์ ์ฝ๋๋ฅผ ๋ฃ์ผ์ธ์.
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { POST, createUserSession } from "shared/api";
export const register = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const username = formData.get("username")?.toString() ?? "";
const email = formData.get("email")?.toString() ?? "";
const password = formData.get("password")?.toString() ?? "";
const { data, error } = await POST("/users", {
body: { user: { email, password, username } },
});
if (error) {
return json({ error }, { status: 400 });
} else {
return createUserSession({
request: request,
user: data.user,
redirectTo: "/",
});
}
};
export { RegisterPage } from './ui/RegisterPage';
export { register } from './api/register';
๊ฑฐ์ ๋ค ์์ต๋๋ค! ํ์ด์ง์ ์ก์
์ /register
๋ผ์ฐํธ์ ์ฐ๊ฒฐํ๊ธฐ๋ง ํ๋ฉด ๋ฉ๋๋ค. app/routes
์ register.tsx
๋ฅผ ๋ง๋์ธ์.
import { RegisterPage, register } from "pages/sign-in";
export { register as action };
export default RegisterPage;
์ด์ http://localhost:3000/register๋ก ๊ฐ๋ฉด ์ฌ์ฉ์๋ฅผ ์์ฑํ ์ ์์ด์ผ ํฉ๋๋ค! ์ ํ๋ฆฌ์ผ์ด์ ์ ๋๋จธ์ง ๋ถ๋ถ์ ์์ง ์ด์ ๋ฐ์ํ์ง ์์ ๊ฒ์ ๋๋ค. ๊ณง ๊ทธ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ฒ ์ต๋๋ค.
๋งค์ฐ ์ ์ฌํ ๋ฐฉ์์ผ๋ก ๋ก๊ทธ์ธ ํ์ด์ง๋ฅผ ๊ตฌํํ ์ ์์ต๋๋ค. ์ง์ ์๋ํด ๋ณด๊ฑฐ๋ ๊ทธ๋ฅ ์ฝ๋๋ฅผ ๊ฐ์ ธ์์ ๊ณ์ ์งํํ์ธ์.
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { POST, createUserSession } from "shared/api";
export const signIn = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = formData.get("email")?.toString() ?? "";
const password = formData.get("password")?.toString() ?? "";
const { data, error } = await POST("/users/login", {
body: { user: { email, password } },
});
if (error) {
return json({ error }, { status: 400 });
} else {
return createUserSession({
request: request,
user: data.user,
redirectTo: "/",
});
}
};
import { Form, Link, useActionData } from "@remix-run/react";
import type { signIn } from "../api/sign-in";
export function SignInPage() {
const signInData = useActionData<typeof signIn>();
return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Sign in</h1>
<p className="text-xs-center">
<Link to="/register">Need an account?</Link>
</p>
{signInData?.error && (
<ul className="error-messages">
{signInData.error.errors.body.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<Form method="post">
<fieldset className="form-group">
<input
className="form-control form-control-lg"
name="email"
type="text"
placeholder="Email"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
name="password"
type="password"
placeholder="Password"
/>
</fieldset>
<button className="btn btn-lg btn-primary pull-xs-right">
Sign in
</button>
</Form>
</div>
</div>
</div>
</div>
);
}
export { RegisterPage } from './ui/RegisterPage';
export { register } from './api/register';
export { SignInPage } from './ui/SignInPage';
export { signIn } from './api/sign-in';
import { SignInPage, signIn } from "pages/sign-in";
export { signIn as action };
export default SignInPage;
์ด์ ์ฌ์ฉ์๊ฐ ์ด ํ์ด์ง์ ์ค์ ๋ก ์ ๊ทผํ ์ ์๋ ๋ฐฉ๋ฒ์ ์ ๊ณตํด ๋ด ์๋ค.
ํค๋โ
1๋ถ์์ ๋ ผ์ํ๋ฏ์ด, ์ฑ ํค๋๋ ์ผ๋ฐ์ ์ผ๋ก Widgets๋ Shared์ ๋ฐฐ์น๋ฉ๋๋ค. ๋งค์ฐ ๊ฐ๋จํ๊ณ ๋ชจ๋ ๋น์ฆ๋์ค ๋ก์ง์ ์ธ๋ถ์ ์ ์งํ ์ ์๊ธฐ ๋๋ฌธ์ Shared์ ๋ฃ์ ๊ฒ์ ๋๋ค. ์ด๋ฅผ ์ํ ์ฅ์๋ฅผ ๋ง๋ค์ด ๋ด ์๋ค.
npx fsd shared ui
์ด์ ๋ค์ ๋ด์ฉ์ผ๋ก shared/ui/Header.tsx
๋ฅผ ๋ง๋์ธ์.
import { useContext } from "react";
import { Link, useLocation } from "@remix-run/react";
import { CurrentUser } from "../api/currentUser";
export function Header() {
const currentUser = useContext(CurrentUser);
const { pathname } = useLocation();
return (
<nav className="navbar navbar-light">
<div className="container">
<Link className="navbar-brand" to="/" prefetch="intent">
conduit
</Link>
<ul className="nav navbar-nav pull-xs-right">
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/" ? "active" : ""}`}
to="/"
>
Home
</Link>
</li>
{currentUser == null ? (
<>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/login" ? "active" : ""}`}
to="/login"
>
Sign in
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/register" ? "active" : ""}`}
to="/register"
>
Sign up
</Link>
</li>
</>
) : (
<>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/editor" ? "active" : ""}`}
to="/editor"
>
<i className="ion-compose"></i> New Article{" "}
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/settings" ? "active" : ""}`}
to="/settings"
>
{" "}
<i className="ion-gear-a"></i> Settings{" "}
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname.includes("/profile") ? "active" : ""}`}
to={`/profile/${currentUser.username}`}
>
{currentUser.image && (
<img
width={25}
height={25}
src={currentUser.image}
className="user-pic"
alt=""
/>
)}
{currentUser.username}
</Link>
</li>
</>
)}
</ul>
</div>
</nav>
);
}
์ด ์ปดํฌ๋ํธ๋ฅผ shared/ui
์์ ๋ด๋ณด๋ด์ธ์.
export { Header } from "./Header";
ํค๋์์๋ shared/api
์ ์ ์ง๋๋ ์ปจํ
์คํธ์ ์์กดํฉ๋๋ค. ๊ทธ๊ฒ๋ ๋ง๋์ธ์.
import { createContext } from "react";
import type { User } from "./models";
export const CurrentUser = createContext<User | null>(null);
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
export { createUserSession, getUserFromSession, requireUser } from "./auth.server";
export { CurrentUser } from "./currentUser";
์ด์ ํ์ด์ง์ ํค๋๋ฅผ ์ถ๊ฐํด ๋ด
์๋ค. ๋ชจ๋ ํ์ด์ง์ ์์ด์ผ ํ๋ฏ๋ก ๋ฃจํธ ๋ผ์ฐํธ์ ์ถ๊ฐํ๊ณ outlet(ํ์ด์ง๊ฐ ๋ ๋๋ง๋ ์์น)์ CurrentUser
์ปจํ
์คํธ ์ ๊ณต์๋ก ๊ฐ์ธ๋ ๊ฒ์ด ํฉ๋ฆฌ์ ์
๋๋ค. ์ด๋ ๊ฒ ํ๋ฉด ์ ์ฒด ์ฑ๊ณผ ํค๋๊ฐ ํ์ฌ ์ฌ์ฉ์ ๊ฐ์ฒด์ ์ ๊ทผํ ์ ์์ต๋๋ค. ๋ํ ์ฟ ํค์์ ์ค์ ๋ก ํ์ฌ ์ฌ์ฉ์ ๊ฐ์ฒด๋ฅผ ๊ฐ์ ธ์ค๋ ๋ก๋๋ฅผ ์ถ๊ฐํ ๊ฒ์
๋๋ค. app/root.tsx
์ ๋ค์ ๋ด์ฉ์ ๋ฃ์ผ์ธ์.
import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import { Header } from "shared/ui";
import { getUserFromSession, CurrentUser } from "shared/api";
export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];
export const loader = ({ request }: LoaderFunctionArgs) =>
getUserFromSession(request);
export default function App() {
const user = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<link
href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
rel="stylesheet"
type="text/css"
/>
<link
href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
rel="stylesheet"
type="text/css"
/>
<link rel="stylesheet" href="//demo.productionready.io/main.css" />
<style>{`
button {
border: 0;
}
`}</style>
</head>
<body>
<CurrentUser.Provider value={user}>
<Header />
<Outlet />
</CurrentUser.Provider>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
์ด ์์ ์์ ํ ํ์ด์ง์ ๋ค์๊ณผ ๊ฐ์ ๋ด์ฉ์ด ํ์๋์ด์ผ ํฉ๋๋ค.
ํญโ
์ด์ ์ธ์ฆ ์ํ๋ฅผ ๊ฐ์งํ ์ ์์ผ๋ฏ๋ก ํญ๊ณผ ๊ธ ์ข์์๋ฅผ ๋น ๋ฅด๊ฒ ๊ตฌํํ์ฌ ํผ๋ ํ์ด์ง๋ฅผ ์์ฑํด ๋ด
์๋ค. ๋ ๋ค๋ฅธ ํผ์ด ํ์ํ์ง๋ง ์ด ํ์ด์ง ํ์ผ์ด ๊ฝค ์ปค์ง๊ณ ์์ผ๋ฏ๋ก ์ด๋ฌํ ํผ์ ์ธ์ ํ ํ์ผ๋ก ์ฎ๊ธฐ๊ฒ ์ต๋๋ค. Tabs.tsx
, PopularTags.tsx
, Pagination.tsx
๋ฅผ ๋ค์ ๋ด์ฉ์ผ๋ก ๋ง๋ค ๊ฒ์
๋๋ค.
import { useContext } from "react";
import { Form, useSearchParams } from "@remix-run/react";
import { CurrentUser } from "shared/api";
export function Tabs() {
const [searchParams] = useSearchParams();
const currentUser = useContext(CurrentUser);
return (
<Form>
<div className="feed-toggle">
<ul className="nav nav-pills outline-active">
{currentUser !== null && (
<li className="nav-item">
<button
name="source"
value="my-feed"
className={`nav-link ${searchParams.get("source") === "my-feed" ? "active" : ""}`}
>
Your Feed
</button>
</li>
)}
<li className="nav-item">
<button
className={`nav-link ${searchParams.has("tag") || searchParams.has("source") ? "" : "active"}`}
>
Global Feed
</button>
</li>
{searchParams.has("tag") && (
<li className="nav-item">
<span className="nav-link active">
<i className="ion-pound"></i> {searchParams.get("tag")}
</span>
</li>
)}
</ul>
</div>
</Form>
);
}
import { Form, useLoaderData } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import type { loader } from "../api/loader";
export function PopularTags() {
const { tags } = useLoaderData<typeof loader>();
return (
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag", "page", "source"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
);
}
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import { LIMIT, type loader } from "../api/loader";
export function Pagination() {
const [searchParams] = useSearchParams();
const { articles } = useLoaderData<typeof loader>();
const pageAmount = Math.ceil(articles.articlesCount / LIMIT);
const currentPage = parseInt(searchParams.get("page") ?? "1", 10);
return (
<Form>
<ExistingSearchParams exclude={["page"]} />
<ul className="pagination">
{Array(pageAmount)
.fill(null)
.map((_, index) =>
index + 1 === currentPage ? (
<li key={index} className="page-item active">
<span className="page-link">{index + 1}</span>
</li>
) : (
<li key={index} className="page-item">
<button className="page-link" name="page" value={index + 1}>
{index + 1}
</button>
</li>
),
)}
</ul>
</Form>
);
}
์ด์ FeedPage
๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ์
๋ฐ์ดํธํ์ธ์.
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
import { Tabs } from "./Tabs";
import { PopularTags } from "./PopularTags";
import { Pagination } from "./Pagination";
export function FeedPage() {
const { articles } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
<Tabs />
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
<Pagination />
</div>
<div className="col-md-3">
<PopularTags />
</div>
</div>
</div>
</div>
);
}
๋ง์ง๋ง์ผ๋ก ๋ก๋๋ฅผ ์ ๋ฐ์ดํธํ์ฌ ์๋ก์ด ํํฐ๋ฅผ ์ฒ๋ฆฌํ์ธ์.
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET, requireUser } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
/* unchanged */
}
/** Amount of articles on one page. */
export const LIMIT = 20;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
const page = parseInt(url.searchParams.get("page") ?? "", 10);
if (url.searchParams.get("source") === "my-feed") {
const userSession = await requireUser(request);
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles/feed", {
params: {
query: {
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
headers: { Authorization: `Token ${userSession.token}` },
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
}
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", {
params: {
query: {
tag: selectedTag,
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
ํผ๋ ํ์ด์ง๋ฅผ ๋ ๋๊ธฐ ์ ์, ๊ธ์ ๋ํ ์ข์์๋ฅผ ์ฒ๋ฆฌํ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํด ๋ด
์๋ค. ArticlePreview.tsx
๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ๋ณ๊ฒฝํ์ธ์.
import { Form, Link } from "@remix-run/react";
import type { Article } from "shared/api";
interface ArticlePreviewProps {
article: Article;
}
export function ArticlePreview({ article }: ArticlePreviewProps) {
return (
<div className="article-preview">
<div className="article-meta">
<Link to={`/profile/${article.author.username}`} prefetch="intent">
<img src={article.author.image} alt="" />
</Link>
<div className="info">
<Link
to={`/profile/${article.author.username}`}
className="author"
prefetch="intent"
>
{article.author.username}
</Link>
<span className="date" suppressHydrationWarning>
{new Date(article.createdAt).toLocaleDateString(undefined, {
dateStyle: "long",
})}
</span>
</div>
<Form
method="post"
action={`/article/${article.slug}`}
preventScrollReset
>
<button
name="_action"
value={article.favorited ? "unfavorite" : "favorite"}
className={`btn ${article.favorited ? "btn-primary" : "btn-outline-primary"} btn-sm pull-xs-right`}
>
<i className="ion-heart"></i> {article.favoritesCount}
</button>
</Form>
</div>
<Link
to={`/article/${article.slug}`}
className="preview-link"
prefetch="intent"
>
<h1>{article.title}</h1>
<p>{article.description}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tagList.map((tag) => (
<li key={tag} className="tag-default tag-pill tag-outline">
{tag}
</li>
))}
</ul>
</Link>
</div>
);
}
์ด ์ฝ๋๋ ๊ธ์ ์ข์์๋ฅผ ํ์ํ๊ธฐ ์ํด /article/:slug
๋ก _action=favorite
๊ณผ ํจ๊ป POST ์์ฒญ์ ๋ณด๋
๋๋ค. ์์ง ์๋ํ์ง ์๊ฒ ์ง๋ง, ๊ธ ์ฝ๊ธฐ ํ์ด์ง ์์
์ ์์ํ๋ฉด์ ์ด๊ฒ๋ ๊ตฌํํ ๊ฒ์
๋๋ค.
์ด๊ฒ์ผ๋ก ํผ๋๊ฐ ๊ณต์์ ์ผ๋ก ์์ฑ๋์์ต๋๋ค! ์ผํธ!
๊ธ ์ฝ๊ธฐ ํ์ด์งโ
๋จผ์ ๋ฐ์ดํฐ๊ฐ ํ์ํฉ๋๋ค. ๋ก๋๋ฅผ ๋ง๋ค์ด ๋ด ์๋ค.
npx fsd pages article-read -s api
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET, getUserFromSession } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
invariant(params.slug, "Expected a slug parameter");
const currentUser = await getUserFromSession(request);
const authorization = currentUser
? { Authorization: `Token ${currentUser.token}` }
: undefined;
return json(
await promiseHash({
article: throwAnyErrors(
GET("/articles/{slug}", {
params: {
path: { slug: params.slug },
},
headers: authorization,
}),
),
comments: throwAnyErrors(
GET("/articles/{slug}/comments", {
params: {
path: { slug: params.slug },
},
headers: authorization,
}),
),
}),
);
};
export { loader } from "./api/loader";
์ด์ /article/:slug
๋ผ์ฐํธ์ ์ฐ๊ฒฐํ ์ ์์ต๋๋ค. article.$slug.tsx
๋ผ๋ ๋ผ์ฐํธ ํ์ผ์ ๋ง๋์ธ์.
export { loader } from "pages/article-read";
ํ์ด์ง ์์ฒด๋ ์ธ ๊ฐ์ง ์ฃผ์ ๋ธ๋ก์ผ๋ก ๊ตฌ์ฑ๋ฉ๋๋ค - ๊ธ ํค๋์ ์ก์ (๋ ๋ฒ ๋ฐ๋ณต), ๊ธ ๋ณธ๋ฌธ, ๋๊ธ ์น์ ์ ๋๋ค. ๋ค์์ ํ์ด์ง์ ๋งํฌ์ ์ ๋๋ค. ํน๋ณํ ํฅ๋ฏธ๋ก์ด ๋ด์ฉ์ ์์ต๋๋ค:
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticleMeta } from "./ArticleMeta";
import { Comments } from "./Comments";
export function ArticleReadPage() {
const { article } = useLoaderData<typeof loader>();
return (
<div className="article-page">
<div className="banner">
<div className="container">
<h1>{article.article.title}</h1>
<ArticleMeta />
</div>
</div>
<div className="container page">
<div className="row article-content">
<div className="col-md-12">
<p>{article.article.body}</p>
<ul className="tag-list">
{article.article.tagList.map((tag) => (
<li className="tag-default tag-pill tag-outline" key={tag}>
{tag}
</li>
))}
</ul>
</div>
</div>
<hr />
<div className="article-actions">
<ArticleMeta />
</div>
<div className="row">
<Comments />
</div>
</div>
</div>
);
}
๋ ํฅ๋ฏธ๋ก์ด ๊ฒ์ ArticleMeta
์ Comments
์
๋๋ค. ์ด๋ค์ ๊ธ ์ข์์, ๋๊ธ ์์ฑ ๋ฑ๊ณผ ๊ฐ์ ์ฐ๊ธฐ ์์
์ ํฌํจํฉ๋๋ค. ์ด๋ค์ ์๋์ํค๋ ค๋ฉด ๋จผ์ ๋ฐฑ์๋ ๋ถ๋ถ์ ๊ตฌํํด์ผ ํฉ๋๋ค. ํ์ด์ง์ api
์ธ๊ทธ๋จผํธ์ action.ts
๋ฅผ ๋ง๋์ธ์:
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
import { namedAction } from "remix-utils/named-action";
import { redirectBack } from "remix-utils/redirect-back";
import invariant from "tiny-invariant";
import { DELETE, POST, requireUser } from "shared/api";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const currentUser = await requireUser(request);
const authorization = { Authorization: `Token ${currentUser.token}` };
const formData = await request.formData();
return namedAction(formData, {
async delete() {
invariant(params.slug, "Expected a slug parameter");
await DELETE("/articles/{slug}", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirect("/");
},
async favorite() {
invariant(params.slug, "Expected a slug parameter");
await POST("/articles/{slug}/favorite", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async unfavorite() {
invariant(params.slug, "Expected a slug parameter");
await DELETE("/articles/{slug}/favorite", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async createComment() {
invariant(params.slug, "Expected a slug parameter");
const comment = formData.get("comment");
invariant(typeof comment === "string", "Expected a comment parameter");
await POST("/articles/{slug}/comments", {
params: { path: { slug: params.slug } },
headers: { ...authorization, "Content-Type": "application/json" },
body: { comment: { body: comment } },
});
return redirectBack(request, { fallback: "/" });
},
async deleteComment() {
invariant(params.slug, "Expected a slug parameter");
const commentId = formData.get("id");
invariant(typeof commentId === "string", "Expected an id parameter");
const commentIdNumeric = parseInt(commentId, 10);
invariant(
!Number.isNaN(commentIdNumeric),
"Expected a numeric id parameter",
);
await DELETE("/articles/{slug}/comments/{id}", {
params: { path: { slug: params.slug, id: commentIdNumeric } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async followAuthor() {
const authorUsername = formData.get("username");
invariant(
typeof authorUsername === "string",
"Expected a username parameter",
);
await POST("/profiles/{username}/follow", {
params: { path: { username: authorUsername } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async unfollowAuthor() {
const authorUsername = formData.get("username");
invariant(
typeof authorUsername === "string",
"Expected a username parameter",
);
await DELETE("/profiles/{username}/follow", {
params: { path: { username: authorUsername } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
});
};
๊ทธ ์ฌ๋ผ์ด์ค์์ ์ด๋ฅผ ๋ด๋ณด๋ด๊ณ ๋ผ์ฐํธ์์๋ ๋ด๋ณด๋ด์ธ์. ๊ทธ๋ฆฌ๊ณ ํ์ด์ง ์์ฒด๋ ์ฐ๊ฒฐํ๊ฒ ์ต๋๋ค.
export { ArticleReadPage } from "./ui/ArticleReadPage";
export { loader } from "./api/loader";
export { action } from "./api/action";
import { ArticleReadPage } from "pages/article-read";
export { loader, action } from "pages/article-read";
export default ArticleReadPage;
์ด์ ๋ ์ ํ์ด์ง์์ ์ข์์ ๋ฒํผ์ ์์ง ๊ตฌํํ์ง ์์์ง๋ง, ํผ๋์ ์ข์์ ๋ฒํผ์ด ์๋ํ๊ธฐ ์์ํ ๊ฒ์ ๋๋ค! ์ด ๋ผ์ฐํธ๋ก "์ข์์" ์์ฒญ์ ๋ณด๋ด๊ณ ์์๊ธฐ ๋๋ฌธ์ ๋๋ค. ํ๋ฒ ์๋ํด ๋ณด์ธ์.
ArticleMeta
์ Comments
๋ ๋ค์ ํ๋ฒ ํผ๋ค์ ๋ชจ์์
๋๋ค. ์ด์ ์ ์ด๋ฏธ ํด๋ดค์ผ๋, ์ฝ๋๋ฅผ ๊ฐ์ ธ์์ ๋์ด๊ฐ๊ฒ ์ต๋๋ค.
import { Form, Link, useLoaderData } from "@remix-run/react";
import { useContext } from "react";
import { CurrentUser } from "shared/api";
import type { loader } from "../api/loader";
export function ArticleMeta() {
const currentUser = useContext(CurrentUser);
const { article } = useLoaderData<typeof loader>();
return (
<Form method="post">
<div className="article-meta">
<Link
prefetch="intent"
to={`/profile/${article.article.author.username}`}
>
<img src={article.article.author.image} alt="" />
</Link>
<div className="info">
<Link
prefetch="intent"
to={`/profile/${article.article.author.username}`}
className="author"
>
{article.article.author.username}
</Link>
<span className="date">{article.article.createdAt}</span>
</div>
{article.article.author.username == currentUser?.username ? (
<>
<Link
prefetch="intent"
to={`/editor/${article.article.slug}`}
className="btn btn-sm btn-outline-secondary"
>
<i className="ion-edit"></i> Edit Article
</Link>
<button
name="_action"
value="delete"
className="btn btn-sm btn-outline-danger"
>
<i className="ion-trash-a"></i> Delete Article
</button>
</>
) : (
<>
<input
name="username"
value={article.article.author.username}
type="hidden"
/>
<button
name="_action"
value={
article.article.author.following
? "unfollowAuthor"
: "followAuthor"
}
className={`btn btn-sm ${article.article.author.following ? "btn-secondary" : "btn-outline-secondary"}`}
>
<i className="ion-plus-round"></i>
{" "}
{article.article.author.following
? "Unfollow"
: "Follow"}{" "}
{article.article.author.username}
</button>
<button
name="_action"
value={article.article.favorited ? "unfavorite" : "favorite"}
className={`btn btn-sm ${article.article.favorited ? "btn-primary" : "btn-outline-primary"}`}
>
<i className="ion-heart"></i>
{article.article.favorited
? "Unfavorite"
: "Favorite"}{" "}
Post{" "}
<span className="counter">
({article.article.favoritesCount})
</span>
</button>
</>
)}
</div>
</Form>
);
}
import { useContext } from "react";
import { Form, Link, useLoaderData } from "@remix-run/react";
import { CurrentUser } from "shared/api";
import type { loader } from "../api/loader";
export function Comments() {
const { comments } = useLoaderData<typeof loader>();
const currentUser = useContext(CurrentUser);
return (
<div className="col-xs-12 col-md-8 offset-md-2">
{currentUser !== null ? (
<Form
preventScrollReset={true}
method="post"
className="card comment-form"
>
<div className="card-block">
<textarea
required
className="form-control"
name="comment"
placeholder="Write a comment..."
rows={3}
></textarea>
</div>
<div className="card-footer">
<img
src={currentUser.image}
className="comment-author-img"
alt=""
/>
<button
className="btn btn-sm btn-primary"
name="_action"
value="createComment"
>
Post Comment
</button>
</div>
</Form>
) : (
<div className="row">
<div className="col-xs-12 col-md-8 offset-md-2">
<p>
<Link to="/login">Sign in</Link>
or
<Link to="/register">Sign up</Link>
to add comments on this article.
</p>
</div>
</div>
)}
{comments.comments.map((comment) => (
<div className="card" key={comment.id}>
<div className="card-block">
<p className="card-text">{comment.body}</p>
</div>
<div className="card-footer">
<Link
to={`/profile/${comment.author.username}`}
className="comment-author"
>
<img
src={comment.author.image}
className="comment-author-img"
alt=""
/>
</Link>
<Link
to={`/profile/${comment.author.username}`}
className="comment-author"
>
{comment.author.username}
</Link>
<span className="date-posted">{comment.createdAt}</span>
{comment.author.username === currentUser?.username && (
<span className="mod-options">
<Form method="post" preventScrollReset={true}>
<input type="hidden" name="id" value={comment.id} />
<button
name="_action"
value="deleteComment"
style={{
border: "none",
outline: "none",
backgroundColor: "transparent",
}}
>
<i className="ion-trash-a"></i>
</button>
</Form>
</span>
)}
</div>
</div>
))}
</div>
);
}
์ด๊ฒ์ผ๋ก ์ฐ๋ฆฌ์ ๊ธ ์ฝ๊ธฐ ํ์ด์ง๋ ์์ฑ๋์์ต๋๋ค! ์ด์ ์์ฑ์๋ฅผ ํ๋ก์ฐํ๊ณ , ๊ธ์ ์ข์์๋ฅผ ๋๋ฅด๊ณ , ๋๊ธ์ ๋จ๊ธฐ๋ ๋ฒํผ๋ค์ด ์์๋๋ก ์๋ํด์ผ ํฉ๋๋ค.
๊ธ ์์ฑ ํ์ด์งโ
์ด๊ฒ์ ์ด ํํ ๋ฆฌ์ผ์์ ๋ค๋ฃฐ ๋ง์ง๋ง ํ์ด์ง์ด๋ฉฐ, ์ฌ๊ธฐ์ ๊ฐ์ฅ ํฅ๋ฏธ๋ก์ด ๋ถ๋ถ์ ํผ ๋ฐ์ดํฐ๋ฅผ ์ด๋ป๊ฒ ๊ฒ์ฆํ ๊ฒ์ธ๊ฐ ์ ๋๋ค.
ํ์ด์ง ์์ฒด์ธ article-edit/ui/ArticleEditPage.tsx
๋ ๊ฝค ๊ฐ๋จํ ๊ฒ์ด๋ฉฐ, ์ถ๊ฐ์ ์ธ ๋ณต์ก์ฑ์ ๋ค๋ฅธ ๋ ๊ฐ์ ์ปดํฌ๋ํธ๋ก ์จ๊ฒจ์ง ๊ฒ์
๋๋ค.
import { Form, useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { TagsInput } from "./TagsInput";
import { FormErrors } from "./FormErrors";
export function ArticleEditPage() {
const article = useLoaderData<typeof loader>();
return (
<div className="editor-page">
<div className="container page">
<div className="row">
<div className="col-md-10 offset-md-1 col-xs-12">
<FormErrors />
<Form method="post">
<fieldset>
<fieldset className="form-group">
<input
type="text"
className="form-control form-control-lg"
name="title"
placeholder="Article Title"
defaultValue={article.article?.title}
/>
</fieldset>
<fieldset className="form-group">
<input
type="text"
className="form-control"
name="description"
placeholder="What's this article about?"
defaultValue={article.article?.description}
/>
</fieldset>
<fieldset className="form-group">
<textarea
className="form-control"
name="body"
rows={8}
placeholder="Write your article (in markdown)"
defaultValue={article.article?.body}
></textarea>
</fieldset>
<fieldset className="form-group">
<TagsInput
name="tags"
defaultValue={article.article?.tagList ?? []}
/>
</fieldset>
<button className="btn btn-lg pull-xs-right btn-primary">
Publish Article
</button>
</fieldset>
</Form>
</div>
</div>
</div>
</div>
);
}
์ด ํ์ด์ง๋ ํ์ฌ ๊ธ(์๋ก ์์ฑํ๋ ๊ฒฝ์ฐ๊ฐ ์๋๋ผ๋ฉด)์ ๊ฐ์ ธ์์ ํด๋นํ๋ ํผ ํ๋๋ฅผ ์ฑ์๋๋ค. ์ด์ ์ ๋ณธ ์ ์ด ์์ต๋๋ค. ํฅ๋ฏธ๋ก์ด ๋ถ๋ถ์ FormErrors
์ธ๋ฐ, ์ด๋ ๊ฒ์ฆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ์ ์ฌ์ฉ์์๊ฒ ํ์ํ ๊ฒ์
๋๋ค. ํ๋ฒ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
import { useActionData } from "@remix-run/react";
import type { action } from "../api/action";
export function FormErrors() {
const actionData = useActionData<typeof action>();
return actionData?.errors != null ? (
<ul className="error-messages">
{actionData.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null;
}
์ฌ๊ธฐ์๋ ์ฐ๋ฆฌ์ ์ก์
์ด errors
ํ๋, ์ฆ ์ฌ๋์ด ์ฝ์ ์ ์๋ ์ค๋ฅ ๋ฉ์์ง ๋ฐฐ์ด์ ๋ฐํํ ๊ฒ์ด๋ผ๊ณ ๊ฐ์ ํ๊ณ ์์ต๋๋ค. ๊ณง ์ก์
์ ๋ํด ๋ค๋ฃจ๊ฒ ์ต๋๋ค.
๋ ๋ค๋ฅธ ์ปดํฌ๋ํธ๋ ํ๊ทธ ์ ๋ ฅ์ ๋๋ค. ์ด๋ ๋จ์ํ ์ ๋ ฅ ํ๋์ ์ ํ๋ ํ๊ทธ์ ์ถ๊ฐ์ ์ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๊ฐ ์๋ ๊ฒ์ ๋๋ค. ์ฌ๊ธฐ์๋ ํน๋ณํ ๊ฒ์ด ์์ต๋๋ค:
import { useEffect, useRef, useState } from "react";
export function TagsInput({
name,
defaultValue,
}: {
name: string;
defaultValue?: Array<string>;
}) {
const [tagListState, setTagListState] = useState(defaultValue ?? []);
function removeTag(tag: string): void {
const newTagList = tagListState.filter((t) => t !== tag);
setTagListState(newTagList);
}
const tagsInput = useRef<HTMLInputElement>(null);
useEffect(() => {
tagsInput.current && (tagsInput.current.value = tagListState.join(","));
}, [tagListState]);
return (
<>
<input
type="text"
className="form-control"
id="tags"
name={name}
placeholder="Enter tags"
defaultValue={tagListState.join(",")}
onChange={(e) =>
setTagListState(e.target.value.split(",").filter(Boolean))
}
/>
<div className="tag-list">
{tagListState.map((tag) => (
<span className="tag-default tag-pill" key={tag}>
<i
className="ion-close-round"
role="button"
tabIndex={0}
onKeyDown={(e) =>
[" ", "Enter"].includes(e.key) && removeTag(tag)
}
onClick={() => removeTag(tag)}
></i>{" "}
{tag}
</span>
))}
</div>
</>
);
}
์ด์ API ๋ถ๋ถ์ ๋๋ค. ๋ก๋๋ URL์ ์ดํด๋ณด๊ณ , ๊ธ ์ฌ๋ฌ๊ทธ๊ฐ ํฌํจ๋์ด ์๋ค๋ฉด ๊ธฐ์กด ๊ธ์ ์์ ํ๋ ๊ฒ์ด๋ฏ๋ก ํด๋น ๋ฐ์ดํฐ๋ฅผ ๋ก๋ํด์ผ ํฉ๋๋ค. ๊ทธ๋ ์ง ์์ผ๋ฉด ์๋ฌด๊ฒ๋ ๋ฐํํ์ง ์์ต๋๋ค. ๊ทธ ๋ก๋๋ฅผ ๋ง๋ค์ด ๋ด ์๋ค.
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { GET, requireUser } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const currentUser = await requireUser(request);
if (!params.slug) {
return { article: null };
}
return throwAnyErrors(
GET("/articles/{slug}", {
params: { path: { slug: params.slug } },
headers: { Authorization: `Token ${currentUser.token}` },
}),
);
};
์ก์ ์ ์๋ก์ด ํ๋ ๊ฐ๋ค์ ๋ฐ์ ์ฐ๋ฆฌ์ ๋ฐ์ดํฐ ์คํค๋ง๋ฅผ ํตํด ์คํํ๊ณ , ๋ชจ๋ ๊ฒ์ด ์ฌ๋ฐ๋ฅด๋ค๋ฉด ์ด๋ฌํ ๋ณ๊ฒฝ์ฌํญ์ ๋ฐฑ์๋์ ์ปค๋ฐํฉ๋๋ค. ์ด๋ ๊ธฐ์กด ๊ธ์ ์ ๋ฐ์ดํธํ๊ฑฐ๋ ์ ๊ธ์ ์์ฑํ๋ ๋ฐฉ์์ผ๋ก ์ด๋ฃจ์ด์ง๋๋ค.
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { POST, PUT, requireUser } from "shared/api";
import { parseAsArticle } from "../model/parseAsArticle";
export const action = async ({ request, params }: ActionFunctionArgs) => {
try {
const { body, description, title, tags } = parseAsArticle(
await request.formData(),
);
const tagList = tags?.split(",") ?? [];
const currentUser = await requireUser(request);
const payload = {
body: {
article: {
title,
description,
body,
tagList,
},
},
headers: { Authorization: `Token ${currentUser.token}` },
};
const { data, error } = await (params.slug
? PUT("/articles/{slug}", {
params: { path: { slug: params.slug } },
...payload,
})
: POST("/articles", payload));
if (error) {
return json({ errors: error }, { status: 422 });
}
return redirect(`/article/${data.article.slug ?? ""}`);
} catch (errors) {
return json({ errors }, { status: 400 });
}
};
์คํค๋ง๋ FormData
๋ฅผ ์ํ ํ์ฑ ํจ์๋ก๋ ์๋ํ์ฌ, ๊นจ๋ํ ํ๋๋ฅผ ํธ๋ฆฌํ๊ฒ ์ป๊ฑฐ๋ ๋ง์ง๋ง์ ์ฒ๋ฆฌํ ์ค๋ฅ๋ฅผ ๋์ง ์ ์๊ฒ ํด์ค๋๋ค. ๊ทธ ํ์ฑ ํจ์๋ ๋ค์๊ณผ ๊ฐ์ด ๋ณด์ผ ์ ์์ต๋๋ค.
export function parseAsArticle(data: FormData) {
const errors = [];
const title = data.get("title");
if (typeof title !== "string" || title === "") {
errors.push("Give this article a title");
}
const description = data.get("description");
if (typeof description !== "string" || description === "") {
errors.push("Describe what this article is about");
}
const body = data.get("body");
if (typeof body !== "string" || body === "") {
errors.push("Write the article itself");
}
const tags = data.get("tags");
if (typeof tags !== "string") {
errors.push("The tags must be a string");
}
if (errors.length > 0) {
throw errors;
}
return { title, description, body, tags: data.get("tags") ?? "" } as {
title: string;
description: string;
body: string;
tags: string;
};
}
๋ฌผ๋ก ์ด๋ ๋ค์ ๊ธธ๊ณ ๋ฐ๋ณต์ ์ด์ง๋ง, ์ฌ๋์ด ์ฝ์ ์ ์๋ ์ค๋ฅ ๋ฉ์์ง๋ฅผ ์ํด ์ฐ๋ฆฌ๊ฐ ์ง๋ถํด์ผ ํ๋ ๋๊ฐ์ ๋๋ค. ์ด๊ฒ์ Zod ์คํค๋ง์ผ ์๋ ์์ง๋ง, ๊ทธ๋ ๊ฒ ํ๋ฉด ํ๋ก ํธ์๋์์ ์ค๋ฅ ๋ฉ์์ง๋ฅผ ๋ ๋๋งํด์ผ ํ๊ณ , ์ด ํผ์ ๊ทธ๋ฐ ๋ณต์ก์ฑ์ ๊ฐ๋นํ ๋งํ ๊ฐ์น๊ฐ ์์ต๋๋ค.
๋ง์ง๋ง ๋จ๊ณ๋ก - ํ์ด์ง, ๋ก๋, ๊ทธ๋ฆฌ๊ณ ์ก์
์ ๋ผ์ฐํธ์ ์ฐ๊ฒฐํฉ๋๋ค. ์ฐ๋ฆฌ๋ ์์ฑ๊ณผ ํธ์ง์ ๋ชจ๋ ๊น๋ํ๊ฒ ์ง์ํ๋ฏ๋ก editor._index.tsx
์ editor.$slug.tsx
๋ชจ๋์์ ๋์ผํ ๊ฒ์ ๋ด๋ณด๋ผ ์ ์์ต๋๋ค.
export { ArticleEditPage } from "./ui/ArticleEditPage";
export { loader } from "./api/loader";
export { action } from "./api/action";
import { ArticleEditPage } from "pages/article-edit";
export { loader, action } from "pages/article-edit";
export default ArticleEditPage;
์ด์ ์๋ฃ๋์์ต๋๋ค! ๋ก๊ทธ์ธํ๊ณ ์ ๊ธ์ ์์ฑํด๋ณด์ธ์. ๋๋ ๊ธ์ "์์ด๋ฒ๋ฆฌ๊ณ " ๊ฒ์ฆ์ด ์๋ํ๋ ๊ฒ์ ํ์ธํด๋ณด์ธ์.
ํ๋กํ๊ณผ ์ค์ ํ์ด์ง๋ ๊ธ ์ฝ๊ธฐ์ ํธ์ง๊ธฐ ํ์ด์ง์ ๋งค์ฐ ์ ์ฌํ๋ฏ๋ก, ๋ ์์ธ ์ฌ๋ฌ๋ถ์ ์ฐ์ต ๊ณผ์ ๋ก ๋จ๊ฒจ๋๊ฒ ์ต๋๋ค :)