/

React 콜백 prop 네이밍 컨벤션

prop으로 콜백 함수를 넘겨 줄 때 on[EventName] 형태로 이름을 짓는다. 예를 들어 클릭 이벤트가 발생했을 때 이를 처리하는 prop은 onClick이다. 카드 컴포넌트 안의 버튼을 눌렀을 때 작동하는 prop은 onButtonClick(아니면 onClickButton)이다. 클린 코드 어딘가 나올 것 같은 "함수는 동사로 시작한다."라는 말을 왜 콜백 함수 prop에는 적용하지 않을까?

함수는 사용하는 위치에 따라 다양한 명명 규칙을 사용한다. 일반적인 함수는 입력을 받아서 어떤 행동을 취한 다음 값을 반환한다. 이 때문에 동사로 시작하는 이름이 그 역할을 잘 설명할 수 있다. 예를 들어, 날짜를 포매팅하는 함수의 이름은 formatDate로 짓는다. 날짜를 포매팅하는데 이름이 dateAsString이라거나, 그냥 date라면 어떤 함수인지 이름만 보고는 사용하기 힘들 것이다. 심지어는 함수가 아니라고 생각할 수도 있다. 행동을 잘 설명하는 이름이 인터페이스가 되어야 이 함수를 잘 활용할 수 있다.

한편, React의 컴포넌트나 훅을 생각해보자. 이들은 어떤 행동을 취한다기 보다는 UI의 일부를 추상화한다. 선언적 프로그래밍의 가호 아래 컴포넌트와 훅은 행동 대신에 역할 자체를 추상화하는 함수가 되었다. TextEditor 컴포넌트를 정의할 때 이 함수의 상태, 콜백 함수, side effect, 그리고 렌더링하는 다른 컴포넌트들은 이름에 나타낼 필요가 없다. (만약 나타낸다면 renderTextareaWithToolbar 같은 이름이 되지 않을까...) 그냥 TextEditor라고 하면 컴포넌트의 역할을 충분히 설명할 수 있고, 그 외의 것은 디테일일 뿐 인터페이스일 필요가 없다.

그리고 마침내 처음 눈에 띄였던 콜백 함수 prop이 있다. 이들의 이름은 함수 자체를 의미하지 않는다. 대신 함수의 자리를 의미한다. 행동이든 역할이든 일정한 개념을 코드로 만들고 거기에 이름을 붙여주는 것이 지금까지의 함수였다면, 콜백 함수 prop은 코드에 이름을 붙여주는 것이 아니다. 어떤 코드를 주면 컴포넌트가 그걸 특정한 상황에 실행시켜 주리라는 약속을 의미한다. 그렇기 때문에 on[EventName] 형태의 이름을 사용한다.

다음과 같은 코드가 있다.

function App() {
  const posts = usePosts()

  const addToFavorite = (id: string) => {
    /* 구현 생략 */
  }

  const deletePost = (id: string) => {
    /* 구현 생략 */
  }

  return (
    <>
      {posts.map((post) => (
        <PostCard
          key={post.id}
          post={post}
          onFavoriteButtonClick={addToFavorite}
          onDelete={deletePost}
        />
      ))}
    </>
  )
}

function PostCard({
  post,
  onButtonClick,
  onDelete,
}: {
  post: { id: string }
  onFavoriteButtonClick: () => void
  onDelete: (id: string) => void
}) {
  return <>{/* 구현 생략 */}</>
}

onButtonClick에는 addToFavorite 함수가 들어가고, onDelete에는 deletePost 함수가 들어간다. 특정한 역할을 수행하는 실제 코드에는 addToFavorite, deletePost 등의 동사형 이름이 붙어있다. 그리고 이들 함수가 위치하는 자리에 on[EventName] 형태의 이름이 붙어있다. onFavoriteButtonClick, onDelete은 어떤 실체를 의미하지 않는다. 실체에는 이미 다른 이름이 붙어있다. PostCard 컴포넌트는 콜백 prop을 이용해 자신을 사용할 코드에게 약속을 하는 것이다.

