Context란

Context는 안드로이드 시스템에 대한 인터페이스 역할을 하는 객체이다.

흠…?

 

좀 더 쉽게 풀어보면, Context는 어플리케이션 리소스, 파일, 시스템 서비스 등에 접근할 수 있는 메소드들을 갖고 있다. 그래서 앱의 resources나 File과 같은 시스템 자원에 접근하거나 새로운 Activity 실행, Intent 수신과 같이 애플리케이션 레벨의 작업을 요청할 수 있다.

또한, Context는 추상 클래스이며 구현체는 안드로이드 시스템에 의해 제공된다

Context 쓰임 정리

  • 앱의 리소스에 접근: res, assets, internal storage
  • 컴포넌트 통신: 액티비티 시작, Intent 수신 등
  • 시스템 서비스에 접근: SystemServices
  • 앱 정보에 접근: ApplicationInfo
  • GUI 작업: ex: AlertDialog.Builder(requireContext())

실제로는 훨씬 다양한 상황에서 사용된다!


Context의 종류 (feat. 계층 구조)

Context의 클래스 계층 구조를 보면 재밌는 사실을 확인할 수 있다.

https://medium.com/swlh/context-and-memory-leaks-in-android-82a39ed33002

(1) Application, Service, Activity 모두 Context를 상속받고 있다. Activity가 Context 자리를 this로 대신할 수 있었던 이유가 이것이었다.

 

(2) Context는 Activity, Service, Application 등 종류가 나뉘어 있다.

앱 안에는 여러 Activity와 Service가 존재할 수 있으므로 Activity Context는 여러 객체가 있을 수 있다. 반면, Application은 하나라서 Singleton으로 앱 내에서 하나만 존재한다.

즉, Context는 종류가 나뉘며 각각이 다른 생명주기를 갖고 있다. 따라서, 올바르지 않은 Context를 사용할 경우 메모리 누수가 발생할 수 있다.


Context와 Memory Leak

Context는 크게 ApplicationContext와 나머지 Context, 그중에서도 Activity Context로 나눌 수 있다.

Application Context

ApplicationContext는 Applicaton 생명주기를 따르는 싱글턴 객체이다. 따라서, 앱 전역에서 사용될Context가 필요하다면 ApplicationContext를 사용하자. (ex: RoomDatabase 객체 생성, 싱글턴 객체 생성)

 

싱글턴(or 액티비티 생명주기를 넘어가는) 작업에 Activity Context를 사용할 경우 메모리 누수가 발생하므로 주의해야 한다! Activity가 종료되더라도 다른 객체에서 Activity를 Context로 참조하고 있다면 garbage collector가 메모리를 해제할 수 없기 때문이다.

Activity Context

Activity Context는 Activity의 생명주기에 종속되어 있다. 따라서, Activity 생명주기 내에서 사용될 Context가 필요하다면 Activity Context를 사용하는 것이 좋다.

 

특히, GUI 작업을 한다면 반드시 Application Context가 아닌 Acitivty Context를 사용해야 한다. (ex: 다이얼로그 생성, Toast 출력) 왜냐면 Acitivty의 Theme과 같은 고유한 정보는 해당 Context만이 갖고 있지 때문이다. 다른 Context를 사용할 경우 예상과 다른 결과를 보게 되거나 crash로 앱이 종료될 수도 있다.

정리

  • 앱 전역에서 사용될 Context가 필요하다 → Application Context
  • 액티비티 생명주기 내에서 or GUI 작업에 사용할 Context가 필요하다 → Activity Context

부록) Context를 가져오는 방법

1. Activity

액티비티가 Context 그 자체이므로, this로 Activity Context에 접근할 수 있다.

2. Fragment

context or requireContext 를 통해 Fragment를 hosting하는 Activity Context에 접근할 수 있다.

3. View

view.context 를 통해 View가 포함되어 있는 Activity의 Context에 접근할 수 있다.

4. ContentProvider

ContentProvider 클래스 안에서 context 를 통해 Application Context에 접근할 수 있다.

5. Application Context

각 Context에서 applicationContext을 통해 Application Context에 접근할 수 있다. (ex: context?.applicationContext)


정리

Context는 안드로이드 시스템과 통신할 수 있는 인터페이스로, 앱 리소스, 시스템 작업, 컴포넌트 통신 등을 요청할 수 있다.

