Compound Component: ์ฑ…์ž„ ๋ถ„๋ฆฌํ•˜๊ธฐ

SRP: ์™œ ๋‚˜๋ˆ„์ง€?

์ž ์‹œ Java์˜ ๊ฐ์ฒด์ง€ํ–ฅ ์ด์•ผ๊ธฐ๋ฅผ ํ•ด๋ณด์ž.

Clean Architecture์—์„œ๋Š” ๊ฐ์ฒด์ง€ํ–ฅ ์„ค๊ณ„์˜ 5๊ฐ€์ง€ ์›์น™ SOLID๋ฅผ ๋ช…์‹œํ•˜๊ณ  ์žˆ๋‹ค.

5๊ฐœ ์›์น™์˜ ์ดˆ์„ฑ์„ ๋”ฐ์„œ SOLID์ธ๋ฐ, ๊ทธ ์ค‘ S๋ฅผ ๋‹ด๋‹นํ•˜๋Š” SRP(Single Responsibility Principle, ๋‹จ์ผ ์ฑ…์ž„ ์›์น™)์— ๋Œ€ํ•ด์„œ ์•Œ์•„๋ณด์ž.

โ€œA module should be responsible to one, and only one, actor.โ€

์—ฌ๊ธฐ์„œ actor๋Š” ์ฝ”๋“œ ๋ณ€๊ฒฝ์„ ์š”์ฒญํ•˜๋Š” ๋ถ€์„œ์ด๋‹ค.

์ฑ…์˜ ์˜ˆ์‹œ๋ฅผ ๋นŒ๋ฆฌ๋ฉด, ๊ธ‰์—ฌ ์‹œ์Šคํ…œ Employee ํด๋ž˜์Šค์—์„œ CFO, COO, CTO๋Š” ๊ฐ๊ฐ calculatePay(), reportHours(), save() ๋ฉ”์„œ๋“œ์˜ ๋ช…์„ธ๋ฅผ ๊ฐ€์ง„๋‹ค.

actor๊ฐ€ ์„ธ ๋ช…์ด ๋˜๋Š” ๊ฒƒ์ด๋‹ค.

// Clean Architecture ์˜ˆ์‹œ๋ฅผ ์žฌ๊ตฌ์„ฑํ•œ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค.
class Employee {

  // CFO: ๊ธ‰์—ฌ ๊ณ„์‚ฐ
  double calculatePay() {
    double regularHours = getRegularHours();
    return regularHours * hourlyRate;
  }

  // COO: ์—…๋ฌด ์‹œ๊ฐ„
  double reportHours() {
    double regularHours = getRegularHours(); // CFO๋ž‘ ๊ฐ™์€ ๋กœ์ง ๊ณต์œ 
    return regularHours;
  }

  // CTO: DB ์ €์žฅ
  void save() {
    database.save(this);
  }

  // calculatePay์™€ reportHours๊ฐ€ ๊ณต์œ 
  private double getRegularHours() {
    return totalHours - overtimeHours;
  }
}

์ด๋•Œ CFO์˜ ๋ช…์„ธ ๋ณ€๊ฒฝ์œผ๋กœ calculatePay()๋ฅผ ์ˆ˜์ •ํ•˜๋ฉด reportHours()๊ฐ€ ๋‚ด๋ถ€์ ์œผ๋กœ ๊ฐ™์€ ๋กœ์ง์„ ๊ณต์œ ํ•˜๊ณ  ์žˆ๊ธฐ์— COO์˜ ๊ธฐ๋Šฅ reportHours() ๋˜ํ•œ ์˜๋„์น˜ ์•Š๊ฒŒ ์˜ํ–ฅ์„ ๋ฐ›๊ฒŒ ๋œ๋‹ค.

// ๊ฐ actor๋ณ„๋กœ ํด๋ž˜์Šค ๋ถ„๋ฆฌ
class PayCalculator {
  double calculatePay(Employee e) { ... } // CFO actor
}

class HourReporter {
  double reportHours(Employee e) { ... }  // COO actor
}

class EmployeeRepository {
  void save(Employee e) { ... }           // CTO actor
}

