본문 바로가기
JAVA

[Spring] 스프링 제어의 역전(IoC) 와 의존성 주입(DI) 완벽 이해하기 Feat.빈(Bean)

by code:J 2023. 8. 1.
반응형
SMALL

제어의 역전(IoC)과 의존성 주입(DI)에 대해서 알아보기 전에 한 번쯤은 들었지만 무엇인지 자세하게 모르는 빈(Bean)이라는 설명드리고 시작하겠습니다.

 

빈(Bean)

스프링 프레임워크는 스프링 규약에 의해 스프링 컨테이너가 관리하는 객체를 빈(Bean)이라고 지칭합니다.

빈은 스프링 컨테이너에 의하여 생성되고, 구성되고, 관리되는 객체를 말합니다.

 

빈을 선언하기 위해서는 스프링 컨테이너 설정파일(XML 또는 Java Config)에서 빈을 정의하거나 어노테이션을 사용하여 빈으로 등록할 수 있습니다.

 

스프링 컨테이너는 이러한 빈들을 생성하고, 필요에 따라 의존성을 주입하여 관리합니다.

 

스프링 빈은 일반적으로 싱글톤(Singleton)으로 생성되어 기본적으로 애플리케이션 전체에서 하나의 인스턴스가 공유됩니다.

 

하지만 빈의 스코프(scope)를 조정하여 프로토타입(Prototype)과 같이 다른 스코프로 동작하도록 설정할 수도 있습니다.

 

 

스프링 제어의 역전(IoC)

제어의 역전은 애플리케이션의 제어 흐름이 개발자가 작성한 코드에서 스프링 프레임워크로 넘어간다는 개념을 의미합니다.

'개발자가 작성한 코드에서 스프링 프레임워크로 넘어간다'라는 말은

일반적으로 개발자가 프로그램의 흐름을 제어하는 것은 콜스택(Call Stack)을 따라가며 직접 객체를 생성하고, 의존성을 주입하며, 메서드를 호출하는 것과 같은 작업을 수행합니다.

 

그러나 이런 방식은 유지 보수 측면에서 어려울 뿐만 아니라 테스트코드를 작성하기도 어려워지는 단점이 있습니다.

 

이렇게 말씀드리면 와닿지 않을 수도 있기 때문에 가장 많이 사용하는 UserService라는 서비스 계층으로 예시를 들어 보겠습니다.

 

제어의 역전이 적용되지 않는 경우

public class UserService {
    private UserRepository userRepository;

    public UserService() {
        userRepository = new UserRepository(); // 직접 UserRepository 인스턴스를 생성
    }

    public void saveUser(User user) {
        // 유효성 검사, 비즈니스 로직 등을 수행
        userRepository.save(user); // 직접 UserRepository의 메서드를 호출
    }
}

위의 UserService 클래스는 UserRepository에 의존하고 있으며, 직접 인스턴스를 생성하고 있습니다.

이러한 방식은 유연성이 떨어지고, 객체 간의 결합도가 높아져서 확장이 어렵습니다.

 

유연성이 떨어지고, 객체 간의 결합도가 높아진다는 말은 아래 예시로 설명드리겠습니다.

 

간단한 사용자 관리 웹 애플리케이션을 만든다고 가정해 봅니다.

이 애플리케이션은 사용자 정보를 데이터베이스에 저장하고 조회하기 위하여 UserRepository를 사용합니다.

또한, 사용자가 회원가입을 할 때, 알림메일을 보내는 EmailService를 활용합니다.

public class UserService {
    private UserRepository userRepository;
    private EmailService emailService;

    public UserService() {
        userRepository = new UserRepository(); // 직접 UserRepository 인스턴스를 생성
        emailService = new EmailService(); // 직접 EmailService 인스턴스를 생성
    }

    public void registerUser(User user) {
        userRepository.save(user); // 직접 UserRepository의 메서드를 호출
        emailService.sendWelcomeEmail(user.getEmail()); // 직접 EmailService의 메서드를 호출
    }

    // 기타 사용자 관련 비즈니스 로직 메서드들
}

UserService 클래스가 UserRepository와 EmailService를 직접 생성하고 의존하고 있습니다.

따라서, UserRepository와 EmailService의 메서드를 직접 호출하고 있습니다.

 

 

이때, 만약에 요즘에는 Email 보다는 SMS, 카카오톡 알림을 많이 사용한다고 하여 EmailService를 변경해주어야 합니다.

