해당 설계 원칙은 객체 지향 프로그래밍에 초점을 두고 설계된 원칙이긴 하지만, 언어에 구애받지 않고 추상화 수준이 높기 때문에 입맛에 맞춰서 함수형 React 코드에 적용할 수 있는 원칙입니다.
하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임을 져야한다.
즉, '모든 함수/모듈/컴포넌트는 정확히 한 가지 작업을 수행해야 한다.' 라는 의미를 가집니다.
코드에서는 단일 책임 원칙을 위해 아래와 같은 사항들을 시도해볼 수 있을 거 같습니다.
아래 코드처럼 검색어 입력하는 UI의 경우 코드를 하나의 컴포넌트로 분리할 수 있을 거 같습니다.
[AS-IS]
const Page = () => {
return (
...
<div className={cx('search_wrap')}>
<div className={cx('search_ip')}>
<div className={cx('search_ip_icon')}>
<SvgIcon id={'ico-search'} width={16} height={16} />
</div>
<input
ref={inputRef}
inputMode="search"
onKeyDown={handleKeyDown}
type="text"
placeholder={t('calendar.searchPlaceholder')}
value={inputText}
onChange={onChangeInput}
/>
<button
type="button"
className={cx('search_ip_delete')}
onClick={onResetInput}
data-attribute={'data_calendar_resetBtn'}
>
<SvgIcon id={'ico-delete'} width={24} height={24} />
</button>
</div>
</div>
)
}
[TO-BE]
const Page = () => {
return (
...
<SearchInput />
)
}
타임존에 맞춰 현재 시간을 알고 싶을 때, 해당 함수는 다른 컴포넌트에서도 쓰일 수 있기 때문에 utils 함수로 빼줍니다.
// utils.ts
export const getTimeZoneDate = (date: string | Date) => {
const originDate = new Date(date).getTime(),
newDateMilliesec = originDate - timeZoneOffsetMillisec;
return new Date(newDateMilliesec);
};
[AS-IS]
//search input
const [inputText, setInputText] = useState("");
/* search input */
const onChangeInput = (e) => {
setInputText(e.target.value);
};
[TO-BE]
import { useState } from "react";
export const useInput = () => {
const [inputText, setInputText] = useState("");
const onChangeInput = (e) => {
setInputText(e.target.value);
};
return {
inputText,
onChangeInput,
};
};
소프트웨어 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
"기능의 작동"이 변경될 수는 있지만 "기능의 작동을 작성한 코드 자체"를 변경하지 않아야 한다는 뜻입니다.
// Button.tsx
import {
HiOutlineArrowNarrowRight,
HiOutlineArrowNarrowLeft,
} from "react-icons/hi";
interface IButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string;
role?: "back" | "forward";
}
export function Button(props: IButtonProps) {
const { text, role } = props;
return (
<button {...props}>
{text}
<div>
{role === "forward" && <HiOutlineArrowNarrowRight />
{role === "back" && <HiOutlineArrowNarrowLeft />}
</div>
</button>
);
}
위의 Button 컴포넌트를 사용할 때, 만약 role이 추가되게 되면 Button 코드에 role === ~~ 이라는 코드를 추가해야될 것입니다.
이는 "기능의 작동을 작성한 코드 자체"를 변경하는 행위가 되는 것입니다.
// Button.tsx
interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string;
icon?: React.ReactNode;
}
export function Button(props: IButtonProps) {
const { text, icon } = props;
return (
<button {...props}>
{text}
<div>{icon}</div>
</button>
);
}
OCP 원칙을 적용하게되면 props로 받는 객체를 통해 "기능의 작동을 작성한 코드 자체"를 변경하는 것이 아닌 "기능의 작동"을 추가하는 코드가 될 수 있습니다.
const Page = () => {
return (
<div>
<Button text="Go Home" icon={<HiOutlineArrowNarrowRight />} />
<Button text="Go Back" icon={<HiOutlineArrowNarrowLeft />} />
</div>
);
};
export default Page;
파생 클래스는 기본 클래스로 대체 가능해야 한다.
클래스를 거의 다루지 않는다면, React에서는 거의 적용할 수 없는 개념입니다. 하지만, React와 같이 쓰이는 Typescript 에서는 Type을 확장하는데 쓰일 수 있습니다.
interface Animal {
name: string;
}
interface Dog extends Animal {
bark: "왈왈";
}
클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 합니다. 즉, 하나의 일반적인 인터페이스보다는 여러 개의 특정 인터페이스가 낫다는 원칙입니다.
컴포넌트나 함수에서 필요로 하지않는 props의 값이 있는 경우, 해당 값으로 인해 문제가 발생할 수 있을 거 같습니다.
type Data = {
name: string
age: number
profileUrl: string
address: string
company: string
}
function Page() {
const [data, setData] = useState<Data[]>([])
return (
<div>
{data.map((item) => (
<Component key={item.name} {...item} />
))}
</div>
)
}
export default Page
interface Props extends Data {}
function Component({ name, profileUrl }: Props) {
return (
<div>
<img src={profileUrl} alt="" />
<p>{name}</p>
</div>
);
}
export default Component;
Component 의 Props 는 Data 타입을 상속 받았습니다. 하지만, 실제로
Component에서 사용하는 값은 name, thumbnail 이 두 값 밖에 없습니다.
이와 같은 설계는 불필요한 의존성이 생기기 때문에, Component 를 사용하는 다른 페이지에서 실제로는 사용되지도 않을 age, address, company 값도 선언해서 사용해야한다는 것입니다. 이는 낭비인 거 같아요.
interface Props extends Pick<Data, "name" | "profileUrl"> {}
function Component({ name, profileUrl }: Props) {
return (
<div>
<img src={profileUrl} alt="" />
<p>{name}</p>
</div>
);
}
export default Component;
Component의 Props 정의를 위와 같이 바꿔주면, name, profileUrl 값만 받아서 쓸 수 있기떄문에 다른 값에 대한 의존성이 사라지게 됩니다.
고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다.
예시 )
A 컴포넌트 , B 컴포넌트, C 컴포넌트가 있다고 할때,
B 컴포넌트는 단순히 페이지의 레이아웃만을 잡아주는 컴포넌트라 따로 필요한 data가 없다. 하지만 C 컴포넌트는 A가 주는 데이터가 필요한 상황일 때, B는 A로 부터 데이터를 받아서 C에게 전달해주어야한다.
구조가 A -> B -> C 이런식으로 이루어진 컴포넌트에서는, A의 props이 C로 전달되기 위해, B로 Prop Drilling이 발생하게 됩니다.
B에게 데이터를 굳이 전달하지 않으면서, 구조를 변경하는 방법이 있습니다.
children 개념을 사용하여 B 컴포넌트에 C컴포넌트를 props로 받아서 전달하는 방식입니다.
// CreateContLink : A
// CreateContBasicLayout : B
// FileUpload : C
const CreateContLink = ({
postId,
onChangePostType,
beforeHistoryChangeCallback,
}: CreateContBasicProps): React.ReactElement => {
...
return (
<>
...
<CreateContBasicLayout
registerPost={registerPost}
postType={getValues(POST_FORM_KEY.POSTTYPE)}
onChangePostType={onChangePostType}
>
<div className={cx('post_type_section')}>
<FileUpload
fileProps={{
maxFiles: 10,
maxFileSize: IMAGE_MAX_SIZE, //bytes
uploadTypeList: {
'image/png': ['.png'],
'image/jpg': ['.jpg'],
'image/jpeg': ['.jpeg'],
},
name: 'uploadImg',
}}
uploadedFiles={images}
error={errors.images?.message ? true : false}
onChangeFiles={onChangeFiles}
/>
</div>
</CreateContBasicLayout>
</>
);
};
export default CreateContLink;
CreateContLink 에서 images 라는 데이터를 굳이 CreateContBasicLayout 를 거쳐서 FileUpload 컴포넌트로 전달하지 않고, CreateContBasicLayout에서 FileUpload를 children으로 받아서 렌더링하고 CreateContLink에서는 FileUpload에게 바로 데이터를 주입할 수 있는 형태로 사용하고 있습니다.
이는 A -> B -> C 방식(props drilling) 에서 A -> C -> B 형태로 의존성이 역전된 형태라고 보시면 될 거 같습니다.
A -> B -> C 방식(props drilling) 형태의 경우, C에서 필요로하는 데이터가 바뀌었을 때 B는 데이터를 사용하지도 않지만 영향을 받아 코드 수정이 필요하게 되지만, A -> C -> B로 바뀌게되면 B는 C로 인한 데이터 변경이 없어지게 됩니다.
SOLID 원칙을 공부하면서 회사 코드를 리팩토링하였는데, 꽤나 효과적이면서도 처음부터 설계를 잘해야겠다는 생각이 강하게 들었다.
Pub팀과 같이 일을하다보니, 코드가 자주 충돌할 때도 있고 나의 생각과 다르게 컴포넌트가 분리되어 있는 경우도 종종 있었다.
코드 리뷰를 할 때, SOLID 원칙을 근거로 수정해보자고 제안하는 것도 하나의 방법이 될 수 있을 거 같다!