객체지향 개발에서 유지보수성과 확장성을 높이기 위해 SOLID 원칙을 적용하는데요 그 마지막인 DIP 원칙에 대해서 알아보고 예제를 통해 알아보도록 하겠습니다 :)

1.DIP(의존성 역전 원칙)이란?

의존성 역전 원칙은 다음 두 가지 핵심 개념을 따릅니다.

  1. 고수준 모듈은 저수준 모듈에 의존하면 안된다.
  2. 둘 다 추상화(인터페이스)에 의존해야 한다.

쉽게 말해, 구체적인 구현이 아닌 추상화(인터페이스 또는 추상 클래스)에 의존해야 한다는 의미입니다.
이를 통해 코드 변경에 유연하게 대응하고 테스트가 용이한 구조를 만들 수 있습니다

2. DIP 위반 사례


Kotlin/ Spring Boot 프로젝트의 사례를 통해 알아보도록 하겠습니다.

@Component
class EmailSender {
  fun sendEmail(to: String, subject: String, body: String) {
    println("Sending email to $to with subject : $subject body: body")
  }
}

위 처럼 Email을 전송하는 클래스가 있다고 했을때, 일반적으로 아래와 같이 작성하는 사람이 많을 것 같아요. 저도 그랬거든요

class NotificationService(
  private val emailSender: EmailSender
) {
  fun sendNotification(to: String, message: String) {
    emailSender.sendEmail(to, "subject", message) 
  }
}

이렇게 NotificationService에 emailSender의 의존성을 바로 주입해 해당 서비스를 구현하는 경우가 많을 것 같습니다.

그런데 이렇게 작성한다면 NotificationService -> EmailSender 서비스에 직접적으로 의존하게 되요 이게 고수준 모듈(NotificationService)이 저수준(EmailSender)에 직접적으로 의존하면 안된다는 이야기예요

이렇게 코드를 구현하게 되면 나중에 SMS 또는 PUSH 알림이 추가적으로 구현되야 할때, NotificationService 로직을 수정해야 할 수 있어요.

또한 테스트에도 용이하지 않게 되요, EmailSender를 Mock으로 대체하기가 불편하게 되거든요 이렇게 되면 종합적으로 유지보수성이 떨어지고, 확장성이 떨어지는 구조를 갖게 되는 것이에요.

그렇다면 코드를 수정해서 확장성을 높여보겠습니다. — —

3. DIP 원칙 적용코드로 수정

interface NotificationSender {
  fun send(to: String, message: String)
}

이렇게 인터페이스로 중간 중계자 역할(?)을 하는 인터페이스를 만들어서 고수준 모델이 직접적으로 저수준 모델을 의존하지 않게 중간 중계자를 생성해주는 거에요. 중계자(?)의 역할을 하는 인터페이스는 실제 구현체를 아우룰 수 있게 작성해주시면 확장성에 좋습니다 :)

class EmailSender: NotificationSender {
  override fun send(to: String, message: String)  {
    println("Sending email to $to with subject : $subject body: body")
  }
}

이렇게 EmailSender 서비스가 NotificationSender를 상속받아 구현하면 됩니다. 이렇게 하면 추가적으로 SMS, Push 와 같은 서비스가 추가적으로 생성되더라도 기존 코드의 변경 없이 구현할 수 있게 되면서 확장성과 유지보수성이 증가하게 됩니다.

예시)

class PushSender: NotificationSender {
  override fun send(to: String, message: String)  {
    println("Sending push to $to with subject : $subject body: body")
  }
}

class SmsSender: NotificationSender {
  override fun send(to: String, message: String)  {
    println("Sending sms to $to with subject : $subject body: body")
  }
}

이렇게 추가적으로 클래스를 생성해서 확장성을 유지할 수 있게 되는 것이죠

그러면 이제 서비스단에서는 다음과 같이 작성해볼 수 있을 것 같습니다.

class NotificationService( private val sender: NotificationSender ) {
  fun sendNotification(to: String, message: String) {
    sender.send(to, message)
  }
}

이렇게 인터페이스에 의존하게 되면 SMS, PUSH 등 다양한 구현을 지원할 수 있게 됩니다.

느낀점

이렇게 객체 지향의 5대 원칙인 SOLID 원칙에 대해서 공부해봤는데요. 계속 개발을 하면서, 기능 단위에 대해서 개발하면서 더 나은 개발이 무엇인지에 대한 고민을 했었는데 정답을 잘 찾지 못했었습니다. 이직을 준비하면서 공부할 시간들이 생기면서 SOLID 원칙에 대해서 예시를 들며 공부해봤는데요. 앞으로의 코드에 대한 방향을 조금이나마 찾은 듯하여 진작에 공부를 할 수 있었으면 얼마나 좋았을까라는 생각이 듭니다. 그래도 이번 기회에 자세하게 공부하며 조금 더 발전한 개발자가 된 것 같아 뿌듯함을 조금 느낄 수 있었습니다.