avatar
Octoping Blog
React에서 Class 사용해보기
클래스로 뚱뚱한 컴포넌트를 정리해보기
frontendreactjavascriptTypescript
Aug 19
·
14 min read

클래스 컴포넌트에 대한 글이 아님을 먼저 남깁니다

자바스크립트는 ES6 환경에서 클래스 문법을 지원하기 시작했다. 물론 그 전에도 클래스처럼 객체를 사용하는 것이 가능했지만..

아무튼 그럼에도 NestJS 같은 백엔드 환경에선 클래스를 적극적으로 쓰는 것과 별개로, 프론트 환경에서는 클래스를 딱히 쓰지 않고 있는 것 같다.

왜일까? React에서는 불변성을 중요하게 생각하고 있고, 그렇기 때문에 useState 같은 상태값에 클래스를 이용하기 어려운 실정이다.

useState에 클래스 객체?

function Component() {
  const [cart, setCart] = useState(new ShoppingCart())

  const onClick = (e) => {
    cart.addItem(e.target.name)
  }

  return (
    <button onClick={onClick}>
      카트에 담기
    </button>
  )
}

class ShoppingCart {
  constructor(items) {
    this.items = items
  }

  addItem(item) {
    this.items.push(item)
  }
}

리액트 사용자라면 이 코드가 제대로 동작하지 않을 것임을 알 것이다. useState로 선언한 상태값은 반드시 setState로 상태값을 바꿔야하니까.

function Component() {
  const [cart, setCart] = useState(new ShoppingCart())

  const onClick = (e) => {
    cart.addItem(e.target.name)
    setCart(cart)
  }

  return (
    <button onClick={onClick}>
      카트에 담기
    </button>
  )
}

class ShoppingCart {
  constructor(items) {
    this.items = items
  }

  addItem(item) {
    this.items.push(item)
  }
}

이러면 어떨까? 물론 제대로 작동하지 않을 것이다. cart의 addItem 메소드로 cart의 값을 바꿨지만, 이전의 cart와 동등한 값 ( Object.is 로 비교했을 때)이기 때문이다.

따라서 정말 이렇게 써먹고 싶다면 ShoppingCart 클래스는 새로운 객체를 반환하도록 변경되어야 할 것이다.

class ShoppingCart {
  items
  
  constructor(items) {
    this.items = items
  }

  addItem(item) {
    return new ShoppingCart([...this.items, item])
  }
}

하지만 매번 코드를 이렇게 쓰는건 좀 어렵다.

반려생활에서는...

반려생활에서는 이런 문제를 해결하기 위해 valtio라는 라이브러리를 사용한다고 한다.

https://github.com/pmndrs/valtio

프록시를 이용해서 객체에 값이 바뀔 때에 setState로 전파를 해주는 기능으로 보이는데.

역시나 외부 라이브러리를 사용하는 방법인지라 접근성이 그렇게 좋지는 않고, 원래 쓰던 방식이 있는데 클래스를 사용하기 위해 굳이 이렇게까지 해야할까 라는 생각이 들기도 한다.

그렇다면 클래스는 리액트에서 쓸 일이 없는 도구인걸까?

문제 제기

예시를 위해 언틸의 프론트엔드 코드를 조금 가져와보자.

다음은 언틸의 댓글을 쓸 때 내용을 적는 input form 컴포넌트의 코드다.

type Props = {
  commentId?: number;
  replyToCommentId?: number;
  initialContent: string;
  onCancel?: () => void;
}

const WriteForm = ({
  commentId,
  replyToCommentId,
  initialContent = "",
  onCancel = () => {},
}: Props) => {
  const { isLoggedIn } = useAuthInfo()
  const [content, setContent] = useState(initialContent)

  const handleSubmit: FormEventHandler = async (e) => {
    e.preventDefault()

    if (!isLoggedIn || !content) return

    if (!commentId) {
      await postComment({ content, replyToCommentId })
      setContent("")
    }

    if (commentId) {
      await putComment({ content })
    }

    onCancel()
  }

  const submitBtnText = (() => {
    if (commentId) {
      return "수정하기"
    }

    if (replyToCommentId) {
      return "답글 등록"
    }

    return "댓글 등록"
  })()

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        autoFocus={!!(commentId || replyToCommentId)}
        disabled={!isLoggedIn}
        value={content}
        onChange={(e) => setContent(e.target.value)}
      />
      <div>
        {(commentId || replyToCommentId) && (
          <button onClick={onCancel}>취소</button>
        )}
        <button type="submit">{submitBtnText}</button>
      </div>
    </form>
  )
}

