단일 책임 원칙
컴포넌트의 주요 역할을 정확히 파악하는것 컴포넌트가 수행할 핵심 기능을 분리하면 부가적인 기능들을 리팩터링 하고 추상화 하기 쉬워짐
가장 많이 사용되는 기술 2가지는 render prop과 합성(composition)이다. render prop으로 구현된 컴포넌트는 자체적으로 렌더링 로직을 구현하는 대신 리액트 요소를 반환하고 이를 호출하는 함수를 이용한다. 합성은 작고 재사용 가능한 컴포넌트들을 만들어 이들을 조합해 더 복잡한 UI 요소를 만드는 기술이다.
render prop 패턴을 통한 단일 책임 원칙 적용
간단한 함수 컴포넌트인 Title이다.
const Title = () => <div>Title | This is a title</div>이 컴포넌트는 정적인 문자열을 표시할 뿐이다. 다른 제목을 표시하려면 title prop이 필요하다.
const Title = ({ title }: { title: string }) => <div>Title | {title}</div>고차 함수인 아래 코드 조각의 transformer 매개변수를 사용하여 Title 컴포넌트를 다음과 같이 수정할 수 있다.
const Title = ({
title,
transformer,
}: {
title: string;
transformer: (s: string) => string;
}) => <div>Title | {transformer(title)}</div>;고차함수: JS를 포함한 대다수의 프로그래밍 언어에서 어떤 함수가 매개변수로 함수를 전달 받거나, 반환값이 함수이거나 2가지 모두에 해당하면 고차 함수라고 함
타이틀을 단순한 div 태그보다 h3태그로 표현하고 싶다면 JSX요소를 반환하는 리액트 컴포넌트를 넘겨주면 된다.
const Title = ({
title,
render,
}: {
title: string;
render: (s: string) => React.ReactNode;
}) => <div>{render(title)}</div>;render prop을 사용한 코드를 살펴보면 title을 넘겨서 함수를 호출했다.
render prop을 사용하기 위해 다음 코드와 같이 대괄호 안쪽에 익명 함수를 남겼다.
<Title
title="This is a title"
render={(s: string) => {
const formatted = s.toUpperCase();
return <h3>{formatted}</h3>
}}
/>고차 함수의 이름을 꼭 render로 할 필요는 없다. 보다 직관적인 설계를 위해 prop이름을 children으로 바꿔보자.
const Title = ({
title,
children,
}: {
title: string,
children: (s: string) => React.ReactNode;
}) => <div>{children(title)}</div>;이렇게 하면 children을 일반 함수처럼 호출할 수 있다.
<Title title="This is a title">
{(s: string) => {
const formatted = s.toUpperCase();
return <h3>{formatted}</h3>;
}}
</Title>이 패턴은 부모 컴포넌트가 자식 컴포넌트의 렌더링 로직을 제어할 수 있게 하므로 컴포넌트를 더욱 유연하게 하고 재사용할 수 있게 만들어준다. 특히 여러 컴포넌트로 동작을 공유할 때 유용하다.
합성을 통한 단일 책임 원칙 적용
사용자 컴포넌트에 name prop을 전달하면, 아바타 위로 마우스 호버 시 하단에 사용자의 이름을 내용으로 표시하는 툴팁이 보이게 된다.
내부적으로 Avatar는 이를 구현하기 위해 또다른 Tooltip 컴포넌트를 활용한다.
import Tooltip from "./Tooltip";
type AvatarProps = {
name?: string;
role?: string;
url: string;
};
const Avatar = ({ name, role, url }: AvatarProps) => {
if (name) {
return (
<Tooltip name={name} role={role}>
<div className="rounded">
<img src={url} alt={`${name}'s profile`} />
</div>
</Tooltip>
);
};
return (
<div className="rounded">
<img src={url} alt="" />
</div>
);
};Avatar 컴포넌트의 원래 코드는 Tooltip 기능과 밀접하게 연관되어있다. 사용자가 보다 세부적인 툴팁의 기능을 원한다면, 이렇게 강하게 연결되어 있는 코드를 유지하기 쉽지 않다. prop을 추가하여 툴팁의 기능을 수정하기 위해서는 Avatar 컴포넌트 코드를 변경해야 하므로 관리하기 까다로워진다.
단순화된 Avatar 컴포넌트는 다음과 같다.
const Avatar = ({ name = "", url }: AvatarProps) => (
<div className="rounded">
<img src={url} alt={name} title={name} />
</div>
);이렇게 Avatar와 Tooltip 컴포넌트는 각자의 역할을 하면서 서로 합성할 수 있게 되었다. 필요한 경우에 Avatar를 Tooltip으로 감싸서 다음과 같은 형태로 사용할 수 있다.
import Avatar from "./Avatar";
import Tooltip from "./Tooltip";
const MyAvatar = () => (
<Tooltip name="Juntao Qio" role="Software Engineer">
<Avatar
name="Juntao Qio"
url="https://avatars.githubusercontent.com/u/122324"
/>
</Tooltip>
);위 접근 방식으로 Avatar에 영향을 주지 않으면서 Tooltip 기능을 자유롭게 변경하거나 교체 가능하다.
의존관계 역전 원칙
Dependency Inversion Principle은 유지보수 가능하며 유연하고 확장 가능한 소프트웨어를 만들기 위해 필요한 SOLID 5가지 원칙 중 하나로 구체적인 구현 보다는 추상화에 초점을 맞춘다.
의존관계 역전 원칙의 원리
예시는 Application이 EmailNotification과 강하게 연관되어 있다. 알림 전송 방식을 email 에서 SMS로 변경하려면 Application 클래스를 직접 수정해야 한다.
class EmailNotification {
send(message: string, type: string) {
console.log(`Sending email with message: ${messagge}, tyep: ${type}`);
}
}
class Application {
private emailNotification: EmailNotification;
constructor(emailNotifacation: EmailNotification) {
this.emailNotification = emailNotification;
}
process() {
// 사용자 응답에 반응하는 동작을 수행
this.emailNotification.send("Some events happened", "info")
}
}
const app = new Application(new EmailNotification());
app.proccess();이 문제를 해결하기 위해 Notification 인터페이스를 추가하고 EmailNotification을 인터페이스에 맞게 구현한다. 이제 인터페이스 구격을 만족하는 여러개의 구현체를 만들 수 있다. 이제 인터페이스 구격을 만족하는 여러개의 구현체를 만들 수 있다.
interface Notification {
send(message: string, type: string): void;
}
class EmailNotification implements Notification {
send(message: string, type: string) {
console.log(`Sending email with message: ${message}, type: ${type}`);
}
}
class Application {
private notifier: Notification;
constructor(notifier: Notification) {
this.notifier = notifier;
}
process() {
// 사용자 응답에 반응하는 동작을 수행
this.notifier.send("Some event happened", "info");
}
}Application 은 EmailNotification 클래스 대신 Notification 인터페이스에 의존하기 때문에, Notification 인터페이스를 준수하는 어떤 구현체도 사용할 수 있다. 그러므로 쉽게 SMSNotification 클래스로 바꿀 수 있다.
const app = new Application(new EmailNotification());
app.proccess();
// or
cosnt app = new Application(new SMSNotification());
app.proccess();버튼 클릭 로그 수집에 의존관계 역전 원칙 적용하기
애플리케이션 전반에 걸쳐 사용할 범용 버튼 컴포넌트를 만들것이다. 문제는 범용 버튼은 이미 여러 제품에서 사용되고 있고, 모든 곳에서 로그 수집 기능이 필요하지는 않다. 그래서 단순히 onClick 핸들러를 변경하면 많은 사용자에게 불편을 끼칠 수 있다.
const Button = ({ onClick: provided, name, ...rest }: ButtonProps) => {
const onClick = (e) => {
// 로그 분석 서버로 전송하는 이벤트 발생
return provided(e);
};
return <button onClick={onClick} {...rest} />;
};기존의 버튼을 감싸서, 클릭 핸들러를 가로채는 새로운 컴포넌트를 만든다.
import Button from "../button/Button";
const FancyButton = ({
onClick: originalOnClick,
...rest
}: FancyButtonProps) => {
const onClick = (e) => {
// 로그 분석 서버로 전송하는 이벤트 발생
console.log('sending analytics event to a remote server');
return originalOnClick(e);
};
return <Button onClick={onClick} {...rest} />;
};여기서 문제는 Button 컴포넌트를 사용하는 여러 곳에서 유사한 로그 수집 코드들을 포함하게 되어, 코드 베이스에 반복되는 로직이 많아지게 된다. 이러한 중복은 로그 수집 코드를 변경해야 할 때 여러 위치에서 수정해야 하므로 오류 발생 가능성이 높아 바람직하지 않다.
이제 의존관계 역전 원칙을 적용해 보자. 기존의 Button 컴포넌트에 변경이 필요하지만, 로그 수집 이벤트를 컴포넌트 안에서 직접 전송하는 대신에 인터페이스를 분리하여 버튼이 인터페이스에 의존하도록 한다.
Notification 예제와 같이 EmailNotification 은 알림을 전송하는 방법 중 하나이다. 이 예제에서 어떤 구현체는 이벤트를 전송하도록 만들었지만, 로그 수집이 필요 없는 경우에는 빈 구현체를 전달한다.
이렇게 바꾸기 위해서는 새로운 인터페이스 타입을 정의하고, 인터페이스 규격에 맞는 구현체를 담아둘 컨텍스트가 필요하다.
import { createContext } from "react";
export interface InteractionMeasurement {
measure(name: string | undefined, timestamp?: number): void;
}
export default createContext<InteractionMeasurement | null>(null);Button 컴포넌트 안에서 useContext를 통해 정의한 컨텍스트에 접근할 수 있다.
이 방법으로 클릭 추적은 컨텍스트로 추상화하였고, Button컴포넌트는 재사용성과 유지보수성이 향상되었다.
로그 수집 기능을 사용하고 싶다면 Button을 InteractionMeasurement 구현체를 포함하는 컨텍스트 내에서 사용하면 된다.
import InteractionContext, {InteractionMeasurement} from "./InteractionContext";
const Button = ({ name, onClick: providedOnClick, children }: ButtonType) => {
const interactionContext = useContext<InteractionMeasuerment | null>(
InteractionContext
);
const handleClick = useCallback(
(e) => {
interactionContext &&
interactionContext.measure(name, e.timeStamp);
providedOnClick(e);
},
[providedOnClick, interactionContext, name]
);
return <button onClick={handleClick}>{children}</button>;
};또 다른 예제로 InteractionContext 인스턴스 안에서 Button을 사용한 FormApp 애플리케이션이 있다고 가정해 보자.
FormApp 컴포넌트는 자체적인 분석 로직을 context 객체 안의 measure 함수에 정의했다. 그리고 InteractionContext.Provider 를 통해 자식 컴포넌트로 전달한다. form 안의 버튼을 클릭하면 버튼의 onClick 로직이 실행될 뿐만 아니라 measure 함수도 실행되어, 이벤트와 타임스탬프 데이터를 원격 서버에 분석 용도로 전달한다. 이 설정은 Button 컴포넌트에 별도 구현 없이도 컨텍스트 기반의 분석을 가능하게 한다.
import InteractionContext from "./InteractionContext";
import Button from "./AnalyticsButton";
const FormApp = () => {
const context = {
measure: (e: any, t: any) => {
// 이벤트와 발생시각 타임스탬프를 서버로 전송
console.log(`sending to remote server ${e}: ${t}`);
},
};
const onClick = () => {
console.log("submit");
};
return (
<InteractionContext.Provider value={context}>
<form>
<Button name="submit-button" onClick={onClick}>
Submit
</Button>
</form>
</InteractionContext.Provider>
);
};이 방식은 극도의 동적인 유연함을 제공하여 공통 컴포넌트를 설계할 때 매우 유용하다. 코드 재사용성과 시스템의 유지보수성을 높여줄 뿐만 아니라 전체 코드 번들 사이즈를 줄일 수 있다.
명령과 조회 책임 분리 원칙
Command and Query Responsibility Segregation(CQRS)은 소프트웨어 설계에서
메서드나 함수는 시스템의 상태를 수정하는 명령이거나 시스템 상태에 대한 정보를 조회하여 반환하는 쿼리 둘중 하나여야 하며, 2가지가 동시에 수행되지 않아야 한다는 원칙이다.
명령과 조회를 분리하면 컴포넌트 사이에 결합을 분리하여 테스트와 유지보수 및 코드 변경을 쉽게 만든다.
아래 예시는 몇가지 문제가 있다. 한 종류의 상품을 중복해서 담으면 아이템 키의 중복이 발생하고, 고유키 경고가 발생한다. 또한 Remove 버튼을 클릭하면 ID에 해당하는 장바구니의 모든 상품을 삭제해 버린다.
type Item = {
id: string;
name: string;
price: number;
};
const ShoppingApplication = () => {
const [cart, setCart] = useState<Item[]>([]);
const addItemToCart = (item: Item) => {
setCart([...cart, item]);
};
const removeItemFromCart = (id: stirng) => {
setCart(cart.filter((item) => item.id !== id));
};
const totalPrice = cart.reduce((total, item) => total + item.price, 0);
return (
<div>
<ProductList addToCart={addItemToCart} />
<h2>Shopping Cart</h2>
<ul>
{cart.map((item) => (
<li key={item.id}>
{item.name} - {item.price}
<button onClick={() => removeItemFromCart(item.id)}>Remove</button>
</li>
))}
</ul>
<p>Total Price: {totalPrice}</p>
</div>
);
};문제를 수정하려면 새로운 uniqKey 필드를 Item 타입에 추가한다. 그리고 cart 배열에 아이템을 추가하기 전에 고유 키를 생성해야 한다.
const addItemToCart = (item: Item) => {
setCart([...cart, { ...item, uniqKey: `${item.id}-${Date.now()}`}]);
};
const removeitemFromCart = (key: string) => {
setCart(cart.filter((item) => item.uniqKey !== key));
};그리고 장바구니를 렌더하는 방법도 변경한다.
<h2>Shopping Cart</h2>
<ul>
{cart.map((item) => (
<li key={item.uniqKey}>
{item.name} - {item.price}
<button onClick={() => removeItemFromCart(item.uniqKey)}>
Remove
</button>
</li>
))}
</ul>useReducer 훅
useReducer 훅은 컴포넌트에서 상태 관리를 하는 데 사용된다. 특히 다음 상태가 이전의 상태에 의존하여 변하거나, 복잡한 상태로직일 때 유용하다. useReducer 훅은 reduce 함수와 초기 상태를 2개의 인자로 받는다. 그리고 현재 상태와 업데이트를 수행하기 위한 dispatch 메서드를 반환한다.
첫번째 인자인 reduce 함수는 현재 상태와 상태 업데이트에 필요한 정보를 담은 action 객체를 전달 받는다. 이 함수는 액션 타입과 페이로드에 따라 새로운 상태를 반환한다. 두번째 인자인 초기상태는 호출했을 때 초깃값으로 사용된다.
ShoppingCartReducer는 현재 상태와 액션, 이 2개의 인자를 전달받는 함수이다.
- 현재 상태는 ShoppingCartState 타입으로, 아이템 배열과 totalPrice를 포함한다.
- 액션은 ActionType 타입으로, string 타입의 액션과 페이로드로 Item 객체를 포함한다.
이 구조를 사용하여 reducer 함수는 여러 액션에 반응하는 장바구니의 상태를 예측 가능하게 관리한다.
const initState = {
items: [],
totalPrice: 0,
};
type ShoppingCartState = {
items: Item[];
totalPrice: number;
};
type ActionType = {
type: string;
payload: Item;
};
const shoppingCartReducer = (
state: ShoppingCartState = initState,
action: ActionType
) => {
switch (action.type) {
case "ADD_ITEM": {
const item = {
...action.payload,
uniqKey: `${action.payload.id}-${Date.now()}`,
};
return { ...state, items: [...state.items, item] };
}
case "REMOVE_ITEM":
const newItems = state.items.filter(
(item) => item.uniqKey !== action.payload.uniqKey
);
return { ...state, items: newItems };
default:
return state;
}
};컨텍스트 안에서 reducer 함수 사용하기
import React, { createContext, useContext, useReducer } from "react";
import { Item } from "./type";
type ShoppingCartContextType = {
items: Item[];
addItem: (item: Item) => void;
removeItem: (item: Item) => void;
};
const ShoppingCartContext = createContext<ShoppingCartContextType | null>(null);
export const ShoppingCartProvider = ({
children,
}: {
const [state, dispatch] = useReducer(shoppingCartReducer, {
items: [],
totalPrice: 0,
});
const addItem = (item: Item) => {
dispatch({type: ADD_ITEM, payload: item});
};
const removeItem = (item: Item) => {
dispatch({type: REMOVE_ITEM, payload: item});
};
return (
<ShoppingCartContext.Provider value={{items: state.items, addItem, removeItem}}>
{children}
</ShoppingCartContext.Provider>
);
});