Skip to main content

ํŠœํ† ๋ฆฌ์–ผ

Part 1. ์„ค๊ณ„โ€‹

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Real World App์ด๋ผ๊ณ ๋„ ์•Œ๋ ค์ง„ Conduit๋ฅผ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. Conduit๋Š” ๊ธฐ๋ณธ์ ์ธ Medium ํด๋ก ์ž…๋‹ˆ๋‹ค - ๊ธ€์„ ์ฝ๊ณ  ์“ธ ์ˆ˜ ์žˆ์œผ๋ฉฐ ๋‹ค๋ฅธ ์‚ฌ๋žŒ์˜ ๊ธ€์— ๋Œ“๊ธ€์„ ๋‹ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Conduit home page

์ด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ ๋งค์šฐ ์ž‘์€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด๋ฏ€๋กœ ๊ณผ๋„ํ•œ ๋ถ„ํ•ด๋ฅผ ํ”ผํ•˜๊ณ  ๊ฐ„๋‹จํ•˜๊ฒŒ ์œ ์ง€ํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ „์ฒด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ์„ธ ๊ฐœ์˜ ๋ ˆ์ด์–ด์ธ App, Pages, ๊ทธ๋ฆฌ๊ณ  Shared์— ๋งž์ถฐ ๋“ค์–ด๊ฐˆ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š๋‹ค๋ฉด ์šฐ๋ฆฌ๋Š” ๊ณ„์†ํ•ด์„œ ์ถ”๊ฐ€์ ์ธ ๋ ˆ์ด์–ด๋ฅผ ๋„์ž…ํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ค€๋น„๋˜์…จ๋‚˜์š”?

๋จผ์ € ํŽ˜์ด์ง€๋ฅผ ๋‚˜์—ดํ•ด ๋ด…์‹œ๋‹ค.โ€‹

์œ„์˜ ์Šคํฌ๋ฆฐ์ƒท์„ ๋ณด๋ฉด ์ตœ์†Œํ•œ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํŽ˜์ด์ง€๋“ค์ด ์žˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  • ํ™ˆ (๊ธ€ ํ”ผ๋“œ)
  • ๋กœ๊ทธ์ธ ๋ฐ ํšŒ์›๊ฐ€์ž…
  • ๊ธ€ ์ฝ๊ธฐ
  • ๊ธ€ ํŽธ์ง‘๊ธฐ
  • ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ๋ณด๊ธฐ
  • ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ํŽธ์ง‘ (์‚ฌ์šฉ์ž ์„ค์ •)

์ด ํŽ˜์ด์ง€๋“ค ๊ฐ๊ฐ์€ Pages ๋ ˆ์ด์–ด์˜ ๋…๋ฆฝ๋œ ์Šฌ๋ผ์ด์Šค๊ฐ€ ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๊ฐœ์š”์—์„œ ์–ธ๊ธ‰ํ–ˆ๋“ฏ์ด ์Šฌ๋ผ์ด์Šค๋Š” ๋‹จ์ˆœํžˆ ๋ ˆ์ด์–ด ๋‚ด์˜ ํด๋”์ด๊ณ , ๋ ˆ์ด์–ด๋Š” pages์™€ ๊ฐ™์€ ๋ฏธ๋ฆฌ ์ •์˜๋œ ์ด๋ฆ„์„ ๊ฐ€์ง„ ํด๋”์ผ ๋ฟ์ž…๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ์šฐ๋ฆฌ์˜ Pages ํด๋”๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๐Ÿ“‚ pages/
๐Ÿ“ feed/
๐Ÿ“ sign-in/
๐Ÿ“ article-read/
๐Ÿ“ article-edit/
๐Ÿ“ profile/
๐Ÿ“ settings/

Feature-Sliced Design์ด ๊ทœ์ œ๋˜์ง€ ์•Š์€ ์ฝ”๋“œ ๊ตฌ์กฐ์™€ ๋‹ค๋ฅธ ์ฃผ์š” ์ฐจ์ด์ ์€ ํŽ˜์ด์ง€๋“ค์ด ์„œ๋กœ๋ฅผ ์ฐธ์กฐํ•  ์ˆ˜ ์—†๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ฆ‰, ํ•œ ํŽ˜์ด์ง€๊ฐ€ ๋‹ค๋ฅธ ํŽ˜์ด์ง€์˜ ์ฝ”๋“œ๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ด๋Š” ๋ ˆ์ด์–ด์˜ import ๊ทœ์น™ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

