this is this
상황
내가 혼자 개발하고 있는 onef에 댓글 기능을 추가하기로 했다. 우선은 댓글 작성 로직과 작성 컴포넌트를 구현하였다. 이후에 댓글 수정 기능이 필요하다는 걸 깨닫고 보니 댓글 작성 로직에서 post만 put 메소드로 바꿔주면 된다는 사실을 알게되었다. 이를 위해 댓글 작성 컴포넌트에서 해당 로직을 추출하고, 필요한 로직은 Context를 사용해 외부에서 주입하는 방식으로 변경하였다.
// CommentMutation.ts export class CommentMutation extends MutationFn { constructor() { super(); } postComment() { return ({ parentId, value, depth }: { parentId: string; value: string; depth: number }) => this.mutationFn<{ id: string }>(`/comments/${parentId}`, "post", { comment: value, depth }); }; putComment() { return ({ parentId, value, depth }: { parentId: string; value: string; depth: number }) => this.mutationFn<{ id: string }>(`/comments/${parentId}`, "put", { comment: value, depth }); }; }
// CommentMutationContext.ts export const CommentMutationContext = createContext<{ mutationFn: typeof commentMutation.postComment; parentId: string; onSuccessBehavior?: () => void; }>({ parentId: "", mutationFn: commentMutation.postComment, });
// useCommentMutation.ts const useCommentMutation = ({ ... }: { ... }) => { const queryClient = useQueryClient(); const { parentId, mutationFn, onSuccessBehavior } = useContext(CommentMutationContext); const { mutate, isPending } = useMutation({ mutationFn: mutationFn(), onSuccess: () => { setValue(""); if (onSuccessBehavior) { onSuccessBehavior(); } queryClient.invalidateQueries({ queryKey: ["comment"] }); }, }); return { mutate }; }
문제
이론상으로는 문제가 없어보이는 데도 불구하고 어째서인지 댓글 수정 기능은 동작하지 않았다. 게다가 댓글 작성 기능까지도 동작하지 않게 되었다. Context를 사용하지 않았을 때는 문제 없이 동작했기 때문에, 나는 곧장 Context를 의심했다. 하지만 Context를 사용해서 내려보낸 postComment 함수와 putComment 함수를 콘솔에 찍어보니 별 문제가 없어보였다.
다음으로는 리액트 쿼리를 의심했지만, 요청에 실패했으니 당연히 onSuccess를 제외한 onError와 onSattled가 동작했다. 그런데 정말 요청에 실패했을까? 네트워크 탭을 확인해보니 놀랍게도 요청은 실패하지 않았다. 물론 성공하지도 않았다. 그저 요청 자체가 보내진 적 없었을 뿐이다.
formSubmit 로직 어딘가에 문제가 있다는 사실을 확인했지만, 정확히 뭐가 문제인지는 여전히 알 수 없었다. 나는 조금 더 자세히 확인해보기 위해 하나씩 콘솔에 함수나 값들을 찍어보기 시작했다. 그러다가 정말 이상한 점을 발견하게 되었다.

mutate는 useMutation 함수가 리턴하는 객체의 메소드이다. useMutation의 파라미터로 mutationFn을 넣어주면, 리액트 쿼리는 이 mutationFn을 그대로 mutate로 리턴하게 된다. 따라서 mutate는 ─ 아래의 코드를 살펴보면 알 수 있는 것처럼 ─ postComment가 리턴하는 함수와 동일하고 할 수 있다.
// CommentMutation.ts export class CommentMutation extends MutationFn { constructor() { super(); } postComment() { return ({ parentId, value, depth }: { parentId: string; value: string; depth: number }) => this.mutationFn<{ id: string }>(`/comments/${parentId}`, "post", { comment: value, depth }); }; // useCommentMutation.ts const { parentId, mutationFn, onSuccessBehavior } = useContext(CommentMutationContext); const { mutate, isPending } = useMutation({ mutationFn: mutationFn(), // 따라서 mutate 메소드는 // ({ parentId, value, depth }: { parentId: string; value: string; depth: number }) => // this.mutationFn<{ id: string }>(`/comments/${parentId}`, "post", { comment: value, depth }); // } 와 같다
그렇다면 어째서 mutate를 실행한 결과는 undefined인 것일까. 여기서부터는 아주 천천히 따라와야 한다.
mutate 메소드는 { parentId, value, depth }를 받아서 this.mutationFn() 실행 결과를 리턴한다. 나는 당연히 이 this가 CommentMutation 클래스일 것이라 생각했다. 하지만 실제로는 그렇지 않았다.
해결
확인 결과 Context API를 사용하는 과정에서 postComment가 클래스에 묶인 메소드에서 벗어나 하나의 독립적인 함수로 분리된다는 사실을 알 수 있었다. 때문에 mutate를 호출하면 postComment가 독립적으로 호출되며, postComment 내부의 this가 ─ strict mode에서는 undefined이며, strict mode가 아닐 경우 ─ window나 global과 같은 전역 객체를 가리키게 되어버리고 만 것이다.
<CommentMutationContext.Provider value={{ parentId: id, // 이 과정에서 this가 가리키는 대상이 바뀜 mutationFn: commentMutation.postComment, }} > <Comment.Input depth={depth} inputName="댓글" buttonName="저장" /> </CommentMutationContext.Provider>
전역 객체에 mutationFn이라는 메소드가 있을 리 없으니 당연히 아무런 동작도 하지 않으며, 이로 인해 useMutation({ mutationFn: mutationFn() })이 실제로는 useMutation({ mutationFn: undefined })로 평가되는 것이다.
고로 이를 해결하기 위해서는 크게 두 가지 방법을 사용해볼 수 있을 것 같다. 하나는 bind 같은 메소드를 사용하여 함수에 강제로 this를 바인딩하는 방식이다. 다른 하나는 메소드를 화살표 함수를 사용해 postComment가 하나의 독립적인 함수로 분리되어도 CommentMutation Class를 가리키도록 만드는 방식이다. 나는 후자의 방식을 사용했는데, 아무래도 일일이 bind를 사용하는 방식에 비해 별다른 코드를 추가할 필요도 없고, 훨씬 더 직관적으로 느껴졌기 때문이다. 그리하여 수정한 코드는 아래와 같다.
export class CommentMutation extends MutationFn { constructor() { super(); } postComment = () => { return ({ parentId, value, depth }: { parentId: string; value: string; depth: number }) => this.mutationFn<{ id: string }>(`/comments/${parentId}`, "post", { comment: value, depth }); } putComment = () => { return ({ parentId, value, depth }: { parentId: string; value: string; depth: number }) => this.mutationFn<{ id: string }>(`/comments/${parentId}`, "put", { comment: value, depth }); }; }
결론
JS의 this 바인딩 규칙은 상당히 악명?높다. 자바스크립트의 깊은 곳까지 열심히 찾아보고 다닌 나 조차도 '필요할 때 확인하면 그만이다'하고 대충 보고 넘겼을 정도이니까. 그런데 요즘 프론트 및 백엔드 코드에 점점 더 많이 그리고 자주 클래스를 사용하고 있고, 그에 따라 다양한 곳에서 this를 사용하고 있다. 지금처럼 대충 뉘앙스 정도로만 아는 수준으로 this를 사용하다가는 큰 문제가 일어나도 원인을 찾지 못할지도 모르겠다.
이번 에러 케이스의 결론이랄까, 언젠가 하루 날 잡고 this 바인딩 규칙을 제대로 익혀야겠다.
블로그의 정보
Ayden's journal
Beard Weard Ayden