// Employee๋Š” ๋ฐ์ดํ„ฐ๋งŒ ๋“ค๊ณ  ์žˆ์Œ
class Employee {
  double totalHours;
  double overtimeHours;
  double hourlyRate;
}

Presentational vs Container: ์–ด๋–ป๊ฒŒ ๋‚˜๋ˆ„์ง€?

์ด๋Ÿฌํ•œ SRP์˜ ์›์น™์„ ์ˆ˜์šฉํ•˜์—ฌ, ํ”„๋ก ํŠธ์—”๋“œ์˜ ์ฑ…์ž„์ธ ํ™”๋ฉด ๊ตฌํ˜„๊ณผ ๋ฐ์ดํ„ฐ์˜ ์ฒ˜๋ฆฌ๋ฅผ ๋ถ„๋ฆฌํ•˜๊ณ ์ž Presentational Pattern, Container Pattern์ด ๋„์ž…๋œ๋‹ค.

์ฆ‰, actor๊ฐ€ ๋‘˜์ด๋ฉด, ์ปดํฌ๋„ŒํŠธ๋„ ๋‘˜๋กœ ๋‚˜๋ˆˆ๋‹ค.

์„œ๋กœ์˜ ์ฑ…์ž„์— ๋Œ€ํ•œ ์ •๋ณด๋งŒ์„ ์†Œ์œ , ์ฒ˜๋ฆฌํ•˜๊ณ , ์ƒ๋Œ€์— ๋Œ€ํ•œ ์ •๋ณด๋Š” ์•Œ์ง€ ๋ชปํ•œ๋‹ค๋Š” ์†Œ๋ฆฌ๋‹ค.

Presentational

  • UI ๊ตฌํ˜„์— ๋Œ€ํ•œ view ์ปดํฌ๋„ŒํŠธ
  • ๋ฐ์ดํ„ฐ๊ฐ€ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌ๋˜๋Š”์ง€ ๋ชจ๋ฅธ๋‹ค.

Container

  • ๋ฐ์ดํ„ฐ ๊ทธ ์ž์ฒด์™€ ๋ฐ์ดํ„ฐ์— ๋”ฐ๋ฅธ ๋กœ์ง์— ๋Œ€ํ•œ model, controller ์ปดํฌ๋„ŒํŠธ
  • UI๊ฐ€ ์–ด๋–ป๊ฒŒ ์ƒ๊ฒผ๋Š”์ง€ ๋ชจ๋ฅธ๋‹ค.

์•„๋ž˜์˜ ์˜ˆ์‹œ๋ฅผ ๋ณด๋ฉฐ ์ดํ•ดํ•ด๋ณด์ž.

// AS-IS
function UserCard({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  if (!user) return <div>๋กœ๋”ฉ ์ค‘...</div>

  return (
    <div className="card">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <span>{user.style}</span>
    </div>
  );
}

์œ„ ์ฝ”๋“œ์—์„œ ๋‹จ์ผ ์ปดํฌ๋„ŒํŠธ์—๋Š” ์„ธ ๋ช…์˜ actor๊ฐ€ ์žˆ๋‹ค.

  • endpoint๊ฐ€ ๋ฐ”๋€Œ๋ฉด fetch ์—…๋ฐ์ดํŠธ
  • ์นด๋“œ ๋””์ž์ธ ์Šคํƒ€์ผ
  • ๋กœ๋”ฉ ์ฒ˜๋ฆฌ ๋ฐฉ์‹์ด ๋ฐ”๋€Œ๋ฉด ์ƒํƒœ ์—…๋ฐ์ดํŠธ
// TO-BE: Presentational
type UserCardProps = {
  avatar: string;
  name: string;
  role: string;
};

function UserCard({ avatar, name, role }: UserCardProps) {
  return (
    <div className="card">
      <img src={avatar} alt={name} />
      <h2>{name}</h2>
      <span>{role}</span>
    </div>
  );
}
// TO-BE: Container
function UserCardContainer({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(setUser);
  }, [userId]);

  if (!user) return <div>๋กœ๋”ฉ ์ค‘...</div>

  return (
    <UserCard
      avatar={user.avatar}
      name={user.name}
      role={user.role}
    />
  );
}

Hook: Container์˜ ๋Œ€์ฒด์ œ