์Šฌ๋ผ์ด์Šค์˜ ๋ชจ๋“ˆ์€ ์—„๊ฒฉํžˆ ์•„๋ž˜์— ์žˆ๋Š” ๋ ˆ์ด์–ด์— ์œ„์น˜ํ•œ ๋‹ค๋ฅธ ์Šฌ๋ผ์ด์Šค๋งŒ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด ๊ฒฝ์šฐ ํŽ˜์ด์ง€๋Š” ์Šฌ๋ผ์ด์Šค์ด๋ฏ€๋กœ, ์ด ํŽ˜์ด์ง€ ๋‚ด์˜ ๋ชจ๋“ˆ(ํŒŒ์ผ)์€ ๊ฐ™์€ ๋ ˆ์ด์–ด์ธ Pages๊ฐ€ ์•„๋‹Œ ์•„๋ž˜ ๋ ˆ์ด์–ด์˜ ์ฝ”๋“œ๋งŒ ์ฐธ์กฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ”ผ๋“œ ์ž์„ธํžˆ ๋ณด๊ธฐโ€‹

Anonymous userโ€™s perspective

์ต๋ช… ์‚ฌ์šฉ์ž์˜ ๊ด€์ 

Authenticated userโ€™s perspective

์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ๊ด€์ 

ํ”ผ๋“œ ํŽ˜์ด์ง€์—๋Š” ์„ธ ๊ฐ€์ง€ ๋™์  ์˜์—ญ์ด ์žˆ์Šต๋‹ˆ๋‹ค.

  1. ๋กœ๊ทธ์ธ ์—ฌ๋ถ€๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ๋กœ๊ทธ์ธ ๋งํฌ
  2. ํ”ผ๋“œ์—์„œ ํ•„ํ„ฐ๋ง์„ ํŠธ๋ฆฌ๊ฑฐํ•˜๋Š” ํƒœ๊ทธ ๋ชฉ๋ก
  3. ์ข‹์•„์š” ๋ฒ„ํŠผ์ด ์žˆ๋Š” ํ•˜๋‚˜/๋‘ ๊ฐœ์˜ ๊ธ€ ํ”ผ๋“œ

๋กœ๊ทธ์ธ ๋งํฌ๋Š” ๋ชจ๋“  ํŽ˜์ด์ง€์— ๊ณตํ†ต์ ์ธ ํ—ค๋”์˜ ์ผ๋ถ€์ด๋ฏ€๋กœ ๋‚˜์ค‘์— ๋”ฐ๋กœ ๋‹ค๋ฃจ๊ฒ ์Šต๋‹ˆ๋‹ค.

ํƒœ๊ทธ ๋ชฉ๋กโ€‹

ํƒœ๊ทธ ๋ชฉ๋ก์„ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด์„œ๋Š” ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํƒœ๊ทธ๋ฅผ ๊ฐ€์ ธ์˜ค๊ณ , ๊ฐ ํƒœ๊ทธ๋ฅผ ์นฉ์œผ๋กœ ๋ Œ๋”๋งํ•˜๊ณ , ์„ ํƒ๋œ ํƒœ๊ทธ๋ฅผ ํด๋ผ์ด์–ธํŠธ ์ธก ์ €์žฅ์†Œ์— ์ €์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์ž‘์—…๋“ค์€ ๊ฐ๊ฐ "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์— ์œ ์ง€ํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํผ์ด ์žˆ๋Š” ํŽ˜์ด์ง€ ์ž์„ธํžˆ ๋ณด๊ธฐโ€‹

์ฝ๊ธฐ๊ฐ€ ์•„๋‹Œ ํŽธ์ง‘์„ ์œ„ํ•œ ํŽ˜์ด์ง€๋„ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

Conduit post editor

๊ฐ„๋‹จํ•ด ๋ณด์ด์ง€๋งŒ, ํผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ, ์˜ค๋ฅ˜ ์ƒํƒœ, ๋ฐ์ดํ„ฐ ์ง€์†์„ฑ ๋“ฑ ์•„์ง ํƒ๊ตฌํ•˜์ง€ ์•Š์€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ฐœ๋ฐœ์˜ ์—ฌ๋Ÿฌ ์ธก๋ฉด์„ ํฌํ•จํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

์ด ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค๋ ค๋ฉด Shared์—์„œ ์ผ๋ถ€ ์ž…๋ ฅ๊ณผ ๋ฒ„ํŠผ์„ ๊ฐ€์ ธ์™€ ์ด ํŽ˜์ด์ง€์˜ ui ์„ธ๊ทธ๋จผํŠธ์—์„œ ํผ์„ ๊ตฌ์„ฑํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ ๋‹ค์Œ api ์„ธ๊ทธ๋จผํŠธ์—์„œ ๋ฐฑ์—”๋“œ์— ๊ธ€์„ ์ƒ์„ฑํ•˜๋Š” ๋ณ€๊ฒฝ ์š”์ฒญ์„ ์ •์˜ํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์š”์ฒญ์„ ๋ณด๋‚ด๊ธฐ ์ „์— ์œ ํšจ์„ฑ์„ ๊ฒ€์‚ฌํ•˜๋ ค๋ฉด ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์Šคํ‚ค๋งˆ๊ฐ€ ํ•„์š”ํ•˜๋ฉฐ, ์ด๋ฅผ ์œ„ํ•œ ์ข‹์€ ์œ„์น˜๋Š” ๋ฐ์ดํ„ฐ ๋ชจ๋ธ์ด๊ธฐ ๋•Œ๋ฌธ์— model ์„ธ๊ทธ๋จผํŠธ์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ui ์„ธ๊ทธ๋จผํŠธ์˜ ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ‘œ์‹œํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ฐœ์„ ํ•˜๊ธฐ ์œ„ํ•ด ์šฐ๋ฐœ์ ์ธ ๋ฐ์ดํ„ฐ ์†์‹ค์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ์ž…๋ ฅ์„ ์ง€์†์‹œํ‚ฌ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๊ฒƒ๋„ model ์„ธ๊ทธ๋จผํŠธ์˜ ์ž‘์—…์ž…๋‹ˆ๋‹ค.

