
오늘은 "적절한 추상화는 무엇일까?" 고민하면서 코드 리팩토링한 과정을 코드와 함께 공유드리겠습니다.
위 영상처럼 채팅을 치면 자동으로 스크롤이 밑으로 내려가는 컴포넌트가 있습니다.
해당 기능이 다른 컴포넌트에서도 사용하게 되어 추상화하여 공통 훅으로 만들어야 했습니다.
// Before
const useScrollToBottom = () => {
const messages = useChattingWindowStore(state => state.messages);
useLayoutEffect(() => {
const list = document.querySelector('[aria-label="scrollable-list"]');
list?.scrollTo({ top: list.scrollHeight, behavior: 'instant' });
}, [messages]);
return messages;
};
다만 현재 코드는 messages라는 상태에 강하게 결합되어 범용적인 스크롤 하단 이동 훅으로 추상화하지 못했습니다.
// After
const useScrollToBottom = ({
dependencies,
scrollRef,
}: useScrollToBottomProps) => {
useLayoutEffect(() => {
const scroll = scrollRef.current;
scroll?.scrollTo({ top: scroll.scrollHeight });
}, dependencies);
};
// 사용 예시
function ScrollableList({ children }: ScrollableListProps) {
const listRef = useRef<HTMLUListElement>(null);
useScrollToBottom({
dependencies: [children],
scrollRef: listRef,
});
return (
<ul
className="h-full w-full overflow-y-auto px-5"
aria-label="scrollable-list"
ref={listRef}
>
{children}
</ul>
);
}
우선, useScrollToBottom 훅에서의 messages 의존을 제거하였습니다.
useLayoutEffect의 의존성을 주입받고, 리렌더링 되어도 항상 같은 값을 참조하는 useRef를 사용하여 안정성을 높였습니다.
따라서 useScrollToBottom 훅은 컨텐츠가 추가되어도 자동으로 최하단이 보여야 하는 모든 컴포넌트에서 사용 가능한 훅으로 개선되었습니다.

프로젝트 내 채팅창에는 4가지 형태의 메세지가 보여져야 합니다.
처음 프로젝트 개발 당시에는 해당 메세지가 단순히 보여지는 형태만 다를 거라고 생각했습니다.
그래서 이 네 가지 메시지를 하나의 컴포넌트인 NameTaggedMessage로 구현해 재사용하려 했습니다.
function NameTaggedMessage({ sender, message, type }: NameTaggedMessageProps) {
const isRightAligned = type === CHAT_TYPES.ANSWER || type === CHAT_TYPES.USER;
return (
<div className={`flex flex-col ${isRightAligned ? 'items-end' : 'items-start'} mb-3`}>
<div className="mb-1 text-xs text-gray-400">{sender}</div>
<div
role="listitem"
aria-label="name-tagged-message"
className={`max-w-[75%] rounded-2xl px-4 py-2 text-sm break-words whitespace-pre-wrap shadow-sm ${chatStyleMap[type]} ${
isRightAligned
? 'rounded-tl-2xl rounded-tr-md rounded-bl-2xl'
: 'rounded-tl-md rounded-tr-2xl rounded-br-2xl'
}`}
>
{message}
</div>
</div>
);
}
다만 실제 서버에서는 메시지 타입이 Question, Answer, Chat의 세 가지로만 구분되어 전달되었고, 그 중 Chat 타입은 메시지를 보낸 sender가 나인지 아닌지를 비교해 내 채팅인지 상대방 채팅인지 클라이언트에서 직접 판단해야 했습니다.
이로 인해 NameTaggedMessage 컴포넌트는 다음과 같은 이중 분기 구조를 가지게 되었습니다.
1차 분기: 메시지의 type에 따라 스타일/정렬 결정2차 분기: Chat 타입일 경우, sender가 나인지 비교하여 정렬/색상 분기그래서 이를 다음과 같이 두 개의 역할 중심 컴포넌트로 분리하였습니다:
NameTaggedMessage: 일반 채팅 메시지 전용 컴포넌트QuestionAnswerMessage: 면접 질문/답변 전용 컴포넌트각 컴포넌트는 자신이 다루는 메시지 유형에만 집중할 수 있게 되어, 내부 분기 로직이 단순해지고 UI 표현도 명확해졌습니다.
function NameTaggedChatMessage({ message }: NameTaggedChatMessageProps) {
const { sender, text, isMyMessage = false } = message;
return (
...
);
}
function QuestionAnswerMessage({ message }: { message: Message }) {
const { sender, text, type } = message;
const isQuestion = type === CHAT_TYPES.question;
return (
...
);
}
UI가 비슷하다는 이유로 하나의 컴포넌트로 묶었지만, 실제로는 표현해야 하는 맥락이 다른 메세지들이었습니다. 실제 컴포넌트의 역할을 기준으로 분리하는 게 중요하다는 걸 배우게 된 것 같습니다.
코드의 재사용만 생각하면서 구현을 하다가 오히려 좋지 않은 추상화를 하게 되었습니다.
차라리 가벼운 코드 중복은 눈 감아주고 3군데 이상 공통적으로 쓰이게 될 경우 기능을 기준으로 추상화하고자 마음 먹었습니다.