댓글 컴포넌트를 리팩토링 하며 효율적인 컴포넌트 설계란 무엇인지 고민했습니다.

효율적인 컴포넌트

저는 효율적인 컴포넌트란 아래의 두가지를 만족하는 컴포넌트라고 생각합니다.

  1. 유지보수가 용이하고, 새로운 기능을 추가하기 좋은 컴포넌트
  2. 재사용성이 좋아 공간 복잡도가 낮고, 리렌더링이 적어 시간 복잡도 또한 낮은 컴포넌트

각각 개발자와 유저의 관점에서 고려하면 좋을 포인트들입니다.

이번 포스트에서는 1번에 집중해 본 블로그의 댓글과 관련된 컴포넌트들을 리팩토링하며 어떻게 하면 이 목표들을 달성할 수 있을지 고민한 내용을 적어보겠습니다.

리팩토링 전

먼저 리팩토링 대상이 무엇인지 확인해보겠습니다.

리팩토링 전

리팩토링 전 컴포넌트의 모습입니다.

구조적으로는 댓글을 작성하는 폼과 댓글들을 포함하는 ol, 각 댓글 li들을 보여주는 카드 컴포넌트로 이뤄져 있습니다. 기능적으로는 폼의 댓글 작성 기능, 댓글 아이템의 수정 및 삭제 기능을 가지고 있습니다.

문제와 해결

각 문제 상황과 해결한 방법입니다.

prop drilling

다음은 formol, li를 모두 가지고 있는 Comments 컴포넌트입니다.

const Comments = ({ title }: Props) => {
  const [comments, setComments] = useState<ICommentData[]>([])
 
  useEffect(() => {
    const unSubscribeComments = getComments(title, setComments)
 
    return () => unSubscribeComments()
  }, [title])
 
  return (
    <Container>
      <CommentsTitle>Comments({comments.length})</CommentsTitle>
      <CommentForm title={title} />
      <ol>
        {React.Children.toArray(
          comments.map((comment) => <CommentCard comment={comment} title={title} />),
        )}
      </ol>
    </Container>
  )
}
const Comments = ({ title }: Props) => {
  const [comments, setComments] = useState<ICommentData[]>([])
 
  useEffect(() => {
    const unSubscribeComments = getComments(title, setComments)
 
    return () => unSubscribeComments()
  }, [title])
 
  return (
    <Container>
      <CommentsTitle>Comments({comments.length})</CommentsTitle>
      <CommentForm title={title} />
      <ol>
        {React.Children.toArray(
          comments.map((comment) => <CommentCard comment={comment} title={title} />),
        )}
      </ol>
    </Container>
  )
}

댓글 작성, 수정, 삭제시 firebase에서 docRef를 선언하기 위해 포스트 제목을 나타내는 title: string 값이 필요합니다. 이는 Commants 컴포넌트에서 받아 다시 CommentForm과 각 CommentCard에 prop으로 전달하고 있습니다.

댓글 리스트 데이터를 가지고 올 때 title 값을 사용하기 때문에 의미없이 자식으로 prop을 전달만 하고 있다고 볼순 없지만, 이게 과연 좋은 방법일지 고민됐습니다. 만약 여기서 CommentsList라는 depth를 추가하는 컴포넌트를 새로 만든다면, 이 컴포넌트는 title을 받아 또 다시 자식이 될 CommentCard로 넘겨줘야 할 것입니다.

이를 해결하기 위한 가장 간단한 방법으로 저는 Context API를 사용했습니다.

const CommentPostTitleContextProvider = ({ children, postTitle }: Props) => {
  return (
    <CommentPostTitleContext.Provider value={{ postTitle }}>
      {children}
    </CommentPostTitleContext.Provider>
  )
}
const CommentPostTitleContextProvider = ({ children, postTitle }: Props) => {
  return (
    <CommentPostTitleContext.Provider value={{ postTitle }}>
      {children}
    </CommentPostTitleContext.Provider>
  )
}

prop으로 title을 받고 이를 context provider로 감싸주는 간단한 컴포넌트입니다. 댓글 컴포넌트를 리팩토링하면서 이렇게 Context API를 여러겹 사용했는데, 그럴 때마다 저는 provider 역할의 컴포넌트를 따로 선언해서 context 관리를 분리하고자 했습니다.