์š”์•ฝโ€‹

์šฐ๋ฆฌ๋Š” ์—ฌ๋Ÿฌ ํŽ˜์ด์ง€๋ฅผ ๊ฒ€ํ† ํ•˜๊ณ  ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ์˜ˆ๋น„ ๊ตฌ์กฐ๋ฅผ ๊ฐœ๋žต์ ์œผ๋กœ ์„ค๋ช…ํ–ˆ์Šต๋‹ˆ๋‹ค.

  1. Shared layer
    1. ui๋Š” ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ UI ํ‚คํŠธ๋ฅผ ํฌํ•จํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.
    2. api๋Š” ๋ฐฑ์—”๋“œ์™€์˜ ๊ธฐ๋ณธ์ ์ธ ์ƒํ˜ธ์ž‘์šฉ์„ ํฌํ•จํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.
    3. ๋‚˜๋จธ์ง€๋Š” ํ•„์š”์— ๋”ฐ๋ผ ์ •๋ฆฌ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.
  2. Pages layer โ€” ๊ฐ ํŽ˜์ด์ง€๋Š” ๋ณ„๋„์˜ ์Šฌ๋ผ์ด์Šค์ž…๋‹ˆ๋‹ค.
    1. ui๋Š” ํŽ˜์ด์ง€ ์ž์ฒด์™€ ๋ชจ๋“  ๋ถ€๋ถ„์„ ํฌํ•จํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.
    2. api๋Š” shared/api๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋” ํŠนํ™”๋œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ๋ฅผ ํฌํ•จํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.
    3. 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 ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ๋‚ด์šฉ์„ ๋„ฃ์œผ์„ธ์š”:

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 ํŒŒ์ผ์—์„œ ์ด ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์‹œ ๋‚ด๋ณด๋‚ด์„ธ์š”.

pages/feed/index.ts
export { FeedPage } from "./ui/FeedPage";

์ด์ œ ๋ฃจํŠธ ๊ฒฝ๋กœ์— ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค. Remix์—์„œ ๋ผ์šฐํŒ…์€ ํŒŒ์ผ ๊ธฐ๋ฐ˜์ด๋ฉฐ, ๋ผ์šฐํŠธ ํŒŒ์ผ์€ app/routes ํด๋”์— ์žˆ์–ด Feature-Sliced Design๊ณผ ์ž˜ ๋งž์Šต๋‹ˆ๋‹ค.

app/routes/_index.tsx์—์„œ FeedPage ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”.

app/routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";
import { FeedPage } from "pages/feed";

export const meta: MetaFunction = () => {
return [{ title: "Conduit" }];
};

export default FeedPage;

๊ทธ๋Ÿฐ ๋‹ค์Œ ๊ฐœ๋ฐœ ์„œ๋ฒ„๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์—ด๋ฉด Conduit ๋ฐฐ๋„ˆ๊ฐ€ ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค!

The banner of Conduit

API ํด๋ผ์ด์–ธํŠธโ€‹

RealWorld ๋ฐฑ์—”๋“œ์™€ ํ†ต์‹ ํ•˜๊ธฐ ์œ„ํ•ด Shared์— ํŽธ๋ฆฌํ•œ API ํด๋ผ์ด์–ธํŠธ๋ฅผ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค. ํด๋ผ์ด์–ธํŠธ๋ฅผ ์œ„ํ•œ api์™€ ๋ฐฑ์—”๋“œ ๊ธฐ๋ณธ URL๊ณผ ๊ฐ™์€ ๋ณ€์ˆ˜๋ฅผ ์œ„ํ•œ config, ๋‘ ๊ฐœ์˜ ์„ธ๊ทธ๋จผํŠธ๋ฅผ ๋งŒ๋“œ์„ธ์š”.

npx fsd shared --segments api config

๊ทธ๋Ÿฐ ๋‹ค์Œ shared/config/backend.ts๋ฅผ ๋งŒ๋“œ์„ธ์š”.