๋‹ค๋งŒ, ์ง€๊ธˆ๋„ ์ด ํŒจํ„ด์„ ์จ์•ผ ํ• ๊นŒ?

โ€œI no longer suggest splitting your components this way.โ€

์ด ๋””์ž์ธ ํŒจํ„ด์„ ๊ณ ์•ˆํ•œ Dan Abramov๊ฐ€ ์—…๋ฐ์ดํŠธํ•œ ๋‚ด์šฉ์ด๋‹ค.

Container์˜ ์—ญํ• ์„ ๋Œ€์ฒดํ•  ์ˆ˜ ์žˆ๋Š” Custom Hook์ด ๋“ฑ์žฅํ–ˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

Hook์€ ๊ธฐ์กด ์ปดํฌ๋„ŒํŠธ์™€ ๋‹ฌ๋ฆฌ ์–ด๋–ค ์ปดํฌ๋„ŒํŠธ์—๋„, ํ˜น์€ ๋‹ค๋ฅธ ๋กœ์ง์—๋„ ์กฐํ•ฉ์ด ์œ ์—ฐํ•˜๊ฒŒ ๊ฐ€๋Šฅํ•˜๊ธฐ์— Container์— ๋น„ํ•ด ๋„์ž…์— ์ด์ ์ด ์žˆ๋‹ค.

// AS-IS
function UserCardContainer({ userId }) {
  const [user, setUser] = useState(null);
  useEffect(() => { fetch(...) }, [userId]);

  return <UserCard {...user} />;
}
// TO-BE
function useUser(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);
  return user;
}
// AS-IS
<UserContainer userId={userId}>
  {(user) => (
    <PostsContainer userId={userId}>
      {(posts) => (
        <OnlineStatusContainer userId={userId}>
          {(isOnline) => (
            <Dashboard user={user} posts={posts} isOnline={isOnline} />
          )}
        </OnlineStatusContainer>
      )}
    </PostsContainer>
  )}
</UserContainer>
// TO-BE
function UserDashboard({ userId }) {
  const user = useUser(userId);
  const posts = useUserPosts(userId);
  const isOnline = useOnlineStatus(userId);

  return (
    <Dashboard
      user={user}
      posts={posts}
      isOnline={isOnline}
    />
  );
}

๊ทธ๋Ÿผ ๋ฌด์˜๋ฏธํ•œ ๋ ˆ๊ฑฐ์‹œ ์•„๋‹Œ๊ฐ€?

๋„์ž… ๋ฐฐ๊ฒฝ์— ์ฃผ๋ชฉํ•  ํ•„์š”๊ฐ€ ์žˆ๋‹ค.

UI ์ปดํฌ๋„ŒํŠธ๋Š” ๋ฐ์ดํ„ฐ์™€ ๊ด€๋ จ๋œ ์ถœ์ฒ˜๋ฅผ ๋ชฐ๋ผ์•ผ ํ•œ๋‹ค๋Š” ์›์น™์€ ์—ฌ์ „ํžˆ ์œ ํšจํ•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

Hook์ด Container์˜ ์—ญํ• ์„ ๋Œ€์ฒดํ–ˆ์„ ๋ฟ, Presentational ์ปดํฌ๋„ŒํŠธ์˜ ๊ฐ€์น˜๋Š” ๋ณ€ํ•˜์ง€ ์•Š์•˜๋‹ค.

Compound Component: ๋ณตํ•ฉํ•œ ๊ตฌ์กฐ๋ฅผ ์šฐ์•„ํ•˜๊ฒŒ

๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ๊ฐ€ Context๋กœ ์ƒํƒœ๋ฅผ ๋“ค๊ณ , ์„œ๋ธŒ ์ปดํฌ๋„ŒํŠธ๋“ค์ด ๊ทธ๊ฑธ ์ง์ ‘ ์†Œ๋น„ํ•˜๋Š” ํŒจํ„ด์ด๋‹ค.

<TodoList>
  <TodoList.Header title="๋‚ด ํ•  ์ผ" />
  <TodoList.AddForm />
  <TodoList.Items />
</TodoList>

TodoList.Header๋‚˜ TodoList.Items๋Š” props๋ฅผ ๋”ฐ๋กœ ๋ฐ›์ง€ ์•Š์•„๋„, ๋‚ด๋ถ€์ ์œผ๋กœ Context์—์„œ ์ƒํƒœ๋ฅผ ๊บผ๋‚ด ์“ด๋‹ค.

