우리가 리액트로 프로젝트를 진행하면서 한 번쯤은 꼭 고민하게 되는, 하지만 용어만 들으면 왠지 모르게 머리가 아파지는 주제를 가져왔습니다. 바로 ‘리액트 의존성 주입’과 그게 우리 코드를 얼마나 유연하게 만들어주는지에 대한 이야기입니다.
사실 저도 처음 개발을 시작했을 때는 “그냥 코드 짜서 돌아가기만 하면 되는 거 아니야?”라고 생각했습니다. 그런데 프로젝트 규모가 커지고 기획이 바뀌면서 남이 짠 코드를 수정해야 하는 상황이 오니, 정말 지옥이 따로 없었습니다. 그때 코드는 단순히 돌아가는 게 문제가 아니라 ‘어떻게 잘 바뀌느냐’가 핵심이라는 것을 깨달았습니다.
자, 그럼 서론은 이쯤 하고, 동료와 커피 한 잔 마시면서 수다를 떤다는 기분으로 편하게 시작해 보겠습니다!
의존성 주입, 어떻게 생겨난 용어인가요?
이 용어는 이름부터가 좀 딱딱하지요? 원래 이 개념은 우리가 잘 아는 마틴 파울러(Martin Fowler)라는 아주 유명한 엔지니어가 2004년에 처음 제안한 것입니다. 그전에는 ‘제어의 역전(Inversion of Control, IoC)’이라는 더 포괄적인 용어를 사용했는데, 범위가 너무 넓다 보니 “좀 더 구체적으로 어떤 패턴인지 이름을 정하자” 하여 나온 것이 바로 ‘의존성 주입(Dependency Injection, DI)’입니다.
백엔드 개발자분들, 특히 스프링(Spring) 프레임워크를 사용하는 분들에게는 거의 숨 쉬듯 당연한 개념이지만, 우리 리액트 개발자들에게는 조금 생소할 수 있습니다. 하지만 걱정하지 마세요. 용어만 거창할 뿐, 사실 우리는 이미 은연중에 리액트 의존성 주입을 사용하고 있습니다.
의존성이 뭔데요?
쉽게 말해볼까요? 여러분이 요리사라고 가정해 봅시다. 맛있는 파스타를 만들려면 ‘칼’과 ‘냄비’가 필요하겠지요? 이때 요리사인 여러분은 칼과 냄비에 의존하고 있는 것입니다. 프로그램에서도 마찬가지입니다. 어떤 객체나 함수가 자기 기능을 다 하기 위해서 외부의 다른 객체나 함수를 가져다 쓴다면, 그것을 ‘의존성’이라고 부릅니다.
예를 들어, 우리가 API 통신을 할 때 axios나 fetch를 사용한다면, 우리 컴포넌트는 axios라는 라이브러리에 의존하고 있는 셈입니다.
왜 이게 중요한데요?
이게 왜 중요할까요? 제가 겪은 실화 하나를 들려드리겠습니다. 예전에 결제 기능을 만들 때였습니다. 처음에는 ‘A 결제 모듈’만 사용하기로 해서 컴포넌트 깊숙한 곳에 A 모듈 코드를 다 박아넣었습니다. 그런데 갑자기 기획이 바뀌어 ‘B 결제 모듈’로 교체해야 하는 상황이 발생했습니다.
결제 관련 컴포넌트 수십 개를 일일이 다 찾아가서 코드를 고치는데, 정말 눈물이 나더라고요. 이때 만약 제가 리액트 의존성 주입 개념을 제대로 알고 코드를 짰다면, 컴포넌트 내부 코드는 건드리지 않고 밖에서 모듈만 쉽게 갈아 끼웠을 것입니다. 즉, 코드가 ‘변경에 유연’해지는 것이지요. 이것이 바로 우리가 DI를 공부해야 하는 가장 큰 이유입니다.
의존성은 교체 가능해야 합니다.
유연한 코드의 핵심은 ‘교체 가능성’입니다. 제가 예시를 하나 바꿔서 들어보겠습니다. 우리가 ‘알림 서비스’를 만든다고 생각해 봅시다.
보통 초보 때는 이렇게 작성하기 쉽습니다.
// 알림을 보내는 서비스
class KakaoNotifier {
send(message) {
console.log(`카톡 알림: ${message}`);
}
}
class UserActivityLogger {
constructor() {
this.notifier = new KakaoNotifier(); // 클래스 내부에서 직접 생성 (하드코딩)
}
log(activity) {
this.notifier.send(`사용자 활동: ${activity}`);
}
}
자, 여기서 UserActivityLogger는 KakaoNotifier를 직접 만들어서 쓰고 있습니다. 이것을 ‘하드코딩된 의존성’이라고 합니다. 만약 나중에 “카톡 말고 이메일로 보내줘!”라고 한다면 UserActivityLogger 내부 코드를 직접 수정해야 합니다.
하지만 DI를 적용하면 코드는 다음과 같이 바뀝니다.
class UserActivityLogger {
constructor(notifier) { // 밖에서 주입받음!
this.notifier = notifier;
}
log(activity) {
this.notifier.send(`사용자 활동: ${activity}`);
}
}
이제 밖에서 카톡 알림이를 넣어주든 이메일 알림이를 넣어주든 UserActivityLogger는 신경 쓰지 않아도 됩니다. 주입받은 것을 사용하기만 하면 되기 때문입니다. 이것이 바로 교체 가능한 구조입니다.
테스트 용이한가
의존성 주입의 또 다른 엄청난 장점은 ‘테스트’입니다. 여러분, 실제로 API를 호출하는 컴포넌트를 테스트해 본 적이 있으신가요? 매번 테스트할 때마다 실제 서버에 요청을 보낼 수는 없습니다. 비용도 들고 속도도 느리기 때문이지요.
만약 의존성을 주입받는 구조라면, 테스트할 때만 ‘가짜(Mock)’ 의존성을 주입하면 됩니다. “실제 서버에 요청 보내지 말고, 그냥 성공했다고 가정하고 이 데이터만 돌려줘!”라고 가짜 객체를 던져주는 것이지요. 그러면 컴포넌트가 로직대로 잘 돌아가는지 아주 빠르고 안전하게 확인할 수 있습니다. 테스트가 쉬운 코드가 곧 좋은 코드라는 말은 공연히 있는 것이 아니었습니다.
컴포넌트를 통합하는데 어려움을 겪지는 않는가
리액트에서 컴포넌트를 설계할 때도 이 원리가 똑같이 적용됩니다. 예를 들어, 어떤 데이터를 리스트로 보여주는 DataList 컴포넌트가 있다고 해봅시다. 이 컴포넌트가 내부에서 직접 데이터를 fetch 해오면 다른 곳에서 재사용하기가 참 까다롭습니다.
대신, 데이터를 가져오는 함수나 혹은 리스트의 각 항목을 그리는 컴포넌트를 props로 주입받는다고 생각해 보세요.
const DataList = ({ fetchData, ItemComponent }) => {
const [data, setData] = useState([]);
useEffect(() => {
fetchData().then(setData);
}, [fetchData]);
return (
<div>
{data.map(item => <ItemComponent key={item.id} data={item} />)}
</div>
);
}
이렇게 작성하면 DataList는 어떤 데이터를 가져오는지, 어떤 모양으로 그리는지 몰라도 됩니다. 그저 시키는 대로 할 뿐이지요. 나중에 ‘공지사항 리스트’로도 사용할 수 있고 ‘상품 리스트’로도 활용할 수 있게 되는 것입니다.
fetch api와 response type
우리가 흔히 사용하는 fetch나 axios 로직도 마찬가지입니다. 컴포넌트 여기저기에 API 호출 주소와 응답 데이터 가공 로직이 흩어져 있으면, 서버 응답 형식이 바뀌는 순간 대재앙이 일어납니다.
이럴 때 ‘서비스 레이어’를 만들어서 Context API 같은 것으로 주입해 주면 관리가 훨씬 편해집니다. 사실 서버 응답 형식이 바뀌었을 때 컴포넌트 100개를 고칠지, 아니면 주입해 주는 서비스 로직 1개만 고칠지는 순전히 우리의 설계에 달린 문제이기 때문입니다.
리액트에서는 어떻게 의존성 주입을 할 수 있나요?
자, 이제 실전입니다. 리액트에서 DI를 구현하는 방법은 크게 몇 가지가 있습니다.
1. Props
가장 기본이지요. 위에서 보여드린 것처럼 함수나 객체를 props로 넘겨주는 것입니다. “이게 무슨 주입이야? 그냥 프롭스지!”라고 하실 수도 있겠지만, 객체 지향 관점에서 보면 이것도 훌륭한 의존성 주입입니다.
2. Context API
이것은 아주 강력합니다. 많은 분이 Context API를 ‘상태 관리 도구(Redux 같은 것)’라고 생각하시지만, 사실 이것은 리액트 의존성 주입 도구에 더 가깝습니다.
상태 관리 도구라면 상태를 업데이트하는 기능이 핵심이어야 하는데, Context 자체에는 그런 기능이 없기 때문입니다. 그저 ‘저 멀리 있는 조상 컴포넌트가 자손들에게 필요한 물건(의존성)을 전달해 주는 통로’ 역할을 하는 것입니다.
예를 들어, AuthService나 LoggingService 같은 것들을 Provider에 담아서 최상단에 하나만 두면, 아래에 있는 어떤 컴포넌트든 useContext로 꺼내서 사용할 수 있습니다.
글을 마치며
오늘은 리액트 의존성 주입이라는 주제로 이야기를 나누어 보았습니다. 처음에는 좀 어렵게 느껴질 수 있지만, 핵심은 딱 하나입니다. “내 코드가 외부 환경(라이브러리, API 등)에 너무 꽉 묶여있지 않은가?”를 고민해 보는 것입니다.
물론 모든 곳에 DI를 적용할 필요는 없습니다. 작은 프로젝트나 간단한 컴포넌트에는 오히려 오버엔지니어링이 될 수도 있기 때문입니다. 하지만 여러분의 서비스가 점점 커지고 있다면, 한 번쯤은 “어떻게 하면 더 유연하게 바꿀 수 있을까?”라는 질문을 던져보시길 바랍니다.
참고로 제가 아까 ‘IoC’가 범위가 너무 넓어서 ‘DI’라는 용어가 나왔다고 말씀드렸는데, 사실 IoC의 구체적인 구현 방식 중 ‘프록시 패턴’이나 ‘런타임 바인딩’의 아주 깊은 원리까지는 저도 완벽히 다 알지는 못합니다. (솔직히 그쪽은 백엔드 프레임워크 설계자분들의 영역이라 정말 어렵더라고요!) 하지만 프론트엔드 개발자로서 이 개념을 어떻게 우리 컴포넌트에 녹여낼지 고민하는 것만으로도 실력은 쑥쑥 자랄 것이라고 확신합니다.
긴 글 읽어주셔서 감사합니다. 여러분의 코드가 오늘보다 내일 더 유연해지길 응원하겠습니다! 다음에 또 유익한 이야기로 만나 뵙겠습니다. 안녕히 계세요!