shared/config/backend.ts
export const backendBaseUrl = "https://api.realworld.io/api";
shared/config/index.ts
export { backendBaseUrl } from "./backend";

RealWorld ํ”„๋กœ์ ํŠธ๋Š” ํŽธ๋ฆฌํ•˜๊ฒŒ OpenAPI ์‚ฌ์–‘์„ ์ œ๊ณตํ•˜๋ฏ€๋กœ, ํด๋ผ์ด์–ธํŠธ๋ฅผ ์œ„ํ•œ ์ž๋™ ์ƒ์„ฑ ํƒ€์ž…์„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ถ”๊ฐ€ ํƒ€์ž… ์ƒ์„ฑ๊ธฐ๊ฐ€ ํฌํ•จ๋œ openapi-fetch ํŒจํ‚ค์ง€๋ฅผ ์‚ฌ์šฉํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋‹ค์Œ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜์—ฌ ์ตœ์‹  API ํƒ€์ž…์„ ์ƒ์„ฑํ•˜์„ธ์š”.

npm run generate-api-types

์ด๋ ‡๊ฒŒ ํ•˜๋ฉด shared/api/v1.d.ts ํŒŒ์ผ์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. ์ด ํŒŒ์ผ์„ ์‚ฌ์šฉํ•˜์—ฌ shared/api/client.ts์— ํƒ€์ž…์ด ์ง€์ •๋œ API ํด๋ผ์ด์–ธํŠธ๋ฅผ ๋งŒ๋“ค ๊ฒƒ์ž…๋‹ˆ๋‹ค.

shared/api/client.ts
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 });
shared/api/index.ts
export { GET, POST, PUT, DELETE } from "./client";

ํ”ผ๋“œ์˜ ์‹ค์ œ ๋ฐ์ดํ„ฐโ€‹

์ด์ œ ๋ฐฑ์—”๋“œ์—์„œ ๊ฐ€์ ธ์˜จ ๊ธ€์„ ํ”ผ๋“œ์— ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ธ€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๋‹ค์Œ ๋‚ด์šฉ์œผ๋กœ pages/feed/ui/ArticlePreview.tsx๋ฅผ ๋งŒ๋“œ์„ธ์š”.

pages/feed/ui/ArticlePreview.tsx
export function ArticlePreview({ article }) { /* TODO */ }

TypeScript๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์œผ๋ฏ€๋กœ ๊ธ€ ๊ฐ์ฒด์— ํƒ€์ž…์„ ์ง€์ •ํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ v1.d.ts๋ฅผ ์‚ดํŽด๋ณด๋ฉด ๊ธ€ ๊ฐ์ฒด๊ฐ€ components["schemas"]["Article"]์„ ํ†ตํ•ด ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ Shared์— ๋ฐ์ดํ„ฐ ๋ชจ๋ธ์ด ์žˆ๋Š” ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ๋ชจ๋ธ์„ ๋‚ด๋ณด๋‚ด๊ฒ ์Šต๋‹ˆ๋‹ค.

shared/api/models.ts
import type { components } from "./v1";

export type Article = components["schemas"]["Article"];
shared/api/index.ts
export { GET, POST, PUT, DELETE } from "./client";

export type { Article } from "./models";

์ด์ œ ๊ธ€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ปดํฌ๋„ŒํŠธ๋กœ ๋Œ์•„๊ฐ€ ๋ฐ์ดํ„ฐ๋กœ ๋งˆํฌ์—…์„ ์ฑ„์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ ๋‚ด์šฉ์œผ๋กœ ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”.

pages/feed/ui/ArticlePreview.tsx
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 ์„ธ๊ทธ๋จผํŠธ์— ๋„ฃ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

pages/feed/api/loader.ts
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๋ผ๋Š” ์ด๋ฆ„์œผ๋กœ ๋‚ด๋ณด๋‚ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

pages/feed/index.ts
export { FeedPage } from "./ui/FeedPage";
export { loader } from "./api/loader";
app/routes/_index.tsx
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๋ฅผ ๋‹ค์Œ ์ฝ”๋“œ๋กœ ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”.

pages/feed/ui/FeedPage.tsx
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๋ฅผ ๋‹ค์Œ ์ฝ”๋“œ๋กœ ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”.

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๋ฅผ ๋‹ค์Œ ์ฝ”๋“œ๋กœ ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”.

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 ํ•จ์ˆ˜๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณ€๊ฒฝํ•˜์„ธ์š”.

pages/feed/api/loader.ts
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๋Š” ๊ฝค ๊น”๋”ํ•˜์ฃ .

ํŽ˜์ด์ง€๋„ค์ด์…˜โ€‹