Context는 크게 Applicaton Context와 Activity Context로 나눌 수 있는데, 상황에 적절한 것을 사용하지 않으면 메모리 누수나 앱 크래시가 발생한다. 앱 전역에서 사용된다면 Applicaton Context를, 액티비티 내에서 유효하거나 GUI 작업에 필요한 것이라면 Activity Context를 사용하자.


참조

(1) 의존성 주입의 개념과 장점


SOLID 원칙이란?

SOLID 원칙이란 로버트 마틴이 소개한 객체 지향 프로그래밍 및 설계의 5가지 기본 원칙을 마이클 페더스가 약어를 따서 정리한 것이다. SOLID 원칙은 소스 코드의 가독성을 높이고 확장하기 쉬운 구조를 만들기 위한 지침이다.

SOLID

  • SRP(Single Responsibility Principle, 단일 책임 원칙)
  • OCP(Open-Closed Principle, 개방-폐쇄 원칙)
  • LSP(Liskov Substitution Principle, 리스코프 치환 원칙)
  • ISP(Interface Segregation Principle, 인터페이스 분리 원칙)
  • DIP(Dependency Inversion Principle, 의존성 역전 원칙)

Single Responsibility Principle (단일 책임 원칙)

“클래스를 변경하는 이유(책임)는 오직 하나이어야 한다.”

 

“클래스가 오직 하나의 책임을 갖도록 하라”라고도 한다. 그러면, 마치 함수처럼 클래스에 하나의 기능만 구현하라는 걸까? 그렇지 않다. 여기서 하나의 의미는 관련성이 적은 다른 기능의 코드까지 함께 변경하도록 만들지 말라는 것이다.

 

예를 들어, 요구사항A를 반영하여 클래스를 수정했다. 그 다음에 요구사항B가 새로 들어와서 그것도 반영했다. 그랬더니 요구사항A와 관련된 코드에 문제가 발생했다. 다시 수정하자니, 요구사항B를 반영할 수 없다. 서로 다른 책임의 코드가 결합된 상황이 발생한 것이다.

 

따라서, 이러한 충돌을 해결하고 앞으로의 변경에도 유연하게 만들기 위해 두 책임의 결합을 분리(ex: 별도의 클래스)하라는 뜻으로 이해할 수 있다.


Open-Closed Principle (개방-폐쇄 원칙)

“기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 설계하라.”

의의

이 원칙을 지킬 수록 프로젝트는 탄탄해진다. 왜냐면,

  1. 기존 코드의 변경은 항상 버그의 가능성을 의미한다.
  2. 변경은 테스트를 필요로 한다.
  3. 코드의 수정이 또 다른 수정을 불러오는 현상은 개발자의 개발 의지를 꺾는다.

OCP의 구현은 클라이언트 클래스와 의존성 클래스 사이에 추상화를 둠으로써 가능하다. (⇒ 의존성 역전 원칙)

예시)

  1. Repository 클래스가 LocalDataSource 클래스에 의존하고 있다. LocalDataSource는 File의 데이터를 읽어오는 기능만 구현되어 있다.
  2. Room에서 데이터를 저장,불러오는 기능을 추가하고 싶다.
  3. LocalDataSource를 인터페이스로 변경한다.그리고 구현을 FileDataSource 클래스로 옮기고 LocalDataSource를 상속한다.
  4. Room 기능을 추가하기 위해 RoomDataSource를 정의하고 똑같이 LocalDataSource를 상속한다. 그러면 기존의 Repository와 FileDataSource의 코드를 변경하지 않으면서 Room 기능을 확장할 수 있게 된다. 또한, 앞으로 다른 종류의 로컬 데이터 기능을 확장하더라도 기존의 클래스를 변경하지 않는 것이 가능해진다.

정리)

  • 원칙: 기존 코드를 수정하지 않고 기능을 확장할 수 있도록 하자.
  • 이유: 기존 코드의 변경은 버그의 가능성을 의미하며, 연쇄적인 수정은 개발의 의지를 꺾는다.
  • 방법: 하위 기능들을 대표하는 추상 클래스(인터페이스)를 정의하고, 새 기능을 추가할 때 구현 클래스가 추상 클래스를 상속하도록 한다. (DIP)

Liskov Substitution Principle (리스코프 치환 원칙)