"내부 어딘가에 좋아요 버튼이 있는데 그걸 자세히 알 필요는 없고 그 버튼을 눌렀을 때 호출하는 함수를 자유롭게 설정할 수 있게 해줄게."

"내가 언젠가 post를 삭제하는 이벤트를 트리거할 텐데 그 때 호출할 함수를 자유롭게 설정할 수 있게 해줄게."

첫 번째 약속과 두 번째 약속은 조금 다르다. 첫 번째 약속은 "FavoriteButton"을 "click"했을 때(on) prop으로 전달한 콜백을 호출하겠다는 약속이다. click 이벤트는 HTML 노드에 일으킬 수 있는 자연스러운(?) 이벤트이다. 그렇기 때문에 이름을 쉽게 이해할 수 있다. 두 번째 약속은 "delete" 이벤트가 발생했을 때(on) prop으로 전달한 콜백을 호출하겠다는 약속이다. delete는 새롭다. 이건 HTML 노드에 일으킬 수 있는 이벤트가 아니다. PostCard가 정의한 이벤트이다. 모종의 이유로 PostCard 내부에서 자기 자신이 의미하는 post 객체를 삭제하고 싶은 것이다. 그리고 특정한 버튼 클릭 이벤트나 input의 변경, 그러니까 하나의 HTML 이벤트로 설명되지 않을 것이다. 그래서 여러 상황을 묶어 하나의 이벤트로 새롭게 정의하고 핸들러 구현을 바깥에 위임하는 것이다.

/**
 * 여러 군데에서 삭제 처리를 수행할 수 있다.
 */
function PostCard({ onDelete }) {
  return (
    <CardContainer>
      <CardHeader>
        <ActionsButton
          actions={[
            { id: "delete-post", name: "게시글 삭제", handler: onDelete },
          ]}
        />
      </CardHeader>

      <CardBody />

      <CardFooter>
        <Button onClick={onDelete}>삭제하기</Button>
      </CardFooter>
    </CardContainer>
  )
}

한동안 이런 생각을 바탕으로 코딩했는데 onDelete를 호출해주는 부분이 어색해졌다. 가끔 다음과 같은 코드가 나타나는데, 자리를 의미하는 on[EventName]을 직접 호출하는 것이 아무래도 어색했다.

const someLocalFunction = () => {
  onDelete(post.id)
}

엄밀하게 따진다면, prop destructuring하는 과정에서 이름을 동사형으로 바꿔서 실체를 만들어줘야 의미를 잘 나타내는 코드라는 생각이 들었다.

function PostCard({ onDelete: deletePost }) {
  const someLocalFunction = () => {
    deletePost(post.id)
  }
}

그런데 이렇게 일일히 이름을 바꾸는 건 비효율적이기도 하고, prop의 이름도 다시 동사형으로 짓고 싶게 만드는 충동을 불러 일으키기 때문에 그리 좋은 방법은 아닌 것 같다.

사실 컴포넌트에 이벤트 핸들러를 정의하지 않고 바로 실체가 있는 함수를 넣어주는 방법이 있다. Context API이다.

function PostCard() {
  const { deletePost } = useContext(PostHandle)
}

Context API를 통해 게시물 조작 객체를 참조할 수 있고 삭제 메서드를 바로 사용할 수 있다.

React가 개발자에게 많은 자유를 주는 라이브러리이기 때문에 prop 이름은 어떻게 지어도 작동에 문제가 되지는 않는다. 하지만 코드를 이해하는 방식을 코드에 최대한 반영한다면 점점 이해하기 쉬운 코드가 되어 다루기 쉬운 코드가 된다고 생각한다. 코드를 이해하는 방식을 맞추는 것은 단순 취향을 맞추는 것보다 합리적인 길이 존재하고, 그래서 더 적은 선택지에서 고민할 수 있다고 생각한다. React의 콜백 prop을 이벤트 핸들러로 이해하는 것도 코드의 작동방식을 이해하는 합리적인 길 중 하나라고 생각한다.