이 컴포넌트에서는 다양한 데이터들이 사용되고 있고, 이것들을 이용해서 컴포넌트의 다양한 행동들과 속성을 정의하고 있다. isLoggedIncontent가 비어있다면 댓글을 작성할 수 없고, commentId가 있다면 버튼의 텍스트는 '수정하기'이고..

그런데, 왜 isLoggedIncontent가 비어있다면 댓글을 작성할 수 없는걸까? 왜 commentId가 있다면 버튼의 텍스트는 '수정하기'일까?

코드를 읽다보면 동작 방식은 알 수 있어도 자세한 이유와 의미를 생각해보는 데에는 시간이 좀 들게 된다. 결국 이런 코드들이 많아질 수록 코드는 점점 읽기 힘들어지고 의미를 이해하기 힘들어진다.

메소드 분리?

클린코드에서는 어떤 함수가 한 가지 일만 하는 것이 아니게 되었다면 함수를 분리해보라고 권장한다.

하지만 리액트의 컴포넌트에서는 자바의 클래스와 다르게, private 메소드 같은 것이 없다.

WriteForm 컴포넌트의 handleSubmit의 로직이 복잡하다고 해서 함수를 분리해봐야 결국 그 함수도 WriteForm에 생겨날 뿐이다. 결국 컴포넌트만 뚱뚱해지는 셈이다.

클래스는 상태와, 상태와 연관된 동작을 정의한다

commentId, replyToCommentId, isLoggedIn 등등.. 이것들을 이용해서 컴포넌트의 다양한 행동들과 속성을 정의하고 있다.

isLoggedIncontent가 비어있다면 댓글을 작성할 수 없고, commentId가 있다면 버튼의 텍스트는 '수정하기'이고..

우리는 이럴 때에 클래스를 사용해볼 수 있다. 클래스는 상태들과, 그 상태들과 연관된 동작을 정의할 때 쓰는 틀이다.

위에 적은 내용을 클래스를 이용해서 맥락이 드러나도록 코드를 만들어볼 수 있지 않을까?

class CommentWriteStatus {
  constructor(
    private readonly isLoggedIn: boolean,
    private readonly commentId: number | undefined,
    private readonly replyToCommentId: number | undefined,
    private readonly content: string
  ) {}

  isNewComment() {
    return !this.commentId
  }

  isReply() {
    return this.replyToCommentId !== undefined
  }

  canPublish() {
    return this.isLoggedIn && this.content
  }

  getSubmitBtnText() {
    if (!this.isNewComment()) {
      return "수정하기"
    }

    if (this.isReply()) {
      return "답글 등록"
    }

    return "댓글 등록"
  }
}

각 상태값들을 받아서, 댓글 입력창에서 취할 수 있는 행동들을 정의했다.

리액트에 클래스 적용하기

const WriteForm = ({
  commentId,
  replyToCommentId,
  initialContent = "",
  onCancel = () => {},
}: Props) => {
  const { isLoggedIn } = useAuthInfo()
  const [content, setContent] = useState(initialContent)

  const status = new CommentWriteStatus(
    isLoggedIn,
    commentId,
    replyToCommentId,
    content
  )

  // ...
}

아까 언급한 대로, state에 클래스를 쓰거나 하는 용도는 어렵다.

하지만 state는 원래의 방식대로 useState를 이용해서 정의하고, 비즈니스 로직을 위한 함수 발사대의 용도로 불변 클래스를 활용해주자.

원래도 객체지향에서 중요한 것은 변수값이 아닌, 메소드들이었으니까.

const handleSubmit: FormEventHandler = async (e) => {
  e.preventDefault()

  if (!status.canPublish()) return

  if (status.isNewComment()) {
    await postComment({ content, replyToCommentId })
    setContent("")
  }

  if (!status.isNewComment()) {
    await putComment({ content })
  }

  onCancel()
};

클래스의 메소드를 이용해서 코드를 교체해줬다.

우리는 어떤 변수가 true고, 어떤 변수가 빈 문자열이 아닐 때 return을 한다~ 따위의 것이 궁금했던게 아니다.

그 대신에 비즈니스의 의미가 잘 드러날 수 있도록 코드를 변경해주니 누구라도 이해할 수 있게 되었다.

 return (
  <form onSubmit={handleSubmit}>
    <textarea
      autoFocus={!status.isNewComment() || status.isReply()}
      disabled={!isLoggedIn}
      value={content}
      onChange={(e) => setContent(e.target.value)}
    />
    <div>
      {(!status.isNewComment() || status.isReply()) && (
        <button onClick={onCancel}>취소</button>
      )}
      <button type="submit">{status.getSubmitBtnText()}</button>
    </div>
  </form>
);

