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.map((shout) => {
+ const author = users.find((u) => u.id === shout.authorId);
+ const image = shout.imageId
+ ? images.find((i) => i.id === shout.imageId)
+ : undefined;
+
+ 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 (
+
+ {shouts.map((shout) => (
+ -
+
+
+ ))}
+
+ );
+}
+```
+
+1. ์ฝ๋๋ ๋ ์ฝ๊ธฐ ์ฌ์์ก์ต๋๋ค. ์ฌ์ฉ์์ ์๊ฐ์ `users.find(...)` ํจ์๋ค์ ID ์กฐํ๋ก ๋ฒ์ญํ ํ์๊ฐ ์๊ธฐ ๋๋ฌธ์
๋๋ค.
+2. ์ผํญ ์ฐ์ฐ์๋ฅผ ์ ๊ฑฐํ์ฌ ๊ฐ๋
์ฑ์ ๋์ฑ ํฅ์์์ผฐ์ต๋๋ค.
+3. ๋ก์ง์ ํ
์คํธํ๊ธฐ ํจ์ฌ ๊ฐ๋จํด์ก์ต๋๋ค.
+
+"ํ
์คํธํ๊ธฐ ๊ฐ๋จํด์ก๋ค"๋ ๊ฒ์ ๋ฌด์์ ์๋ฏธํ ๊น์?
+
+## 3๋จ๊ณ: ๋๋ฉ์ธ ๋ก์ง ๋จ์ ํ
์คํธ
+
+์๋ ์ฝ๋์์๋ `shouts`, `users`, ๊ทธ๋ฆฌ๊ณ `images` props์ ๋ค์ํ ๋ฒ์ ์ ์ ๋ฌํ์ฌ ๋ก์ง์ ํ
์คํธํด์ผ ํ์ต๋๋ค. ๋ค์์ ์๋ ์ฝ๋์ ๊ฐ๋จํ ๋ณต์ต์
๋๋ค:
+
+```tsx
+export function ShoutList({ shouts, users, images }: ShoutListProps) {
+ return (
+
+ {shouts.map((shout) => {
+ const author = users.find((u) => u.id === shout.authorId);
+ const image = shout.imageId
+ ? images.find((i) => i.id === shout.imageId)
+ : undefined;
+
+ 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๊ณผ ๊ฐ์ ๋ค๋ฅธ ์๋ฒ ์ํ ๊ด๋ฆฌ ๋ผ์ด๋ธ๋ฌ๋ฆฌ)์
๋๋ค. ์ด๋ฌํ ๋๊ตฌ๋ค์ ์ฐ๋ฆฌ์ ์ํคํ
์ฒ์ ์ด๋ป๊ฒ ์ ํฉํ ๊น์? ๋ค์ ๊ธ์์ ๋ค๋ฃจ๊ฒ ์ต๋๋ค.