From cbff168f3123ca4d7ce1bd2a9ae4511aa1e4c1dc Mon Sep 17 00:00:00 2001 From: jayming66 Date: Wed, 17 Jul 2024 09:58:37 +0900 Subject: [PATCH] docs: Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic --- ...React-Architecture-(Part7)-Domain-Logic.md | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 July/article/Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic.md diff --git a/July/article/Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic.md b/July/article/Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic.md new file mode 100644 index 0000000..62b9686 --- /dev/null +++ b/July/article/Path-To-A-Clean(er)-React-Architecture-(Part7)-Domain-Logic.md @@ -0,0 +1,501 @@ +## ๐Ÿ”— [Path To A Clean(er) React Architecture (Part 7) - Domain Logic](https://overreacted.io/goodbye-clean-code/) + +### ๐Ÿ—“๏ธ ๋ฒˆ์—ญ ๋‚ ์งœ: 2024.07.17 + +### ๐Ÿงš ๋ฒˆ์—ญํ•œ ํฌ๋ฃจ: ๋งˆ์Šคํ„ฐ์œ„(๋ช…์žฌ์œ„) + +--- + +Johannes Kettmann +Updated On July 5, 2024 +Keyword : **React**, **Domain**, **Refactoring**, **Component**, **Structure** + +๋ฆฌ์•กํŠธ์˜ ๋น„์˜์กด์ ์ธ ํŠน์„ฑ์€ ์–‘๋‚ ์˜ ๊ฒ€์ž…๋‹ˆ๋‹ค: + +- ํ•œํŽธ์œผ๋กœ๋Š” ์„ ํƒ์˜ ์ž์œ ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +- ๋‹ค๋ฅธ ํ•œํŽธ์œผ๋กœ๋Š” ๋งŽ์€ ํ”„๋กœ์ ํŠธ๊ฐ€ ์ปค์Šคํ…€ํ™”๋˜๊ณ  ์ข…์ข… ํ˜ผ๋ž€์Šค๋Ÿฌ์šด ์•„ํ‚คํ…์ฒ˜๋กœ ๋๋‚˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. + +์ด ๊ธ€์€ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜์™€ ๋ฆฌ์•กํŠธ ์•ฑ์— ๋Œ€ํ•œ ์‹œ๋ฆฌ์ฆˆ์˜ ์ผ๊ณฑ ๋ฒˆ์งธ ๋ถ€๋ถ„์œผ๋กœ, ๋งŽ์€ ์ž˜๋ชป๋œ ๊ด€ํ–‰์ด ์žˆ๋Š” ์ฝ”๋“œ๋ฒ ์ด์Šค๋ฅผ ๋‹จ๊ณ„์ ์œผ๋กœ ๋ฆฌํŒฉํ† ๋งํ•˜๋Š” ๊ณผ์ •์„ ๋‹ค๋ฃน๋‹ˆ๋‹ค. + +์ด์ „ ๊ธ€๋กœ๋Š”, + +- [we created the initial API layer and extracted fetch functions](https://profy.dev/article/react-architecture-api-layer-and-fetch-functions) +- [added data transformations](https://profy.dev/article/react-architecture-api-layer-and-data-transformations) +- [separated domain entities and DTOs](https://profy.dev/article/react-architecture-domain-entities-and-dtos) +- [introduced infrastructure services using dependency injection and](https://profy.dev/article/react-architecture-infrastructure-services-and-dependency-injection) +- [separated business logic from the components.](https://profy.dev/article/react-architecture-business-logic-and-dependency-injection) + +์ด๊ฒƒ์€ ์šฐ๋ฆฌ์˜ UI ์ฝ”๋“œ๋ฅผ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ถ„๋ฆฌํ•˜๊ณ , ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ UI ํ”„๋ ˆ์ž„์›Œํฌ์™€ ๋…๋ฆฝ์ ์œผ๋กœ ๋งŒ๋“ค๋ฉฐ, ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ์„ ๋†’์ด๋Š” ๋ฐ ๋„์›€์ด ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +ํ•˜์ง€๋งŒ ์•„์ง ๋๋‚˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. + +์ด ๊ธ€์—์„œ๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ํ•ต์‹ฌ์ธ ๋„๋ฉ”์ธ์— ์ดˆ์ ์„ ๋งž์ถฅ๋‹ˆ๋‹ค. + +๋„๋ฉ”์ธ ๋กœ์ง์€ ์‚ฌ์šฉ์ž ๊ฐ์ฒด์™€ ๊ฐ™์€ ๋„๋ฉ”์ธ ๋ชจ๋ธ์—์„œ ์ž‘๋™ํ•˜๋Š” ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค. ์ถ”์ƒ์ ์œผ๋กœ ๋“ค๋ฆด ์ˆ˜ ์žˆ์ง€๋งŒ, ์‹ค์šฉ์ ์ธ ์˜ˆ์ œ๋ฅผ ํ†ตํ•ด ๊ทธ ์˜๋ฏธ๋ฅผ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ๋ชฉํ‘œ๋Š” ์ด ๋กœ์ง์„ ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ถ„๋ฆฌํ•˜๊ณ , ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์˜ ํŠน์ • ์œ„์น˜๋กœ ์ด๋™์‹œํ‚ค๋ฉฐ, ๋‹จ์œ„ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. + +์œ ํ‹ธ๋ฆฌํ‹ฐ ํŒŒ์ผ์ด๋‚˜ ์ปค์Šคํ…€ ํ›…์ด ์•„๋‹Œ ๋‹ค๋ฅธ ๊ณณ์— ํŠน์ • ๋กœ์ง์„ ๋ฐฐ์น˜ํ•  ์ˆ˜ ์žˆ๋Š” ์œ„์น˜๋ฅผ ๊ถ๊ธˆํ•ดํ•œ ์ ์ด ์žˆ๋‹ค๋ฉด, ์ด ๊ธ€์ด ํฅ๋ฏธ๋กœ์šธ ๊ฒƒ์ž…๋‹ˆ๋‹ค. + +[์œ ํŠœ๋ธŒ ๋งํฌ](https://youtu.be/r2zuEqPFDDY) + +## Table of Contents + +- ๋ฌธ์ œ๊ฐ€ ์žˆ๋Š” ์ฝ”๋“œ ์˜ˆ์‹œ: ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์˜ ๋„๋ฉ”์ธ ๋กœ์ง +- ๋ฌธ์ œ: ํ˜ผํ•ฉ๋œ ๊ด€์‹ฌ์‚ฌ์™€ ๋‚ฎ์€ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ +- ํ•ด๊ฒฐ์ฑ…: ๋กœ์ง์„ ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด๋กœ ์ถ”์ถœ + - 1๋‹จ๊ณ„: ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด์— ํ•จ์ˆ˜ ์ƒ์„ฑ + - 2๋‹จ๊ณ„: ์ปดํฌ๋„ŒํŠธ ์—…๋ฐ์ดํŠธ + - 3๋‹จ๊ณ„: ๋„๋ฉ”์ธ ๋กœ์ง ๋‹จ์œ„ ํ…Œ์ŠคํŠธ +- ๋˜ ๋‹ค๋ฅธ ๋ฌธ์ œ ์žˆ๋Š” ์ฝ”๋“œ ์˜ˆ์‹œ + - ๋ฌธ์ œ: ๋„๋ฉ”์ธ ๋กœ์ง๊ณผ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์˜ ํ˜ผํ•ฉ + - ํ•ด๊ฒฐ์ฑ…: ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด์— ํ•จ์ˆ˜ ์ƒ์„ฑ +- ๋„๋ฉ”์ธ ๋กœ์ง ์ถ”์ถœ์˜ ์žฅ๋‹จ์  + - ์žฅ์  + - ๋‹จ์  +- ๋‹ค์Œ ๋ฆฌํŒฉํ† ๋ง ๋‹จ๊ณ„ + +## ๋ฌธ์ œ๊ฐ€ ์žˆ๋Š” ์ฝ”๋“œ ์˜ˆ์‹œ: ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์˜ ๋„๋ฉ”์ธ ๋กœ์ง + +๋ฌธ์ œ๊ฐ€ ์žˆ๋Š” ์ฝ”๋“œ ์˜ˆ์‹œ๋ฅผ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ์€ Shouts(์ผ๋ช… ํŠธ์œ— ๋˜๋Š” ๊ฒŒ์‹œ๋ฌผ) ๋ชฉ๋ก์„ ๋ Œ๋”๋งํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +> ์ด ๋ฆฌํŒฉํ† ๋ง [์ „](https://github.com/jkettmann/react-architecture/tree/81378b16b13f0ff916498e276979a99353f67604)๊ณผ [ํ›„](https://github.com/jkettmann/react-architecture/tree/step-7-domain-logic)์˜ ์†Œ์Šค ์ฝ”๋“œ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ์ด ๊ธ€์˜ ๋ชจ๋“  ๋ณ€๊ฒฝ ์‚ฌํ•ญ์— ๋Œ€ํ•œ ๊ฐœ์š”๋„ [์—ฌ๊ธฐ](https://github.com/jkettmann/react-architecture/pull/14/files/81378b16b13f0ff916498e276979a99353f67604..65afcc8181d9971cebdbbea6b0d3737f8968e9de)์— ์žˆ์Šต๋‹ˆ๋‹ค. + +```tsx +// src/components/shout-list/shout-list.tsx + +import { Shout } from '@/components/shout'; +import { Image } from '@/domain/media'; +import { Shout as IShout } from '@/domain/shout'; +import { User } from '@/domain/user'; + +interface ShoutListProps { + shouts: IShout[]; + images: Image[]; + users: User[]; +} + +export function ShoutList({ shouts, users, images }: ShoutListProps) { + return ( + + ); +} +``` + +- ์ปดํฌ๋„ŒํŠธ๋Š” shouts ๋ชฉ๋ก๊ณผ ํ•ด๋‹น shouts์— ์†ํ•˜๋Š” ์‚ฌ์šฉ์ž ๋ฐ ์ด๋ฏธ์ง€ ๋ชฉ๋ก์„ ๋ฐ›์Šต๋‹ˆ๋‹ค. +- shouts๋ฅผ ๋ฐ˜๋ณตํ•ฉ๋‹ˆ๋‹ค. +- ๊ฐ shout์— ๋Œ€ํ•ด ํ•ด๋‹น ์ž‘์„ฑ์ž์™€ ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. +- ๋งˆ์ง€๋ง‰์œผ๋กœ ๊ฐ shout์— ๋Œ€ํ•œ ๋ฆฌ์ŠคํŠธ ์•„์ดํ…œ ์š”์†Œ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + +## ๋ฌธ์ œ: ํ˜ผํ•ฉ๋œ ๊ด€์‹ฌ์‚ฌ์™€ ๋‚ฎ์€ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ + +์ด ์ปดํฌ๋„ŒํŠธ์˜ ๋ฌธ์ œ๋Š” Shout์˜ ์ž‘์„ฑ์ž์™€ ์ด๋ฏธ์ง€๋ฅผ ์ฐพ๋Š” ๋ถ€๋ถ„์— ์žˆ์Šต๋‹ˆ๋‹ค. + +```tsx +const author = users.find((u) => u.id === shout.authorId); +const image = shout.imageId + ? images.find((i) => i.id === shout.imageId) + : undefined; +``` + +์ด๋Š” ์‚ฌ์šฉ์ž์™€ ์ด๋ฏธ์ง€ ์—”ํ„ฐํ‹ฐ์— ๋Œ€ํ•œ ์ž‘์—…์ž…๋‹ˆ๋‹ค. ์ฆ‰, ์ด ์ฝ”๋“œ๋Š” ์šฐ๋ฆฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์‚ฌ์šฉ์ž์™€ ์ด๋ฏธ์ง€ ์กฐํšŒ๊ฐ€ ์–ด๋–ป๊ฒŒ ์ž‘๋™ํ•˜๋Š”์ง€๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. + +๋˜ํ•œ Shout์— ์ด๋ฏธ์ง€๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์‚ผํ•ญ ์—ฐ์‚ฐ์ž๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์ œ ๊ด€์ ์—์„œ ์ด๋Š” ๊ฐ€์žฅ ์•„๋ฆ„๋‹ต๊ฑฐ๋‚˜ ์ฝ๊ธฐ ์‰ฌ์šด ์ฝ”๋“œ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. + +## ํ•ด๊ฒฐ์ฑ…: ๋กœ์ง์„ ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด๋กœ ์ถ”์ถœ + +[์ด์ „ ๊ธ€์—์„œ](https://profy.dev/article/react-architecture-domain-entities-and-dtos), ์šฐ๋ฆฌ๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ํ•ต์‹ฌ ๋ชจ๋ธ์ธ `User`๋‚˜ `Image`๋ฅผ TypeScript ์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋ณด์œ ํ•˜๋Š” ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด๋ฅผ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ ˆ์ด์–ด๋Š” ๋„๋ฉ”์ธ ์—”ํ„ฐํ‹ฐ์—์„œ ์ž‘๋™ํ•˜๋Š” ๋กœ์ง์„ ๋ฐฐ์น˜ํ•˜๊ธฐ์— ํ›Œ๋ฅญํ•œ ์žฅ์†Œ์ž„์ด ๋“œ๋Ÿฌ๋‚ฌ์Šต๋‹ˆ๋‹ค. + +์˜ˆ๋ฅผ ๋ณด์—ฌ๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค... + +## 1๋‹จ๊ณ„: ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด์— ํ•จ์ˆ˜ ์ƒ์„ฑ + +์œ„์˜ ์˜ˆ์ œ ์ฝ”๋“œ๋ฅผ ๊ฐ€์ง€๊ณ  `getUserById`๋ผ๋Š” ์ƒˆ๋กœ์šด ๋„๋ฉ”์ธ ํ•จ์ˆ˜๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +```tsx +// src/domain/user/user.ts + +export interface User { + id: string; + handle: string; + avatar: string; + info?: string; + blockedUserIds: string[]; + followerIds: string[]; +} + +export function getUserById(users?: User[], userId?: string) { + if (!userId || !users) return; + return users.find((u) => u.id === userId); +} +``` + +์ด ์˜ˆ์ œ์—์„œ ๋‘ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์„ ํƒ ์‚ฌํ•ญ์œผ๋กœ ๋งŒ๋“ค์–ด ํ•จ์ˆ˜์˜ ์œ ์—ฐ์„ฑ์„ ๋†’์˜€์Šต๋‹ˆ๋‹ค. ์ด๋Š” ๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ๋‚˜ `react-query` ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ ํŠนํžˆ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค. + +์ด๋ฏธ์ง€์— ๋Œ€ํ•ด์„œ๋„ ๋™์ผํ•œ ์ž‘์—…์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +```tsx +// src/domain/media/media.ts + +export interface Image { + id: string; + url: string; +} + +export function getImageById(images?: Image[], imageId?: string) { + if (!imageId || !images) return; + return images.find((i) => i.id === imageId); +} +``` + +## 2๋‹จ๊ณ„: ์ปดํฌ๋„ŒํŠธ ์—…๋ฐ์ดํŠธ + +์ด์ œ ์ปดํฌ๋„ŒํŠธ์—์„œ์˜ ์žฅ์ ์ด ๋ช…ํ™•ํ•ด์ง‘๋‹ˆ๋‹ค: + +```tsx +// src/components/shout-list/shout-list.tsx + +import { Shout } from "@/components/shout"; +import { Image, getImageById } from "@/domain/media"; // ์ถ”๊ฐ€ +import { Shout as IShout } from "@/domain/shout"; +import { User, getUserById } from "@/domain/user"; // ์ถ”๊ฐ€ + +... + +export function ShoutList({ shouts, users, images }: ShoutListProps) { + return ( + + ); +} +``` + +1. ์ฝ”๋“œ๋Š” ๋” ์ฝ๊ธฐ ์‰ฌ์›Œ์กŒ์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž์˜ ์ƒ๊ฐ์— `users.find(...)` ํ•จ์ˆ˜๋“ค์„ ID ์กฐํšŒ๋กœ ๋ฒˆ์—ญํ•  ํ•„์š”๊ฐ€ ์—†๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. +2. ์‚ผํ•ญ ์—ฐ์‚ฐ์ž๋ฅผ ์ œ๊ฑฐํ•˜์—ฌ ๊ฐ€๋…์„ฑ์„ ๋”์šฑ ํ–ฅ์ƒ์‹œ์ผฐ์Šต๋‹ˆ๋‹ค. +3. ๋กœ์ง์„ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ํ›จ์”ฌ ๊ฐ„๋‹จํ•ด์กŒ์Šต๋‹ˆ๋‹ค. + +"ํ…Œ์ŠคํŠธํ•˜๊ธฐ ๊ฐ„๋‹จํ•ด์กŒ๋‹ค"๋Š” ๊ฒƒ์€ ๋ฌด์—‡์„ ์˜๋ฏธํ• ๊นŒ์š”? + +## 3๋‹จ๊ณ„: ๋„๋ฉ”์ธ ๋กœ์ง ๋‹จ์œ„ ํ…Œ์ŠคํŠธ + +์›๋ž˜ ์ฝ”๋“œ์—์„œ๋Š” `shouts`, `users`, ๊ทธ๋ฆฌ๊ณ  `images` props์˜ ๋‹ค์–‘ํ•œ ๋ฒ„์ „์„ ์ „๋‹ฌํ•˜์—ฌ ๋กœ์ง์„ ํ…Œ์ŠคํŠธํ•ด์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ์€ ์›๋ž˜ ์ฝ”๋“œ์˜ ๊ฐ„๋‹จํ•œ ๋ณต์Šต์ž…๋‹ˆ๋‹ค: + +```tsx +export function ShoutList({ shouts, users, images }: ShoutListProps) { + return ( + + ); +} +``` + +ํŠนํžˆ `imageId`๋ฅผ ํฌํ•จํ•˜๋Š” Shout์™€ ํฌํ•จํ•˜์ง€ ์•Š๋Š” Shout์— ๋Œ€ํ•ด ๋‹ค๋ฅธ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋ฅผ ์ œ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ž‘์„ฑ์ž๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ์™€ ๊ฐ™์€ ๋‹ค๋ฅธ ๊ทน๋‹จ์ ์ธ ๊ฒฝ์šฐ๋„ ํ…Œ์ŠคํŠธํ•˜๊ณ  ์‹ถ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ ์™ธ์—๋„ React Testing Library์™€ ๊ฐ™์€ ๋„๊ตฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ…Œ์ŠคํŠธํ•ด์•ผ ํ•˜๋ฏ€๋กœ ์ถ”๊ฐ€์ ์ธ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. + +์ด์— ๋น„ํ•ด ์ƒˆ๋กœ์šด ๋„๋ฉ”์ธ ํ•จ์ˆ˜์— ๋Œ€ํ•œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ์€ ๋งค์šฐ ๊ฐ„๋‹จํ•ฉ๋‹ˆ๋‹ค: + +```tsx +// src/domain/media/media.test.ts + +import { describe, expect, it } from 'vitest'; + +import { getImageById } from './media'; + +const mockImage = { + id: '1', + url: 'test', +}; + +describe('Media domain', () => { + describe('getImageById', () => { + it('should be able to get image by id', () => { + const image = getImageById([mockImage], '1'); + expect(image).toEqual(mockImage); + }); + + it('should return undefined if image is not found', () => { + const image = getImageById([{ ...mockImage, id: '2' }], '1'); + expect(image).toEqual(undefined); + }); + + it('should return undefined if provided images are not defined', () => { + const image = getImageById(undefined, '1'); + expect(image).toEqual(undefined); + }); + + it('should return undefined if provided image id is not defined', () => { + const image = getImageById([mockImage], undefined); + expect(image).toEqual(undefined); + }); + }); +}); +``` + +React Testing Library๊ฐ€ ์ž์ฃผ ์š”๊ตฌํ•˜๋Š” ์„ค์ • ์ฝ”๋“œ ์—†์ด ๋ชจ๋“  ๊ฐ€๋Šฅํ•œ ๋ถ„๊ธฐ๋ฅผ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ด๋Ÿฌํ•œ ํ…Œ์ŠคํŠธ๋Š” ๋งค์šฐ ๋น ๋ฅด๊ฒŒ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. + +์ด๋กœ ์ธํ•ด, ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ํ•„์š”ํ•  (๋” ๋น„์‹ผ) ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ค‘ ์ผ๋ถ€๋ฅผ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐํšŒ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + +## ๋˜ ๋‹ค๋ฅธ ๋ฌธ์ œ ์žˆ๋Š” ์ฝ”๋“œ ์˜ˆ์‹œ + +์ด ์ ‘๊ทผ ๋ฐฉ์‹์„ ๋˜ ๋‹ค๋ฅธ ์˜ˆ์ œ๋กœ ํ™•๊ณ ํžˆ ํ•ด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. [์ด์ „ ๊ธ€์—์„œ ์šฐ๋ฆฌ๋Š” ์ปดํฌ๋„ŒํŠธ์—์„œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ œ๊ฑฐํ•˜๋Š” ๋ฐฉ๋ฒ•](https://profy.dev/article/react-architecture-business-logic-and-dependency-injection)์œผ๋กœ use-case ํ•จ์ˆ˜๋ฅผ ๋…ธ์ถœํ•˜๋Š” ํ›…์„ ๋‹ค๋ฃจ์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ํ•จ์ˆ˜๋Š” + +- ๋จผ์ € ๋ช‡ ์ค„์˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋กœ์ง์„ ์‹คํ–‰ํ•˜๊ณ , +- ๊ทธ ๋‹ค์Œ ์ผ๋ จ์˜ ์„œ๋น„์Šค ํ˜ธ์ถœ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +```tsx +// src/application/reply-to-shout/reply-to-shout.ts + +import { useCallback } from "react"; + +import MediaService from "@/infrastructure/media"; +import ShoutService from "@/infrastructure/shout"; +import UserService from "@/infrastructure/user"; + +... + +export async function replyToShout( + { recipientHandle, shoutId, message, files }: ReplyToShoutInput, + { getMe, getUser, saveImage, createReply, createShout }: typeof dependencies +) { + const me = await getMe(); + if (me.numShoutsPastDay >= 5) { + return { error: ErrorMessages.TooManyShouts }; + } + + const recipient = await getUser(recipientHandle); + if (!recipient) { + return { error: ErrorMessages.RecipientNotFound }; + } + if (recipient.blockedUserIds.includes(me.id)) { + return { error: ErrorMessages.AuthorBlockedByRecipient }; + } + + try { + let image; + if (files?.length) { + image = await saveImage(files[0]); + } + + const newShout = await createShout({ + message, + imageId: image?.id, + }); + + await createReply({ + shoutId, + replyId: newShout.id, + }); + + return { error: undefined }; + } catch { + return { error: ErrorMessages.UnknownError }; + } +} + +export function useReplyToShout() { + return useCallback( + (input: ReplyToShoutInput) => replyToShout(input, dependencies), + [] + ); +} +``` + +## ๋ฌธ์ œ: ๋„๋ฉ”์ธ ๋กœ์ง๊ณผ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์˜ ํ˜ผํ•ฉ + +๋ฌธ์ œ์˜ ์ฝ”๋“œ ๋ผ์ธ์€ ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ์— ์žˆ์Šต๋‹ˆ๋‹ค: + +```tsx +const me = await getMe(); +if (me.numShoutsPastDay >= 5) { + // ์—ฌ๊ธฐ + return { error: ErrorMessages.TooManyShouts }; +} + +const recipient = await getUser(recipientHandle); +if (!recipient) { + return { error: ErrorMessages.RecipientNotFound }; +} +if (recipient.blockedUserIds.includes(me.id)) { + // ์—ฌ๊ธฐ + return { error: ErrorMessages.AuthorBlockedByRecipient }; +} +``` + +์ด๋“ค์€ ๋‹ค์‹œ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ(`User`์™€ `Me`(ํ˜„์žฌ ์‚ฌ์šฉ์ž))์— ๋Œ€ํ•œ ์ž‘์—…์ž…๋‹ˆ๋‹ค. + +## ํ•ด๊ฒฐ์ฑ…: ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด์— ํ•จ์ˆ˜ ์ƒ์„ฑ + +์ด ๋กœ์ง์„ ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด๋กœ ์ด๋™ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค: + +```tsx +// src/domain/me/me.ts + +import { User } from '@/domain/user'; + +export const MAX_NUM_SHOUTS_PER_DAY = 5; + +export interface Me extends User { + numShoutsPastDay: number; +} + +export function hasExceededShoutLimit(me: Me) { + return me.numShoutsPastDay >= MAX_NUM_SHOUTS_PER_DAY; +} +``` + +์ด์ œ ๋„๋ฉ”์ธ์€ ์‚ฌ์šฉ์ž๊ฐ€ ์ƒค์šฐํŠธ ์ œํ•œ์„ ์ดˆ๊ณผํ–ˆ๋Š”์ง€ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•˜๋Š” ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด UI์™€ ๊ฐ€๊นŒ์šด ์ฝ”๋“œ์—์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌํ˜„ ์„ธ๋ถ€ ์‚ฌํ•ญ์„ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- ํ—ˆ์šฉ๋œ ์ƒค์šฐํŠธ ์ˆ˜ +- ์ด ์ž„๊ณ„๊ฐ’์˜ ๊ฐ„๊ฒฉ(์ด ๊ฒฝ์šฐ ํ•˜๋ฃจ) + +`blockedUserIds` ์ฒดํฌ๋„ ๋™์ผํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค: + +```tsx +// src/domain/user/user.ts + +export interface User { + id: string; + handle: string; + avatar: string; + info?: string; + blockedUserIds: string[]; + followerIds: string[]; +} + +... + +export function hasBlockedUser(user?: User, userId?: string) { + if (!user || !userId) return false; + return user.blockedUserIds.includes(userId); +} +``` + +์ด์ œ ์œ ์Šค์ผ€์ด์Šค ํ•จ์ˆ˜๋Š” ๋” ๊ฐ„๋‹จํ•˜๊ฒŒ ์ฝํžˆ๋ฉฐ, ๋„๋ฉ”์ธ๊ณผ ๊ด€๋ จ๋œ ๊ตฌํ˜„ ์„ธ๋ถ€ ์‚ฌํ•ญ(์˜ˆ: ์‚ฌ์šฉ์ž๊ฐ€ ์ฃผ์–ด์ง„ ๊ฐ„๊ฒฉ ๋‚ด์— ๋ช‡ ๋ฒˆ ์ƒค์šฐํŠธํ•  ์ˆ˜ ์žˆ๋Š”์ง€)์„ ๋œ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + +```tsx +// src/application/reply-to-shout/reply-to-shout.ts + +import { hasExceededShoutLimit } from "@/domain/me"; +import { hasBlockedUser } from "@/domain/user"; + +export async function replyToShout( + { recipientHandle, shoutId, message, files }: ReplyToShoutInput, + { getMe, getUser, saveImage, createReply, createShout }: typeof dependencies +) { + const me = await getMe(); + if (hasExceededShoutLimit(me)) { // ์—ฌ๊ธฐ + return { error: ErrorMessages.TooManyShouts }; + } + + const recipient = await getUser(recipientHandle); + if (!recipient) { + return { error: ErrorMessages.RecipientNotFound }; + } + if (hasBlockedUser(recipient, me.id)) { // ์—ฌ๊ธฐ + return { error: ErrorMessages.AuthorBlockedByRecipient }; + } +``` + +๋งˆ์ง€๋ง‰์œผ๋กœ, ์šฐ๋ฆฌ๋Š” ์ด ๋กœ์ง์„ ๋‹จ์ˆœํ•œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋กœ ๋‹ค์‹œ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค: + +```tsx +// src/domain/user/user.test.ts + +import { describe, expect, it } from "vitest"; + +import { getUserById, hasBlockedUser } from "./user"; + +const mockUser = { + id: "1", + handle: "test", + avatar: "test", + numShoutsPastDay: 0, + blockedUserIds: [], + followerIds: [], +}; + +describe("User domain", () => { + describe("getUserById", () => { ... }); + + describe("hasBlockedUser", () => { + it("should be false if user has not blocked the user", () => { + const user = { ...mockUser, blockedUserIds: ["2"] }; + const hasBlocked = hasBlockedUser(user, "3"); + expect(hasBlocked).toEqual(false); + }); + + it("should be true if user has blocked the user", () => { + const user = { ...mockUser, blockedUserIds: ["2"] }; + const hasBlocked = hasBlockedUser(user, "2"); + expect(hasBlocked).toEqual(true); + }); + + it("should be false if user is not defined", () => { + const hasBlocked = hasBlockedUser(undefined, "2"); + expect(hasBlocked).toEqual(false); + }); + + it("should be false if user id is not defined", () => { + const hasBlocked = hasBlockedUser(mockUser, undefined); + expect(hasBlocked).toEqual(false); + }); + }); +}); +``` + +## ๋„๋ฉ”์ธ ๋กœ์ง ์ถ”์ถœ์˜ ์žฅ๋‹จ์  + +์ด ์ ‘๊ทผ ๋ฐฉ์‹์˜ ์žฅ๋‹จ์ ์„ ๊ฐ„๋žตํžˆ ๋…ผ์˜ํ•ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. + +### ์žฅ์  + +- ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ ๊ฐ์†Œ: ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด๊ฐ€ ์—†์œผ๋ฉด ์œ„์™€ ๊ฐ™์€ ๋กœ์ง์„ ์–ด๋””์— ๋„ฃ์–ด์•ผ ํ• ์ง€ ๋ถˆ๋ช…ํ™•ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ œ ๊ฒฝํ—˜์ƒ, ์ด๋Ÿฌํ•œ ๋กœ์ง์€ ์ข…์ข… ์ปดํฌ๋„ŒํŠธ์— ํฉ์–ด์ ธ ์žˆ๊ฑฐ๋‚˜ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํŒŒ์ผ์— ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํŒŒ์ผ์€ ๋‹ค์–‘ํ•œ ๊ณต์œ  ์ฝ”๋“œ์˜ ๋คํ•‘์žฅ์ด ๋˜๊ธฐ ์‰ฝ์Šต๋‹ˆ๋‹ค. +- ๊ฐ€๋…์„ฑ: `users.find(({ id }) => id === userId)`๋Š” ID ์กฐํšŒ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ๋ฐ ์•ฝ๊ฐ„์˜ ์ธ์ง€์  ๋ถ€๋‹ด์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋Œ€์‹  `getUserById(users, userId)`๋ฅผ ์ฝ๋Š” ๊ฒƒ์ด ํ›จ์”ฌ ๋” ์„ค๋ช…์ ์ž…๋‹ˆ๋‹ค. ํŠนํžˆ ์ด๋Ÿฌํ•œ ์ค„์ด ์—ฌ๋Ÿฌ ๊ฐœ ๋ชจ์—ฌ ์žˆ์„ ๋•Œ(์˜ˆ: ์ปดํฌ๋„ŒํŠธ ์ƒ๋‹จ) ํšจ๊ณผ์ ์ž…๋‹ˆ๋‹ค. +- ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ: if/switch ๋ฌธ์ด๋‚˜ ์‚ผํ•ญ ์—ฐ์‚ฐ์ž๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ž์ฃผ ์ฐพ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ๊ฐ๊ฐ์˜ ๊ฒฝ์šฐ๋Š” ์—ฌ๋Ÿฌ ํ…Œ์ŠคํŠธ ๋ถ„๊ธฐ๊ฐ€ ํ•„์š”ํ•จ์„ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค. ๋ชจ๋“  ์—ฃ์ง€ ์ผ€์ด์Šค์— ๋Œ€ํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜๋Š” ๊ฒƒ์ด ํ›จ์”ฌ ๋” ์‰ฝ๊ณ , ๊ผญ ํ•„์š”ํ•œ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ์˜ ์ˆ˜๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- ์žฌ์‚ฌ์šฉ์„ฑ : ์ด๋Ÿฌํ•œ ์ž‘์€ ๋กœ์ง ์กฐ๊ฐ๋“ค์€ ๋ณ„๋„์˜ ํ•จ์ˆ˜๋กœ ์ถ”์ถœํ•  ๊ฐ€์น˜๊ฐ€ ์—†์–ด ๋ณด์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ์ฝ”๋“œ ๋‚ด์—์„œ ๋ฌดํ•œํžˆ ๋ฐ˜๋ณต๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์š”๊ตฌ ์‚ฌํ•ญ์˜ ์ž‘์€ ๋ณ€๊ฒฝ์€ ์‰ฝ๊ฒŒ ๋” ํฐ ๋ฆฌํŒฉํ† ๋ง์œผ๋กœ ์ด์–ด์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅ์„ฑ: `users.find(({ id }) => id === userId)`์™€ ๊ฐ™์€ ์ฝ”๋“œ๋ฅผ ์ „์—ญ ๊ฒ€์ƒ‰ํ•˜๊ธฐ๋Š” ๊ฐ„๋‹จํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ฝ”๋“œ๊ฐ€ ๋‹ค๋ฅธ ๋ณ€์ˆ˜ ์ด๋ฆ„์œผ๋กœ ๋‹ค์–‘ํ•œ ๋ฐฉ์‹์œผ๋กœ ์ž‘์„ฑ๋  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด์— getUserById๋Š” ์ „์—ญ ๊ฒ€์ƒ‰์ด ๊ฐ„๋‹จํ•ฉ๋‹ˆ๋‹ค. + +### ๋‹จ์  + +- ์—ฐ์Šต: ๋ชจ๋“  ๊ฐœ๋ฐœ์ž๊ฐ€ ๋‹ค์–‘ํ•œ ๋กœ์ง์„ ์ƒ๊ฐํ•˜๋Š” ๋ฐ ์ต์ˆ™ํ•œ ๊ฒƒ์€ ์•„๋‹™๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์ฝ”๋“œ ๋ฒ ์ด์Šค๋ฅผ ์ผ๊ด€๋˜๊ฒŒ ์œ ์ง€ํ•˜๋ ค๋ฉด ๋ฌธ์„œํ™”์™€ ๊ต์œก์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- ์˜ค๋ฒ„ํ—ค๋“œ: ์œ„์˜ ์˜ˆ์‹œ ์ค‘ ํ•˜๋‚˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ๋“ฏ์ด, ํŠน์ • ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์š”๊ตฌํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค ๋„๋ฉ”์ธ ํ•จ์ˆ˜๋ฅผ ๋” ์œ ์—ฐํ•˜๊ฒŒ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค(์—ฌ๊ธฐ์„œ `getUserById` ํ•จ์ˆ˜์˜ `users`์™€ `userId` ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์„ ํƒ ์‚ฌํ•ญ์œผ๋กœ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค). ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋กœ ์ด๋Ÿฌํ•œ ๊ฒฝ์šฐ๋ฅผ ์ปค๋ฒ„ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— ๋” ๋งŽ์€ ์ฝ”๋“œ๋ฅผ ๋„์ž…ํ•˜์—ฌ **์œ ์ง€๋ณด์ˆ˜ ๋…ธ๋ ฅ**์ด ์ฆ๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ด๋Š” ์˜ˆ๋ฅผ ๋“ค์–ด ์„œ๋ฒ„์—์„œ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์‘๋‹ต ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ‘์ž๊ธฐ ๋งˆ์ฃผ์ณค์„ ๋•Œ ๋„์›€์ด ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +## ๋‹ค์Œ ๋ฆฌํŒฉํ† ๋ง ๋‹จ๊ณ„ + +์ง€๊ธˆ๊นŒ์ง€๋Š” React ์ž์ฒด ์™ธ์— ๋‹ค๋ฅธ ๋„๊ตฌ๋ฅผ ๋„์ž…ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ œ ๊ด€์ ์—์„œ ์ด๋Š” ์ค‘์š”ํ•œ๋ฐ, ์—ฌ๋Ÿฌ ๊ธฐ์ˆ  ์Šคํƒ์ด ํ˜ผํ•ฉ๋˜๋ฉด ์ง€์‹์„ ๋‹ค๋ฅธ ๊ธฐ์ˆ  ์Šคํƒ์œผ๋กœ ์ด์ „ํ•˜๊ธฐ ์–ด๋ ค์šธ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. + +ํ•˜์ง€๋งŒ ์ด์ œ ์‹ค์ œ ํ”„๋กœ๋•์…˜ React ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ํ˜„์‹ค์„ ์ง์‹œํ•  ๋•Œ์ž…๋‹ˆ๋‹ค. + +ํ˜„์žฅ์—์„œ ๊ฐ€์žฅ ๋งŽ์ด ์‚ฌ์šฉ๋˜๋Š” ๋„๊ตฌ ์ค‘ ํ•˜๋‚˜๋Š” `react-query`(๋˜๋Š” RTK query, SWR๊ณผ ๊ฐ™์€ ๋‹ค๋ฅธ ์„œ๋ฒ„ ์ƒํƒœ ๊ด€๋ฆฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ)์ž…๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ๋„๊ตฌ๋“ค์€ ์šฐ๋ฆฌ์˜ ์•„ํ‚คํ…์ฒ˜์— ์–ด๋–ป๊ฒŒ ์ ํ•ฉํ• ๊นŒ์š”? ๋‹ค์Œ ๊ธ€์—์„œ ๋‹ค๋ฃจ๊ฒ ์Šต๋‹ˆ๋‹ค.