(Java/Kotlin) SOLID - ISP 인터페이스 분리 원칙 완전 정리
개요
- SOLID 원칙 중 네 번째 ISP(Interface Segregation Principle) 를 다룹니다.
- ISP는 “클라이언트가 자신이 사용하지 않는 메서드에 의존하도록 강제하지 말라”는 원칙입니다.
- 이 글에서는 다음을 설명합니다.
- ISP가 정확히 무엇인지
- 왜 필요한지
- 위반 사례와 개선 방법
- 역할 단위로 인터페이스를 분리하는 방법
- Android / Kotlin 실전 예제
1. 정의
클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강제받아서는 안 된다.
— Robert C. Martin
비대한 인터페이스 하나 → 작고 구체적인 인터페이스 여럿으로 분리
- 인터페이스가 너무 많은 메서드를 포함하면 → 구현 클래스가 필요 없는 메서드까지 구현해야 합니다.
- 역할 단위로 인터페이스를 나누면 → 구현 클래스는 필요한 것만 골라 구현할 수 있습니다.
2. 왜 필요한가
비대한 인터페이스 변경 → 해당 인터페이스를 구현하는 모든 클래스에 영향
- 사용하지도 않는 메서드가 바뀌면 → 내 클래스도 재컴파일/재배포해야 합니다.
- 구현 클래스에 빈 메서드나
UnsupportedOperationException이 늘어납니다. - ISP를 지키면 변경의 파급 범위가 좁아지고, 인터페이스의 의도가 명확해집니다.
3. 위반 사례 — Before
❌ 모든 기능을 하나의 인터페이스에 몰아넣은 경우
interface Worker {
fun work()
fun eat()
fun sleep()
fun attendMeeting()
fun writeReport()
}
// 일반 직원 — 모두 사용
class Employee : Worker {
override fun work() = println("업무 처리")
override fun eat() = println("식사")
override fun sleep() = println("수면")
override fun attendMeeting() = println("회의 참석")
override fun writeReport() = println("보고서 작성")
}
// 로봇 — eat, sleep, attendMeeting이 필요 없음
class Robot : Worker {
override fun work() = println("로봇 작업 수행")
override fun eat() { /* 로봇은 먹지 않음 */ } // ❌ 빈 구현
override fun sleep() { /* 로봇은 자지 않음 */ } // ❌ 빈 구현
override fun attendMeeting() { /* 로봇은 회의에 참석 안 함 */ } // ❌ 빈 구현
override fun writeReport() = println("보고서 자동 생성")
}
// 인턴 — writeReport, attendMeeting 권한 없음
class Intern : Worker {
override fun work() = println("보조 업무")
override fun eat() = println("식사")
override fun sleep() = println("수면")
override fun attendMeeting() { throw UnsupportedOperationException("인턴은 회의 불가") } // ❌
override fun writeReport() { throw UnsupportedOperationException("인턴은 보고서 불가") } // ❌
}
문제점:
Robot은eat(),sleep(),attendMeeting()이 필요 없는데 구현해야 합니다.Intern은 권한 없는 기능에 예외를 던집니다 → LSP 위반도 함께 발생합니다.Worker인터페이스에 메서드가 하나 추가되면 모든 구현 클래스를 수정해야 합니다.
4. 개선 — After
✅ 역할 단위로 인터페이스 분리
interface Workable {
fun work()
}
interface Eatable {
fun eat()
}
interface Sleepable {
fun sleep()
}
interface MeetingAttendable {
fun attendMeeting()
}
interface Reportable {
fun writeReport()
}
// 일반 직원 — 필요한 인터페이스를 모두 구현
class Employee : Workable, Eatable, Sleepable, MeetingAttendable, Reportable {
override fun work() = println("업무 처리")
override fun eat() = println("식사")
override fun sleep() = println("수면")
override fun attendMeeting() = println("회의 참석")
override fun writeReport() = println("보고서 작성")
}
// 로봇 — 필요한 것만 구현
class Robot : Workable, Reportable {
override fun work() = println("로봇 작업 수행")
override fun writeReport() = println("보고서 자동 생성")
}
// 인턴 — 필요한 것만 구현
class Intern : Workable, Eatable, Sleepable {
override fun work() = println("보조 업무")
override fun eat() = println("식사")
override fun sleep() = println("수면")
}
// 업무만 처리하는 함수 — Workable만 받으면 됨
fun assignWork(worker: Workable) {
worker.work()
}
// 보고서 수집 — Reportable만 받으면 됨
fun collectReports(reporter: Reportable) {
reporter.writeReport()
}
assignWork(Employee()) // 업무 처리 ✅
assignWork(Robot()) // 로봇 작업 수행 ✅
assignWork(Intern()) // 보조 업무 ✅
collectReports(Employee()) // 보고서 작성 ✅
collectReports(Robot()) // 보고서 자동 생성 ✅
// collectReports(Intern()) — 컴파일 에러로 방지 ✅
개선 효과:
- 각 클래스는 자신이 필요한 인터페이스만 구현합니다.
- 함수는 필요한 능력만을 타입으로 요구합니다.
- 인터페이스 하나가 바뀌어도 영향 범위가 최소화됩니다.
5. 인터페이스 조합 — 여러 역할을 묶을 때
분리한 인터페이스가 항상 함께 쓰인다면 타입 별칭이나 결합 인터페이스 로 묶을 수 있습니다.
// 풀타임 직원의 역할을 묶은 결합 인터페이스
interface FullTimeEmployee : Workable, Eatable, Sleepable, MeetingAttendable, Reportable
class SeniorEngineer : FullTimeEmployee {
override fun work() = println("설계 및 개발")
override fun eat() = println("식사")
override fun sleep() = println("수면")
override fun attendMeeting() = println("기술 회의 참석")
override fun writeReport() = println("기술 보고서 작성")
}
- 분리와 조합을 함께 활용하면 유연성과 편의성을 모두 챙길 수 있습니다.
6. Android 실전 예제
❌ ISP 위반 — 비대한 Listener 인터페이스
interface VideoPlayerListener {
fun onPlay()
fun onPause()
fun onStop()
fun onBuffering()
fun onError(error: Throwable)
fun onFullScreenEntered()
fun onFullScreenExited()
fun onSubtitleChanged(subtitle: String)
fun onQualityChanged(quality: Int)
}
// 재생/일시정지만 필요한 MiniPlayer — 나머지 7개를 빈 구현
class MiniPlayerView : VideoPlayerListener {
override fun onPlay() = println("미니 플레이어 재생")
override fun onPause() = println("미니 플레이어 일시정지")
override fun onStop() { } // ❌ 빈 구현
override fun onBuffering() { } // ❌ 빈 구현
override fun onError(error: Throwable) { } // ❌ 빈 구현
override fun onFullScreenEntered() { } // ❌ 빈 구현
override fun onFullScreenExited() { } // ❌ 빈 구현
override fun onSubtitleChanged(subtitle: String) { } // ❌ 빈 구현
override fun onQualityChanged(quality: Int) { } // ❌ 빈 구현
}
✅ ISP 적용 — 역할 단위로 Listener 분리
interface PlaybackListener {
fun onPlay()
fun onPause()
fun onStop()
}
interface BufferingListener {
fun onBuffering()
fun onError(error: Throwable)
}
interface FullScreenListener {
fun onFullScreenEntered()
fun onFullScreenExited()
}
interface SubtitleListener {
fun onSubtitleChanged(subtitle: String)
}
interface QualityListener {
fun onQualityChanged(quality: Int)
}
// 미니 플레이어 — 재생 제어만 필요
class MiniPlayerView : PlaybackListener {
override fun onPlay() = println("미니 플레이어 재생")
override fun onPause() = println("미니 플레이어 일시정지")
override fun onStop() = println("미니 플레이어 정지")
}
// 풀 플레이어 — 모든 기능 필요
class FullPlayerView : PlaybackListener, BufferingListener, FullScreenListener,
SubtitleListener, QualityListener {
override fun onPlay() = println("풀 플레이어 재생")
override fun onPause() = println("풀 플레이어 일시정지")
override fun onStop() = println("풀 플레이어 정지")
override fun onBuffering() = println("버퍼링 표시")
override fun onError(error: Throwable) = println("오류: ${error.message}")
override fun onFullScreenEntered() = println("전체화면 진입")
override fun onFullScreenExited() = println("전체화면 종료")
override fun onSubtitleChanged(subtitle: String) = println("자막: $subtitle")
override fun onQualityChanged(quality: Int) = println("화질: ${quality}p")
}
class VideoPlayer {
private val playbackListeners = mutableListOf<PlaybackListener>()
private val bufferingListeners = mutableListOf<BufferingListener>()
fun addPlaybackListener(listener: PlaybackListener) {
playbackListeners.add(listener)
}
fun addBufferingListener(listener: BufferingListener) {
bufferingListeners.add(listener)
}
fun play() = playbackListeners.forEach { it.onPlay() }
fun onBufferingStart() = bufferingListeners.forEach { it.onBuffering() }
}
// 사용
val player = VideoPlayer()
player.addPlaybackListener(MiniPlayerView())
player.addPlaybackListener(FullPlayerView())
player.addBufferingListener(FullPlayerView()) // MiniPlayerView는 버퍼링 리스너 불필요
7. RecyclerView Adapter 예제
❌ ISP 위반 — 하나의 인터페이스로 모든 이벤트를 처리
interface ItemEventListener {
fun onItemClick(position: Int)
fun onItemLongClick(position: Int)
fun onItemSwipe(position: Int)
fun onItemFavoriteToggle(position: Int)
fun onItemShareClick(position: Int)
}
// 클릭만 필요한 간단한 화면 — 나머지 4개를 빈 구현
class SimpleAdapter(private val listener: ItemEventListener) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// ...
}
// 사용 측에서 빈 구현을 강제로 작성해야 함
SimpleAdapter(object : ItemEventListener {
override fun onItemClick(position: Int) = openDetail(position)
override fun onItemLongClick(position: Int) { } // ❌ 필요 없음
override fun onItemSwipe(position: Int) { } // ❌ 필요 없음
override fun onItemFavoriteToggle(position: Int) { } // ❌ 필요 없음
override fun onItemShareClick(position: Int) { } // ❌ 필요 없음
})
✅ ISP 적용 — 이벤트 단위로 분리
interface OnItemClickListener {
fun onItemClick(position: Int)
}
interface OnItemLongClickListener {
fun onItemLongClick(position: Int)
}
interface OnItemSwipeListener {
fun onItemSwipe(position: Int)
}
interface OnItemFavoriteListener {
fun onItemFavoriteToggle(position: Int)
}
interface OnItemShareListener {
fun onItemShareClick(position: Int)
}
class SimpleAdapter(
private val clickListener: OnItemClickListener? = null,
private val longClickListener: OnItemLongClickListener? = null,
private val swipeListener: OnItemSwipeListener? = null
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// ViewHolder bind 시 필요한 리스너만 연결
}
// 사용 — 필요한 것만 전달
SimpleAdapter(
clickListener = OnItemClickListener { position -> openDetail(position) }
)
8. Kotlin fun interface (SAM) 활용
Kotlin의 fun interface를 활용하면 ISP를 더 간결하게 표현할 수 있습니다.
fun interface OnItemClickListener {
fun onItemClick(position: Int)
}
fun interface OnItemLongClickListener {
fun onItemLongClick(position: Int)
}
// 람다로 바로 전달 가능
val clickListener = OnItemClickListener { position -> openDetail(position) }
val longClickListener = OnItemLongClickListener { position -> showContextMenu(position) }
fun interface는 단일 추상 메서드(SAM)를 강제 → ISP의 “작은 인터페이스” 원칙과 잘 맞습니다.- 람다로 전달할 수 있어 구현 클래스 없이도 사용 가능합니다.
9. ISP vs 관련 원칙 비교
| 원칙 | 대상 | 핵심 질문 |
|---|---|---|
| SRP | 클래스 | 이 클래스가 변경되는 이유가 하나인가? |
| OCP | 클래스 | 기존 코드를 수정하지 않고 확장할 수 있는가? |
| LSP | 상속 | 자식이 부모를 완전히 대체할 수 있는가? |
| ISP | 인터페이스 | 클라이언트가 필요 없는 메서드에 의존하지 않는가? |
- ISP는 인터페이스 설계 관점의 SRP라고 볼 수 있습니다.
- SRP가 클래스의 책임을 분리한다면, ISP는 인터페이스의 역할을 분리합니다.
10. ISP 위반을 감지하는 신호
✔ 인터페이스를 구현하는데 빈 메서드({ })가 많다
✔ 인터페이스를 구현하는데 UnsupportedOperationException을 던지는 메서드가 있다
✔ 새 메서드를 인터페이스에 추가할 때 구현 클래스 대부분을 수정해야 한다
✔ 인터페이스 이름이 모호하다 (Manager, Handler, Helper — 너무 많은 책임을 암시)
✔ 인터페이스 메서드가 10개를 넘는다
11. 정리
| 항목 | 내용 |
|---|---|
| 정의 | 클라이언트는 사용하지 않는 메서드에 의존하면 안 된다 |
| 핵심 | 비대한 인터페이스를 역할 단위의 작은 인터페이스로 분리 |
| 위반 신호 | 빈 구현, UnsupportedOperationException, 비대한 인터페이스 |
| 해결 방법 | 역할별 인터페이스 분리, 결합 인터페이스로 조합 |
| 효과 | 변경 파급 범위 축소, 구현 클래스 단순화 |
| Kotlin 활용 | interface 분리, fun interface (SAM) |
- ISP는 “인터페이스도 단일 책임을 가져야 한다” 는 원칙입니다.
- 처음부터 완벽하게 분리하려 하기보다는, 빈 구현이나 예외가 생기는 순간을 신호로 삼아 분리를 시작하세요.
참고
- Clean Code — Robert C. Martin
- SOLID 전체 포스팅 보기
- SRP 포스팅 보기
- OCP 포스팅 보기
- LSP 포스팅 보기