React 객체지향 5원칙 (SOLID)

Junbro·2022년 8월 8일
3

이번 개인 프로젝트(LandSoundScape) 에서 객체 지향 프로그래밍(OOP)의 세계에서 영향력이 큰 SOLID 원칙을 적용시켜 보았다.

1. SOLID?

SOLID는 로버트 마틴이 주장한 유지보수가 쉽고 확장이 용이한 코드를 만들 때 적용하면 좋은 객제 지향 프로그래밍 원칙이다.

주로 객체 지향 언어(C++, Java)에 많이 사용되지만 언어에 상관없이 적용할 수 있는 훌륭한 개발 방법론이고, 객체 지향 설계의 정수라고 할 수 있다.

  • SRP(Single Responsibility Principle): 단일 책임 원칙
  • OCP(Open Closed Priciple): 개방 폐쇄 원칙
  • LSP(Listov Substitution Priciple): 리스코프 치환 원칙
  • ISP(Interface Segregation Principle): 인터페이스 분리 원칙
  • DIP(Dependency Inversion Principle): 의존 역전 원칙

원칙들에 대해서 이해를 돕기 위해 아래의 글을 읽는 것을 추천한다.

객체 지향 설계 5원칙 - SOLID

JS에서는 인터페이스 개념이 존재하지 않고, 리액트에서는 함수형 프로그래밍을 강조하여 조금의 어색한 부분이 있을 수 있지만 이번 프로젝트에서 리액트에 SOLID원칙을 적용시켜 보았다.

1. 단일 책임 원칙(Single responsibility principle)

올바른 객체지향 설계를 위한 원칙 중 하나인 단일 책임 원칙은 모든 클래스는 하나의 책임만 가져야 한다라고 강조한다.

‘책임' 기준의 상당히 모호하고 사람마다 다르게 생각될 수 있다. SOLID 원칙 창시자의 말을 빌리면

같이 수정해야될 것들은 묶고 따로 수정해야될 것들은 분리하는 것

이러한 클래스의 단일 책임 기준을 리액트에서 ‘컴포넌트’에 적용시킬 수 있다. 즉, 컴포넌트는 한 가지 일을 하는게 이상적이라는 원칙을 가지고 설계를 진행할 수 있다.

이번 프로젝트에서 단일 책임 원칙 테크닉을 이용하여 컴포넌트 계층 구족를 나누어 보았다.

이번 개인 프로젝트에서 단일 책임 원칙을 강조하는 리액트에서 추천하는 설계 가이드 ‘React로 사고하기' 과정을 따라 페이지 및 컴포넌트를 설계하였다.

React로 사고하기 - React

메인 페이지 목업

1단계: UI를 컴포넌트 계층 구조로 나누기

  • MainPage: 전체페이지

  • PhtotView: 사진을 보여준다.

  • Header: 페이지 헤더 ( 사진 작성자, 나라, 도시 정보를 보여준다.)

  • AsideButtons: 해당 관심사의 맞는 모달을 띄우는 버튼들의 집합

  • BottomButtons: 페이지 아래에 배치되어 있는 버튼들의 집합

  • MainPage

    • PhotoView
      • Header
      • AsideButtons
        • ProfileIcon
        • UploadIcon
        • BookmarkIcon
        • SearchIcon
      • BottomButtons
        • BookmarkButton
        • NewLandscapeButton
        • VolumeButton

하나의 컴포넌트가 커지게 되었을 때 컴포넌트들의 집합으로 묶고 하나의 일을 담당하는 하위 컴포넌트를 배치하였다.

위의 설계 구조에 따라 실제 개발 시 'MainPage’ 컴포넌트 배치이다.

<PhotoWrapper>
    <PhotoView />
    <MainPageHeader />
    <AsideButtons />
    <BottomButtons />
</PhotoWrapper>

그리고, ‘AsideButtons’ 컴포넌트들의 집합을 하나의 동작을 담당하는 하위 컴포넌트로 분리하였습니다.

