Intro

위 사진은 리액트 공식 홈페이지 메인 화면입니다. 여기서 주목해야 하는 것은 library라는 표현입니다.
React를 라이브러리라고 하는 이유는 UI를 어떻게 구성할지에만 집중하고, 애플리케이션의 전체 구조나 흐름을 강제하지 않기 때문입니다.
라우팅, 상태 관리, 데이터 패칭 같은 핵심 요소를 자체적으로 포함하지 않고 선택을 개발자에게 맡깁니다.
즉, 이러한 자율성으로 인해 시간이 지날수록, 서로 다른 목적의 로직이 한 파일에 뒤섞인 구조를 만들기 쉽습니다.
특히 UI 로직과 비즈니스 로직이 구분되지 않은 코드는, 당장은 잘 동작하지만 시간이 흐를수록 기술 부채가 되기 쉽습니다.
아래 코드는 실제 현업에서 매우 흔하게 볼 수 있는 예시입니다.
// @feature/user/ui/user-invite-dialog.tsx
export function UserInviteDialog({ teamId }: { teamId: string }) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleInvite = async (email: string) => {
setLoading(true);
const me = await UserService.getMe();
if (me.role !== 'admin') {
setError('관리자만 초대할 수 있습니다');
setLoading(false);
return;
}
const members = await TeamService.getMembers(teamId);
if (members.length >= 10) {
setError('팀 인원 제한을 초과했습니다');
setLoading(false);
return;
}
try {
await InviteService.sendInvite({ teamId, email });
} catch {
setError('초대 전송에 실패했습니다');
} finally {
setLoading(false);
}
}
return <Dialog onSubmit={handleInvite} />;
}
겉보기에는 단순한 다이얼로그 컴포넌트입니다. 하지만 이 코드 안에는 화면을 그리는 책임과 제품의 규칙이 동시에 들어 있습니다.
이 글은 다음과 같은 고민을 겪어보신 분들께 추천드립니다.
- 컴포넌트가 커져서 기능을 수정하기 어렵다.
- 로직을 분리해야 하는데 어떤 기준으로 분리해야할지 잘 모르겠다.
- 테스트 코드를 작성할 때 UI부터 막힌다
이 글의 골자는 다음과 같습니다.
같은 목적을 가진 변경은 같은 위치에서 일어나야 하며,
UI 로직과 비즈니스 로직이 분리될 때 관리 비용이 감소한다.
이제, 왜 위 코드가 문제인지부터 차근히 살펴보겠습니다.
문제: 목적이 다른 로직이 한 곳에 모여 있다
많은 React 코드베이스에서 컴포넌트는 자연스럽게 다음 역할을 동시에 떠안습니다.
- 화면 렌더링
- 이벤트 처리
- 상태 관리
- 서버 데이터 검증
- 비즈니스 규칙 판단
- 여러 API 호출의 순서 제어
표면적으로는 “submit handler 하나”일 수 있습니다. 하지만 실제로는 UI 관심사와 비즈니스 관심사가 강하게 결합된 상태입니다.
이 결합이 왜 문제가 될까요?
문제 ① 변경 이유가 다르다
UI 변경과 비즈니스 변경은 출발점이 다릅니다.
- UI 변경: 로딩 스피너, 에러 메시지 문구, 인터랙션
- 비즈니스 변경: 관리자 정책, 인원 제한, 권한 규칙
그럼에도 이 둘이 같은 함수 안에 있으면, 서로 무관한 변경이 항상 같은 파일을 수정하게 됩니다.
이는 곧 사이드 이펙트와 리뷰 비용 증가로 이어집니다.
더 나은 유지보수를 위해서는 관심사에 따라 모듈이 분리되어야 합니다.
비즈니스 정책이 변경됐다 -> 비즈니스 로직 파일 변경
UI 표현 방식이 변경됐다 -> UI 로직 파일 변경
문제 ② 테스트가 어렵다.
비즈니스 분기가 5개라면, UI 테스트도 최소 5개의 시나리오를 강제로 거칩니다.
- 폼 렌더링
- 입력 채우기
- 버튼 클릭
- 네트워크 mocking
- UI 결과 검증
비즈니스 규칙을 검증하기 위해 매번 UI를 통과하는 구조는 목적에 맞는 테스트를 어렵게 만듭니다.
문제 해결 접근방식: “이 로직은 화면을 위해 존재하는가?”
로직을 분리할 때 가장 실용적인 질문은 이것입니다.
이 코드가 없다면 화면을 그릴 수 없는가?
- 그렇다 → UI 로직
- 아니다 → 비즈니스 로직
권한 검증, 정책 조건, API 호출 순서는 화면을 위해 존재하지 않습니다.
이는 비즈니스 로직이며, UI와 무관한 영역입니다.
해결: 비즈니스 로직을 Use Case로 격리한다