또, 아래와 같은 형식으로 context를 사용하는 커스텀 훅을 선언해, useContext의 직접적인 노출을 줄이고 더 명시적으로 context를 사용을 표현하고자 했습니다.

export const useCommentPostTitleContext = () => {
  const { postTitle } = useContext(CommentPostTitleContext)
  return postTitle
}
export const useCommentPostTitleContext = () => {
  const { postTitle } = useContext(CommentPostTitleContext)
  return postTitle
}
React TypeScript에서 Children을 가지고 있는 컴포넌트의 타입
import { PropsWithChildren } from "react"
 
type Props = PropsWithChildren<{
  postTitle: string
}>
import { PropsWithChildren } from "react"
 
type Props = PropsWithChildren<{
  postTitle: string
}>

CommentPostTitleContextProvider 컴포넌트의 Prop 타입은 이렇게 선언했습니다. 이런 식으로 react 라이브러리에는 타입스크립트에서 유용하게 사용할 수 있는 타입을 다양하게 제공해줍니다. 이 cheet sheet를 참고해주세요.

구조 파악이 어려운 컴포넌트

Comments 컴포넌트 하나에는 section, form, ol 등 다양한 종류의 컴포넌트들이 모여있습니다. 때문에 사용할 때는 그냥 Comments 컴포넌트를 놓기만 하면 됩니다.

export default function Post({ post }: Props) {
  return (
    <>
      <Meta
        title={post.title}
        description={post.excerpt}
        image={post.coverImage}
        tags={post.category}
      />
      <PostContainer>
        {/* 중간 생략 */}
        <Comments title={post.title} />
      </PostContainer>
    </>
  )
}
export default function Post({ post }: Props) {
  return (
    <>
      <Meta
        title={post.title}
        description={post.excerpt}
        image={post.coverImage}
        tags={post.category}
      />
      <PostContainer>
        {/* 중간 생략 */}
        <Comments title={post.title} />
      </PostContainer>
    </>
  )
}

간단해서 좋긴 하지만, 저 한줄 안에 저 많은 컴포넌트들이 함축적으로 들어가는게 맞을까 하는 고민이 들었습니다.

댓글이라는 공통 관심사로 컴포넌트를 묶으면서도, 구조를 보여줄 수 있다면 컴포넌트의 구조를 Post 페이지 컴포넌트에서도 파악할 수 있게 만들 수 있지 않을까요?

이를 위해 컴파운드 컴포넌트 패턴을 사용해봤습니다.

export default function Post({ post }: Props) {
  return (
    <>
      <Meta
        title={post.title}
        description={post.excerpt}
        image={post.coverImage}
        tags={post.category}
      />
      <PostContainer>
        {/* 중간 생략 */}
        <Comments postTitle={postTitle}>
          <Comments.Title>Comments({comments.length})</Comments.Title>
          <Comments.Form />
          <Comments.List>
            {comments.map((commentData) => (
              <Comments.Item key={commentData.id} commentId={commentData.id} {...commentData} />
            ))}
          </Comments.List>
        </Comments>
      </PostContainer>
    </>
  )
}
export default function Post({ post }: Props) {
  return (
    <>
      <Meta
        title={post.title}
        description={post.excerpt}
        image={post.coverImage}
        tags={post.category}
      />
      <PostContainer>
        {/* 중간 생략 */}
        <Comments postTitle={postTitle}>
          <Comments.Title>Comments({comments.length})</Comments.Title>
          <Comments.Form />
          <Comments.List>
            {comments.map((commentData) => (
              <Comments.Item key={commentData.id} commentId={commentData.id} {...commentData} />
            ))}
          </Comments.List>
        </Comments>
      </PostContainer>
    </>
  )
}

Comments를 하나의 객체로 보고 key, value로 자식이 될 컴포넌트들을 할당해, 이 컴포넌트를 사용하는 쪽에 마크업을 노출하도록 변경했습니다. 이 때, 앞서 만든 title provider를 Comments 컴포넌트에 할당해 자식 컴포넌트들이 title을 가지고 있는 context에 접근할 수 있습니다.

데이터는 따로 가지고 오기

위 변화와 더불어 댓글 데이터를 가지고 오는 로직의 위치를 바꿀 필요가 생겼습니다. 이제 CommentCard에 접근할 수 있는건 Comments가 아닌 이를 호출하는 Post 페이지 컴포넌트이기 때문입니다.

