좋은 프론트엔드 설계는 경계를 명확하게 만든다
재사용성보다 책임의 경계를 먼저 설계합니다. UI와 애플리케이션 의존성을 분리해 테스트와 Storybook 활용성을 높이는 방법을 다룹니다.
프론트엔드 컴포넌트를 설계할 때 흔히 재사용성부터 따진다. 버튼을 얼마나 범용적으로 만들지, 컴포넌트를 어느 크기로 나눌지, 중복 코드를 어떻게 줄일지 고민한다.
하지만 그보다 먼저 물어야 할 것이 있다.
이 컴포넌트는 어디까지 알아야 하는가?
화면을 그리는 컴포넌트가 API 호출 방법과 라우터 구조, 인증 상태, 전역 스토어까지 알고 있으면 처음에는 편하다. 문제는 애플리케이션이 커진 뒤다. 컴포넌트가 특정 실행 환경에 묶이고 독립적으로 렌더링하기 어려워진다. 테스트와 Storybook 작성도 번거로워진다. 디자인만 바꾸려 해도 비즈니스 로직을 함께 건드리게 된다.
설계를 잘하려면 컴포넌트에 많은 역할을 맡기기보다 서로 다른 책임이 만나는 자리를 분명히 해야 한다.
UI와 애플리케이션의 경계를 나눈다
프론트엔드 컴포넌트는 크게 두 부류로 볼 수 있다.
하나는 화면을 표현하고 사용자와 상호작용하는 UI 컴포넌트다. UI 컴포넌트는 외부 환경을 몰라도 작동해야 한다. 필요한 데이터는 props로 받고 사용자 행동은 이벤트로 내보낸다. 입력값이나 펼침 상태처럼 화면 안에서만 의미가 있는 상태는 직접 관리해도 된다. 다만 API나 라우터, 인증 컨텍스트, 전역 스토어에는 접근하지 않는다.
다른 하나는 UI와 애플리케이션을 잇는 Composition 컴포넌트다. 서버 상태를 조회하고, 데이터를 UI가 쓰기 좋은 형태로 바꾸며, UI에서 발생한 이벤트를 실제 애플리케이션 동작에 연결한다.
프로필 수정 화면을 예로 들어 보자.
type ProfileEditorProps = {
profile: ProfileViewModel;
status: 'idle' | 'pending' | 'success' | 'error';
errorMessage?: string;
onSubmit: (input: ProfileInput) => void;
onCancel: () => void;
};
export function ProfileEditor({
profile,
status,
errorMessage,
onSubmit,
onCancel,
}: ProfileEditorProps) {
// 화면 렌더링과 로컬 상호작용만 담당한다.
}ProfileEditor는 프로필 데이터가 어디서 왔는지 알 필요가 없다. 제출할 때 REST API를 호출하는지, GraphQL mutation을 실행하는지, 로컬 저장소에 기록하는지도 몰라도 된다. 현재 상태를 보여주고 사용자의 의도를 전달하면 그만이다.
애플리케이션 의존성은 별도 컴포넌트에서 연결한다.
export function ProfileEditorComposition() {
const profile = useCurrentProfile();
const updateProfile = useUpdateProfileMutation();
const router = useRouter();
return (
<ProfileEditor
profile={toProfileViewModel(profile.data)}
status={getMutationStatus(updateProfile)}
errorMessage={getErrorMessage(updateProfile.error)}
onSubmit={(input) => updateProfile.mutate(input)}
onCancel={() => router.back()}
/>
);
}이 구분은 파일을 두 개로 나누자는 형식적인 규칙이 아니다. 화면 표현과 애플리케이션 동작을 따로 바꿀 수 있게 하는 장치다. 디자인이 달라지면 UI 컴포넌트를 고치고, 데이터 저장 방식이 달라지면 Composition 컴포넌트를 고친다. 한쪽의 변화가 다른 쪽으로 함부로 번지지 않는다.
처리 방법이 아니라 사용자의 의도를 알린다
UI 컴포넌트는 사용자 행동을 직접 처리하기보다 이름이 분명한 이벤트로 표현해야 한다.
type LoginFormProps = {
onSubmit: (input: LoginInput) => void;
onForgotPassword: () => void;
onSignUp: () => void;
};onSubmit, onForgotPassword, onSignUp은 사용자가 무엇을 하려는지 보여준다. 이벤트 이름은 UI의 언어로 짓는다. 항목을 골랐다면 onSelect, 삭제를 요청했다면 onDelete, 실패한 작업을 다시 시도했다면 onRetry다.
여러 행동을 onEvent 하나로 묶고 event.type으로 나누는 방식보다 인터페이스를 읽기 쉽고 타입 검사도 수월하다. 어떤 상호작용을 제공하는지 props만 보고도 알 수 있기 때문이다.
UI는 이벤트 뒤에 무슨 일이 벌어질지도 정하지 않는다. 제출 이벤트가 API 호출로 이어질 수도 있고 임시 저장으로 끝날 수도 있다. 분석 이벤트를 함께 기록할 수도 있다. 그 선택은 애플리케이션 몫이다.
데이터와 이벤트는 한 방향으로 흐른다
흐름은 단순하다.
애플리케이션 상태
→ UI props
→ UI 렌더링
→ 사용자 이벤트
→ Composition
→ 애플리케이션 동작
→ 갱신된 상태
→ 새로운 UI props여기서 UI는 외부 작업의 결과를 짐작하지 않아야 한다.
async function handleSubmit() {
await onSubmit(values);
setCompleted(true);
}이 코드는 자연스러워 보이지만 문제가 있다. onSubmit이 끝났다고 화면을 바로 완료 상태로 바꿔도 되는지는 UI가 판단할 수 없다. 서버 응답을 더 확인해야 할 수 있고 다른 화면으로 이동할 수도 있다. 정책상 완료 표시가 필요 없을 수도 있다.
UI는 제출 의도만 보내고 결과 상태를 props로 받는 편이 낫다.
type FormProps = {
status: 'idle' | 'pending' | 'success' | 'error';
onSubmit: (input: FormInput) => void;
};
function handleSubmit() {
onSubmit(values);
}작업을 실행하는 쪽에서 상태를 갱신하고 UI는 전달받은 status를 화면에 반영한다. 그러면 상태 변화의 출발점과 도착점이 또렷해진다. 화면이 예상과 다르게 움직여도 props와 이벤트를 따라가며 원인을 찾을 수 있다.
모든 상태를 밖으로 밀어낼 필요는 없다
책임을 나눈다고 UI 컴포넌트가 아무 상태도 가져서는 안 된다는 뜻은 아니다. 상태를 어디에 둘지는 로컬인지 전역인지보다 그 상태가 무엇을 뜻하는지 보고 정해야 한다.
다음과 같이 화면 안에서만 의미가 통하는 상태는 UI가 관리해도 좋다.
- 메뉴가 펼쳐져 있는가
- 입력란에 포커스가 있는가
- 사용자가 어떤 값을 입력 중인가
- 마우스 포인터가 항목 위에 있는가
- 입력값이 로컬 형식 검사를 통과했는가
반면 서버에 저장된 프로필, 로그인한 사용자 정보, 결제 처리 결과, URL과 연결된 탐색 상태처럼 애플리케이션의 판단이나 외부 작업과 관계된 상태는 UI 밖에 둔다.
상태 관리는 모든 상태를 한곳에 모으는 작업이 아니다. 각 상태를 가장 잘 이해하는 코드가 맡도록 자리를 정하는 일이다.
Storybook은 설계의 경계를 시험한다
UI 컴포넌트가 API 클라이언트나 인증 Provider 없이 렌더링된다면 Storybook과 테스트에서도 간단히 쓸 수 있다.
<ProfileEditor
profile={profileFixture}
status="idle"
onSubmit={(input) => console.log(input)}
onCancel={() => console.log('cancel')}
/>로딩, 성공, 오류, 긴 텍스트, 빈 데이터 같은 상황을 실제 백엔드 없이 재현할 수 있다.
반대로 컴포넌트 하나를 띄우려고 라우터와 쿼리 클라이언트, 인증 컨텍스트, 전역 스토어를 모두 준비해야 한다면 그 컴포넌트는 애플리케이션 사정을 너무 많이 알고 있을 가능성이 크다.
Storybook에서 쉽게 렌더링되는지는 개발 편의만의 문제가 아니다. 경계가 제대로 나뉘었는지 확인하는 시험에 가깝다.
개발용 동작이 필요할 때도 UI 안에 환경 분기를 넣기보다 연결 계층을 바꾸는 편이 낫다.
const ProfileEditorEntry =
environment === 'development'
? ProfileEditorFixtureComposition
: ProfileEditorComposition;같은 UI를 두고 데이터와 동작을 공급하는 쪽만 교체하면 실제 환경과 개발 환경의 차이도 줄어든다.
분리의 목적은 변경 비용을 낮추는 데 있다
UI와 Composition을 나누면 파일이 늘어난다. 처음에는 지나친 구조처럼 보일 수 있다. 그렇다고 모든 작은 컴포넌트를 반드시 두 파일로 쪼개야 하는 것은 아니다. 외부 문맥이 없는 아이콘이나 버튼에는 Composition이 필요 없다.
중요한 것은 파일 이름이나 개수가 아니라 변경의 방향이다.
- API 없이 UI를 렌더링할 수 있는가?
- 외부에서 들어오는 값이 props에 드러나는가?
- 사용자 행동이 명시적인
on*이벤트로 표현되는가? - UI가 외부 작업의 실행 방법이나 결과를 넘겨짚고 있지는 않은가?
- 서버 상태와 화면 내부 상태를 누가 관리하는지 분명한가?
- Storybook에서 fixture만으로 주요 상태를 재현할 수 있는가?
- 특정 환경에서만 필요한 코드가 UI 안에 섞여 있지는 않은가?
이 질문에 답하기 어렵다면 경계를 다시 볼 필요가 있다.
좋은 프론트엔드 설계는 컴포넌트를 무조건 작게 쪼개는 기술과 거리가 있다. UI는 현재 상태를 보여주고 사용자의 의도를 알린다. Composition은 그 의도를 애플리케이션 동작으로 옮긴다. 애플리케이션은 바뀐 상태를 다시 UI에 건넨다.
이 흐름이 코드에 드러나면 디자인과 데이터 처리 방식이 달라져도 수정할 자리를 찾기 쉽다. 경계가 잘 잡힌 컴포넌트는 그래서 오래 버틴다.
READ NEXT
같은 주제의 글 더 보기
제 얼굴을 본뜬 SVG 프레젠터를 만들고, 프로그래머블 API와 TTS를 연동해 릴스 자동화 시스템에 넣었습니다. 팔과 손을 움직이던 첫 프로토타입부터 얼굴의 인상, 안경과 옷깃, 음성에 맞춘 동작을 다듬기까지 AI와 함께 겪은 시행착오를 정리했습니다.
Read EntrySpring·Kotlin 백엔드 구조를 다시 설계하며 UseCase, Domain, Repository의 책임과 상태 저장, 트랜잭션 경계를 정리한 기록입니다.
Read Entry
댓글 0
로그인 없이 익명으로 남길 수 있습니다.