๋น„์Šทํ•œ ๋ฐฉ์‹์œผ๋กœ ํŽ˜์ด์ง€๋„ค์ด์…˜์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ง์ ‘ ์‹œ๋„ํ•ด ๋ณด๊ฑฐ๋‚˜ ์•„๋ž˜ ์ฝ”๋“œ๋ฅผ ๋ณต์‚ฌํ•˜์„ธ์š”. ์–ด์ฐจํ”ผ ๋‹น์‹ ์„ ํŒ๋‹จํ•  ์‚ฌ๋žŒ์€ ์—†์Šต๋‹ˆ๋‹ค.

pages/feed/api/loader.ts
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")),
}),
);
};
pages/feed/ui/FeedPage.tsx
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๋ฅผ ๋งŒ๋“œ์„ธ์š”.

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์— ๋งค์šฐ ํŠนํ™”๋œ ๊ฒƒ์ด๋ฏ€๋กœ ๋„ˆ๋ฌด ๊ฑฑ์ •ํ•˜์ง€ ๋งˆ์„ธ์š”. ๊ทธ๋ƒฅ ๋ณต์‚ฌ-๋ถ™์—ฌ๋„ฃ๊ธฐ ํ•˜์„ธ์š”.

shared/api/auth.server.ts
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 ๋ชจ๋ธ๋„ ๋‚ด๋ณด๋‚ด์„ธ์š”.

shared/api/models.ts
import type { components } from "./v1";

export type Article = components["schemas"]["Article"];
export type User = components["schemas"]["User"];

์ด ์ฝ”๋“œ๊ฐ€ ์ž‘๋™ํ•˜๋ ค๋ฉด SESSION_SECRET ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์— .env ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  SESSION_SECRET=์„ ์ž‘์„ฑํ•œ ๋‹ค์Œ ํ‚ค๋ณด๋“œ์—์„œ ๋ฌด์ž‘์œ„๋กœ ํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๊ธด ๋ฌด์ž‘์œ„ ๋ฌธ์ž์—ด์„ ๋งŒ๋“œ์„ธ์š”. ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค.

.env
SESSION_SECRET=dontyoudarecopypastethis

๋งˆ์ง€๋ง‰์œผ๋กœ ์ด ์ฝ”๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด ๊ณต๊ฐœ API์— ์ผ๋ถ€ ๋‚ด๋ณด๋‚ด๊ธฐ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

shared/api/index.ts
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๋ผ๋Š” ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ๋„ฃ์œผ์„ธ์š”.

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: "/",
});
}
};
pages/sign-in/index.ts
export { RegisterPage } from './ui/RegisterPage';
export { register } from './api/register';

๊ฑฐ์˜ ๋‹ค ์™”์Šต๋‹ˆ๋‹ค! ํŽ˜์ด์ง€์™€ ์•ก์…˜์„ /register ๋ผ์šฐํŠธ์— ์—ฐ๊ฒฐํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. app/routes์— register.tsx๋ฅผ ๋งŒ๋“œ์„ธ์š”.

app/routes/register.tsx
import { RegisterPage, register } from "pages/sign-in";

export { register as action };

export default RegisterPage;

์ด์ œ http://localhost:3000/register๋กœ ๊ฐ€๋ฉด ์‚ฌ์šฉ์ž๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค! ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ๋‚˜๋จธ์ง€ ๋ถ€๋ถ„์€ ์•„์ง ์ด์— ๋ฐ˜์‘ํ•˜์ง€ ์•Š์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๊ณง ๊ทธ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๋งค์šฐ ์œ ์‚ฌํ•œ ๋ฐฉ์‹์œผ๋กœ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ง์ ‘ ์‹œ๋„ํ•ด ๋ณด๊ฑฐ๋‚˜ ๊ทธ๋ƒฅ ์ฝ”๋“œ๋ฅผ ๊ฐ€์ ธ์™€์„œ ๊ณ„์† ์ง„ํ–‰ํ•˜์„ธ์š”.

pages/sign-in/api/sign-in.ts
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: "/",
});
}
};
pages/sign-in/ui/SignInPage.tsx
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>
);
}
pages/sign-in/index.ts
export { RegisterPage } from './ui/RegisterPage';
export { register } from './api/register';
export { SignInPage } from './ui/SignInPage';
export { signIn } from './api/sign-in';
app/routes/login.tsx
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๋ฅผ ๋งŒ๋“œ์„ธ์š”.

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>&nbsp;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>&nbsp;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์—์„œ ๋‚ด๋ณด๋‚ด์„ธ์š”.

shared/ui/index.ts
export { Header } from "./Header";

ํ—ค๋”์—์„œ๋Š” shared/api์— ์œ ์ง€๋˜๋Š” ์ปจํ…์ŠคํŠธ์— ์˜์กดํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๊ฒƒ๋„ ๋งŒ๋“œ์„ธ์š”.

shared/api/currentUser.ts
import { createContext } from "react";

import type { User } from "./models";

export const CurrentUser = createContext<User | null>(null);
shared/api/index.ts
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์— ๋‹ค์Œ ๋‚ด์šฉ์„ ๋„ฃ์œผ์„ธ์š”.

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>
);
}