이를 위해 useComments 컴포넌트를 생성했습니다.

const useComments = (postTitle: string) => {
  const [comments, setComments] = useState<ICommentData[]>([])
 
  useEffect(() => {
    const unSubscribeComments = getComments(postTitle, setComments)
 
    return () => unSubscribeComments()
  }, [postTitle])
 
  return comments
}
const useComments = (postTitle: string) => {
  const [comments, setComments] = useState<ICommentData[]>([])
 
  useEffect(() => {
    const unSubscribeComments = getComments(postTitle, setComments)
 
    return () => unSubscribeComments()
  }, [postTitle])
 
  return comments
}

덕분에 자연스럽게 댓글 데이터를 가지고 오는 로직과, 댓글의 모습을 나타내는 UI를 분리하는 성과를 얻을 수 있었습니다. 이런 요소 하나 하나가 유지보수성을 높이는 데 큰 도움이 될겁니다.

복잡한 형태의 conditional rendering

CommentCard 컴포넌트는 댓글을 나타내는것 뿐만 아니라, 댓글의 수정 및 삭제를 담당하고 있습니다.

그런데, 댓글을 삭제하거나 수정할 때는 댓글 작성시 입력된 비밀번호 확인이 필요합니다.

리팩토링 전 댓글수정 예시

이를 위해 리팩토링 전 코드는 대략 이렇게 짜여 있었습니다.

const CommentCard = ({ comment, title }: Props) => {
  // 로직 코드 생략
 
  return (
    <Container>
      <UserInfo username={comment.username} createdAt={comment.createdAt} />
      {isEditing ? (
        isPasswordCorrect ? (
          isLoading ? (
            {
              /* 로딩중임을 표현하는 인디케이터 */
            }
          ) : (
            {
              /* 댓글 수정용 form */
            }
          )
        ) : (
          {
            /* 비밀번호 확인용 form */
          }
        )
      ) : isDeleting ? (
        {
          /* 비밀번호 확인용 form */
        }
      ) : (
        <>
          <Comment>{comment.comment}</Comment>
          <EditContainer>
            {/* 댓글 수정 버튼 */}
            {/* 댓글 삭제 버튼 */}
          </EditContainer>
        </>
      )}
    </Container>
  )
}
const CommentCard = ({ comment, title }: Props) => {
  // 로직 코드 생략
 
  return (
    <Container>
      <UserInfo username={comment.username} createdAt={comment.createdAt} />
      {isEditing ? (
        isPasswordCorrect ? (
          isLoading ? (
            {
              /* 로딩중임을 표현하는 인디케이터 */
            }
          ) : (
            {
              /* 댓글 수정용 form */
            }
          )
        ) : (
          {
            /* 비밀번호 확인용 form */
          }
        )
      ) : isDeleting ? (
        {
          /* 비밀번호 확인용 form */
        }
      ) : (
        <>
          <Comment>{comment.comment}</Comment>
          <EditContainer>
            {/* 댓글 수정 버튼 */}
            {/* 댓글 삭제 버튼 */}
          </EditContainer>
        </>
      )}
    </Container>
  )
}

실제 코드를 그대로 옮겨오면 너무 끔찍해서 많이 생략했는데도 이정도입니다. 러프하게 설명하면 이런 구조입니다.

  1. 댓글 수정 버튼이 눌리면 isEditingtrue로, 댓글 삭제 버튼이 눌리면 isDeletingtrue로 변경합니다. 둘 다 false라면 댓글 내용과 수정 및 삭제 버튼을 렌더링합니다.
  2. isEditing 혹은 isDeletingtrue라면 비밀번호 확인용 form 컴포넌트를 렌더링합니다.
  3. useEffect 훅을 활용해 isPasswordCorrecttrue로 바뀌었을 경우 isEditingtrue라면 댓글 수정용 form 컴포넌트를 렌더링하고, isDeletingtrue라면 댓글을 삭제합니다.

이 과정을 삼항 연산자로 표현하다보니 state가 과도하게 많이 사용됐고, 컴포넌트 로직 파악은 거의 불가능한 수준에 이르고 말았습니다. (실제 코드는 스타일을 제외하고도 120줄이 넘는 괴물 컴포넌트입니다.)

게다가, onClick, onChange, onSubmit 등 다양한 이벤트에 대한 핸들러를 상황에 따라 다르게 선언하지 않고 하나의 함수에서 모두 대응하고 있었습니다.

