[Spring] 의존성 주입(Dependency Injection, DI) 정리
by Tear94fall[Spring] 의존성 주입(Dependency Injection, DI) 정리
1. 의존성 주입(DI) 이란 무엇인가?
의존성 주입 (Dependency Injection)에 대해 알아보기 전에 객체지향 프로그래밍에 대해 알아보겠습니다.
객체 지향 프로그래밍을 통해 애플리케이션을 만들때 다양한 객체를 생성하게 됩니다.
생성된 객체들은 특정 기능을 수행하기 위해 서로 다른 객체들과 상호 작용(message passing)을 하게됩니다.
좀더 쉽게 설명하기 위해 자동차, 휠, 타이어를 예로 들어보겠습니다.
1. 자동차는 4개의 바퀴를 가지고 있고, 바퀴는 휠과 타이어로 구성되어있습니다.
2. 경우에 따라 자동차의 휠과 타이어를 교체할 수 있습니다.
위의 문장에 객체를 대입해서 생각해보겠습니다.
1. 자동차 객체는 생산 과정에서 휠과 타이어를 필요로 합니다.
2. 자동차 객체는 다른 휠 객체와 타이어 객체로 교체가 가능합니다.
여기서 1번에 대해 자세히 보도록 하곘습니다. 자동차는 반드시 4개의 바퀴를 가지고 있습니다.
바퀴 객체(휠 + 타이어)는 자동차 객체보다 먼저 생성될수 있지만, 자동차 객체는 생성 시점에는 바퀴 객체를 가지고 있어야 합니다. 이것을 의존성
이라고 합니다. 이때 자동차 객체는 바퀴 객체에 의존성을 가지고 있다고 합니다.
자동차 생성시에 무조건 A 바퀴를 가져야 하는것은 아닙니다. A, B, C의 타이어가 있고 호환이 된다면 모두 가질 수 있습니다. 그럼 자동차를 만드는 방법에 대해서 한번 생각 해보겠습니다.
1. 자동차와 바퀴를 한번에 만드는 경우
2. 자동차를 만든후 바퀴를 조립하는 방식
1번 자동차와 바퀴를 모두 한번에 만드는 경우를 객체 지향의 상속이라고 생각 해보겠습니다.
class Wheel {
...
};
class Car implements Wheel {
...
};
2번 자동차와 바퀴를 따로 만든후 조립하는 경우를 생각해보겠습니다.
이때 두가지 방법이 있을것같습니다.
(1) 자동차를 만들고 이후 바퀴를 만들어 조립하는 경우
(2) 자동차와 바퀴를 따로 만든후 조립하는 경우
얼핏보면 같은말 같지만, 좀더 생각을 해보면 바퀴를 만들고 조립하는 시점에 대한 차이가 있습니다.
이해를 위해 (1)의 경우는 같은 공장에서 만든다고 생각을 하고, (2)의 경우는 서로다른 공장에서 만든다고 생각을 해보겠습니다. (1)를 코드로 옮겨 보겠습니다.
class Wheel {
...
};
class Car {
Wheel wheel;
Car() {
wheel = new Wheel();
}
};
자동차와 바퀴를 같은 공장에서 만들고 있으므로, 자동차를 만들때 타이어를 만들어 조립하게 됩니다.
자동차 객체를 만들때 바퀴 객체를 내부에서 만들어 조립 하고 있습니다.
(2)의 경우를 코드로 옮겨보면 다음과 같습니다.
class Wheel {
...
};
class Car {
Wheel wheel
Car(Wheel wheel) {
this.wheel = wheel;
}
};
자동차 공장과 바퀴 공장은 서로 다른 공장이므로, 자동차를 만들때 이미 만들어진 바퀴를 조립하게 됩니다.
자동차 객체를 만들때 외부에서 만들어진 바퀴 객체를 조립 하고 있습니다.
자동차와 바퀴를 조립하는 작업을 왜 이렇게 복잡하게 설명하냐 하실수도 있습니다.
하지만 우리는 예시를 통해 이미 의존성 주입의 개념을 모두 학습하였습니다.
무슨 소리인지 모르시겠다면 다시한번 살펴보겠습니다.
자동차와 바퀴를 서로 다른 공장에서 만들고, 자동차를 만들때 바퀴를 가져와 조립하는 방법
이 바로 의존성 주입 입니다.
2. 의존성 주입(DI)을 사용하는 이유는?
그럼 의존성 주입을 왜 사용하는걸까요?
위의 예제에서 바퀴는 한개만을 가지고 예시를 들었습니다.
하지만 자동차가 한종류의 바퀴만 사용해야 하는걸까요?
아닙니다. 자동차는 여러 종류의 바퀴를 장착해서 사용할수 있습니다.
class WheelA {
...
}
class WheelB {
...
}
다음과 같이 두종류의 바퀴가 있다고 생각해봅시다.
자동차 객체에 두개의 바퀴를 선택해서 사용하려면 다음과 같이 해야할것입니다.
class Car {
Wheel wheel;
Car() {
if() { // A바퀴를 사용하는 경우
wheel = new WheelA();
} else if() { // B바퀴를 사용하는 경우
wheel = new WheelB();
}
}
}
아직은 2개의 바퀴만 사용중이지만, 바퀴가 10개로 늘어나고 100개로 늘어나게 되면 어떻게 될까요?
if...else 구문을 반복해서 만들어줘야 할것입니다.
또한 위의 예제들은 이해를 위해 많은 부분을 생략하였습니다.
예제에서는 Wheel 과 WheelA, WheelB가 마치 같은 객체인것처럼 사용하였습니다.
하지만 세개의 객체는 사실 서로 다른 객체 입니다.
즉, wheel 이라는 객체에 WheelA의 객체를 대입할 수 없습니다.
이를 해결하기 위해서 우리는 좀 더 생각을 해봐야 합니다.
바퀴는 사실 동그란 구체 모양으로 실제로는 모양과 재질만 다르고, 바퀴의 본질은 변하지 않습니다.
그럼 이런 공통적인 기본 특성을 반복적으로 구현하지 않는 방법은 없을까요?
네, 바로 인터페이스를 이용하여 상속관계를 만들어주면 됩니다.
Wheel이라는 객체를 인터페이스로 선언하고
실제 바퀴들은 이 인터페이스를 상속받아 구현하는 방법을 사용하겠습니다.
interface Wheel {
...
}
class WheelA implements Wheel {
...
}
class WheelB implements Wheel {
...
}
바퀴 A, B 객체는 바퀴 프레임 인터페이스를 상속받아 구현되었습니다.
그럼 이제 이 객체들을 통해 좀 더 유연한 방법으로 의존성을 주입하는 코드를 보도록하겠습니다.
class Car {
Wheel wheel;
Car(Wheel wheel) {
this.wheel = wheel;
}
}
어디서 많이본 코드가 아닌가요? 이전 내용을 한번 확인해봅시다.
의존성 주입이란 무엇인가?의 예제의 (2)번에서 이미 봤던 코드입니다.
하지만 (2)에서의 의미와는 다르게 Wheel은 인터페이스 입니다.
생성자를 통해 Wheel 객체의 인스턴스를 인자로 받아 Car객체 내부에 주입하고 있습니다.
이전 예제에서 처럼 if...else 구문없이 원하는 종류의 타이어 객체를 주입할수 있게 되었습니다.
자동차와 바퀴의 관계로 설명을 하였지만, 의존성 주입을 사용해야 하는 이유를 좀더 알아볼까요?
자동차 객체를 우리가 사용하는 백엔드 서버로 바꿔서 생각을 해보고, 바퀴를 데이터 베이스로 바꿔 생각해보겠습니다.
우리가 어떠한 서비스를 만드는데, 저장소가 MySQL로 임시로 결정되었다고 가정해봅시다.
근데 추후에는 다른 데이터 베이스를 사용할수도 있다고 합니다.
의존성 주입을 사용하지 않고 개발을 하였고, 일정 시간이 지났다고 생각해봅시다.
MySQL에 조회, 삽입, 수정, 삭제를 구현하였다고 생각해봅시다.
그리고 데이터 베이스가 Postgresql로 변경되었고, 우리는 변경된 DB에 해당하는 코드를 사용해야 합니다.
이때 변경된 것은 DB인데, 우리는 백엔드 서버의 코드를 변경해야 합니다.
근데 만일 의존성 주입을 사용해서 DB 작업을 하는 인터페이스를 사용했다고 생각해봅시다.
Postgresql 이던 Oracle이던 DB에 대한 코드, 즉 DB의 객체만 변경해주면 됩니다.
3. 의존성 주입 방법
의존성 주입 방법에는 4가지가 있습니다.
첫번째 방법은 위의 예제에서 사용한 생성자 주입방법이 있습니다.
두번째 방법은 필드 주입 방법으로 @Autowired 어노테이션을 사용하는 방법입니다.
세번째 방법은 수정자(setter) 주입입니다.
마지막 네번째 방법은 메서드 주입입니다.
그럼 차례 대로 의존성 주입 방법에 대해 알아보겠습니다.
3.1 생성자 주입
생성자 주입(Constructor Injection)은 생성자를 통해 의존 관계를 주입합니다.
@Service
public class MemberService {
private MemberRepository memberRepository;
@Autowired
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
생성자를 통해 의존성을 주입 받게되므로, 생성자가 가지는 특성을 그대로 갖습니다.
생성자는 객체 생성 시점에서 1회만 호출됩니다.
생성자에서 객체를 주입하고 있기 때문에, 별도의 메소드가 없다면 주입받는 객체의 변경이 불가능합니다.
또한 객체 주입을 강제하고 있기 때문에, 의존성을 주입 하지 않고는 객체가 생성조차 되지 않습니다.
그로인해 객체가 주입되지 않음으로 인해 발생하는 문제가 없습니다. (NullPointerException 등)
생성자가 1개만 존재하는 경우 @Autowired 어노테이션을 생략해도 자동주입이 가능하도록 지원 하고 있습니다.
3.2 필드 주입
필드 주입 (Field Injection)은 필드에 @Autowired 키워드를 통해 자동으로 의존성을 주입합니다.
public class MemberService {
@Autowired
private MemberRepository memberRepository;
...
}
단순히 @Autowired 키워드만으로 의존성 주입이 가능해져 상당히 코드가 간결해집니다.
의존성 주입을 프레임 워크에서 해결해주기 때문에 매우 편리하다고 생각할수도 있습니다.
하지만 이로인해 외부에서 접근이 불가능 하게되며 스프링 프레임워크에 의존적인 코드가 됩니다.
또한 주입받는 클래스를 final 로 선언 할 수 없습니다. 따라서 객체의 상태가 변경될수 있습니다.
마지막으로 테스트코드의 중요성이 커지면서 개발하면서 다양한 테스트 코드를 작성하게 됩니다.
필드 주입을 사용하게 되면 테스트 코드를 작성할때 객체를 수정할수 없습니다.
따라서 의존성 주입을 지원하는 프레임워크가 있어야해 독립적인 테스트 코드 작성이 어렵습니다.
인텔리제이에서도 필드 주입을 사용하면 경고 메시지를 띄움으로 생성자 주입을 적극 권장 하고 있습니다.
필드 주입을 사용하는 경우 Field injection is not recommended
이라는 경고 메세지를 보실수 있습니다.
3.3 수정자 주입
수정자 주입 (Setter Injection)은 Setter 메소드를 통해 읜존관계를 주입합니다.
@Service
public class MemberService {
private MemberRepository memberRepository;
@Autowired
public void setMemberRepository(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
생성자 주입 방법을 사용하는 경우 객체의 변경이 불가능 하다고 설명 하였습니다.
반대로 수정자 주입의 경우는 주입받는 객체의 변경이 필요한 경우에 사용합니다.
반대로 객체의 특성이 언제든 변경될 위험이 있기 때문에 사용을 지양 해야 합니다.
Setter 메소드에 @Autowired 어노테이션 붙여 사용합니다.
예전에 작성된 코드들을 보면 수정자 주입 방법을 통해 의존성을 주입하는 경우를 자주 봅니다.
자바는 객체 지향 프로그래밍 패러다임의 언어이니 만큼 getter/setter를 통해 객체를 캡슐화 합니다.
멤버 변수에 직접 접근을 금지하고, 메소드를 통해 값을 가져오고 값을 설정합니다.
초기에는 이러한 캡슐화의 특성 때문에 수정자 주입을 자주 사용했다고 합니다.
아래 생성자 주입을 사용해야 하는 이유에서 다루겠지만, 최근 추세는 역시 생성자 주입인것 같습니다.
4.2 메소드 주입
일반 메소드를 통해 의존성을 주입하는 한다. 수정자 주입과 거의 동일하며 수정자의 특성을 그대로 가집니다.
한번에 여러 의존성을 주입하는것이 가능하지만, 역시 수정자 주입과 동일한 이유로 사용을 지양해야 합니다.
4. 생성자 주입 방법을 사용해야 하는 이유
언어가 발전하고 스펙이 변경됨에 따라 또 변화 할수 있지만, 수정자 주입을 사용하는게 여러므로 좋아보입니다.
그럼 최근 추세인 생성자 주입을 사용해야 하는 이유에 대해 알아보겠습니다.
4.1. 의존관계가 변경되는 경우가 자주 존재하는가?
사실 개발을 하다보면 의존관계가 프로그램 실행중에 변경되는 경우가 많지 않습니다.
아니 오히려 거의 없다고 보는게 맞을 것 같습니다.
수정자 주입, 메소드 주입을 통해 의존 관계가 변경되는 경우는 객체의 특성이 변경되었다고 봐도 될것입니다.
예를 들어 운영중인 환경에서 데이터 베이스가 MySQL 에서 Postgresql 로 변경되는 경우는 없을 것입니다.
이는 전혀 다른 동작을 수행할수도 있습니다. 이 다른 동작이 원하던 동작이면 다행입니다.
하지만 원하던 동작이 아닌경우 심각한 문제가 발생할수 있습니다.
따라서 객체의 변경 가능성을 최소화 하는게 여러므로 좋습니다.
4.2. 어떻게 객체의 불변성을 확보 할것인가?
또한 생성자 주입을 사용하는 경우 final
키워드를 사용할수 있습니다.final
키워드를 사용하는 경우 초기화 이후에 변경이 불가능 합니다. 이로인해 불변성을 확보 할수있습니다.final
키워드를 멤버 변수에 사용하는 경우 생성자 호출 시점에 초기화가 가능합니다.
따라서 다른 방법을 사용하는 경우와 달리 생성자 주입방법에서만 사용 가능 합니다.
final
키워드를 사용하는 경우 getter/setter등을 자동으로 생성해주는 Lombok 과 같이 사용하면 매우 좋습니다.
Lombok의 @RequiredArgsConstructor
어노테이션은 final
키워드가 붙은 필드의 생성자를 자동으로 생성해줍니다.
따라서 코드를 정말 간결하고 깔끔하게 사용 가능 하며, 다음과 같이 사용이 가능합니다.
앞서 말했듯 @Autowired 어노테이션은 생성자 한개인 경우 생략할수있습니다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
...
}
4.3. 테스트 하기 좋은 코드란?
우리가 백엔드 서버를 개발하면서 베포를 하기전에 테스트를 해야할때 어떻게 해야할지 생각해 봅시다.
테스트를 운영 DB에 할순 없으므로, 테스트시에는 테스트 DB에 접근 할것 입니다.
테스트 환경과 운영 환경을 격리 시키고 싶은데, 그럼 코드를 다르게 짜야하는것 아닌가?
위와 같은 의문이 생길수 있습니다. 이에대한 답변을 하기전에 좀더 생각을 해봅시다.
우리는 앞에서 의존성 주입에 대해 배웠고, 생성자 주입을 사용해야 한다는 것도 배웠습니다.
각각의 의존성 주입방법에 대해 배웠고, 생성자 주입을 써야한다고 배웠습니다.
근데 왜 갑자기 테스트 코드 이야기가 나온것일까요?
그것은 바로 생성자 주입을 사용하면, 테스트 코드하기 좋은 코드를 작성할수 있기 때문입니다.
테스트 코드를 작성할때, 데이터 베이스에 접근하는 객체를 생성자를 통해 주입하였다고 생각해봅시다.
테스트용 데이터 베이스에 접근 객체를 사용해 테스트 코드를 작성한다면
테스트 코드을 조금 수정하는것으로 분리된 환경에서 테스트를 할 수 있습니다.
또한 필드 주입 사용을 멀리해야 한다고 앞서 살짝 이야기 하였습니다.
그중 프레임 워크의 의존성을 가지면 안된다고 이야기했습니다.
이는 단순 하나의 함수를 테스트하더라도 스프링 컨테이너를 구동해야 되기 떄문입니다.
좋은 테스트 코드를 작성하는 방법에는 여러가지가 있지만 그중 하나가 바로 생성자 주입을 통한 방법입니다.
4.4. 순환 참조 방지
객체 A와 B가 있다. A를 B를 의존성으로 가지고, B는 A를 의존성으로 가진다고 생각해봅시다.
또한 A의 a메소드와 B의 b메소드가 있어 서로가 서로를 호출한다고 생각해봅시다.
a() -> b() -> a() -> b().. 가 무한히 반복되다가, 결국 콜스택이 꽉차 프로그램이 비정상 종료되게 됩니다.
memberService
와 teamService
의 두 객채가 있고 서로를 주입받는 의존관계를 가지고 있다고 생각해봅시다.member-service
는 teamService
를 주입받고, teamService
는 memberService
를 주입받는 경우입니다.
물론 이러한 상황이 일반적인 상황은 아니므로, 만약을 가정한다고 생각합니다.
이러한 상황에서 각각의 의존 방법에 따른 차이점을 확인 보겠습니다.
위에서 우리가 배운 주입 방법중 setter, 필드 주입 방식은 동일하게 작동합니다.
그러나 생성자 주입 방식은 조금 다르게 작동합니다.
앞서 생성자 주입을 사용해야 한다고 이야기 하였기 때문에 결론 부터 얘기 해보도록 하겠습니다.
setter, 필드 주입 방식을 사용하는 경우, 어플리케이션 구동 당시에는 순환 참조에 대한 에러가 발생하지 않습니다.
구동 시점에 에러가 발생하지 않는 이유는 필드 주입의 경우 객체의 생성 시점과 의존 관계 주입 시점이 다르기 때문입니다. 실제 객체를 사용하게 되는 시점에 의존 관계가 주입되기 때문이고, 이얘기는 결국 컴파일 타임에는 에러를 발생시키지 않습니다. 구동되는 시점인 런타임에 오류가 발생하게 됩니다.
이와 다르게 생성자 주입은 객체의 생성 시점과 의존성 주입 시점이 같으므로 런타임 시점이 아닌 컴파일 타임 시점에 알수있습니다. 스프링 부트 2.6 버전 이후 부터는 순환 참조가 금지 되었습니다.
따라서 2.6 미만의 버전을 사용하는 경우 조심해야할 필요가 있습니다.
그럼 순환 참조의 발생을 초기에 잡는 방법은 없을까요?
우회하기 위한 방법으로 @Lazy
어노테이션을 사용하는 방법이 있습니다.
하지만 스프링 공식문서에도 단점을 명확하게 서술하고 있느니 만큼 자세히 설명하지 않고 생략 하려고 합니다.
가장 좋은 방법은 순환 참조 구조 자체를 발생하지 않게 설계 하는 방법입니다.
5. 마무리
오늘은 의존성 주입 방법에 대해 알아보았습니다.
의존성 주입은 스프링 프레임 워크가 제공하는 강력한 3대 핵심 요소중 하나입니다.
의존성 주입이 무엇인가에 대해 알아보고, 주입 방법들에 대해 알아보았습니다.
또한 각각의 주입 방법이 가진 장단점들에 대해서도 알아보았고, 어떤 방법이 가장 좋은 방법인지도 고민해보았습니다.
프로그래밍을 하시면서 오늘 배운것들은 잘 사용하시기 바랍니다. 감사합니다.
'Spring' 카테고리의 다른 글
[Spring] profile별 환경 분리 하기 (0) | 2024.02.13 |
---|---|
[Spring] @EnableJpaAuditing 기능 사용해 생성/수정 시간 등록하기 (0) | 2023.06.29 |
블로그의 정보
동상이몽, 코딩으로 서로 다른 꿈을 꾸다
Tear94fall