<Wrapper>
    <ProfileIcon />
    <UploadIcon />
    <BookmarkIcon />
    <SearchIcon />
</Wrapper>

마찬가지로, ‘BottomButtons’ 컴포넌트들의 집합을 하나의 동작을 담당하는 하위 컴포넌트로 분리하였다.

그 후 ‘React로 사고하기' 가이드에 따라 State를 배치(Recoil을 이용하여 전역으로 관리) 하고, 역방향 데이터 흐름을 추가하였다.

리액트를 초기에 학습할 때 부터 ‘React로 사고하기' 절차를 밝고 개발을 진행하였다.

이 절차에서 컴포넌트 게층 구조를 나눌 때 기준을 ‘단일 책임 원칙'으로 잡았을 때 유지보수에 확실한 장점을 보인다는 것을 느꼈다.

개발을 하는 도중 어떤 기능에 문제가 생긴다면 그 기능을 담당하고 있는 컴포넌트에만 관심을 가지고 수정하면 되기 때문에 커플링을 줄이고, 효율적으로 개발할 수 있었다.

예를 들어 위에 예시에서 제가 개발한 서비스에서 사진을 보여주는 기능에 문제가 생겼을 때 저는 주저하지 않고 ‘PhotoView’ 컴포넌트 코드를 확인하고 이 컴포넌트를 수정할 것이다.

또 하나, 단일 책임 원칙을 잘 나타내는 단어가 있다. 바로 ‘모듈화' 이다. 컴포넌트에 존재하는 큰 덩어리 코드들을 각자에 역할에 따라 분리하는 것이다.

이번 프로젝트에서 GraphQL을 이용하였다. REST API를 사용하였을 때 ‘Axios’ 객체를 이용하여 모듈화를 하였듯이 ‘graphql-request' 객체를 이용하여 모듈화를 진행하였다.

import { GraphQLClient } from "graphql-request";

const endpoint = process.env.REACT_APP_API_SERVER_URL;
const API = new GraphQLClient(endpoint);

export const getRandomPhotoAndBookmarks = async (exceptionId, userId) => {
  const { randomPhoto: photo, user } = await API.request(
    GET_RANDOM_PHOTO_WITH_BOOKMARKS,
    {
      photoId: exceptionId,
      userId,
    },
  );

  return { photo, user };
};

export const getUserBookmarks = async userId => {
  const { user } = await API.request(
    GET_USER_BOOKMARKS,
    {
      userId,
    },
    {
      authorization: `Bearer ${
        JSON.parse(localStorage.getItem("loginData")).accessToken
      }`,
    },
  );

  return user;
};

...

위와 같이 API 통신을 담당하는 모듈을 만들었다.

북마크 컴포넌트에서는 위의 API 모듈과 ‘React Query’를 이용하여 특정 유저의 북마크 리스트를 보여줄 수 있었다.

function Bookmarks() {
  const userData = useRecoilValue(loginState);
  const { data } = useQuery(
    ["getBookmarks", userData._id],
    () => getUserBookmarks(userData._id),
    {
      refetchOnWindowFocus: false,
    },
  );

  return (
    <>
      {data.bookmarks.map(bookmark => (
        <PhotoEntry key={bookmark._id} {...bookmark} />
      ))}
    </>
  );
}

컴포넌트에서는 ‘엔드포인트 설정‘, ‘authorization 설정’ 등 API 관련 추가 조작 없이 데이터를 페칭한 다음 렌더링하고만 있다. 이것은 API 로직, 렌더링 로직을 책임을 분리하였기 때문에 가능한 일이다. 그 결과 코드가 직관적여지고 보다 쉽게 유지 관리할 수 있게 되었다.

(조금 더 개선할 수 있는 여지는 ‘React Query’ 관련 로직도 커스텀 훅으로 분리하여 캡슐화 할 수 있다.)

결과적으로, ‘단일 책임 원칙’은 좋은 설계의 이점을 여과없이 보여주는 테크닉이였다.

2. 개방-폐쇄 원칙 (Open-closed principle, OCP)