“서브타입(subtype)은 그것의 기반 타입(base type)으로 완벽하게 치환 가능해야 한다.”

 

즉, 상위 타입 자리에 하위 타입 객체를 사용할 때, 하위 타입 객체가 상위 타입의 동작을 완전히 대체할 수 있어야 한다.

위반 사례)

직사각형(Rectangle)-정사각형(Square) 문제

open class Rectangle {
	open var width = 0
	open var height = 0
	val area: Int
		get() = width * height
} 

class Square : Rectangle() {
	override var width: Int
		get() = super.width
		set(width) {
			super.width = width
			super.height = width  
		}

	override var height: Int
		get() = super.height 
		set(height) {
			super.width = height 
			super.height = height 
		}
}
fun calculateArea(rect: Rectangle): Int {
	rect.width = 2
	rect.height = 3
	return rect.area
}

fun main() {
	val rect = Rectangle()
	val square = Square()
	println(calculateArea(rect)) // 6
	println(calculateArea(square)) // 9
}
  • 문제: 같은 상황에서 같은 메서드를 호출했을 떄 Rectangle과 Square의 결과가 다르다. 이것은 클라이언트 객체 입장에서 에러 상황이다.
  • 원인: 개념적으로 상속 관계처럼 보이지만 실제 동작은 상속 관계가 아닌데 직사각형을 상속했기 때문이다.
  • 해결: 새로운 추상 클래스를 선언하여 둘 다 그것을 상속하게 한다.

Deep: 왜 하위 타입이 상위 타입을 완전히 대체해야만 하는가?

하위 타입은 상위 타입으로 언제나 업캐스팅 되어 사용될 수 있기 때문이다. 따라서, 하위 타입마다 상위 타입의 메서드 호출에 대한 동작이 다르다면 이것을 사용(의존)하는 클라이언트 객체 입장에선 참으로 난감할 것이다.

이것을 해결하는 방법으로, 객체를 사용할 때 if문으로 하위 클래스의 타입을 검사해서 다르게 처리할 수 있는데, 이럴 경우 하위 클래스를 추가할 때마다 사용하는 쪽에 if문을 새로 추가해야 한다. 이것은 기능을 확장할 때마다 기존의 코드를 수정하게 하여 변경을 어렵게 만든다. (OCP 원칙 위반)


Interface Segregation Principle (인터페이스 분리 원칙)

“클라이언트가 사용하지 않는 메소드를 갖지 않도록 인터페이스를 분리하라”

 

범용적인 인터페이스가 아니라 클라이언트가 사용하는 기능만 갖도록 인터페이스를 분리하라는 의미로 볼 수 있다.

왜 인터페이스를 분리하는 것이 좋을까?

범용적인 인터페이스를 구현(상속)하는 클래스 입장에서 아래와 같은 문제가 발생한다.

  1. 사용되지 않을 메서드를 억지로 구현해야 한다.
  2. 사용되지 않는 메서드의 시그니쳐가 변경됐을 때 불필요하게 수정해야 한다.

위반 예시

interface Animal {
	fun eat()
	fun run(from: Where, to: Where)
	fun fly(from: Where, to: Where)
}

class Lion : Animal {
	override fun eat() = println("Lion eat")
	override fun run(from: Where, to: Where) = println("Lion run")
	override fun fly(from: Where, to: Where) {} // 구현 필요 없음
}

Animal의 하위에 Bird와 Mammal로 인터페이스를 분리하고 Lion이 Mammal을 상속한다면 Lion은 필요한 메서드만 구현할 수 있다.


Dependency Inversion Principle (의존성 역전 원칙)

“상위 레벨 모듈은 하위 레벨 모듈에 직접 의존하지 않고 추상화에 의존해야 한다.”

 

클라이언트 클래스가 의존성 대상인 구체 클래스에 의존하지 않고 추상 클래스(인터페이스)에 의존하도록 하라는 것으로 해석할 수 있다.

장점)

DIP를 지키면 하위 모듈을 변경할 때 상위 모듈에 미치는 영향을 최소화 할 수 있다. 즉, 의존 대상에 변경이 생겼을 때 클라이언트에게 미치는 영향을 최소화 할 수 있다.

예시) Car → Engine

Car의 내부에서 가솔린 엔진을 쓰다가 디젤 엔진으로 변경한다.