์™œ ํ•„์š”ํ•œ๊ฐ€?

// ์ค‘๊ฐ„ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์“ฐ์ง€๋„ ์•Š๋Š” props๋ฅผ ๊ณ„์† ๋‚ด๋ ค์ค˜์•ผ ํ•จ
<TodoView
  todos={todos}
  input={input}
  onInputChange={setInput}
  onAdd={addTodo}
  onToggle={toggleTodo}
  onDelete={deleteTodo}
/>

Custom Hook์œผ๋กœ ๋กœ์ง์€ ๋ถ„๋ฆฌํ–ˆ์ง€๋งŒ, ๊ตฌ์กฐ ์ž์ฒด๋Š” ๊ณ ์ •๋ผ ์žˆ๋‹ค.

๋”ฐ๋ผ์„œ Header๋ฅผ ์•„๋ž˜๋กœ ์˜ฎ๊ธฐ๊ฑฐ๋‚˜, AddForm์„ ์—†์• ๋ ค๋ฉด ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€๋ฅผ ์ˆ˜์ •ํ•ด์•ผ ํ•œ๋‹ค.

Compound Component๋Š” ์ด๊ฑธ ํ•ด๊ฒฐํ•œ๋‹ค.

// ๊ตฌ์กฐ๋ฅผ ์‚ฌ์šฉ์ฒ˜์—์„œ ์ž์œ ๋กญ๊ฒŒ ์กฐํ•ฉ
<TodoList>
  <TodoList.Items />
</TodoList>

<TodoList>
  <TodoList.Header title="์˜ค๋Š˜ ํ•  ์ผ" />
  <TodoList.AddForm />
  <TodoList.Items />
</TodoList>

๋‚ด๋ถ€ ์ˆ˜์ • ์—†์ด ์‚ฌ์šฉํ•˜๋Š” ์ชฝ์—์„œ ๊ตฌ์กฐ๋ฅผ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋‹ค.

HTML์˜ <select> + <option>, <table> + <tr> + <td>๊ฐ€ ๋Œ€ํ‘œ์ ์ธ ์˜ˆ์‹œ๋‹ค.

๊ตฌํ˜„ ๋ฐฉ๋ฒ•

1. Context๋กœ ์ƒํƒœ ๊ณต์œ 

const TodoContext = createContext<TodoContextValue | null>(null);

function useTodoContext() {
  const ctx = useContext(TodoContext);
  if (!ctx) throw new Error("TodoList ์ปดํฌ๋„ŒํŠธ ์•ˆ์—์„œ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.");
  return ctx;
}

2. ๋ถ€๋ชจ๊ฐ€ Context Provider

function TodoList({ initialTodos = [], children }: TodoListProps) {
  const [todos, setTodos] = useState<Todo[]>(initialTodos);

  return (
    <TodoContext.Provider value={{ todos, input, setInput, addTodo, toggleTodo, deleteTodo }}>
      <div>{children}</div>
    </TodoContext.Provider>
  );
}

3. ์„œ๋ธŒ ์ปดํฌ๋„ŒํŠธ๊ฐ€ Context๋ฅผ ์ง์ ‘ ์†Œ๋น„

// props ์—†์ด Context์—์„œ ์ง์ ‘ ๊บผ๋ƒ„
TodoList.Header = function Header({ title }: { title: string }) {
  const { todos } = useTodoContext();
  const done = todos.filter((t) => t.completed).length;

  return (
    <div className="flex items-center justify-between">
      <h2>{title}</h2>
      <span>{done} / {todos.length} ์™„๋ฃŒ</span>
    </div>
  );
};

TodoList.Items = function Items() {
  const { todos, toggleTodo, deleteTodo } = useTodoContext();

  if (todos.length === 0) return <TodoList.Empty />;

  return (
    <ul>
      {todos.map((todo) => (
        <TodoList.Item key={todo.id} todo={todo} onToggle={toggleTodo} onDelete={deleteTodo} />
      ))}
    </ul>
  );
};

props-drilling์ด ์‚ฌ๋ผ์ง„ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...