Table of contents
Open Table of contents
함수적 의존관계 주입 사용하기
허브가 외부 리소스에 대한 접근을 위임하는 방법, 즉 허브 내부에서 함수를 주입해 도메인에서 외부 서비스가 통신할 수 있도록 허용하는 방법을 살펴보자
의존관계 주입
의존관계 주입은 객체 지향 프로그래밍 뿐만 아니라 함수형 프로그래밍에서도 좋은 디자인을 하는 데 중요한 원칙이다.
코드에서 외부 의존관계를 사용해야 하는 경우, 이를 직접 생성하지 말고 외부에서 전달받아야 한다.
외부 의존관계의 생성과 사용을 분리하면 서로 다른 동작을 기대하는 여러 구현을 전달할 수 있기 때문에 코드가 더 유연하고 테스트하기 좋아진다.
객체 지향 의존관계 주입과의 비교
함수형 프로그래밍에서는 객체보다 함수가 선호되는 경우가 많다. 그 결과, 의존관계를 함수로 주요 함수에 전달해야 한다.
의존관계를 인자로 받아들여서 내부에 그 의존관계를 포함하는 함수를 생성하는 다른 함수를 사용한다.
typealias ToDoListFetcher = (User, ListName) -> ToDoList?
부분 적용(partical application)
여러 파라미터가 있는 함수가 있다고 가정하자. 이 함수를 어떻게는 첫 번째 파라미터와 연결하고, 나머지 두 개 파라미터만 받는 새 함수를 만들고 싶다.
이를 위해 파라미터가 세 개인 함수와 첫 번째 파라미터를 입력으로 받고 나머지 두 개의 파라미터를 받아서 결과를 내놓은 새 함수를 돌려주는 partial 함수를 만들 수 있다.
fun <A, B, C, R> partial(f: (A, B, C) -> R, a: A): (B, C) -> R = { b, c -> f(a, b, c) }
원한다면 부분 적용의 개념을 더 확장해서 함수 인자가 다 떨어질 때까지 부분 적용을 반복해서 적용할 수 있다.
일반적으로 여러 개의 파라미터가 있는 함수를 하나의 파라미터만 있는 함수들의 연쇄로 변환하는 것은 언제나 가능하다.
일반적으로 이 기법을 하스켈 커리라는 수학자의 이름을 따서 커링(currying)이라고 부른다.
호출 가능한 클래스로서의 함수
코틀린의 타입 시스템에서 놀라운 점은 클래스가 함수 타입을 상속할 수 있다는 점이다.
이렇게 하면 클래스의 구현을 마치 함수처럼 호출할 수 있다.
더 나아가 똑같은 시그니처의 함수가 필요한 곳에 어디든 호출 가능한 클래스의 인스턴스를 전달할 수 있다.
함수 타입의 클래스를 선어하려면 (클래스 시그니처에서) 상위 클래스가 있어야 할 곳에 함수 타입을 적은 다음, invoke
메서드를 오버라이드하면 된다.
클래스를 사용해 함수를 구현하면 몇 가지 장점이 있다.
- 생성자 파라미터를 필요한 함수에 부분적으로 적용할 수 있게 된다.
- 복잡한 코드를 비공개 함수 안에 깔끔하게 포함시킬 수 있다.
- API에 필요한 상태를 함수 호출과 호출 사이에 저장할 수 있다. (데이터베이스나 인터넷 등에 대한 연결)
1. 인터페이스를 활용한 함수 타입 상속
fun interface Transformer {
fun transform(value: String): String
}
val upperCaseTransformer = Transformer { it.uppercase() }
fun main() {
println(upperCaseTransformer.transform("hello")) // HELLO
}
fun interface를
사용하여 **단일 메서드 인터페이스(SAM 인터페이스)**를 정의.invoke
를 직접 오버라이드하지 않고 람다로 인스턴스를 생성 가능.
2. 함수 타입을 상속하는 클래스
class MyFunction : (Int) -> String {
override fun invoke(x: Int): String {
return "Number: $x"
}
}
fun main() {
val f: (Int) -> String = MyFunction()
println(f(42)) // Number: 42
}
(Int) -> String
함수 타입을 상속하는MyFunction
클래스 정의.invoke
메서드를 오버라이드하여 함수처럼 사용 가능.
3. 고차 함수와 함께 사용
class AddFunction(val num: Int) : (Int) -> Int {
override fun invoke(x: Int): Int = x + num
}
fun applyFunction(f: (Int) -> Int, value: Int): Int {
return f(value)
}
fun main() {
val addFive = AddFunction(5)
println(applyFunction(addFive, 10)) // 15
}
AddFunction
클래스가(Int) -> Int
타입을 구현.applyFunction
고차 함수에AddFunction
인스턴스를 전달.
4. object로 함수 타입 객체 생성
val multiplyByTwo = object : (Int) -> Int {
override fun invoke(x: Int): Int = x * 2
}
fun main() {
println(multiplyByTwo(6)) // 12
}
object
를 사용하여(Int) -> Int
함수 타입을 구현.invoke
를 오버라이드하여 함수처럼 호출.
함수형 코드 디버깅 하기
단일 식을 사용하고 가변 변수를 피하면서 모든 함수를 일련의 타입 변환 체인으로 작성하려고 시도하면 코드를 작성하기가 매우 어려울 수 있다.
모든 것이 어떻게 서로 맞물려 돌아가는지, 떄로는 컴파일이 되지 않는 이유조차 전혀 이해하지 못할 수도 있다.
반면 일단 코드가 제대로 컴파일이되고 나면 나중에 예상치 못한 일은 없을 거라고 자신할 수 있다.
일단 이 이 스타일에 익숙해지고 나면 코드를 의외로 이해하기 쉽다.
숨거진 의존관계가 없고 모든 것이 명시적으로 선언되기 때문이다.
의심이 된다면 출력하라
때로는 디버깅을 위해 값을 출력하고 싶다는 이유만으로 변수를 추가하고 싶을 때가 있다. 다음은 제네릭 확장 함수를 정의해 T 타입의 값을 출력하는 보다 우아한 방법이다.
fun <T> T.printIt(prefix: String = ">"): T = also { println("$prefix $it") }
함수형 도메인 모델링
객체 지향 디자인에서는 비즈니스 프로세스에 관여하는 비즈니스 엔터티를 식별하는 것부터 시작한다.
비즈니스 엔터티는 고객, 주문, 제품, 송장과 같은 실제 개념이다.
관련된 비스니스 엔터티를 식별하고 나면 다음 단계는 엔터티들을 코드의 클래스로 매핑하고 특정 개념의 동작을 캡슐화하는 것이다. 따라서 목표는 실제 프로세스를 밀접하게 반영하는 소프트웨어 시스템을 만드는 것이다.
예를 들어 고객의 이름, 연락처 정보, 구매 내역 등의 데이터와 주문이나 프로필 갱신 등의 행동을 캡슐화하는 Customer
클래스를 만들 수 있다.
반면 함수형 디자인은 데이터 타입의 변환(화살표)과 그런 변환의 합성을 기반으로 한다.
따라서 비즈니 프로세스를 매핑할 때는 비즈니스 엔터티에 초점을 맞추지 않고 교환되는 데이터와 그 변환에 초점을 맞춘다. 예들 들어 일부 데이터(CustomerOrder)를 가져오는 함수를 정의한 다음, 약간의 계산(주문의 총 비용 계산)을 수행하고, 다른 데이터(최종 invoke)를 출력으로 반환하는 함수를 정의할 수 있다.
간단히 말해, 객체 지향 디자인에서 함수형 디자인으로의 전환은 도메인을 서로 협업하는 개체로 보는 것에서 불변하는 정보들이 변환되는 네트워크로 보는 방식으로의 전환이다. 그러나 코드에서 비즈니스 도메인을 모델링하는 것은 우리가 사용하는 패러다임과 관계 없이 매우 섬세한 작업이다.
낮은 카디널리티
타입의 카디널리티는 모든 가능한 값의 개수로 정의된다. 예를 들변 다음과 같다.
타입 | 카디널리티 | 값의 예 |
---|---|---|
Boolean | 2 | true, false |
Int | 2^32 | 0, 1, 2, … |
String | 무한 | ”hello”, “world” |
이런식으로 타입을 살펴보는 것이 처음에는 의외일 수 있지만, 카디널리티를 최대한 낮게 유지하면 도메인에서 실제 의미가 없는 값을 표현하지 못하게 할 수 있다. 이렇게 하면 전체 애플리케이션 상태를 더 쉽게 이해할 수 있으며, 중간중간 추가해야 하는 검사도 줄일 수 있다.
또한 낮은 카디널리티는 데이터를 그 소스 근처에서 막을으로써 손상 방지 계층(anti-corruption layer)으로 작용해 잘못된 데이터가 시스템에 유입되지 못하게 막는다. 이를 위해서는 신뢰할 수 없는 소스의 데이터를 검증하는 구체적인 생성자가 필요하다.
오류를 나타내도록 널 반환하기
여기서 사용할 수 있는 좋은 패턴은 기본 생성자를 비공개로 만드는 대신 정적 공개 생성자를 두 개 생성하는 것이다.
두 정적 공개 생성자 중 하나는 우리가 소스를 신뢰할 때 사용하고, 다른 하나는 신뢰할 수 없는 소스를 위해 사용한다.
data class ListName internal constructor(val value: String) {
companion object {
fun fromTrusted(name: String): ListName = ListName(name)
fun fromUntrusted(name: String): ListName? = TODO("not implemented yet")
}
}
fromUntrusted
는 이름이 유효하면 ListName
을 반환하고, 유효하지 않으면 null
을 반환한다.
이 경우 null
은 새 인스턴스를 생서할 수 없음을 나타낸다.
널의 문제
널 참조는 1965년에 토니 호어가 ‘단지 구현이 아주 쉬웠기 때문에’ 알골 언어에 처음 도입했다. 그는 이 결정을 ‘나의 10억 달러짜리 실수’라고 불렀다.
null
의 문제점을 이해하기 위해 null
이 있으면 코드가 우리를 속일 수 있다는 사실을 생각해보자.
예를 들어 자바에서는 다음과 같은 메서드를 정의할 수 있다.
Customer getCustomer(String name) {
if (name.isEmpty())
return null;
else
return new Customer(name);
}
이 함수는 파라미터로 String인 이름을 기대하지만, null
을 받을 수도 있따. 이것은 문제가 되는데, 널은 확실히 문자열이 아니며 문자열로 취급하면 예외가 발생하기 때문이다.
더 따라서 이 메서드를 호출하는 사람은 결과를 사용하기 전에 null
인지를 확인해야 한다. 따라서 코드를 체크포인트로 채우지 않으면 런타임에 NullPointerException
이 발생할 수 있다.
코틀린의 널 가능 타입을 사용하면 안전하게 전 합수를 작성할 수 있다. 컴파일러가 두 경우를 강제로 모두 처리하게 하기 때문이다. 오류 대신 널을 반환하는 것은 실패 원인을 신경 쓰지 않을 때 오류를 처리하기에 아주 편리한 패턴이다.