class GasolineEngine() {
	private val fuel = "Gasoline"
}
class DieselEngine() {
	private val fuel = "diesel"
}

class Car {
	private val engine = GasolineEngine()
}
// 디젤 엔진으로 변경
class Car {
	private val engine = DieselEngine()
}

이 방식의 단점은 Engine 을 변경하는데 Car의 코드도 함께 변경해야 한다는 것이다.

하지만 가운데에 Engine 인터페이스(추상화)를 두면, 의존성을 갖지 않던 하위 레벨 모듈인 가솔린 엔진이 상위 레벨로 도입된 엔진에 의존하는 의존성 역전이 일어난다.

class Car(private val engine: Engine)

interface Engine
class GasolineEngine : Engine
class DieselEngine : Engine
class TestEngine : Engine

fun main() {
    val gasolineCar = Car(GasolineEngine())
    val dieselCar = Car(DieselEngine())
    val testCar = Car(TestEngine())
}

이제 어떤 엔진으로 갈아 끼우더라도 Car엔 변경이 일어나지 않는다. (Engine 자체에 혁신이 일어나서 인터페이스 명세서가 변경되지 않는 한..)

구체적인 장점)

  • 구조가 유연해진다. 즉, 하나의 클래스가 여러 구체 클래스에 의존 할 수 있다.
  • 기존 코드를 변경하지 않으면서 상황에 따라 의존 대상을 변경할 수 있다. ⇒ DIP는 OCP를 지키기 위한 방법이 된다는 것을 알 수 있다!
  • 구조가 유연해진 덕분에 테스트용 가짜 객체를 사용할 수 있어서 단위 테스트가 편해진다.

참조

scope 함수는 특정 객체에 대한 일시적인 스코프(코드 블록)를 생성해서 그 객체와 관련된 작업을 수행하는 함수이다.

목적은 코드를 더 간결하게 만드는 것에 있다.

사용 예시

아래 코드는 스코프 함수인 let을 사용하고 있다.

Person("Alice", 20, "Amsterdam").let {
    println(it)
    it.moveTo("London")
    it.incrementAge()
    println(it)
}

살펴보면, 객체의 확장 함수 형태로 함수 let을 호출하고 있으며, let은 람다를 인자로 받는다.

람다는 수신 객체(Person)를 인자로 받아서 작업을 수행하는 블록이며, it으로 수신 객체에 접근하고 있다.

 

참고로 스코프 함수를 호출할 때 사용하는 객체를 수신 객체, 수신자, context object 등으로 표현하는데, 나는 수신 객체가 편해서 그렇게 부르고 있다.

let을 사용하지 않은 코드

val alice = Person("Alice", 20, "Amsterdam")
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)

위 코드는 let을 사용하지 않게 바꾼 것이다. 솔직히 이 예시만 봐선 간결해졌는지 잘 모르겠다. 하지만, alice 객체를 사용하는 작업들을 하나로 묶었다는 점은 확실히 눈에 들어온다!


Scope 함수의 종류

스코프 함수에는 let, run, also, apply, with 5개가 있는데, 함수마다 호출 형식에 약간의 차이가 있다. 그 차이를 아래에 표로 첨부했다. 눈에 잘 안 들어온다면 우선 스킵하고 그 아래의 함수 소개로 고고.

함수 객체 참조 리턴 값  확장 함수인가?
let it 람다 리턴 값
Yes 
run this 람다 리턴 값 Yes
run 없음 람다 리턴 값 NO (객체 없이 호출)
with this 람다 리턴 값 NO (인자로 객체 전달)
apply this 수신 객체 Yes
also it 수신 객체 Yes

함수의 호출 형태에서 구분되는 요소는 3가지이다

  • 객체 참조: 수신 객체의 참조를 this로 하는가, it으로 하는가
  • 리턴 값: 리턴 값이 람다의 반환 값인가, 수신 객체 자체인가?
  • 호출 형태: 확장 함수로 호출하는가, 일반 함수처럼 호출 하는가?

1. let

fun <T, R> T.let(block: (T) -> R): R

let은 객체(T)의 확장 함수 형태로 사용된다. 함수의 인자로 람다 블록을 입력 받는데, 람다는 자기 자신(T)을 받아서 R을 반환하며, 람다 안에서 T는 it으로 접근 가능하다. let은 최종적으로 람다의 반환 값 R을 리턴한다.

