IoC와 DI 개념 설명 - 개발자를 위한 가이드
안녕하세요! 제어의 역전(IoC)과 의존성 주입(DI)이 처음에는 복잡하게 느껴질 수 있어요. 코드 예제를 통해 쉽게 이해해봅시다.
전통적인 방식의 문제점
먼저 전통적인 코드를 살펴보겠습니다:
class UserService {
private UserRepository userRepository;
public UserService() {
// 서비스가 직접 의존성을 생성 (강한 결합)
this.userRepository = new UserRepository();
}
public User getUserById(int id) {
return userRepository.findById(id);
}
}
이 코드의 문제점:
- UserService가 UserRepository를 직접 생성합니다
- 만약 UserRepository를 다른 구현체로 바꾸고 싶다면? (예: 테스트용 가짜 리포지토리)
- 코드를 변경해야 하기 때문에 유연하지 않습니다
IoC와 DI가 해결하는 방법
IoC/DI 방식은 이렇게 바뀝니다:
class UserService {
private IUserRepository userRepository;
// 외부에서 필요한 객체를 받아옵니다 (의존성 주입)
public UserService(IUserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(int id) {
return userRepository.findById(id);
}
}
개선된 점:
- UserService는 더 이상 UserRepository를 직접 생성하지 않습니다
- 대신 생성자를 통해 외부에서 주입받습니다
- 인터페이스(IUserRepository)에 의존하므로 실제 구현체는 언제든지 교체할 수 있어요
쉬운 비유로 이해하기
IoC와 DI를 식당에 비유해 볼게요:
전통적인 방식 (IoC 없음):
- 당신(UserService)이 직접 요리사(UserRepository)를 고용하고 관리해야 함
- 요리사를 바꾸려면 많은 절차가 필요함
IoC/DI 방식:
- 식당 매니저(IoC 컨테이너)가 요리사를 고용하고 관리함
- 당신은 그냥 "요리사 보내주세요"라고 요청하면 됨 (의존성 주입)
- 언제든 다른 요리사로 쉽게 교체 가능
코드로 더 쉽게 이해하기
1. 인터페이스 정의하기
인터페이스는 "계약서"와 같습니다. "이런 기능을 제공해야 해"라고 정의만 하는 거예요.
// Java
interface IUserRepository {
User findById(int id); // "사용자를 ID로 찾을 수 있어야 해"라는 계약
}
// C#
interface IUserRepository
{
User FindById(int id); // 같은 의미의 계약
}
2. 구현체 만들기
이제 이 계약을 실제로 구현한 클래스들을 만듭니다:
// 실제 데이터베이스 사용 구현체
class UserRepository implements IUserRepository {
@Override
public User findById(int id) {
// 실제 데이터베이스에서 조회
return new User(id, "사용자" + id);
}
}
// 테스트용 가짜 구현체
class MockUserRepository implements IUserRepository {
@Override
public User findById(int id) {
// 데이터베이스 없이 가짜 데이터 반환
return new User(id, "테스트 사용자");
}
}
3. 의존성 주입 사용하기
이제 실제 서비스에서 이 구현체들을 사용할 차례입니다:
// 서비스는 인터페이스에만 의존함
class UserService {
private IUserRepository userRepository;
// 생성자 주입 - 외부에서 필요한 것을 주입받음
public UserService(IUserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(int id) {
return userRepository.findById(id);
}
}
4. 클라이언트 코드에서 활용하기
// 메인 애플리케이션에서
public class Main {
public static void main(String[] args) {
// 실제 리포지토리 사용
IUserRepository realRepo = new UserRepository();
UserService productionService = new UserService(realRepo);
User user = productionService.getUserById(1);
// 테스트 시에는 가짜 리포지토리 사용
IUserRepository mockRepo = new MockUserRepository();
UserService testService = new UserService(mockRepo);
User testUser = testService.getUserById(1);
}
}
의존성 주입의 3가지 방법
1. 생성자 주입 (가장 권장됨)
class UserService {
private final IUserRepository userRepository;
// 생성자를 통해 주입
public UserService(IUserRepository userRepository) {
this.userRepository = userRepository;
}
}
장점:
- 객체 생성 시점에 모든 의존성이 준비됨
- final 키워드로 불변성 보장 가능
- 필수 의존성을 명확히 표현
2. 세터 주입
class UserService {
private IUserRepository userRepository;
// 세터를 통해 주입
public void setUserRepository(IUserRepository userRepository) {
this.userRepository = userRepository;
}
}
언제 사용?
- 선택적 의존성이 있을 때
- 의존성을 나중에 바꿔야 할 때
3. 필드 주입 (프레임워크 사용 시)
// Spring 사용 시
class UserService {
@Autowired // 스프링이 알아서 주입해줌
private IUserRepository userRepository;
}
특징:
- 코드는 간결하지만 테스트하기 어려움
- 숨은 의존성이 생길 수 있어 권장되지 않음
실제 사용 예: 데이터베이스 변경 시나리오
다음 시나리오를 상상해보세요:
- 처음에는 MySQL 데이터베이스를 사용
- 나중에 MongoDB로 변경하기로 결정
전통적인 방식:
- UserService 내부의 코드를 직접 수정해야 함
- 모든 관련 파일을 찾아 변경해야 함
- 새 코드에 대한 테스트가 어려움
IoC/DI 방식:
// 먼저 MySQL 사용
UserRepository mysqlRepo = new MySqlUserRepository();
UserService service = new UserService(mysqlRepo);
// MongoDB로 변경 결정 시
UserRepository mongoRepo = new MongoUserRepository();
UserService service = new UserService(mongoRepo);
// UserService 코드는 전혀 변경할 필요 없음!
DI 컨테이너란?
실제 프로젝트에서는 수많은 객체와 의존성이 있습니다. 이런 복잡한 객체 생성과 주입을 자동화해주는 도구가 DI 컨테이너입니다.
Spring (Java) 예제:
@Configuration
class AppConfig {
@Bean
public IUserRepository userRepository() {
return new UserRepository();
}
@Bean
public UserService userService(IUserRepository userRepository) {
return new UserService(userRepository);
}
}
ASP.NET Core (C#) 예제:
// 시작 시 의존성 등록
services.AddSingleton<IUserRepository, UserRepository>();
services.AddScoped<UserService>();
// 사용할 때 (컨트롤러)
public class UserController : Controller
{
private readonly UserService _userService;
// 프레임워크가 자동으로 주입해줌
public UserController(UserService userService)
{
_userService = userService;
}
}
핵심 정리
- IoC (제어의 역전) - 객체 생성 책임이 개발자에서 프레임워크로 넘어감
- DI (의존성 주입) - 필요한 객체를 직접 생성하지 않고 외부에서 받아옴
- 장점:
- 코드 유연성 증가 (쉽게 구현체 교체 가능)
- 테스트 용이성 (가짜 객체로 테스트 가능)
- 코드 재사용성 향상
- 관심사 분리 (객체는 자신의 기능에만 집중)
IoC와 DI는 처음에는 복잡해 보이지만, 이해하고 나면 코드의 품질을 크게 향상시켜주는 중요한 패턴입니다. 실제로 Spring, ASP.NET Core와 같은 현대적인 프레임워크들은 모두 이 개념을 핵심으로 사용하고 있어요!