그러나 이렇게 변경하면 UserService와 EmailService가 강하게 결합되어 있으므로,

UserService를 수정하는 것뿐만 아니라 다른 클래스들까지 함께 수정해야 할 수도 있습니다.

 

해결 방법으로 아래의 제어의 역전이 적용된 경우에 해결할 수 있는 방법을 보여드리겠습니다.

 

제어의 역전이 적용된 경우(스프링 IoC를 활용한 경우)

public class UserService {
    private UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository; // 생성자를 통해 의존성 주입
    }

    public void saveUser(User user) {
        // 유효성 검사, 비즈니스 로직 등을 수행
        userRepository.save(user); // 의존성 주입된 UserRepository의 메서드를 호출
    }
}

위의 UserService는 생성자를 통해 UserRepository 객체를 주입받도록 변경하였습니다.

이제 UserService는 UserRepository에 대하여 직접적으로 알 필요가 없습니다.

 

이렇게 객체의 생성과 관리는 스프링 프레임워크에 의해 이루어집니다.

 

제어의 역전을 통해 스프링 프레임워크는 객체들의 라이플사이클과 의존성 관리를 담당합니다.

개발자는 단순히 필요한 객체를 요청하는 방식으로 개발을 진행하고, 스프링 컨테이너가 해당 객체들을 생성하고 주입해 줍니다.

이렇게 함으로써, 애플리케이션의 구성과 확장성이 향상되고, 유지보수와 테스트가 용이해집니다.

 

 

위에서 제어의 역전을 사용하지 않았을 때의 예시와 비교해 보겠습니다.

public class UserService {
    private UserRepository userRepository;
    private NotificationService notificationService;

    public UserService(UserRepository userRepository, NotificationService notificationService) {
        this.userRepository = userRepository; // 생성자를 통해 의존성 주입
        this.notificationService = notificationService; // 생성자를 통해 의존성 주입
    }

    public void registerUser(User user) {
        userRepository.save(user); // 의존성 주입된 UserRepository의 메서드를 호출
        notificationService.notifyUserRegistration(user.getEmail()); // 의존성 주입된 NotificationService의 메서드를 호출
    }

    // 기타 사용자 관련 비즈니스 로직 메서드들
}

이제는 UserService는 생성자를 통해 UserService와 NotificationService를 주입받도록 변경되었습니다.

이렇게 하면 UserService는 이제 UserRepository와 NotificationService의 구현에 대해 알 필요가 없습니다.

 

이제 이메일 전송 기능을 변경해야 하는 상황이 오더라도, UserService의 코드를 수정할 필요가 없습니다.

스프링의 IoC 컨테이너가 UserRepository와 NotificationService를 관리하고 주입하기 때문에, 이메일 전송 기능을 변경하더라도 UserService에 영향을 미치지 않습니다.

 

이렇게 IoC를 활용하면 객체 간의 결합도가 낮아지고, 유연성과 확장성이 향상됩니다.

사용자 관리 이외에도 다른 기능을 추가하거나 변경해야 하는 경우에도 비슷한 방식으로 주입된 객체들을 수정하면 되므로 애플리케이션의 유지보수와 확장이 용이해집니다.


의존성 주입(DI : Dependency Injection)

객체가 직접 필요로 하는 의존성을 직접 생성하거나 관리하는 것이 아닌 외부로부터 주입받는 방식을 의미합니다.
스프링 프레임워크는 DI를 통해 객체 간의 결합도를 낮추고 유연하고 확장 가능한 애플리케이션을 구축하는데 도움을 줍니다.

 

의존성 주입의 필요성

Java를 공부할 때는 객체 간의 의존성을 관리할 때는 객체가 직접 의존하는 객체를 생성하고 사용했었습니다.

이로 인해 객체 간의 결합도가 높아지며, 코드의 유지보수와 확장이 어려워지는 문제가 발생합니다.

 

예를 들어 A라는 클래스가 B라는 클래스를 사용하고자 할 때, 클래스 A에서 직접 클래스 B의 인스턴스를 생성하는 경우가 있었습니다.

public class A {
    private B b;

    public A() {
        b = new B(); // 클래스 A가 클래스 B의 인스턴스를 직접 생성
    }

    // 기타 로직들
}

이런 경우, A Class는 B Class와 강하게 결합되어 있으며, B Class의 변경이 발생한다면 클래스 A도 수정해주어야 합니다.

 

