이전 글에서 설명했다시피 일반적으로 사용되는 프로그래밍 언어인 C 계얼 언어, Java나 Kotlin 같은 프로그래밍 언어에서는 생성자 주입, setter 함수를 이용해 가장 일반적인 DI 패턴을 활용할 수 있습니다.
Scala 언어에서는 이 두 가지 방법 외에도 trait와 self type을 이용해 Cake Pattern을 구현하고, 이를 통해 DI를 활용할 수 있습니다.
Trait
Trait는 자바의 interface와 매우 비슷하지만 몇 가지 추가적인 기능들을 제공해줍니다.
먼저 다중 상속을 지원해주고, 메서드를 직접 구현할 수도 있으며, 특정 필드를 포함할 수 있습니다.
자바8 이후의 interface는 이러한 기능들을 지원해주지만, scala는 훨씬 이전부터 이러한 기능들을 trait를 통해 지원해주었습니다.
trait를 중첩해서 사용할 때는 상속이라고 하지않고 mixin이라고 부릅니다. 이는 상속이 아닌 조합의 개념이라고 이해할 수 있으며, 이러한 조합을 통해 훨씬 유연한 구현이 가능해집니다.
trait Animal {
val name: String // 필드도 가질 수 있습니다
// val name: String = "default" 처럼 기본값도 줄 수 있습니다
def sound(): String // 추상 메서드로 구현하지 않아도 됩니다
def sleep(): String = "The animal is sleeping" // 직접 구현도 가능합니다
}
trait Leg {
def legNumber(): Int = 4
}
// extends와 with을 통해 여러 trait를 mixin 할 수 있습니다
// 필드를 제공해줘야 합니다
class Dog(override val name: String) extends Animal with Leg {
// 추상 메서드를 구현해줘야 합니다
override def sound(): String = "Wooah!"
}
val myDog = new Dog("candy")
println(myDog.sound()) // "Wooah!"
println(myDog.sleep()) // "The animal is sleeping"
println(s"My dog ${myDog.name} has ${myDog.legNumber()} legs") // "My dog candy has 4 legs"
Trait를 사용할 때 중요하게 알아야 할 점은 여러 trait를 mixin할 때, 어느 순서로 mixin했느냐에 따라 동작이 달라집니다. 예를 들면, Dog에서 가장 마지막에 있는 Leg trait가 먼저 실행되게 됩니다. 이를 scala에서는 선형화(linearization)라고 부르고, 이를 통해 언어적으로 메서드 호출의 우선순위를 결정합니다(다이아몬드 상속문제 같은 문제를 해결해줍니다). 이는 나중에 다른 포스트로 다루겠습니다.
Self type
스칼라에서는 self type이라는 기능을 통해 특정 타입간의 의존성을 지정해줄 수 있습니다. 만약 A가 B라는 타입에 의존하고 있다면, A는 B를 제공해주지 않으면 직접 생성할 수 없습니다. 다른 언어에서는 이를 생성자에서 받는 식으로 해결하지만, scala에서는 타입 선언에서부터 강제할 수 있습니다. 뒤에 보시면 나오지만 단순히 생성자를 통해 받는 방식과는 다른 방식입니다.
trait SettingConfig {
// 현재 실행 환경을 의미합니다 (dev/production etc...)
val phase: String
def readConfig(): Map[String, String]
}
// self를 통해 어떤 의존성이 필요한지 정의해줄 수 있습니다
class TestExecutor { self: SettingConfig =>
// 현재 환경값들을 이용해 함수를 실행해줍니다
def execute(f: Map[String, String] => Boolean): Boolean = {
println("Execute function...")
f(readConfig())
}
}
TestExecutor의 execute 함수를 실행하기 위해서는 SettingConfig를 정의해줘야 합니다. 위의 코드에 적힌 self: SettingConfig => 부분이 TestExecutor가 SettingConfig가 필요하다고 하는 부분입니다.
실제로 사용하는 코드를 확인해봅시다.
// 개발 환경을 만들어줍니다
trait DevelopmentSettingConfig extends SettingConfig {
override val phase: String = "development"
override def readConfig(): Map[String, String] = Map("phase" -> phase)
}
// TestExecutor에 개발 환경인 DevelopmentSettingConfig를 주입해줍니다
class DevelopmentTestExecutor extends TestExecutor with DevelopmentSettingConfig
// 실행해보면 I have config가 출력됩니다
val executor = new DevelopmentTestExecutor
executor.execute { config =>
if (config.isEmpty) {
println("Config is empty!!!")
false
} else {
println("I have config!!!")
true
}
}
알맞은 예는 아닐 수 있지만 설명하기 위함이니 적절히 봐주시길 바랍니다.
추가적으로 하나 더 설명하자면, self type에서 self는 다른 이름으로도 정의될 수 있습니다. 위의 self: SettingConfig => 부분을 config: SettingConfig => 로 바꾸어도 정상적으로 동작합니다.
Cake Pattern을 이용한 DI
Cake Pattern은 Scala에서 사용되는 Dependency Injection의 대안 패턴으로, 트레이트(trait)를 이용해 의존성을 해결하는 방식입니다. 주로 복잡한 객체의 의존성을 설정할 때 유용하게 사용됩니다. 전통적인 DI 프레임워크를 사용하는 대신, Scala의 trait와 mixin, 그리고 self-type을 활용하면 외부 라이브러리에 대한 의존 없이도 직접 DI 패턴을 구현할 수 있습니다.
위에서 설명한 부분이 모두 Dependency Injection을 구현하기 위한 재료입니다. 우리는 trait를 mixin해서 필요한 기능들을 연결할 수 있고, self-type을 이용해서 의존성을 선언할 수 있고, 실제 구현체를 만들어서 의존성을 주입합니다!
Cake Pattern의 구조
Cake pattern을 적용한 시스템은 보통 다음과 같은 구조를 지닙니다.
- Component trait: 필요한 기능이나 의존성을 정의하는 trait입니다.
- Implementation trait: Component trait에 정의된 기능을 실제로 구현하는 trait입니다. 추가적인 확장성이 필요하지 않다면 Component trait 내부에서 실제 구현체를 정의해도 됩니다.
- Module: 여러 trait를 조합해서 최종적으로 완성된 객체를 정의하는 곳입니다.
Cake Pattern의 구현
Cake Pattern의 설명을 위해 간단한 예시를 사용할 예정입니다.
우리는 UserRepository를 통해서 사용자를 조회해오는 UserService를 구현할 것입니다.
설명에서 알 수 있듯이 UserService는 UserRepository를 의존하고 있으니 이는 DI를 통해 조금 더 유연하게 구현할 수 있습니다.
먼저 Repository를 위한 Component를 정의합니다. UserRepositoryComponent를 정의하고, 내부에 UserRepository를 정의합니다.
case class User(name: String)
trait UserRepositoryComponent {
val userRepository: UserRepository
trait UserRepository {
def findUserByName(name: String): Option[User]
}
}
다음으로, 우리는 UserRepository의 실제 구현체를 만들어줍니다.
여기서는 InMemory로 구현했지만, 필요하다면 다양한 구현체를 지원해줄 수 있습니다.
trait UserRepositoryComponentImpl extends UserRepositoryComponent {
class InMemoryUserRepositoryImpl extends UserRepository {
private val memory: Map[String, User] = Map("herry" -> User("herry"))
override def findUserByName(name: String): Option[User] = memory.get(name)
}
// 이외에도 MySql, MongoDB 등을 이용한 UserRepository를 구현해서 지원해줄 수 있습니다.
// 여기에 추가적으로 정의해도 되고, 필요하다면 Implementation을 추가로 구현할 수도 있습니다.
}
다음으로, Repository를 활용하는 UserService를 만들어줍니다.
우리의 UserServiceComponent는 UserRepository를 요구합니다. 이는 self: UserRepositoryComponent => 구문을 통해 명시적으로 표현됩니다.
여기서는 추가적인 확장이 존재하지 않을 예정이라 Component와 Implementation을 나누지 않고 내부에서 전부 구현해서 해결할 수 있습니다.
trait UserServiceComponent { self: UserRepositoryComponent =>
val userService: UserService
class UserService {
def describeUser(name: String): String = {
userRepository.findUserByName(name) // : Option[String]
.map(user => s"${user.name} is printed") // : Option[String]
.getOrElse("empty!") // : String
}
}
}
마지막으로, 이 trait를 모두 조합해서 우리의 모듈, 즉 Cake Pattern의 Cake를 완성합니다.
object UserModule extends UserServiceComponent with UserRepositoryComponentImpl {
val userRepository = new InMemoryUserRepositoryImpl()
val userService = new UserService()
}
object Main extends App {
// 사용의 편의성을 위해 UserModule의 모든 정보를 import합니다
// 이렇게하면 UserModule.userService로 접근하지 않고 userService로 직접 접근할 수 있습니다.
import UserModule._
println(userService.describeUser())
}
짠! 우리의 Cake가 완성되었습니다. 우리는 복잡하게 생성자 주입이나 setter 주입의 방법을 사용하지 않고 trait와 self-type을 이용해 우리만의 다양한 Cake를 바로바로 만들 수 있게 되었습니다. 만약 MySQL을 이용하는 Repository를 사용하고 싶다면 MySQLUserRepository를 만든 후 mixin하는 타입만 바꿔주면 됩니다!
이러한 장점은 특히 테스트에서 사용할 때 큰 도움이 됩니다. 우리는 우리만의 모듈을 만들고, 이 모듈에서 특정 객체만 모킹해서 테스트 용도로 사용하면 됩니다.
class MyTest extends AnyFunSuite {
object TestUserServiceModule extends UserServiceComponent with UserRepositoryComponent {
override val userRepository = mock[UserRepository]
override val userService = new UserService()
}
val sut = TestUserServiceComponent.userService
test("????") {
// 여기서 테스트를 짜면 됩니다
}
}
추가 개선사항
위의 구현사항대로 구현하다보면, 우리가 실제로 사용할 객체는 userService인데 외부로는 userRepository까지 같이 노출되는 문제가 있습니다. 이 문제는 외부로 노출할 객체만 public으로 두고, 그 외에는 protected로 바꾸는 것으로 해결할 수 있습니다.
object UserModule extends UserServiceComponent with UserRepositoryComponentImpl {
protected val userRepository = new InMemoryUserRepositoryImpl()
val userService = new UserService()
}
object Main extends App {
// 사용의 편의성을 위해 UserModule의 모든 정보를 import합니다
// 이렇게하면 UserModule.userService로 접근하지 않고 userService로 직접 접근할 수 있습니다.
import UserModule._
println(userService.describeUser())
// userService.userRepository를 접근할경우 컴파일 에러가 발생합니다
}
object는 다른 object나 class에게 상속될 수 없으므로 protected는 사실상 private과 동일한 역할을 하게 됩니다.
결론
우리는 trait와 self-type을 이용한 Cake Pattern을 활용해 Scala만의 DI를 구현할 수 있게 되었습니다.
다만 이러한 방법은 초기에 코드를 짜는게 많이 힘들고 귀찮을 수 있어 외부 라이브러리를 쓰는게 더 쉬울 수 있습니다.
생성자 주입도 훌륭한 방법이지만, Main 같은 초기 시작위치에 모든 dependency를 정의하고 생성해야 하는 부담이 있습니다. Cake Pattern은 이를 특정 모듈 내부로 강제하여 더욱 깔끔한 구현이 가능하게 만들어줍니다.
라이브러리를 사용하는 경우가 아니라면 이는 생성자 주입이나 setter 구현보다 더욱 훌륭한 대안이 될 것입니다.