์ด ์‹œ์ ์—์„œ ํ™ˆ ํŽ˜์ด์ง€์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋‚ด์šฉ์ด ํ‘œ์‹œ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.

ํ—ค๋”, ํ”ผ๋“œ, ํƒœ๊ทธ๋ฅผ ํฌํ•จํ•œ Conduit์˜ ํ”ผ๋“œ ํŽ˜์ด์ง€. ํƒญ์€ ์•„์ง ์—†์Šต๋‹ˆ๋‹ค.

ํƒญโ€‹

์ด์ œ ์ธ์ฆ ์ƒํƒœ๋ฅผ ๊ฐ์ง€ํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ํƒญ๊ณผ ๊ธ€ ์ข‹์•„์š”๋ฅผ ๋น ๋ฅด๊ฒŒ ๊ตฌํ˜„ํ•˜์—ฌ ํ”ผ๋“œ ํŽ˜์ด์ง€๋ฅผ ์™„์„ฑํ•ด ๋ด…์‹œ๋‹ค. ๋˜ ๋‹ค๋ฅธ ํผ์ด ํ•„์š”ํ•˜์ง€๋งŒ ์ด ํŽ˜์ด์ง€ ํŒŒ์ผ์ด ๊ฝค ์ปค์ง€๊ณ  ์žˆ์œผ๋ฏ€๋กœ ์ด๋Ÿฌํ•œ ํผ์„ ์ธ์ ‘ํ•œ ํŒŒ์ผ๋กœ ์˜ฎ๊ธฐ๊ฒ ์Šต๋‹ˆ๋‹ค. Tabs.tsx, PopularTags.tsx, Pagination.tsx๋ฅผ ๋‹ค์Œ ๋‚ด์šฉ์œผ๋กœ ๋งŒ๋“ค ๊ฒƒ์ž…๋‹ˆ๋‹ค.

pages/feed/ui/Tabs.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>
);
}
pages/feed/ui/PopularTags.tsx
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>
);
}
pages/feed/ui/Pagination.tsx
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๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”.

pages/feed/ui/FeedPage.tsx
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>
);
}

๋งˆ์ง€๋ง‰์œผ๋กœ ๋กœ๋”๋ฅผ ์—…๋ฐ์ดํŠธํ•˜์—ฌ ์ƒˆ๋กœ์šด ํ•„ํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜์„ธ์š”.

pages/feed/api/loader.ts
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๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณ€๊ฒฝํ•˜์„ธ์š”.

pages/feed/ui/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
pages/article-read/api/loader.ts
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,
}),
),
}),
);
};
pages/article-read/index.ts
export { loader } from "./api/loader";

์ด์ œ /article/:slug ๋ผ์šฐํŠธ์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. article.$slug.tsx๋ผ๋Š” ๋ผ์šฐํŠธ ํŒŒ์ผ์„ ๋งŒ๋“œ์„ธ์š”.

app/routes/article.$slug.tsx
export { loader } from "pages/article-read";

ํŽ˜์ด์ง€ ์ž์ฒด๋Š” ์„ธ ๊ฐ€์ง€ ์ฃผ์š” ๋ธ”๋ก์œผ๋กœ ๊ตฌ์„ฑ๋ฉ๋‹ˆ๋‹ค - ๊ธ€ ํ—ค๋”์™€ ์•ก์…˜(๋‘ ๋ฒˆ ๋ฐ˜๋ณต), ๊ธ€ ๋ณธ๋ฌธ, ๋Œ“๊ธ€ ์„น์…˜์ž…๋‹ˆ๋‹ค. ๋‹ค์Œ์€ ํŽ˜์ด์ง€์˜ ๋งˆํฌ์—…์ž…๋‹ˆ๋‹ค. ํŠน๋ณ„ํžˆ ํฅ๋ฏธ๋กœ์šด ๋‚ด์šฉ์€ ์—†์Šต๋‹ˆ๋‹ค:

pages/article-read/ui/ArticleReadPage.tsx
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๋ฅผ ๋งŒ๋“œ์„ธ์š”:

pages/article-read/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: "/" });
},
});
};

๊ทธ ์Šฌ๋ผ์ด์Šค์—์„œ ์ด๋ฅผ ๋‚ด๋ณด๋‚ด๊ณ  ๋ผ์šฐํŠธ์—์„œ๋„ ๋‚ด๋ณด๋‚ด์„ธ์š”. ๊ทธ๋ฆฌ๊ณ  ํŽ˜์ด์ง€ ์ž์ฒด๋„ ์—ฐ๊ฒฐํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

pages/article-read/index.ts
export { ArticleReadPage } from "./ui/ArticleReadPage";
export { loader } from "./api/loader";
export { action } from "./api/action";
app/routes/article.$slug.tsx
import { ArticleReadPage } from "pages/article-read";