chain operation에서 사용

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let { 
    println(it)
}

// vs
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)

위와 같이 chain operation의 끝에서 값을 받아서 최종 결과를 만들어 내는 형태로 사용할 수 있다.

그 아랫 줄의 코드는 같은 상황에서 let을 사용하지 않는 경우이다. 한 줄이라서 어떻게 해도 가독성에 차이는 없어 보이지만, let에 들어갈 코드가 많아진다면 let 사용문이 좀 더 간결하게 느껴질 것 같다.

객체가 non-null 일 경우의 작업에 사용

let을 가장 많이 활용하는 형태이다. nullable 객체에 대해 non-null일 경우에 수행할 작업을 정의할 수 있다.

또한, 블록 안에서 non-null로 casting되어서 !!이나 ? 없이 접근할 수 있다.

val str: String? = "Hello"
val str2: String? = null
val length = str?.let { it.length }
val length2 = str2?.let { it.length }
val length3 = str2?.let { it.length } ?: 0
print(length) // 5
print(length2) // null
print(length3) // 0
  • str가 null이 아니므로, length에는 람다의 결과 값인 str의 length가 할당된다.
  • str2는 null이기 때문에 let 함수가 실행되지 않고 null을 반환한다. elvis(?:) 연산자로 null일 때의 처리를 해줄 수도 있다.

2. with

fun <T, R> with(receiver: T, block: T.() -> R): R

with는 let과 달리 일반 함수 형태로 사용된다. 객체 T를 인자로 받아서 임의의 결과 값 R을 반환하는 람다를 입력 받으며, 함수의 최종 결과로 람다의 반환 값 R을 반환한다.

람다에서 받는 객체 T를 보면 T.() 라고 되어 있는데, 이렇게 받으면 확장 함수와 같이 처럼 this로 T에 접근할 수 있다. 또한, this 없이도 T의 멤버에 접근할 수도 있다.

단순히 함수를 호출하는 경우 or 함수 호출을 그룹화 하는 경우

val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    println("인자 $this로 with를 호출했다.")
    println("이것은 $size개의 원소를 갖고 있다.")
}

with(viewModel) {
	dataA.observe(viewLifecycleOwner) {
			// ... 
	}
	this.dataB.observe(viewLifecycleOwner) {
			// ...
		}
}

공식 문서에 따르면 객체의 메서드의 결과 값이 필요하지 않고 호출만 하는 경우에 사용하길 추천하고 있다.

특히, 객체의 여러 함수를 호출할 때 보기 좋게 모아두는 용도로 사용할 수 있다.

값을 계산하기 위한 helper 객체로 활용하는 경우

val firstAndLast = with(numbers) { 
	"FirstElement is ${first()}, the last is ${last()}"
} 
println(firstAndLast)

어떤 객체를 활용해서 값을 계산하고, 결과 값이 필요할 때 사용할 수 있다.


3-1. run (extension)

fun <T, R> T.run(block: T.() -> R): R

run은 확장 함수 형태로 호출된다. 람다 블록은 자신을 T.()로 입력 받기 때문에 this로 접근 가능하고, 임의의 결과 R을 반환한다.

확장 함수로 호출한다는 점을 제외하면 with와 완전히 같지만, 확장 함수 방식 덕분에 null-safe(?.)호출이 가능하다. (object?.run())

객체를 초기화하고 값을 계산하는 경우

val service = MultiportService("<https://example.kotlinlang.org>", 80)
val result = service.run {
    port = 8080
    query(prepareRequest() + " to port $port")
}

// with을 활용한 같은 코드
val letResult = with(service) {
    port = 8080
    query(it.prepareRequest() + " to port ${it.port}")
}

run은 this로 객체 접근이 가능하고 결과 값을 반환하므로, 객체를 초기화 하고 그것으로 값을 계산하는 상황에서 사용하는 것을 공식 문서에서 권장하고 있다.

그 아래는 with로 같은 상황의 코드를 작성한 것이다. 큰 차이가 없어서 앞으로 뭘 사용해야 가독성이 나아질지 고민될 것 같다. with는 읽었을 때 “T객체로 ~~작업을 합니다” 라는 의미가 있어서 개인적으로 객체를 초기화 하는 내용이 있된다면 run이 조금 더 적절할 것 같다.