개방-폐쇄 원칙은 소프트웨어 구성요소(컴포넌트,클래스,모듈,함수)는 확장을 위해 열려야 하지만 수정을 위해 닫혀 있어야 한다 라고 강조한다.

프로젝트에서 컴포넌트 내부의 코드를 변경하지 않고 확장할 수 있는 방식으로 구조화 시켜보았다.

사실 이 원칙은 ‘컴포넌트의 재사용성’을 생각하면 쉽게 이해할 수 있을 것이다.

거창하게 적용시키지 않고, 우리가 자주쓰는 모달에 이 원칙을 적용시켜 보았다.

먼저, 원칙을 적용시키지 않고 모달을 설계해보자.

export default function Modal({ title, type}) {
  const { hideModal } = useModal();

  return ReactDom.createPortal(
    <ModalOverlay onClick={hideModal}>
      <ModalContainer onClick={e => e.stopPropagation()}>
        <ModalTitle>{title}</ModalTitle>
				{type === "LoginModal" && <LoginView />}
        {type === "UploadModal" && <UploadView />}
				...
      </ModalContainer>
    </ModalOverlay>,
    document.getElementById("portal"),
  );
}

위에 구조에서 아래와 같은 단점이 존재한다.

  • 새로운 모달 타입이 생길 때 마다 Modal 컴포넌트로 돌아가서 조건부 렌더링 로직을 수정해야 한다.
  • 확정성이 고려되지 않았고, 개방-폐쇄 원칙에 위배된다.

이 문제를 해결하기 위해 컴포넌트 합성을 사용할 수 있다. children prop를 사용해서 Modal을 사용할 컴포넌트에게 이 책임을 위임할 수 있다. 그렇다면 더 이상 Modal 컴포넌트는 다른 타입의 모달이 생겨도 수정에는 닫힌 구조를 가질 수 있다.

export default function Modal({ children, title }) {
  const { hideModal } = useModal();

  return ReactDom.createPortal(
    <ModalOverlay onClick={hideModal}>
      <ModalContainer onClick={e => e.stopPropagation()}>
        <ModalTitle>{title}</ModalTitle>
          {children}
      </ModalContainer>
    </ModalOverlay>,
    document.getElementById("portal"),
  );
}

위에 구조에서 Recoil을 이용하여 전역적으로 모달창을 띄우는 상태와 타입을 함께 관리하는 GlobalModal을 통해 확장하였다.

function GlobalModal() {
  const { modalType, modalProps } = useRecoilValue(modalState) || {};

  if (modalType === "LoginModal") {
    return (
      <Modal {...modalProps}>
        <Login />
      </Modal>
    );
  }

  if (modalType === "UploadModal") {
    return (
      <Modal {...modalProps}>
        <Upload />
      </Modal>
    );
  }

	...
}

이제 Modal 컴포넌트 자체를 수정하지 않고도 합성을 사용하여 확장에는 열려있는 구조가 완성되었다.

리액트에서 이 원칙을 잘 활용하면 컴포넌트의 확장성과 재사용성을 높일 수 있는 기회가 될 수 있을것이라 생각한다.

3. 리스코프 치환 원칙 (Liskov substitution principle)

이 원칙은 자식 클래스는 부모 클래스에서 가능한 행위를 수행해야한다는 원칙이다. 쉽게 말해 하위 타입 객체가 상위 타입 객체를 대체할 수 있다는 뜻이다.

함수형 컴포넌트 구조로 가고있는 리액트에서는 거의 적용할 수 없는 규칙이다 게다가, 공식문서에서 이 원칙에서 불가피한 ‘상속’ 대신 위에서 사용한 ‘합성’을 강력하게 추천하고 있다.

Composition vs Inheritance - React

(’아티클 지니’를 통해 공유하고자 하는 문단을 하이라이트 하였습니다.)

4. 인터페이스 분리 원칙 (Interface segregation principle)

인터페이스 분리 원칙은 하나의 클래스는 자신이 이용하지 않는 인터페이스는 의존하지 말아야 한다는 원칙이다. 이것을 리액트에서는 컴포넌트에 사용하지 않는 props에 의존하지 말아야 한다로 해석할 수 있다.

