소개
이전 글에서는 SOLID 원칙 중 첫 번째, 두 번째, 세 번째 원칙인 단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP), 리스코프 치환 원칙(LSP)을 다뤘습니다.
아직 읽어보지 않았다면 먼저 확인해 보시길 추천합니다.
- 🔗 SRP(Single Responsibility Principle) 글 보기
- 🔗 OCP(Open-Closed Principle) 글 보기
- 🔗 LSP(Liskov Substitution Principle) 글 보기
이번 글에서는 네 번째 원칙인 인터페이스 분리 원칙(Interface Segregation Principle, ISP)을 살펴보겠습니다.
이 원칙은 클래스가 실제로 사용하는 기능만을 포함하도록 인터페이스를 분리하여 유연하고 유지보수하기 쉬운 시스템을 설계하는 것을 목표로 합니다.
ISP(인터페이스 분리 원칙)란?
인터페이스 분리 원칙(Interface Segregation Principle, ISP)은 다음과 같이 정의됩니다.
"클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다."
쉽게 풀어보면:
- 하나의 인터페이스가 너무 많은 기능을 포함하면, 이를 구현하는 클래스는 필요하지 않은 기능도 구현해야 한다.
- 인터페이스는 작고 명확하게, 하나의 책임에 집중하여 설계해야 한다.
- 필요하지 않은 메서드를 강제로 구현하면 코드가 복잡해지고 유지보수성이 떨어진다.
ISP 를 위반하면:
- 불필요한 의존성이 생기고,
- 클래스 간 결합도가 증가하며,
- 유지보수가 어려워질 수 있다.
이를 실제 코드 예제를 통해 확인해보겠습니다.
ISP를 위반하는 예제
📌 시나리오: 로또 판매자와 자동 판매기
고객이 로또를 구매할 수 있는 시스템을 만든다고 가정해 보겠습니다.
이 시스템에는 사람이 운영하는 로또 판매자(Human Lotto Seller) 와 자동 판매기(Lotto Vending Machine) 두 가지가 존재합니다.
- 사람 로또 판매자(Human Lotto Seller)는 고객과 대화(`chat`)할 수 있습니다.
- 자동 로또 판매기(Lotto Vending Machine) 필요할 때 리셋(`reset`)할 수 있습니다.
🔍 초기 구현 (ISP 위반)
다음은 'LottoSeller' 추상 클래스를 만들어, 사람 판매자와 자동 판매기가 상속받도록 한 코드입니다.
abstract class LottoSeller {
private var _restRequired: Boolean = false
val restRequired: Boolean
get() = _restRequired
abstract fun soldLotto(money: Int): List<Lottery>
abstract fun chat(): String
abstract fun reset(): String
}
class DiscountedLottoSeller : LottoSeller() {
override fun soldLotto(money: Int): List<Lottery> {
if (restRequired) println(chat())
return List(money / LOTTO_PRICE) {
LotteryGenerateStrategy().autoGenerate()
}
}
override fun chat(): String = "Good morning!"
override fun reset(): String = error("사람은 기계가 아니므로 리셋할 수 없습니다.")
companion object {
private const val LOTTO_PRICE = 500
}
}
class NormalLottoSeller : LottoSeller() {
override fun soldLotto(money: Int): List<Lottery> {
if (restRequired) println(chat())
return List(money / LOTTO_PRICE) { LotteryGenerateStrategy().autoGenerate() }
}
override fun chat(): String = "Hello!"
override fun reset(): String = error("사람은 기계가 아니므로 리셋할 수 없습니다.")
companion object {
private const val LOTTO_PRICE = 1000
}
}
class NormalLottoVendingMachine : LottoSeller() {
override fun soldLotto(money: Int): List<Lottery> {
if (restRequired) println(reset())
return List(money / LOTTO_PRICE) { LotteryGenerateStrategy().autoGenerate() }
}
override fun chat(): String = error("기계는 대화할 수 없습니다.")
override fun reset(): String = "조용히 리셋"
companion object {
private const val LOTTO_PRICE = 500
}
}
class NoisyLottoVendingMachine : LottoSeller() {
override fun soldLotto(money: Int): List<Lottery> {
if (restRequired) println(reset())
return List(money / LOTTO_PRICE) { LotteryGenerateStrategy().autoGenerate() }
}
override fun chat(): String = error("기계는 대화할 수 없습니다.")
override fun reset(): String = "소리를 내며 리셋"
companion object {
private const val LOTTO_PRICE = 500
}
}
왜 ISP를 위반했을까?
🚨 불필요한 메서드 구현
- 사람 로또 판매자는 `reset()` 메서드를 구현해야 하지만, 사실 필요하지 않습니다.
- 자동 판매기는 `chat()` 메서드를 구현해야 하지만, 사실 필요하지 않습니다.
🚨 강한 결합(Tight Coupling)
- `LottoSeller` 클래스에 모든 기능(`chat`, `reset`, `soldLotto`)을 포함시켜, 필요하지 않은 기능도 강제하게 되었습니다.
- 새로운 판매자 유형을 추가하면 기존 코드도 수정해야 하므로, OCP(개방-폐쇄 원칙)도 위반됩니다.
🚨 유지보수 어려움
- 로또 판매자의 유형이 많아질수록, 각 서브 클래스에서 필요하지 않은 메서드를 처리하는 코드가 증가합니다.
- `error("사람은 기계가 아니므로 리셋할 수 없습니다.")` 같은 코드가 계속 반복됩니다.
ISP를 준수하는 방법: 인터페이스 분리
ISP를 지키기 위해, 우리는 각 역할을 분리된 인터페이스로 나눕니다.
📌 리팩토링 1: 인간과 기계를 구분하기
손님과 대화할 수 있는 사람 로또 판매자와 리셋할 수 있는 기계 로또 판매자로 인터페이스를 나눕니다.
abstract class LottoSeller {
/* ... */
abstract fun soldLotto(money: Int): List<Lottery>
}
abstract class HumanLottoSeller : LottoSeller() {
/* ... */
abstract fun chat(): String
}
abstract class MachineLottoSeller : LottoSeller() {
/* ... */
abstract fun reset(): String
}
전체 코드
class Customer {
fun buyLotto(
money: Int,
lottoSeller: LottoSeller,
): List<Lottery> {
when (lottoSeller) {
is HumanLottoSeller -> println(lottoSeller.chat())
is MachineLottoSeller -> println(lottoSeller.reset())
}
return lottoSeller.soldLotto(money)
}
}
abstract class LottoSeller {
private var _restRequired: Boolean = true
val restRequired: Boolean
get() = _restRequired
abstract val lottoPrice: Int
abstract fun soldLotto(money: Int): List<Lottery>
}
abstract class HumanLottoSeller : LottoSeller() {
override fun soldLotto(money: Int): List<Lottery> {
if (restRequired) println(chat())
return List(money / LOTTO_PRICE) {
LotteryGenerateStrategy().autoGenerate()
}
}
abstract fun chat(): String
}
abstract class MachineLottoSeller : LottoSeller() {
override fun soldLotto(money: Int): List<Lottery> {
if (restRequired) println(reset())
return List(money / LOTTO_PRICE) { LotteryGenerateStrategy().autoGenerate() }
}
abstract fun reset(): String
}
class NormalLottoSeller : HumanLottoSeller() {
override val lottoPrice: Int = 1_000
override fun chat(): String = "Hello!"
}
class DiscountedLottoSeller : HumanLottoSeller() {
override val lottoPrice: Int = 500
override fun chat(): String = "Good morning!"
}
class NormalLottoVendingMachine : MachineLottoSeller() {
override val lottoPrice: Int = 1_000
override fun reset(): String = "Reset quietly"
}
class NoisyLottoVendingMachine : MachineLottoSeller() {
override val lottoPrice: Int = 1_000
override fun reset(): String = "Reset with noise"
}
이제 인간 판매자와 자판기는 별개의 책임이 있습니다.
`HumanLottoSeller` 는 잡담(`chat()`)을 오직 인간 판매자에 대해서,
`MachineLottoSeller`는 리셋(`reset()`)을 오직 기계 판매자에 대해서만 정의합니다.
하지만, 이 방식은 완벽하지 않습니다.
리팩토링된 코드 1: '인간과 기계를 구분하기' 의 문제점
잡담과 리셋은 기반 클래스(base class)와 연결되어 있습니다.
만약 잠담과 리셋 기능이 모두 필요한 어떠한 판매자가 있다면 어떨까요?
그렇다면 잡담(`chat`)과 리셋(`reset`)할 수 있는 또 다른 추상 클래스 혹은 인터페이스를 만들어야 합니다.
💡 (`HumanLottoSeller` 와 `MachineLottoSeller` 는 추상 클래스이기 때문에 코틀린에서는 동시에 상속할 수 없습니다.)
📌 리팩토링 2: 역할 기반 인터페이스 분리
`ChatCapable`, `ResetCapable`이라는 별도의 인터페이스를 도입하여 역할을 분리합니다.
interface ChatCapable { fun chat(): String }
interface ResetCapable { fun reset(): String }
abstract class LottoSeller {
abstract fun soldLotto(money: Int): List<Lottery>
}
class NormalLottoSeller : LottoSeller(), ChatCapable {
override fun chat(): String = "Hello!"
override fun soldLotto(money: Int): List<Lottery> = /* ... */
/* ... */
}
class NormalLottoVendingMachine : LottoSeller(), ResetCapable {
override fun reset(): String = "Reset quietly"
override fun soldLotto(money: Int): List<Lottery> = /* ... */
/* ... */
}
개선 사항
✅ 완전히 분리된 인터페이스
- 이제 채팅 기능과 리셋 기능이 완전히 분리되었습니다.
- 새로운 기능 중 하나 또는 두 가지 기능이 모두 필요한 경우 독립적으로 구현할 수 있습니다.
✅ 더 쉬워진 확장성
- 채팅과 초기화가 모두 필요한 챗봇 로토셀러가 도입된다면,
- 기존 코드를 수정하지 않고 두 인터페이스를 모두 구현하면 됩니다.
그렇다면 무조건 2번째 코드를 쓰면 되겠다!
🚨🚨🚨 아닙니다.
2번째 코드가 1번째 코드보다 항상 더 나은 코드는 아니라는 것을 알아야 합니다.
각 코드는 장단점이 있으며 특정 사용 사례(use case), 서비스 요구사항 및 팀 내 상황에 따라 선택해야 합니다.
✔️ 코드 2 (확장 가능성과 유지보수성이 좋음)
- ✅ `ChatCapable` 인터페이스와 `ResetCapable` 인터페이스를 도입해서 관심사를 분리합니다.
- ✅ 더 많은 판매자 타입(예: 챗봇 기반 셀러, 온라인 셀러)) 가 도입되면 인터페이스(`ChatCapable`, `ResetCapable`, `LottoSeller`) 를 구현하기만 하면 됩니다.
- ❌ 그러나 이 추상화 때문에 계층의 복잡성이 증가할 수 있습니다.
- ❌ 프고젝트 범위가 작고, 새로운 판매자 타입이 추가될 가능성이 낮다면, 이 코드는 오버엔지니어링(Overengineering)일 수 있습니다.
✔️ 코드 1 (간단하고 가독성이 좋음)
- ✅ 더 간단하고 가독성이 좋습니다.
- ✅ 소규모 프로젝트이거나 판매자 타입의 수가 적고, 늘어날 가능성이 적을 때 채택하기 좋습니다.
- ❌ 더 많은 판매자 타입(예: 챗봇 기반 셀러, 온라인 셀러)이 도입되면, `LottoSeller` 레벨에서 추상 클래스를 추가해야 합니다.
- 그러나 소규모 프로젝트의 경우 이 수정은 여전히 간단합니다.
- 다른 추상 클래스(예: 휴머노이드 로토셀러)를 추가하여 기능을 확장하고 구조를 관리하기 쉽게 유지할 수 있습니다.
기준 | 코드 1(단순) | 코드 2(유지보수성) |
단순성 | ✅ 읽기 쉬움 | ❌ 약간 더 복잡함 |
확장성 | ❌ 새로운 기능 추가 시 수정 필요 | ✅ 쉽게 확장 가능 |
프로젝트 규모 | ✅ 작은 프로젝트에 적합 | ✅ 대규모, 확장 가능한 프로젝트에 적합 |
결론
✅ 인터페이스 분리 원칙(ISP)은 클래스가 실제로 사용하는 메서드만 구현하도록 보장합니다.
✅ ISP를 따르면:
- 큰 인터페이스를 더 작고 집중된 인터페이스로 분할함으로써 유연성, 유지보수성, 확장성을 향상시킵니다.
- 기존 코드를 깨지 않고 새로운 클래스와 기능을 더 쉽게 추가할 수 있습니다.
- 불필요한 메서드 구현을 피할 수 있습니다.
💡 어떠한 수준까지 인터페이스를 분리할 지에 대해서는 정답이 없습니다.
다음 글 예고
다음 글에서는 DIP(의존성 역전 원칙, Dependency Inversion Principle)을 다루겠습니다! 🚀
'Computer Science > 객체지향' 카테고리의 다른 글
LSP (리스코프 치환 원칙) in SOLID (0) | 2025.02.11 |
---|---|
OCP (개방-폐쇄 원칙) in SOLID (0) | 2025.01.14 |
SRP(단일 책임 원칙) in SOLID (0) | 2024.12.31 |
코드의 네이밍과 코딩 컨벤션은 왜 중요한가? (0) | 2024.02.20 |
서브클래싱과 서브타이핑 - 코드로 이해하는 객체지향 (0) | 2023.09.19 |