함수나 클래스는 한 가지 일만 수행해야 합니다. 이는 리액트의 컴포넌트도 마찬가지입니다. 컴포넌트 하나가 수행하는 일은 하나인 것이 이상적입니다.
예시를 통해 이해해봅시다.
import React, {useEffect, useReducer, useState} from "react";
const initialState = {
isLoading: true
};
// COMPLEX STATE MANAGEMENT
function reducer(state, action) {
switch (action.type) {
case 'LOADING':
return {isLoading: true};
case 'FINISHED':
return {isLoading: false};
default:
return state;
}
}
export const SingleResponsibilityPrinciple = () => {
const [users , setUsers] = useState([])
const [filteredUsers , setFilteredUsers] = useState([])
const [state, dispatch] = useReducer(reducer, initialState);
const showDetails = (userId) => {
const user = filteredUsers.find(user => user.id===userId);
alert(user.contact)
}
// REMOTE DATA FETCHING
useEffect(() => {
dispatch({type:'LOADING'})
fetch('https://jsonplaceholder.typicode.com/users')
.then(response => response.json())
.then(json => {
dispatch({type:'FINISHED'})
setUsers(json)
})
},[])
// PROCESSING DATA
useEffect(() => {
const filteredUsers = users.map(user => {
return {
id: user.id,
name: user.name,
contact: `${user.phone} , ${user.email}`
};
});
setFilteredUsers(filteredUsers)
},[users])
// COMPLEX UI RENDERING
return <>
<div> Users List</div>
<div> Loading state: {state.isLoading? 'Loading': 'Success'}</div>
{users.map(user => {
return <div key={user.id} onClick={() => showDetails(user.id)}>
<div>{user.name}</div>
<div>{user.email}</div>
</div>
})}
</>
}
이 컴포넌트는 하는 일이 굉장히 많습니다.
하나의 컴포넌트에서 너무 많은 일을 하는 것은 좋은 코드가 아닙니다. 각각을 분리해서 각 파일의 양을 줄이고 재사용할 수 있도록 분리해 주는 것이 단일 책임 원칙을 따르는 것입니다.
소프트 웨어 엔티티는 확장을 위해 열려 있어야 하지만 수정을 위해 닫혀야 합니다. 즉, 소스 코드를 수정하지 않고 확장할 수 있습니다.
리액트에서 이를 어떻게 사용할 지 알아보겠습니다.
import React, { FC } from "react";
interface InputProps {
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
className?: string;
label: string;
}
const Input: FC<InputProps> = ({ label, ...props }) => {
return (
<div>
<label>{label}</label>
<input {...props} />
</div>
);
};
export default Input;
위와 같은 컴포넌트가 있습니다. 나름 standard 하지만 개발을 하다보면 props가 다음처럼 늘어날 수 있습니다.
import React, { FC } from "react";
interface InputProps {
inputRef: React.RefObject<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>;
className?: string;
containerClassName?: string;
formGroupClassName?: string;
type?: "text" | "search" | "password" | "email" | "textarea" | "select";
textareaRows?: number;
name?: string;
value: string;
isTouched: boolean;
onChange: React.ChangeEventHandler<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>;
onInput: React.FormEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onClearClick: React.MouseEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onBlur: React.FocusEventHandler<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>;
onInvalid: React.FormEventHandler<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>;
placeholder?: string;
id: string;
maxLength?: number;
hasClearButton?: boolean;
label?: React.ReactNode;
labelStyle?: "onTop" | "onLeft";
addonLeft?: React.ReactNode;
addonRight?: React.ReactNode;
addonLeftClassName?: string;
addonRightClassName?: string;
helpText?: React.ReactNode;
isShortInput?: boolean;
isLoading?: boolean;
}
const Input: FC<InputProps> = ({
className,
containerClassName,
formGroupClassName,
type = "text",
name,
value = "",
placeholder,
id,
onChange,
onInput,
onInvalid,
onBlur,
maxLength,
hasClearButton,
label,
isTouched,
onClearClick,
addonLeft,
addonRight,
addonLeftClassName,
addonRightClassName,
inputRef,
helpText,
labelStyle,
textareaRows,
isShortInput,
isLoading,
...validationProps
}) => {
return <div><input />...</div>;
};
(물론, props를 이렇게 많이 넘겨줘야 한다면 잘못 짠 코드이지만 예시를 들기 위해 사용했습니다.)
확장 가능하지만 수정하지 않는 컴포넌트를 만드는 방법은 다음처럼 쓸 수 있습니다.
<InputContainer ...props>
<InputLabel ...props />
<InputLeftAddon ...props />
<InputControl ...props />
<InputRightAddon ...props />
</InputContainer>
이렇게 하면 얼마든지 확장할 수 있지만 코드를 직접 수정할 필요가 없습니다.
shape
라는 슈퍼 클래스가 있고 이로 부터 파생된 서브 클래스 T와 S가 있다고 가정합시다. Liskov 대체 원리란, T와 S가 같은 슈퍼 클래스를 공유하는 한 T와 S는 서로 교체할 수 있어야 합니다.
class Shape {
render() {
throw new Error("Cannot render 'Shape'");
}
}
class Square extends Shape {
constructor(height, width) {
this.height = height;
this.width = width;
}
render() {
// psuedocode
Canvas2d
.drawRect(0, 0, height, width)
.fill("white")
.border("1px", "black");
console.log(`Rendering Square (0, 0, ${height}, ${width})`);
}
class Circle extends Shape {
constructor(height, width) {
this.height = height;
this.width = width;
}
render() {
// psuedocode
Canvas2d
.drawCircle(0, 0, height, width)
.fill("white")
.border("1px", "black");
console.log(`Rendering Circle (0, 0, ${height}, ${width})`);
}
}
class ShapeRenderer {
constructor(shape) {
this.shape = shape;
}
setShape(shape) {
this.shape = shape;
}
render() {
this.shape.render();
}
}
// Create our instances of subtype 'Shape'
const mySquare = new Square(5, 5);
const myCircle = new Circle(8, 8);
// Create our instance of renderer
const myRenderer = new ShapeRenderer(mySquare);
myRenderer.render();
myRenderer.setShape(circle);
myRenderer.render();
슈퍼 클래스 Shape
로부터 Square
와 Circle
을 만들고 Renderer
에서 인스턴스를 교체했습니다.
교체할 수 있었던 이유는 둘 다 같은 슈퍼 클래스로부터 파생됐기 때문입니다.
우리가 필요하지 않은 것에 의존해서는 안됩니다. 다시 말해, 자신이 사용하지 않는 기능(인터페이스)에는 영향을 받지 말아야 합니다.
예를 통해 살펴봅시다. 다음 예시는 shape 기능을 유지하면서 하위 타입으로 확장할 수 있기 때문에 Liskov 원칙의 예로 적합합니다.
// General purpose interface
interface Shape {
render(): void;
area(): number;
radius(): number;
}
공통적으로 사용할 수 있는 객체 Shape
가 있고 이 객체 안의 프로퍼티들을 예제처럼 작성했다고 가정합시다.
만약에, 사각형이나 삼각형 객체가 필요하다면 반지름 값인 radius가 있을 수 없습니다. 따라서 인터페이스를 분리할 필요가 있습니다.
이 부분을 수정하면 다음과 같이 고칠 수 있습니다.
interface Shape {
render(): void;
area(): number;
}
interface Circle extends Shape {
radius(): number;
}
이렇게 인터페이스를 분리하면 사각형 객체는 있을 수 없는 radius 값을 필수적으로 구현하지 않도록 합니다.
고수준 모듈이 저수준 모듈의 구현에 의존해서는 안된다.
다시 말하자면, 어플리케이션이 특정한 함수, 인스턴스가 아닌 인터페이스 또는 추상화에 의존해야 합니다.
그런데, 리액트에서 이를 지키기는 쉽지 않습니다. 왜냐하면 우리는 n 개의 컴포넌트를 가지고 하나의 결과를 내기 때문입니다.
의존성 반전을 리액트에서 적용한다면 다음과 같을 수 있습니다.
const Foo = ({ someVal }) => {
return (
<div>{someFilterFn(someval)}</div>
);
}
여기서 someFilterFn() 함수는 외부의 함수, 클래스 혹은 모듈에서 받았다고 가정합시다. 그러면 자식 컴포넌트 부모로부터 받은 someVal과 외부로 받아온someFilterFn 두 가지입니다.
이 경우 filtering 함수를 부모로부터 받아와 자식 컴포넌트의 종속성을 줄일 수 있습니다. 이를 수정하면 다음과 같습니다.
const Foo = ({ callback, someVal }) => {
return (
<div>{callback(someval)}</div>
);
}
필터를 수행하는 로직이 상위 컴포넌트 내에 캡슐화 되어 있기 때문에 구성 요소에 대한 테스트가 단순화됩니다.