대표적으로 onClick 핸들러는 이렇게 생겼습니다.

const onClick = useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
  event.preventDefault()
  event.stopPropagation()
 
  switch (event.currentTarget.name) {
    case "edit":
      setIsEditing(true)
      break
    case "delete":
      setIsDeleting(true)
      break
    case "cancel":
      setIsEditing(false)
      setIsPasswordCorrect(false)
      setIsDeleting(false)
      setPassword("")
  }
}, [])
const onClick = useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
  event.preventDefault()
  event.stopPropagation()
 
  switch (event.currentTarget.name) {
    case "edit":
      setIsEditing(true)
      break
    case "delete":
      setIsDeleting(true)
      break
    case "cancel":
      setIsEditing(false)
      setIsPasswordCorrect(false)
      setIsDeleting(false)
      setPassword("")
  }
}, [])

이 함수 하나만으로 수정, 삭제, 취소의 세가지 로직을 처리하고 있는겁니다. 이런 구조때문에 로직을 파악하고 수정하기 매우 어려웠습니다.

이벤트의 종류에 따라 핸들링하면 복잡한 로직을 컨트롤하기 편할것이라고 생각했던 오판과, 컴포넌트가 비대해져도 분리하려고 하지 않았던 탓에 이런 결과가 이어졌습니다.

복잡한 형태의 conditional rendering이 필요할 경우 어떻게 하면 더 보기 좋게 처리할 수 있을까요? 이를 위해 먼저 CommentCard가 표현해야 하는 상태를 정리해봤습니다.

  • default: 댓글을 나타냅니다. 수정 및 삭제 버튼은 노출되지 않은 상태입니다.
  • option opened: 수정 및 삭제 버튼을 노출시킨 상태입니다.
  • password: 댓글 수정 및 삭제 전 비밀번호를 입력받고, 통과 여부를 확인하는 form을 렌더링합니다.
  • edit: 댓글을 수정할 수 있는 form을 렌더링합니다.

수정, 삭제 버튼이 모든 댓글에 항상 노출되는건 UX에 악영향을 준다고 생각해 리팩토링 하며 하나의 조건을 추가하기로 했습니다. 즉, 버튼들을 접았다 펼 수 있게 만들었습니다.

수정, 삭제 버튼을 담는 컴포넌트에서 상태별로 렌더링할 요소를 결정하는 것이 옳다고 판단했고, CommentEditorContainer라는 이름을 붙였습니다.

이 컴포넌트에선 현재 상태값을 기반으로 무엇을 렌더링할지 결정해야 합니다. 이런 conditional 로직은 보통 if문을 사용하거나 삼항 연산자를 쓰지만, 저는 좀 더 깔끔한 해결책을 고민했고 결국 이런 코드를 작성했습니다.

const CommentEditorContainer = () => {
  const editState = useCommentEditState()
  const State = commentEditorStateChildrenMap[editState]
 
  return <State />
}
 
export default CommentEditorContainer
const CommentEditorContainer = () => {
  const editState = useCommentEditState()
  const State = commentEditorStateChildrenMap[editState]
 
  return <State />
}
 
export default CommentEditorContainer

우선 현재 상태값을 Context API로 관리하고, 이 context를 useCommentEditState라는 커스텀 훅으로 사용합니다. 그리고 이 상태값에 맞는 컴포넌트를 commentEditorStateChildrenMap이라는 객체에서 찾아와 렌더링하도록 했습니다.

JSX 문법을 사용할 수 있도록 State라는 대문자로 시작하는 변수를 거쳐 return합니다.

이제 state에 맞는 컴포넌트를 commentEditorStateChildrenMap에 넣어주면 됩니다.

