느슨한 결합의 장점
느슨한 결합을 사용하게 됐을 때의 장점은 크게 다음 두가지가 있다.
1. 변경 혹은 리펙토링이 편해진다.
2. 컴파일 타임의 최소화
지금부터 각각이 무엇을 뜻하는지 알아보자.
1. 변경 혹은 리펙토링이 편해진다.
클래스만을 사용해 구현을 하게 되면, 해당 클래스의 역할이 처음에 생각했던 범위를 넘어서는 일이 자주 생긴다.
예를 들어 검색을 하는 클래스와 검색 결과 상태를 저장하는 클래스가 분리되어 있다고 하고, 검색을 하는 클래스는 검색 결과 클래스에 의존성이 있기 때문에 이 상태값을 알 수 있는 상황을 가정해보자. 누군가 검색 결과 상태를 검색을 하는 클래스로부터 가져올 수 있으니 검색을 하는 클래스로부터 검색 결과를 가져오는 로직을 추가하게 되면 검색을 하는 클래스는 졸지에 검색 결과 상태를 가져오는 역할도 맡게 된다.
특히 함께 일하는 공간에서 여러 사람이 같은 클래스를 함께 수정하게 되면 코드 리뷰가 있더라도 클래스의 역할이 너무 많아지고 클래스에서 관리하는 상태가 너무 많아지는 문제가 생기고 이는 엄청난 기술 부채로 다가온다. 특히 하나의 클래스에서 여러 상태를 관리하게 되고 여러 클래스에서 이 상태를 변경한다면 이는 손대기 어려운 최악의 상황이 된다.
인터페이스를 사용하면 이러한 문제를 완화할 수 있다. 인터페이스에 정의된 동작들만 사용하도록 하면 해당 클래스의 역할이 무한히 확장되는 것을 방지할 수 있다. 또한 이 인터페이스의 동작들에 대한 테스트 코드를 작성해 두면 언제든지 변경이나 리펙토링을 하는 것이 가능해진다.
2. 컴파일 타임의 최소화
인터페이스를 사용했을 때의 큰 장점 중의 하나는 컴파일 타임에는 인터페이스만 제공하고, 런타임에 실제 구현체를 제공함으로써 컴파일 타임을 짧게 만들 수 있다는 점이다. 이러한 전략을 사용하는 대표적인 라이브러리는 JUnit5이다. JUnit5의 junit-jupiter-api 라이브러리에는 인터페이스가 들어있고, junit-jupiter-engine에는 구현체가 들어있어 컴파일 타임과 런타임에는 junit-jupiter-api 라이브러리에 대한 의존성만 추가하고 런타임에만 junit-jupiter-engine 라이브러리에 대한 의존성을 추가하는 것이다. 아래는 그 예시를 적용한 Gradle 파일 중 일부 이다.
dependencies {
// JUnit5 테스트 프레임워크의 API
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
// JUnit5 테스트 프레임워크의 구현체
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.0")
}
이 예시에는 testImplementation을 통해 테스트의 컴파일 타임과 런타임에 junit-jupiter-api 라이브러리를 추가하고, testRuntimeOnly를 통해 런타임에만 junit-jupiter-engine 라이브러리를 추가한다.
느슨한 결합의 한계
느슨한 결합은 많은 장점이 있지만, 항상 옳은 것은 아니다. 인터페이스를 작성한다는 것은 어떻게 보면 규격을 만드는 것이다. 이 규격과는 다른 작업이 들어올 경우 잘못하면, 해당 규격을 따르는 인터페이스를 구현하는 모든 클래스에 영향을 미칠 수 있다.
예를 들어 ChatMessageNotificationService라는 채팅방의 특정 유저에게 알림을 보내는 새로운 NotificationService가 필요하고, 이 Toast를 발생시키기 위해서는 chatRoomId란 객체가 필요하다고 해보자. 이런 경우 NotificationService는 어떻게 바뀌어야 할까? NotificationService을 사용을 계속하면서 바꾸려면 가장 최선은 다음과 같이 바뀌는 것이다.
interface NotificationService {
fun sendNotification(receiverId: String, message: String, chatRoomId: String? = null)
}
class ChatMessageNotificationService : NotificationService {
override fun sendNotification(receiverId: String, message: String, chatRoomId: String?) {
println("Sending toast notification to $receiverId: $message in $chatRoomId")
}
}
하지만 이 변경은 기존의 다른 NotificationService 들에도 영향을 미친다. 사용하지 않는 파라미터를 넣어야 하는 것이다. 예를 들어 PushNotificationService도 다음과 같이 변경돼야 한다.
class PushNotificationService : NotificationService {
override fun sendNotification(message: String, receiverId: String, context: String?) {
println("Sending push notification to $receiverId: $message")
}
}
서비스가 확장될 수록 인자가 늘어나면, 이는 기술 부채가 된다. 하나의 클래스를 위해 인터페이스를 구현하는 모든 클래스에 영향을 미치는 것이다.
이 문제를 해결하기 위해서는 성격이 다른 코드를 분리하고 이를 관리할 유즈 케이스를 만들어야 한다. 예를 들어 ChatMessageNotificationService는 ChatNotificationService 인터페이스를 구현하도록 하고, NotificationExecutor에서 어떤 NotificationService를 사용할지 결정하도록 바꾸는 것이다. 즉, 다음과 같은 구조가 된다.
그런데 이렇게 되면 거의 인터페이스와 클래스가 1:1 매칭 관계가 되는 경우가 많아 인터페이스를 사용하는 것이 보일러 플레이트 코드를 만들어낼뿐 이라는 의견을 가진 개발자들도 적지 않다. 만약 위의 구조가 인터페이스 없이 구현됐으면 다음과 같이 훨씬 간단하고 직관적이어 지기 때문이다.
인터페이스를 사용하는 것이 꼭 필요한 경우
위와 같은 한계에도 불구하고, 인터페이스를 사용하는 것이 꼭 필요한 경우가 있다. 인터페이스를 사용하는 가장 큰 강점은 다른 사람들과 협업할 때 나타난다고 생각한다. 외부에 공개되는 인터페이스를 잘 설계했을 때, 이 인터페이스를 바탕으로 기능들이 효율적이고 간결하게 구현될 수 있고 이때 인터페이스의 효용은 배가 된다.
예를 들어 위의 그림에서 Notification과 관련된 부분들을 별도 모듈로 분리하는 상황을 생각해보자. 그러면 외부 사용자는 어떤 클래스에 어떤 함수를 어떤 파라미터로 호출하면 Notification을 보낼 수 있을지만 알면 된다. 따라서 다음과 같이 NotificationExecutor을 인터페이스로 다음과 같이 만들면 된다.
interface NotificationExecutor {
fun executeNotification(notificationRequest: NotificationRequest)
}
sealed class NotificationRequest {
data class SendChatNotification(
val receiverId: String,
val message: String,
val chatRoomId: String
) : NotificationRequest()
data class SendPushNotification(
val receiverId: String,
val message: String
) : NotificationRequest()
data class SendKakaoNotification(
val receiverId: String,
val message: String
) : NotificationRequest()
}
그런 후 NotificationExecutor의 구현체인 NotificationExecutorImpl을 다음과 같이 만들고, 위의 NotificationExecutor 인터페이스만 외부 모듈로 공개하면 된다.
class NotificationExecutorImpl(
private val chatNotificationService: ChatNotificationService,
private val kakaoNotificationService: KakaoMessageService,
private val pushNotificationService: PushNotificationService
): NotificationExecutor {
override fun executeNotification(notificationRequest: NotificationRequest) {
when(notificationRequest) {
is NotificationRequest.SendChatNotification -> {
chatNotificationService.sendNotification(
receiverId = notificationRequest.receiverId,
message = notificationRequest.message,
chatRoomId = notificationRequest.chatRoomId
)
}
is NotificationRequest.SendPushNotification -> {
pushNotificationService.sendNotification(
receiverId = notificationRequest.receiverId,
message = notificationRequest.message
)
}
is NotificationRequest.SendKakaoNotification -> {
kakaoNotificationService.sendNotification(
receiverId = notificationRequest.receiverId,
message = notificationRequest.message
)
}
}
}
}
그러면 사용자는 NotificationExecutor만을 보고 작업을 수행할 수 있게 된다.