3-2. run (not extension)

fun <R> run(block: () -> R): R

run은 수신 객체 없이 함수만 호출하는 한 가지의 형태가 더 있다. 입력 받는 람다는 매개 변수 없이 반환 값 R만 존재한다. 함수의 최종 결과는 람다의 R을 반환한다.

여러 중간 값과 함수 호출을 거쳐서 최종 결과를 계산하는 경우

val hexNumberRegex = run {
    val digits = "0-9"
    val hexDigits = "A-Fa-f"
    val sign = "+-"
    Regex("[$sign]?[$digits$hexDigits]+")
}

위 코드는 Regex 객체를 초기화 하고 있다. 그 과정에서 몇 개의 중간 값들을 통해 최종 객체를 생성하는데, 이런 과정을 run 블록으로 묶어 가독성을 높일 수 있다.


4. apply

fun <T> T.apply(block: T.() -> Unit): T

apply는 확장 함수로 호출되며, 람다에서 수신 객체를 인자로 입력 받는다. 하지만 let, with, run과 다르게 람다에 반환 값이 없고 함수의 최종 결과로 수신 객체 T를 다시 반환한다. 람다의 매개변수 타입이 T.()이므로 this로 수신 객체에 접근한다. ****

객체를 초기화(설정 변경) 하는 경우

val adam = Person("Adam").apply {
    age = 32
    city = "London"        
}

this로 멤버에 쉽게 접근 가능하고 자기 자신을 리턴하기 때문에 객체의 초기화나 설정을 변경할 때 유용하다.


5. also

fun <T> T.also(block: (T) -> Unit): T

apply처럼 확장 함수로 호출되고 수신 객체를 다시 리턴한다. apply와 달리 람다에서 (T)로 수신 객체를 받아서 it으로 접근한다.

객체를 부가적으로 활용해서 다른 작업을 할 때 (ex 함수 인자로 사용)


val numbers = arrayListOf("one", "two", "three")
numbers
    .also { println("add 하기 전에 print: $it") }
    .add("four")

수신 객체를 위한 작업보다 수신 객체를 이용한 다른 작업을 할 때 사용하는 것이 권장된다. 함수의 이름처럼 ***“아, 그리고 ~~한 작업도 함께 해라”***라고 해석할 수 있다. ******


함수별 용도

이 함수는 반드시 이런 상황에서만 써야 해! 이런 건 없지만, 공식 문서에서 권장하는 함수별 이용 상황을 다시 정리해보면 다음과 같다.

  • let
    • non-null 객체에 대한 작업을 수행
    • chain operation의 마지막에 사용하여 가독성 향상
  • with
    • 객체의 함수 호출을 그룹핑 and 결과 값이 필요하지 않을 때
  • run
    • 객체의 설정을 변경하고 그것을 활용한 연산 결과를 반환
    • 하나의 작업이지만 여러 중간 값과 함수 호출이 필요할 때 한 블록으로 모아서 가독성 향상
  • apply
    • 객체의 설정 변경 (ex: 프로퍼티 초기화)
  • also
    • 객체를 타겟팅한 작업이 아니라 객체를 활용한 다른 작업을 수행 (ex 함수 인자로 사용)

참조

의존성 주입(Dependency Injection, DI)이란

의존성 주입이란, 의존성을 클래스 밖에서 생성해서 내부로 전달하는 프로그래밍 방식이다.

의존성은 클래스 A가 클래스 B의 객체를 참조할 때, A가 B에 의존한다 또는 A가 B에 대한 의존성을 갖는다고 한다.

따라서, 의존성 주입은 어떤 클래스의 내부에서 사용되는 객체를 클래스 외부에서 생성해서 전달하는 것이다.


객체를 내부에서 생성할 때 발생하는 문제

왜 굳이 객체를 클래스 안에서 생성하지 않고 밖에서 생성하려는 걸까? 객체를 내부에서 생성하는 예시는 다음과 같다.

class Bread() {
    private val milk: Milk = SeoulMilk()
}

이러한 방식의 문제는 특정한 클래스와 강하게 결합한다는 점이다. 어떤 타입의 구현 클래스가 당장 하나 뿐이면 괜찮을 수도 있다. 하지만 여러 타입의 Milk가 존재한다면? 혹은 앞으로 Milk 타입의 다른 구현 클래스가 추가된다면?