const commentEditorStateChildrenMap: {
  [key in CommentEditState]: () => JSX.Element
} = {
  [CommentEditState.DEFAULT]: DefaultState,
  [CommentEditState.OPTION_OPENED]: OptionOpenedState,
  [CommentEditState.CHECK_PASSWORD_EDIT]: () => (
    <CheckPasswordState stateTo={CommentEditState.EDIT} />
  ),
  [CommentEditState.CHECK_PASSWORD_DELETE]: () => (
    <CheckPasswordState stateTo={CommentEditState.DELETE} />
  ),
  [CommentEditState.EDIT]: EditState,
  [CommentEditState.DELETE]: () => <></>,
}
const commentEditorStateChildrenMap: {
  [key in CommentEditState]: () => JSX.Element
} = {
  [CommentEditState.DEFAULT]: DefaultState,
  [CommentEditState.OPTION_OPENED]: OptionOpenedState,
  [CommentEditState.CHECK_PASSWORD_EDIT]: () => (
    <CheckPasswordState stateTo={CommentEditState.EDIT} />
  ),
  [CommentEditState.CHECK_PASSWORD_DELETE]: () => (
    <CheckPasswordState stateTo={CommentEditState.DELETE} />
  ),
  [CommentEditState.EDIT]: EditState,
  [CommentEditState.DELETE]: () => <></>,
}

CheckPasswordState 컴포넌트의 재활용을 위해 비밀번호 확인이 수정을 위한 것인지 삭제를 위한 것인지를 나타내는 stateTo prop을 넘기도록 설계했습니다. 이제 각 컴포넌트에 수정, 삭제 등의 로직을 분리해 작성할 수 있게 됐습니다.

예를 들어 DefaultState 컴포넌트는 이런식입니다.

const DefaultState = () => {
  const { getStateSetter } = useCommentEditorStateSetter()
 
  return (
    <IconButton
      icon={FiMoreVertical}
      title='댓글 옵션 보기 버튼입니다.'
      onClick={getStateSetter(CommentEditState.OPTION_OPENED)}
    />
  )
}
const DefaultState = () => {
  const { getStateSetter } = useCommentEditorStateSetter()
 
  return (
    <IconButton
      icon={FiMoreVertical}
      title='댓글 옵션 보기 버튼입니다.'
      onClick={getStateSetter(CommentEditState.OPTION_OPENED)}
    />
  )
}

재사용성이 높은 버튼 컴포넌트 개발하기

블로그에는 텍스트가 가운데 위치하고 배경색이 들어가는 일반 버튼, 아이콘이 들어가는 아이콘 버튼 이렇게 두 종류가 존재하는데, 반복적으로 사용되므로 컴포넌트를 따로 분리하기로 했습니다.

그와 동시에 각 컴포넌트에는 일반적인 버튼 컴포넌트와 동일한 prop을 넘길 수 있도록 하고싶었습니다. 단순히 spread 문법으로 그대로 넘겨주는것 이상으로, 자동완성까지 가능하길 원했습니다.

로딩중 표현이 가능한 버튼 컴포넌트

특히 일반 버튼에 로딩중일 경우 로딩 인디케이터를 나타낼 수 있도록 한다면 멋진 버튼 컴포넌트가 될거라고 생각했습니다.

이를 위해 아래와 같은 컴포넌트를 만들었습니다.

type StyleProps = {
  width: string
  height: string
  isLoading: boolean
}
type Props = ComponentPropsWithoutRef<"button"> & StyleProps & { isLoading?: boolean }
const Button = ({ children, width, height, isLoading, onClick, ...props }: Props) => {
  const { subTextColor } = useTheme()
  const handleClick: MouseEventHandler<HTMLButtonElement> = (event) => {
    if (isLoading) {
      event.preventDefault()
      return
    }
 
    onClick?.(event)
  }
 
  return (
    <StyledButton
      width={width}
      height={height}
      isLoading={isLoading}
      onClick={handleClick}
      {...props}
    >
      {isLoading ? <Rings color={subTextColor} width={width} height={width} /> : children}
    </StyledButton>
  )
}
type StyleProps = {
  width: string
  height: string
  isLoading: boolean
}
type Props = ComponentPropsWithoutRef<"button"> & StyleProps & { isLoading?: boolean }
const Button = ({ children, width, height, isLoading, onClick, ...props }: Props) => {
  const { subTextColor } = useTheme()
  const handleClick: MouseEventHandler<HTMLButtonElement> = (event) => {
    if (isLoading) {
      event.preventDefault()
      return
    }
 
    onClick?.(event)
  }
 
  return (
    <StyledButton
      width={width}
      height={height}
      isLoading={isLoading}
      onClick={handleClick}
      {...props}
    >
      {isLoading ? <Rings color={subTextColor} width={width} height={width} /> : children}
    </StyledButton>
  )
}