DI의 장점

  • 결합도 감소: 
    • 의존성 주입을 통해 객체 간의 결합도가 낮아집니다. 객체가 직접 의존하는 객체를 생성하거나 알 필요가 없으므로 코드의 유지보수와 확장이 용이해집니다.
  • 유연성과 테스트 용이성:
    • 의존성 주입을 사용하면 객체를 테스트하기 쉬워집니다. 테스트 환경에서 모킹(mocking)을 사용하여 의존성을 가짜 객체로 대체하여 테스트할 수 있습니다.
  • 컴포넌트 재사용:
    • 의존성 주입을 통해 컴포넌트 간의 재사용이 용이해집니다. 새로운 구현을 주입하면 기존의 코드를 변경하지 않고도 다른 구현을 사용할 수 있습니다.
  • 관심사의 분리:
    • DI를 통해 객체의 생성과 의존성 관리를 담당하는 컨테이너와 비즈니스 로직을 분리할 수 있습니다.

 

스프링의 DI의 종류

1. 생성자 주입 (Constructor Injection)

생성자 주입은 의존성을 객체의 생성 시점에 주입하는 방식입니다.

클래스의 생성자를 통해 의존성을 전달받고, 해당 의존성은 스프링의 IoC 컨테이너로부터 주입됩니다.

 

public class A {
    private B b;

    public A(B b) {
        this.b = b; // 생성자를 통해 B 객체를 주입받음
    }

    // 기타 로직들
}

 

 

2. 세터 주입(Setter Injection)

세터 주입은 의존성을 Setter 메서드를 통해 주입하는 방식입니다.
의존성 주입이 발생하는 시점이 객체 생성 이후이기 때문에 선택적으로 주입할 수 있습니다.
public class A {
    private B b;

    public void setB(B b) {
        this.b = b; // 세터 메서드를 통해 B 객체를 주입받음
    }

    // 기타 로직들
}

3. 필드 주입(Field Injection)

필드 주입은 클래스의 필드에 직접 의존성을 주입하는 방식입니다.
스프링은 리플렉션(Reflection)을 통해 필드에 주입할 의존성을 찾아서 주입합니다.
개인적으로, 생성자 주입이나 세터 주입보다 권장하는 방식은 아니라고 생각합니다.
public class A {
    @Autowired // 필드 주입을 위한 어노테이션
    private B b;

    // 기타 로직들
}

 

 

권장하는 방법 Lombok 라이브러리에서 제공하는 @RequiredAtgsConstructor 애노테이션 활용

Lombok 라이브러리에서 제공하는 애노테이션 중 하나로,
주로 생성자를 자동으로 생성하는 데 사용합니다.

이 어노테이션을 사용하면 해당 클래스의 모든 final 필드(메서드는 x) 또는 @NonNull로 표시된 필드를 가지고 생성자를 생성해 줍니다.

 

이렇게 생성된 생성자를 통해 객체를 초기화할 때 해당 필드들의 값을 인자로 전달할 수 있습니다.

 

예시
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class User {
    private final String username;
    private final int age;
    private String email;
}

// @RequiredArgsConstructor 애노테이션에 의해 아래와 같은 생성자가 자동으로 생성됨
// public User(String username, int age) {
//     this.username = username;
//     this.age = age;
// }

위의 코드에서 User 클래스에 @RequiredArgsConstructor 애노테이션을 사용하면, 

username과 age 필드를 가지고 생성자가 자동으로 생성됩니다. 

email 필드는 생성자에 포함되지 않으므로 일반적인 방법으로 초기화하거나 setter 메서드를 통해 값을 설정할 수 있습니다.

이렇게 Lombok의 @RequiredArgsConstructor를 사용하면 생성자 코드를 직접 작성하는 번거로움을 줄이고, 불변(immutable) 객체를 생성하는 데 편의성을 제공하여 코드의 가독성을 높일 수 있습니다.

 Lombok은 다양한 애노테이션들을 제공하여 코드를 간결하게 작성할 수 있도록 도와주기 때문에 스프링 프로젝트에서 자주 사용되는 라이브러리 중 하나입니다.


 

인터페이스를 활용해야 하는 이유

제어의 역전과 의존성 주입은 인터페이스를 활용하여 객체 간의 결합도를 낮추고 유연성을 제공하는 데 큰 도움이 됩니다.

인터페이스를 활용한 의존성 주입

인터페이스를 사용하여 객체를 정의하고, 해당 인터페이스를 구현한 클래스를 실제로 주입하는 방식으로 의존성 주입을 구현합니다. 

 