interface Milk
class SeoulMilk() : Milk
class MaeilMilk() : Milk
class KonkukMilk() : Milk

빵집마다 사용하는 Milk가 다를 수 있으므로 Milk마다 별도의 Bread 클래스를 정의해야 할 것이다.

// 내부에서 객체를 생성하는 방식
class SeoulMilkBread() : Bread { ... ]
class MaeilMilkBread() : Bread { ... }
class KonkukMilkBread() : Bread { ... }

하지만 빵을 만들기 위한 재료(의존성)는 Milk 외에도 여러 가지가 있다. 따라서, 이러한 방식은 엄청나게 많은 클래스를 만들게 된다.

반면, 외부에서 의존성을 전달하면 하나의 Bread 클래스를 상황에 따라 재사용 할 수 있게 된다.

// 의존성 주입 (생성자 주입)
class Bread(private val milk: Milk) { 
    // ... 
}

val bread1 = Bread(SeoulMilk())
val bread2 = Bread(MaeilMilk())
val bread3 = Bread(KonkukMilk())

// 프로퍼티(setter) 주입 방식
class Bread() {
	lateinit var milk: Milk
}
val bread = Bread()
bread.milk = SeoulMilk()
bread.milk = MaeilMilk()

의존성 주입의 장점

위에서 살펴본 DI의 장점은 클래스를 재사용 할 수 있다는 것이었다. (클래스 재사용성 or 유연성)

의존할 타입의 구체 클래스의 종류가 하나임이 확실하다면, DI의 이점은 없는 것일까? 당연히 다른 장점이 있다!

1. 생성자의 변경으로부터 보호할 수 있다

class SeoulMilk(val a: T1, val b: T2, val c: T3) : Milk
class Bread() {
    private val milk: Milk = SeoulMilk(T1(), T2(), T3())
}

// 생성자 시그니쳐 변경
class SeoulMilk(val a: S1, val b: T2, val c: W1, val d: S2) : Milk
class Bread() {
    private val milk: Milk = SeoulMilk(S1(), T2(), W1(), S2())
}

구현 클래스의 생성자는 변경될 가능성이 높다. 따라서, 클래스 안에서 객체를 생성할 경우 생성자가 변경되면 생성 코드를 일일이 수정해야 한다. 사용하는(클라이언트) 클래스가 하나라면 모르겠지만, 여러 클래스에서 의존하고 있다면 매번 함께 변경해줘야 할 것이다.

반면, DI를 사용하면 객체를 생성하는 코드를 별도의 클래스에서 관리한기 때문에 생성 코드를 한 번만 변경하면 된다. DI 프레임워크를 사용하면 변경하지 않아도 되는 경우도 있다.

2. 단위 테스트가 가능하다.

단위 테스트를 할 때는 특정 시나리오에서 클래스가 잘 동작하는지 확인하기 위해 테스트 용 임시 객체를 사용한다. 그런데, 객체를 내부에서 생성하면 테스트 환경에서 만든 임시 객체를 사용할 수 없다. 따라서, 클라이언트 클래스를 인터페이스에 의존하게 하고 외부에서 구현체를 주입하도록 바꾸면, 테스트 용 객체를 전달해서 단위 테스트를 할 수 있게 된다.

3. 객체 생성 코드가 분리되어 클래스가 간결해진다 (부가적인 요소)

Bread와 Milk 예시에서는 생성 방식이 간결하지만, 실제 개발 과정에서는 객체 생성 코드가 복잡한 경우도 있다. 이럴 때, 객체의 생성과 사용 코드를 분리하면 클라이언트 클래스의 코드 간결해진다. 하지만, 이런 DI 적용을 고려할 때 장점이 이것 하나인 상황이라면, DI를 굳이 적용하지 않아도 된다고 생각한다.


장점 정리

  • 클래스를 재사용 할 수 있다. (재사용성, 유연성)
  • 단위 테스트를 할 때 훨씬 편하다 (테스트 편의성)
  • 구현 클래스의 변경으로부터 보호할 수 있다. (ex: 생성자 시그니처 변경)
  • 객체 생성 코드가 분리되어 클래스가 간결해진다 (부수적 장점)

참조

+ Recent posts