우선 button 요소에 넘길 수 있는 prop을 가져오기 위해 react 라이브러리에서 제공하는 ComponentPropsWithoutRef 타입을 사용했습니다. 제네릭으로 "button"이라는 string을 넘기면 됩니다.

로딩중인지 여부를 확인하는 isLoading prop을 받아 true라면 Ring이라는 로딩 인디케이터를 렌더링합니다. onClickisLoadingtrue라면 함수 동작을 막는 코드도 추가해 견고함을 더했습니다.

리팩토링 전 댓글수정 예시

react-icons를 활용한 아이콘 컴포넌트

이번에는 아이콘을 가지고 있는 버튼 컴포넌트입니다. 로딩중 여부를 확인하는 등의 추가 기능이 있지는 않아서 특별할건 없지만, react-icons에서 가져온 컴포넌트를 prop으로 넘기는 방법을 고민했습니다.

type Props = {
  icon: IconType
  title: string
  size?: string
} & ComponentPropsWithoutRef<"button">
 
const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButtonForwardRef(
  { icon, title, size = "1rem", ...props },
  ref,
) {
  const Icon = icon
  const { textColor } = useTheme()
 
  return (
    <Button ref={ref} {...props}>
      <Icon color={textColor} size={size} title={title} />
    </Button>
  )
})
type Props = {
  icon: IconType
  title: string
  size?: string
} & ComponentPropsWithoutRef<"button">
 
const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButtonForwardRef(
  { icon, title, size = "1rem", ...props },
  ref,
) {
  const Icon = icon
  const { textColor } = useTheme()
 
  return (
    <Button ref={ref} {...props}>
      <Icon color={textColor} size={size} title={title} />
    </Button>
  )
})

이번에는 ref가 필요해서 forwardRef를 사용했습니다.

react-icons 라이브러리에서는 아이콘 컴포넌트를 의미하는 IconType을 제공합니다. 이를 icon 이라는 이름의 prop으로 받고, 다시 JSX 문법을 사용해 렌더링하기 위해 const Icon = icon;이라는 코드를 통해 대문자로 시작하는 변수에 재할당합니다.

덕분에 이렇게 사용할 수 있게 됐습니다.

import { FiMoreVertical } from "react-icons/fi"
 
const Sample = () => {
  return <IconButton icon={FiMoreVertical} title='SVG 요소 title' onClick={() => {}} />
}
import { FiMoreVertical } from "react-icons/fi"
 
const Sample = () => {
  return <IconButton icon={FiMoreVertical} title='SVG 요소 title' onClick={() => {}} />
}

Wrapup

너무 골치아파서 리팩토링을 미뤄왔던 컴포넌트를 드디어 건드렸네요.

처음 이 컴포넌트를 만들었을 때와는 달리 커스텀 훅이나 Context API를 적극적으로 사용할 수 있을 정도로 실력이 늘었다는게 뿌듯했습니다. 지금의 컴포넌트가 가장 좋은 설계라고 생각하진 않지만, 이 작업으로 많은 생각을 할 수 있었습니다.

특히, 복잡한 형태의 conditional rendering을 어떻게 설계하는게 좋을지 고민했던 과정이 재밌었습니다. 더 좋은 방법이 있다면 무엇일지 굉장히 궁금한 부분이기도 합니다.

이처럼 모든 부분에서 해결하고 충족되기만 하지는 않았습니다. 이번 리팩토링을 통해 이런 의문들을 가지게 됐습니다.

  • Context Provider는 어떻게 배치하는게 좋을까?
  • Compound Component와 Context Provider는 어떻게 하면 조화롭게 사용할 수 있을까?
  • 스타일 코드를 컴포넌트 로직과 같은 파일에 놓는것과 다른 파일로 분리하는것, 어느쪽이 더 좋은 방법일까?
  • 버튼이 눌릴 때마다 CSS Animation을 trigger 할 수 있는 더 깔끔한 방법은 뭘까?
    • 글에 적지는 못했지만 비밀번호 입력시 틀렸다면 에러 메세지를 흔드는 애니메이션을 trigger하기 위해 dummy boolean state를 사용했습니다.

저는 컴포넌트 설계를 고민하고 개선해나가는 데에서 리액트의 매력을 느끼나봅니다. 이번에 새로 생긴 질문들에 대한 답을 꼭 찾을 수 있도록 더 많은 코드들을 찾아보고 직접 적용해보고 싶네요.