재우니 개발자 블로그

 

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;
    }
}

 

 

핵심 정리

  1. IoC (제어의 역전) - 객체 생성 책임이 개발자에서 프레임워크로 넘어감
  2. DI (의존성 주입) - 필요한 객체를 직접 생성하지 않고 외부에서 받아옴
  3. 장점:
    • 코드 유연성 증가 (쉽게 구현체 교체 가능)
    • 테스트 용이성 (가짜 객체로 테스트 가능)
    • 코드 재사용성 향상
    • 관심사 분리 (객체는 자신의 기능에만 집중)

 

IoC와 DI는 처음에는 복잡해 보이지만, 이해하고 나면 코드의 품질을 크게 향상시켜주는 중요한 패턴입니다. 실제로 Spring, ASP.NET Core와 같은 현대적인 프레임워크들은 모두 이 개념을 핵심으로 사용하고 있어요!