export { loader, action } from "pages/article-read";

export default ArticleReadPage;

์ด์ œ ๋…์ž ํŽ˜์ด์ง€์—์„œ ์ข‹์•„์š” ๋ฒ„ํŠผ์„ ์•„์ง ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์ง€๋งŒ, ํ”ผ๋“œ์˜ ์ข‹์•„์š” ๋ฒ„ํŠผ์ด ์ž‘๋™ํ•˜๊ธฐ ์‹œ์ž‘ํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค! ์ด ๋ผ์šฐํŠธ๋กœ "์ข‹์•„์š”" ์š”์ฒญ์„ ๋ณด๋‚ด๊ณ  ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ํ•œ๋ฒˆ ์‹œ๋„ํ•ด ๋ณด์„ธ์š”.

ArticleMeta์™€ Comments๋Š” ๋‹ค์‹œ ํ•œ๋ฒˆ ํผ๋“ค์˜ ๋ชจ์Œ์ž…๋‹ˆ๋‹ค. ์ด์ „์— ์ด๋ฏธ ํ•ด๋ดค์œผ๋‹ˆ, ์ฝ”๋“œ๋ฅผ ๊ฐ€์ ธ์™€์„œ ๋„˜์–ด๊ฐ€๊ฒ ์Šต๋‹ˆ๋‹ค.

pages/article-read/ui/ArticleMeta.tsx
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>
&nbsp;&nbsp;
<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>
&nbsp;{" "}
{article.article.author.following
? "Unfollow"
: "Follow"}{" "}
{article.article.author.username}
</button>
&nbsp;&nbsp;
<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>
&nbsp; {article.article.favorited
? "Unfavorite"
: "Favorite"}{" "}
Post{" "}
<span className="counter">
({article.article.favoritesCount})
</span>
</button>
</>
)}
</div>
</Form>
);
}
pages/article-read/ui/Comments.tsx
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>
&nbsp; or &nbsp;
<Link to="/register">Sign up</Link>
&nbsp; 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>
&nbsp;
<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 reader with functioning buttons to like and follow

๊ธฐ๋Šฅํ•˜๋Š” ์ข‹์•„์š”์™€ ํŒ”๋กœ์šฐ ๋ฒ„ํŠผ์ด ์žˆ๋Š” ๊ธ€ ์ฝ๊ธฐ ํŽ˜์ด์ง€

๊ธ€ ์ž‘์„ฑ ํŽ˜์ด์ง€โ€‹

์ด๊ฒƒ์€ ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ๋‹ค๋ฃฐ ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์ด๋ฉฐ, ์—ฌ๊ธฐ์„œ ๊ฐ€์žฅ ํฅ๋ฏธ๋กœ์šด ๋ถ€๋ถ„์€ ํผ ๋ฐ์ดํ„ฐ๋ฅผ ์–ด๋–ป๊ฒŒ ๊ฒ€์ฆํ•  ๊ฒƒ์ธ๊ฐ€ ์ž…๋‹ˆ๋‹ค.

ํŽ˜์ด์ง€ ์ž์ฒด์ธ article-edit/ui/ArticleEditPage.tsx๋Š” ๊ฝค ๊ฐ„๋‹จํ•  ๊ฒƒ์ด๋ฉฐ, ์ถ”๊ฐ€์ ์ธ ๋ณต์žก์„ฑ์€ ๋‹ค๋ฅธ ๋‘ ๊ฐœ์˜ ์ปดํฌ๋„ŒํŠธ๋กœ ์ˆจ๊ฒจ์งˆ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

pages/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์ธ๋ฐ, ์ด๋Š” ๊ฒ€์ฆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์•„ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ‘œ์‹œํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•œ๋ฒˆ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

pages/article-edit/ui/FormErrors.tsx
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 ํ•„๋“œ, ์ฆ‰ ์‚ฌ๋žŒ์ด ์ฝ์„ ์ˆ˜ ์žˆ๋Š” ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•  ๊ฒƒ์ด๋ผ๊ณ  ๊ฐ€์ •ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ณง ์•ก์…˜์— ๋Œ€ํ•ด ๋‹ค๋ฃจ๊ฒ ์Šต๋‹ˆ๋‹ค.

๋˜ ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ๋Š” ํƒœ๊ทธ ์ž…๋ ฅ์ž…๋‹ˆ๋‹ค. ์ด๋Š” ๋‹จ์ˆœํ•œ ์ž…๋ ฅ ํ•„๋“œ์— ์„ ํƒ๋œ ํƒœ๊ทธ์˜ ์ถ”๊ฐ€์ ์ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๊ฐ€ ์žˆ๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์—๋Š” ํŠน๋ณ„ํ•œ ๊ฒƒ์ด ์—†์Šต๋‹ˆ๋‹ค:

pages/article-edit/ui/TagsInput.tsx
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์„ ์‚ดํŽด๋ณด๊ณ , ๊ธ€ ์Šฌ๋Ÿฌ๊ทธ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋‹ค๋ฉด ๊ธฐ์กด ๊ธ€์„ ์ˆ˜์ •ํ•˜๋Š” ๊ฒƒ์ด๋ฏ€๋กœ ํ•ด๋‹น ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ์•„๋ฌด๊ฒƒ๋„ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๊ทธ ๋กœ๋”๋ฅผ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

pages/article-edit/api/loader.ts
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}` },
}),
);
};

์•ก์…˜์€ ์ƒˆ๋กœ์šด ํ•„๋“œ ๊ฐ’๋“ค์„ ๋ฐ›์•„ ์šฐ๋ฆฌ์˜ ๋ฐ์ดํ„ฐ ์Šคํ‚ค๋งˆ๋ฅผ ํ†ตํ•ด ์‹คํ–‰ํ•˜๊ณ , ๋ชจ๋“  ๊ฒƒ์ด ์˜ฌ๋ฐ”๋ฅด๋‹ค๋ฉด ์ด๋Ÿฌํ•œ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ๋ฐฑ์—”๋“œ์— ์ปค๋ฐ‹ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Š” ๊ธฐ์กด ๊ธ€์„ ์—…๋ฐ์ดํŠธํ•˜๊ฑฐ๋‚˜ ์ƒˆ ๊ธ€์„ ์ƒ์„ฑํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ด๋ฃจ์–ด์ง‘๋‹ˆ๋‹ค.

pages/article-edit/api/action.ts
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๋ฅผ ์œ„ํ•œ ํŒŒ์‹ฑ ํ•จ์ˆ˜๋กœ๋„ ์ž‘๋™ํ•˜์—ฌ, ๊นจ๋—ํ•œ ํ•„๋“œ๋ฅผ ํŽธ๋ฆฌํ•˜๊ฒŒ ์–ป๊ฑฐ๋‚˜ ๋งˆ์ง€๋ง‰์— ์ฒ˜๋ฆฌํ•  ์˜ค๋ฅ˜๋ฅผ ๋˜์งˆ ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค๋‹ˆ๋‹ค. ๊ทธ ํŒŒ์‹ฑ ํ•จ์ˆ˜๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณด์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

pages/article-edit/model/parseAsArticle.ts
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 ๋ชจ๋‘์—์„œ ๋™์ผํ•œ ๊ฒƒ์„ ๋‚ด๋ณด๋‚ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

pages/article-edit/index.ts
export { ArticleEditPage } from "./ui/ArticleEditPage";
export { loader } from "./api/loader";
export { action } from "./api/action";
app/routes/editor._index.tsx, app/routes/editor.$slug.tsx (same content)
import { ArticleEditPage } from "pages/article-edit";

export { loader, action } from "pages/article-edit";

export default ArticleEditPage;

์ด์ œ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค! ๋กœ๊ทธ์ธํ•˜๊ณ  ์ƒˆ ๊ธ€์„ ์ž‘์„ฑํ•ด๋ณด์„ธ์š”. ๋˜๋Š” ๊ธ€์„ "์žŠ์–ด๋ฒ„๋ฆฌ๊ณ " ๊ฒ€์ฆ์ด ์ž‘๋™ํ•˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•ด๋ณด์„ธ์š”.

The Conduit article editor, with the title field saying โ€œNew articleโ€ and the rest of the fields empty. Above the form there are two errors: โ€œDescribe what this article is aboutโ€ and โ€œWrite the article itselfโ€.

์ œ๋ชฉ ํ•„๋“œ์— "์ƒˆ ๊ธ€"์ด๋ผ๊ณ  ์“ฐ์—ฌ ์žˆ๊ณ  ๋‚˜๋จธ์ง€ ํ•„๋“œ๋Š” ๋น„์–ด ์žˆ๋Š” Conduit ๊ธ€ ํŽธ์ง‘๊ธฐ. ํผ ์œ„์— ๋‘ ๊ฐœ์˜ ์˜ค๋ฅ˜๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. "์ด ๊ธ€์ด ๋ฌด์—‡์— ๊ด€ํ•œ ๊ฒƒ์ธ์ง€ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”", "๊ธ€ ๋ณธ๋ฌธ์„ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”".

ํ”„๋กœํ•„๊ณผ ์„ค์ • ํŽ˜์ด์ง€๋Š” ๊ธ€ ์ฝ๊ธฐ์™€ ํŽธ์ง‘๊ธฐ ํŽ˜์ด์ง€์™€ ๋งค์šฐ ์œ ์‚ฌํ•˜๋ฏ€๋กœ, ๋…์ž์ธ ์—ฌ๋Ÿฌ๋ถ„์˜ ์—ฐ์Šต ๊ณผ์ œ๋กœ ๋‚จ๊ฒจ๋‘๊ฒ ์Šต๋‹ˆ๋‹ค :)