사실 이 원칙을 리액트에서 잘 적용하기 위해서는 타입스크립트를 사용하여야 된다고 생각한다. 그럼에도 불구하고 이번 프로젝트에서 불필요한 props를 넘겨주지 않는다라는 것에 집중하겨 적용시켜 보았다.

먼저 원칙을 적용시키기 전에는 ‘photo’ 객체를 자식 컴포넌트에 모두 전달하여 특정 프러퍼티를 사용하였다.

function MainPage() {
	const userData = useRecoilValue(loginState);
	const { data, refetch } = useQuery(
	    ["randomPhoto", userData?._id],
	    () => getRandomPhotoAndBookmarks(currentPhotoId, userData?._id),
	    {
	      refetchOnWindowFocus: false,
	    },
	  );
	
	return (
		<PhotoWrapper>
		  <PhotoView photo={data.photo}/>
	    <MainPageHeader />
	    <AsideButtons />
	    <BottomButtons />
		</PhotoWrapper>
	);
}

function PhotoView({ photo }) {
	return <img src={photo.imageUrl} />;

PhotoView.propTypes = {
  photo: PropTypes.shape({
		imageUrl: PropTypes.string,
	  country: PropTypes.string,
	  city: PropTypes.string,
	  tags: PropTypes.array,
  })
};

이 경우, 사용하지 않는 props에 의존하고 있어 재사용성이 낮아지는 결과를 초래할 수 있다.

예를 들어 이번에 오버패칭을 방지하는 GraphQL을 이용하여 내가 필요로 하는 데이터만 들고와여 정제하지 않고도, 바로 사용할 수 있도록 하였다. 그렇기 때문에 ‘photo’ 데이터가 각 컴포넌트에서 ‘tags’ 프러퍼티가 없는 등 다른 형태의 객체일 수 있다. 이 때 propTypes 검사를 통해 리액트에서 ‘Warning’을 뱉을 것이다. (타입스크립트의 경우 에러이다.)

그럼 이 문제를 해결해보자.

function MainPage() {
	const userData = useRecoilValue(loginState);
	const { data, refetch } = useQuery(
	    ["randomPhoto", userData?._id],
	    () => getRandomPhotoAndBookmarks(currentPhotoId, userData?._id),
	    {
	      refetchOnWindowFocus: false,
	    },
	  );
	
	return (
		<PhotoWrapper>
		  <PhotoView photo={data.photo.imageUrl}/>
	    <MainPageHeader />
	    <AsideButtons />
	    <BottomButtons />
		</PhotoWrapper>
	);
}

function PhotoView({ imageUrl }) {
	return <img src={imageUrl} />;

PhotoView.propTypes = {
	imageUrl: PropTypes.string.isRequired,
};

필요한 props에만 의존하도록 PhotoView 컴포넌트를 리팩토링하여, 다른 데이터 객체, 컴포넌트에서도 대응할 수 있게 되었다.

인터페이스 분리 원칙을 통해 컴포넌트들 간의 의존성을 낮추고, 재사용성을 높일 수 있다.

5. 의존관계 역전 원칙 (Dependency inversion principle)

SOLID 원칙 중 가장 이해가 쉽지 않은 부분이다.

한마디로, 객체는 직접적으로 저수준 모듈(객체, 클래스) 보다는 추상화 된 인터페이스와 같은 고수준 모듈에 외존해야 한다는 것이다.

왜 그럴까? 스택오버플로우에서 깔끔하게 설명한 부분이 있다.

What is the dependency inversion principle and why is it important?

(’아티클 지니’를 통해 공유하고자 하는 문단을 하이라이트 하였습니다.)

궁극적으로는 각 객체간의 관계를 최대한 느슨하게 유지하기 위한 것이다.
이것을 리액트에 적용하면 한 컴포넌트가 다른 컴포넌트에 직접적으로 의존해서는 안되며, 둘 다 공통된 추상화에 의존해야 한다.

이 원칙이 적용된 예시를 통해 살펴보자.

이번 프로젝트에서 VAC(View Asset Component) 패턴을 사용하였는데 이 패턴을 통해 의존관계 역전 원칙을 일부 따를 수 있었다.
구글 로그인 버튼을 이용하여 ‘login’ API 요청을 보내는 Login 컴포넌트이다.

import { login } from "../../api";

function Login() {
  const loginMutation = useMutation(login);
  const handleLogin = async credentialResponse => {
    const profileObj = jwtDecode(credentialResponse.credential);

    const { name, email } = profileObj;
    loginMutation.mutate(
      { name, email },
      {
        onSuccess: response => {
          localStorage.setItem("loginData", JSON.stringify(response));
        },
      },
    );
  };

  const LoginProps = {
    onLoginButtonClick: handleLogin,
  };

  return <LoginView {...LoginProps} />;
}
function LoginView({ onLoginButtonClick }) {
  return (
    <Wrapper>
      <Description>
        Fall into the beautiful landscape and the sound of your imagination.
      </Description>
      <GoogleLogin
        onSuccess={onLoginButtonClick}
        onError={() => {
          console.log("Login Failed");
        }}
      />
    </Wrapper>
  );
}

LoginView.propTypes = {
  onLoginButtonClick: PropTypes.func.isRequired,
};

LoginView 컴포넌트는 직접적으로 api 모듈에 직접 의존하지 않고, Login 컴포넌트에서 props로 주입받은 함수를 통해 추상화되었다.

로그인 로직을 구체적인 구현은 LoginView 컴포넌트의 상위 컴포넌트의 Login 컴포넌트의 책임이 되었다. (로그인 로직은 ‘React Query Mutation’을 통해 구현하였다.)

Login 컴포넌트 api 모듈과 LoginView 컴포넌트 사이에 추상화된 인터페이스 역활을 하며 컴포넌트와 모듈간의 직접적인 의존을 부섰다.

하지만, VAC 패턴으로는 완벽하게 의존성을 없애는데에 한계가 존재했고 presentational container pattern을 적용하면 조금 더 객체 지향적으로 이 원칙을 따를 수 있을거라 예상한다.

6. 결론

완벽하지 않지만, SOLID를 원칙을 이번 프로젝트를 기회삼아 리액트에 적용해 보았다.

필자는 아직 실력있는 시니어 개발자들처럼 개발전 아키텍처를 설계하는 능력이 부족하다. 그래서 설계를 완벽하게 하고 개발을 시작하는 것이 아니라 ‘리액트로 사고하기'와 같이 간략한 구조 설계 후 개발을 일단 시작한다. 그 다음 하나의 컴포넌트에 ‘monolithic’ 코드 덩어리를 작성한 후 SOLID원칙에 따라 모듈화 또는 분리를 진행하였다.

객체 지향 원칙이 리액트에서도 적용된다는 부분이 신기하였고, 객체 지향 원칙이 정말 잘 만들어진 구조이기 때문에 이렇게 범용적으로 적용할 수 있다 생각하였다. 결과적으로 이 원칙은 나에게 좋은 개발 경험을 안겨주었다.

하지만, 기존의 설계 패턴이나 소프트웨어 구조 원칙이 그러하듯이 이것을 맹신하여 상황에 맞지 않게 원칙을 지키려고 한다면 코드는 더 복잡해지고 오히려 유지보수가 어려워 질 수도 있다라고 느꼈다.

앞으로 이런 좋은 원칙이나 설계 패턴을 참고하여 팀과 프로젝트 상황에 맞게 적재적소에 적용할 수 있는 능력을 길러야 겠다라고 생각하였다.

7. 참고자료

창시자 앨런 케이가 말하는, 객체 지향 프로그래밍의 본질

SOLID 원칙 : React에 적용해서 생각해보기

Applying SOLID principles in React

The S.O.L.I.D Principles in Pictures

profile
정보를 공유하겠습니다 😀

0개의 댓글