원문: Dependency Inversion in React: Building Truly Testable Components
리액트 개발을 하다 보면 컴포넌트가 외부 의존성과 강하게 결합되는 경우가 많습니다. 이는 테스트를 어렵게 만들고, 유지보수를 힘들게 하며, 사실상 변경을 불가능하게 만듭니다. 의존성 역전 원칙(DIP)은 이러한 문제를 해결할 수 있는 방법을 제시하지만, 리액트에서 이 원칙을 효과적으로 적용하려면 어떻게 해야 할까요?
참고: 백엔드 관점에서 본 의존성 역전에 대해 알고 싶다면, 제가 이전에 작성한 Go에서 플러그인을 활용한 의존성 역전 글을 참고해보세요.
문제: 리액트에서의 강한 결합
다음과 같이 일반적인 상황을 생각해보세요.
const UserProfile = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/user")
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) return <LoadingSpinner />;
return <UserDetails user={user} />;
};
이 컴포넌트에는 아래와 같은 여러 가지 문제가 있습니다.
- fetch API에 강하게 결합되어 있습니다.
- API 호출이 직접적으로 이루어지기 때문에 테스트하기 어렵습니다.
- 데이터 소스를 변경하기가 어렵습니다.
- 로딩 상태를 쉽게 테스트하는 것이 사실상 불가능합니다.

해결책: 의존성 역전
의존성 역전 원칙은 핵심 로직(고수준 모듈)이 세부 구현(저수준 모듈)에 직접 의존해서는 안 되며, 둘 다 공통된 인터페이스와 같은 추상화된 구조에 의존해야 한다는 원칙입니다.
이제 이를 어떻게 리팩터링할 수 있는지 살펴보겠습니다.
interface UserRepository {
getUser: () => Promise<User>;
}
const UserProfile = ({ userRepository }: { userRepository: UserRepository }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
userRepository.getUser().then((data) => {
setUser(data);
setLoading(false);
});
}, [userRepository]);
if (loading) return <LoadingSpinner />;
return <UserDetails user={user} />;
};
(물론 이 모든 상태와 useEffect 로직은 커스텀 훅으로 분리할 수도 있지만, 여기서의 핵심 논점은 아닙니다.)
리포지토리 구현하기
이제 리포지토리의 구체적인 구현체를 만들 수 있습니다.
class ApiUserRepository implements UserRepository {
async getUser(): Promise<User> {
const response = await fetch("/api/user");
return response.json();
}
}
class MockUserRepository implements UserRepository {
private resolveUser: (user: User) => void = () => {};
private rejectUserPromise: (error: Error) => void = () => {};
getUser(): Promise<User> {
return new Promise((resolve, reject) => {
this.resolveUser = resolve;
this.rejectUserPromise = reject;
});
}
// Promise를 resolve 하기 위한 헬퍼 메서드
resolveWithUser(user: User) {
this.resolveUser(user);
}
// Promise를 reject 하기 위한 헬퍼 메서드
rejectUser(error: Error) {
this.rejectUserPromise(error);
}
}
쉬워진 테스트
이 구조를 사용하면 테스트가 훨씬 간단해집니다.
describe("UserProfile", () => {
it("shows loading state initially", () => {
const mockRepo = new MockUserRepository();
render(<UserProfile userRepository={mockRepo} />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
it("displays user data when loaded", async () => {
const mockRepo = new MockUserRepository();
render(<UserProfile userRepository={mockRepo} />);
// 데이터 페칭을 흉내냅니다.
mockRepo.resolveWithUser({
id: 1,
name: "Test User",
email: "test@example.com",
});
const userData = await screen.findByText("Test User");
expect(userData).toBeInTheDocument();
});
// 예외 테스트도 마찬가지로 간단하게 할 수 있지만, 간결함을 위해 제외했습니다.
});
모범 사례
- 명확한 인터페이스 정의하기: 의존성을 나타내는 인터페이스를 생성하세요
- 의존성 주입하기: 의존성을 프로퍼티나 컨텍스트를 통해 전달하거나, 더 나은 방법으로는 TSyringe를 사용하세요
- 독립적인 테스트: 각 컴포넌트는 의존성 없이도 독립적으로 테스트할 수 있어야 합니다.
결론
리액트에서 의존성 역전 원칙을 적용하면 다음과 같은 이점이 있습니다.
- 더 테스트하기 쉬운 컴포넌트
- 더 쉬워지는 유지보수
- 더 나은 관심사 분리
- 더 유연하고 재사용 가능한 코드
기억하세요. 목적은 복잡성을 더하는 것이 아니라, 코드를 더 유지보수하기 쉽고 테스트 가능한 구조로 만드는 데 있습니다. 작게 시작하되, 이 원칙들을 가장 효과적인 지점에 적용해보세요.

추가 읽을거리
- Clean Architecture by Robert C. Martin
- React Testing Library (공식 문서)
- Single Responsibility Principle in React (이전 글)
의존성 주입에 대한 참고 사항
이 가이드는 리액트에서 의존성 역전 원칙을 적용하는 데 초점을 맞추고 있어서, 의존성 주입을 깔끔하고 확장 가능한 방식으로 구현하는 구체적인 방법에 대해서는 깊이 다루지 않습니다. 하지만 이 주제에 대해 더 자세히 알아보고 싶다면, TSyringe와 같은 라이브러리가 리액트 애플리케이션에서 의존성을 효과적으로 관리하는 좋은 시작점이 될 수 있습니다.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article을 구독해주세요!
'개발 > 번역' 카테고리의 다른 글
| [번역] CSS 길이 단위 이해하기 (1) | 2025.09.02 |
|---|---|
| [번역] React.memo 완벽 해부: 언제 쓸모 있고 언제 쓸모없는가 (10) | 2025.08.11 |
| [번역] 리액트의 개방-폐쇄 원칙: 확장 가능한 컴포넌트 만들기 (3) | 2025.06.29 |
| [번역] 클린 코드의 심리학: 우리가 지저분한 리액트 컴포넌트를 작성하는 이유 (0) | 2025.06.21 |
| [번역] 일반적인 리액트 라이브러리 아키텍처 (0) | 2025.04.17 |