이렇게 하면 구체적인 구현이 아닌 인터페이스에만 의존하는 코드를 작성할 수 있습니다. 이로 인해 코드의 결합도가 낮아지고, 변경이 용이해집니다.

예를 들어, UserService의 예시에서 UserRepository와 EmailService를 인터페이스로 추상화하고, 이를 실제로 구현한 클래스를 주입하는 방식으로 IoC와 의존성 주입을 구현해 보겠습니다.

 

// UserRepository 인터페이스
public interface UserRepository {
    void save(User user);
    User getById(int userId);
    // 기타 메서드들
}

// UserRepository의 구체적인 구현
public class UserRepositoryImpl implements UserRepository {
    // UserRepository 인터페이스의 메서드들을 구현
    // 데이터베이스와 연동하는 코드들
}

// NotificationService 인터페이스
public interface NotificationService {
    void notifyUser(String recipient, String message);
}

// NotificationService의 구체적인 구현
public class EmailNotificationService implements NotificationService {
    // NotificationService 인터페이스의 메서드를 구현
    // 이메일을 이용하여 사용자에게 알림을 전송하는 코드들
}

// UserService 클래스
public class UserService {
    private UserRepository userRepository;
    private NotificationService notificationService;

    // 생성자를 통해 의존성 주입
    public UserService(UserRepository userRepository, NotificationService notificationService) {
        this.userRepository = userRepository;
        this.notificationService = notificationService;
    }

    // 사용자 등록과 동시에 알림을 보내는 메서드
    public void registerUser(User user) {
        userRepository.save(user); 
        // 의존성 주입된 UserRepository의 메서드를 호출
        notificationService.notifyUser(user.getEmail(), "환영합니다!"); 
        // 의존성 주입된 NotificationService의 메서드를 호출
    }

    // 기타 사용자 관련 비즈니스 로직 메서드들
}

 

이렇게 인터페이스를 활용하여 객체를 추상화하고 의존성 주입을 구현한다면,

UserService는 UserRepository와 EmailService의 구체적인 구현에 대하여 알 필요가 없습니다.

따라서 구체적인 객체들의 변경이 있더라도 UserService에는 영향을 주지 않습니다.

 

클래스로 사용한 경우의 문제점

// UserRepository 클래스
public class UserRepository {
    public void save(User user) {
        // 데이터베이스에 사용자 정보를 저장하는 코드
    }

    public User getById(int userId) {
        // 데이터베이스에서 해당 사용자 정보를 조회하는 코드
        return null;
    }
    // 기타 메서드들
}

// NotificationService 클래스
public class NotificationService {
    public void notifyUserByEmail(String recipient, String message) {
        // 이메일을 이용하여 사용자에게 알림을 전송하는 코드
    }
    // 기타 메서드들
}

// UserService 클래스
public class UserService {
    private UserRepository userRepository;
    private NotificationService notificationService;

    // 생성자를 통해 의존성 주입
    public UserService(UserRepository userRepository, NotificationService notificationService) {
        this.userRepository = userRepository;
        this.notificationService = notificationService;
    }

    // 사용자 등록과 동시에 알림을 보내는 메서드
    public void registerUser(User user) {
        userRepository.save(user); // 의존성 주입된 UserRepository의 메서드를 호출
        notificationService.notifyUserByEmail(user.getEmail(), "환영합니다!"); // 의존성 주입된 NotificationService의 메서드를 호출
    }

    // 기타 사용자 관련 비즈니스 로직 메서드들
}

위 예시에서는 UserRepository와 NotificationService를 클래스로 정의하고, UserService는 생성자를 통해 UserRepository와 NotificationService를 주입받습니다.

이 경우 UserService는 UserRepository와 NotificationService의 구체적인 구현에 의존하게 됩니다. 따라서 구체적인 객체들의 변경이 있더라도 UserService도 수정해야 합니다. 이로 인해 코드의 유지보수가 어려워지는 문제가 발생합니다.

결과적으로 클래스로 사용하게 되면 객체 간의 결합이 높아져 유지보수가 어려워지고, 확장성이 떨어지는 단점이 있습니다. 

 

이러한 문제를 해결하고 유연성을 높이기 위해서는 인터페이스를 활용하여 객체를 추상화하고, 의존성 주입을 사용하는 방식을 적용하는 것이 좋습니다. 

이를 통해 객체 간의 결합도를 낮추고 코드의 변경이 있을 때 영향을 최소화할 수 있습니다.

반응형
LIST