return 문 쪽도 더 의미가 잘 드러나게 변경된 것 같다.

예시 2

언틸에서는 블로그에 유저 블로그와 그룹 블로그라는 두 가지 종류가 있다. 그와 함께 방문하는 유저도 타입이 여러 개 존재하게 되는데.

  userType:
    | 'guest' // 비로그인
    | 'owner' // 본인
    | 'user' // 다른 유저
    | 'member' // 그룹 멤버
    | 'admin' // 그룹 관리자

이렇게 blogType과 userType이 여러 개 존재함에 따라 각 타입 별로 권한도 다양하게 존재한다.

const FollowButton = () => {
  const { username, isFollowing, blogType, userType } = useBlogQuery()

  if (blogType === "user" && userType === "owner") {
    return (
      <Link href={`/@${username}/setting`}>
        <button>프로필 수정</button>
      </Link>
    )
  }

  return (
    <LoginDialogWrapper
      onClick={() => {
        isFollowing ? postUnfollow() : postFollow()
      }}
    >
      <button>{isFollowing ? "팔로잉" : "팔로우"}</button>
    </LoginDialogWrapper>
  )
}

유저 블로그면서 자신이 그 블로그의 주인일 경우, 팔로우를 할 이유가 없기 때문에 팔로우 버튼 대신에 '프로필 수정' 버튼을 보여주게 된다.

애플리케이션 전체에서 이와 비슷하게 유저/블로그 타입을 통한 로직이 많은데, 이 경우 권한에 대한 내용이 한 곳에 뭉쳐있지 않고 이곳 저곳에 혼재되어 있게 된다.

따라서 실수로 변경 누락을 하거나 잘못 설정하는 일이 발생할 수도 있다.

class BlogUserType {
  constructor(
    private readonly userType:
      | "guest" // 비로그인
      | "owner" // 본인
      | "user" // 다른 유저
      | "member" // 그룹 멤버
      | "admin", // 그룹 관리자
    private readonly blogType: "user" | "group"
  ) {}

  canFollow() {
    return !this.isMyUserBlog()
  }

  canEditAbout() {
    return this.isMyUserBlog() || this.isAdmin()
  }

  private isMyUserBlog() {
    return this.isUserBlog() && this.userType === "owner"
  }

  private isUserBlog() {
    return this.blogType === "user"
  }

  private isAdmin() {
    return this.userType === "admin" || this.userType === "owner"
  }
}

이런 느낌으로 권한을 좀 정리해볼 수 있지 않을까.

커스텀 훅

물론 예제로 들었던 이것들, 커스텀 훅으로 만들 수 있다. 상태값을 받아서 함수 or 값을 뱉는 것이라면 클래스나 훅이나 똑같다.

function useCommentWriteStatus(
  isLoggedIn: boolean,
  commentId: number | undefined,
  replyToCommentId: number | undefined,
  content: string
) {
  const isNewComment = !commentId
  const isReply = replyToCommentId !== undefined
  const canPublish = isLoggedIn && content

  function getSubmitBtnText() {
    if (!isNewComment) {
      return "수정하기"
    }

    if (isReply) {
      return "답글 등록"
    }

    return "댓글 등록"
  }

  return {
    isNewComment,
    isReply,
    canPublish,
    getSubmitBtnText,
  }
}

// 사용 예시
const { canPublish } = useCommentWriteStatus(true, 1, 2, "content")

사실 리액트를 처음 공부하면서 훅에 대해서 배울 때, "아, 이게 리액트에서 제공하는 클래스구나"라는 생각이 들었다.

변수값들을 생성자처럼 받아서 훅 내부에서 함수들을 생성한 후 public으로 공개하고 싶은 녀석들만 return하면 그게 클래스지.

하... 하지만 클래스 쓰는게 멋있잖아?? 리액트에서 클래스 쓰는 힙한 사람처럼 보일 수 있다는 점에서 장점이 있는 것 같다.

농담이고, 이런 커스텀 훅을 만들어서 비즈니스 로직을 묶는 것도 좋은 방법이지만 클래스 쪽이 private 메소드 등을 이용해서 좀 더 가독성 있는 코드를 쓸 수 있는 것 같다는 개인적인 생각이 들어서 적절한 때에 잘 이용하고 있다. 살짝 테스트하기도 더 쉬운 것 같기도 하고..


- 컬렉션 아티클







반갑습니다 :)