먼저 컴포넌트 안에 있던 비즈니스 로직을 하나의 함수로 추출합니다.
// @feature/user/api
import { getMe, getMembers, sendInvite} from './'
export interface InviteUserInput {
teamId: string;
email: string;
}
export async function inviteUser({ teamId, email }: InviteUserInput) {
const me = await getMe();
if (me.role !== 'admin') {
return { error: 'ONLY_ADMIN' } as const;
}
const members = await getMembers(teamId);
if (members.length >= 10) {
return { error: 'TEAM_LIMIT_EXCEEDED' } as const;
}
try {
await sendInvite({ teamId, email });
return { error: null } as const;
} catch {
return { error: 'INVITE_FAILED' } as const;
}
}
이렇게 격리한 비지지스 는 다음의 특징을 가집니다.
- 기술 의존성에서 격리되었습니다 (React를 모릅니다)
- UI 로직에서 격리되었습니다 (DOM과 state를 모릅니다)
- 오직 비즈니스 정책과 절차만 표현합니다
이 파일은 더 이상 “초대 화면”이 아니라, “사용자 초대 규칙”이라는 비즈니스 로직입니다.
UI는 결과를 보여주는데 집중한다
비즈니스 로직의 격리를 통해 UI 컴포넌트는 결과를 보여주는 역할에 집중합니다.
이제 UserInviteDialog는 구체적으로 어떻게 사용자를 초대하는지 알지 못하고 "사용자를 초대한다" 라는 근본적 목적에 집중하게 됐습니다. "어떤 조건으로 사용자를 초대한다"라는 부분은 비즈니스 정책적 부분이고 그 부분과 UI는 완벽히 격리되었습니다. 이제 UI에서는 보여주는 방식에만 집중하고 정책적 부분이 변경됐을 때는 비즈니스 모듈만 수정하면 됩니다.
// @feature/user/ui
export function UserInviteDialog({ teamId }: { teamId: string }) {
const [error, setError] = useState<string | null>(null);
async function handleInvite(email: string) {
const result = await inviteUser({ teamId, email });
if (result.error === 'ONLY_ADMIN') {
setError('관리자만 초대할 수 있습니다');
}
if (result.error === 'TEAM_LIMIT_EXCEEDED') {
setError('팀 인원 제한을 초과했습니다');
}
if (result.error === 'INVITE_FAILED') {
setError('초대 전송에 실패했습니다');
}
}
return <Dialog />;
}
UI(컴포넌트)는 이제 다음만 책임집니다.
- 사용자 입력 수집
- Use Case 호출
- 결과를 UI 언어로 변환
정책 변경은 이 파일을 건드리지 않습니다.
테스트 비용의 차이
분리 전에는 UI를 거쳐야만 테스트가 가능했습니다. 분리 후에는 비즈니스 로직을 바로 검증할 수 있습니다.
it('관리자가 아니면 초대할 수 없다', async () => {
const result = await inviteUser(
{ teamId: 't1', email: 'a@test.com' }
);
expect(result).toEqual({ error: 'ONLY_ADMIN' });
});
- UI 프레임워크라는 환경을 고려할 필요가 없습니다.
- 테스트 코드가 비즈니스 로직 적증이라는 목적에 집중할 수 있습니다.
마무리
UI 로직과 비즈니스 로직 분리는 코드 스타일의 문제가 아닙니다.
- 변경 비용을 줄이기 위한 구조적 선택이며
- 테스트 전략을 단순화하는 설계이고
- 팀이 사고하는 단위를 명확히 만드는 설계입니다.
UI는 표현하고, 비즈니스는 정책적 결정합니다.
이 경계가 명확할수록 앱의 구조가 덜 흔들립니다.
'react' 카테고리의 다른 글
| [설계] 실무에서 자주 겪는 잘못된 추상화 사례 (0) | 2025.12.19 |
|---|---|
| [설계] 모듈 응집도와 단방향 의존성을 통한 유지보수 비용 줄이기 (0) | 2025.12.18 |
| [최적화] Nextjs 번들 최적화 (Bundle Optimization) (0) | 2025.12.12 |
| 2025년에 돌아보는 react-query (0) | 2025.12.10 |
| FSD Architecture TypeScript 예시 (0) | 2025.08.29 |