-
[Code Readability] 7장 Dependency (의존성의 가독성 설계)kotlin/[Book] Code Readability 2025. 11. 9. 13:15
Objective
코드의 가독성을 해치는 가장 큰 요인은 “불투명한 의존성” 이다.
좋은 코드란 “무엇이 무엇에 의존하는지”를 한눈에 알 수 있어야 한다.
이를 위해 결합도(coupling)를 줄이고, 방향(direction)을 정하며, 중복(redundancy)을 없애고, 명시성(explicitness)을 높인다.
Key Results
- [KR1] 의존성은 가능한 한 비순환(acyclic) 구조로 유지한다.
- [KR2] 강결합(content/common/control coupling) 을 피하고, 데이터 중심 결합(data coupling) 을 선호한다.
- [KR3] caller → callee, concrete → abstract, complex → simple 방향을 지킨다.
- [KR4] 중복된 의존성 관계를 제거해, 불필요한 참조나 계층을 줄인다.
- [KR5] 모든 의존 관계를 명시적으로 표현해 숨겨진 연결을 없앤다.
Part 1. Coupling (결합도)
- **결합도(Coupling)**란, 한 모듈이 다른 모듈에 의존하는 정도를 의미한다.
- 결합이 강할수록 수정 시 영향 범위가 커지고, 테스트와 확장이 어렵다.
결합도의 5가지 대표 유형
결합도 유형 위험 수전 Content Coupling (내용 결합) 다른 클래스의 내부 구현에 직접 접근 🚨 매우 위험 Common / External Coupling (공통 결합) 전역 상태나 공유 데이터를 통해 연결 ⚠️ 위험 Control Coupling (제어 결합) 인자나 플래그로 “무엇을 할지” 제어 ⚠️ 주의 필요 Data Coupling (데이터 결합) 필요한 데이터만 명시적으로 전달 ✅ 바람직 Stamp Coupling (도장 결합) 구조체 전체를 전달하지만 일부만 사용 ⚠️ 조건부 허용
1️⃣ Content Coupling (내용 결합)
개념
한 모듈이 다른 모듈의 내부 동작이나 private 데이터에 직접 의존하는 경우.
즉, “내부 구조”가 바뀌면 모든 호출 코드가 함께 깨지는 형태다.
문제 예제
class UserRepository { val users = mutableMapOf<Int, String>() fun addUser(id: Int, name: String) { users[id] = name } } class UserManager(private val repository: UserRepository) { fun printAllUsers() { // ❌ 내부 필드(users)에 직접 접근 println(repository.users) } }
문제점
- UserRepository의 users 구조가 바뀌면 UserManager가 바로 깨진다.
- 캡슐화(encapsulation) 가 완전히 무너짐.
해결 예제
class UserRepository { private val users = mutableMapOf<Int, String>() fun getAllUsers(): Map<Int, String> = users.toMap() } class UserManager(private val repository: UserRepository) { fun printAllUsers() { println(repository.getAllUsers()) } }
💡 해결 풀이
- 클래스의 내부 상태는 반드시 private으로 감추고, 접근이 필요하면 공식 API를 통해서만 접근하도록 만든다.
- 이렇게 하면 내부 구조가 변경되어도 외부 코드는 영향을 받지 않는다.
- → “정보 은닉(Information Hiding)”의 핵심.
2️⃣ Common / External Coupling (공통 결합)
개념
여러 모듈이 전역 변수(Global variable) 이나 외부 공유 데이터를 함께 사용하는 형태.
즉, 한 쪽에서 상태를 변경하면, 다른 모듈이 암묵적으로 영향을 받는다.
문제 예제
object GlobalSettings { var darkMode: Boolean = false } class ThemeManager { fun applyTheme() { if (GlobalSettings.darkMode) { println("Applying dark theme") } else { println("Applying light theme") } } } class SettingsScreen { fun toggleDarkMode() { GlobalSettings.darkMode = !GlobalSettings.darkMode } }
문제점
- 의존 방향이 불명확하다 — 누가 상태를 바꾸는지 추적하기 어려움.
- 스레드 안정성(Thread safety) 문제 발생 가능.
- 테스트 시 전역 상태를 초기화하지 않으면 결과가 달라짐.
해결 예제
data class AppSettings(val darkMode: Boolean) class ThemeManager { fun applyTheme(settings: AppSettings) { if (settings.darkMode) println("Dark mode ON") else println("Light mode ON") } } class SettingsScreen { fun toggle(settings: AppSettings): AppSettings { return settings.copy(darkMode = !settings.darkMode) } }
해결 풀이
- 전역 상태를 제거하고, 필요한 설정을 명시적으로 전달한다.
- 변경 가능한 공유 데이터를 불변 객체(Immutable Object) 로 바꾸면 상태 일관성이 높아진다.
- 즉, “의존 관계를 숨기지 말고 노출하라.”
3️⃣ Control Coupling (제어 결합)
개념
한 함수가 인자를 통해 다른 함수의 동작을 제어할 때 발생한다. 즉, “이 함수가 무엇을 해야 하는가”를 호출자가 결정하는 구조.
문제 예제
fun drawShape(type: String) { if (type == "circle") { println("Draw Circle") } else if (type == "square") { println("Draw Square") } } fun main() { drawShape("circle") drawShape("square") }
문제점
- 함수의 제어 흐름이 외부 플래그(type)에 의해 결정된다.
- 새로운 도형을 추가하려면 drawShape() 내부를 수정해야 한다.
- → OCP(Open-Closed Principle) 위반.
해결 예제
interface Shape { fun draw() } class Circle : Shape { override fun draw() = println("Draw Circle") } class Square : Shape { override fun draw() = println("Draw Square") } fun drawShape(shape: Shape) { shape.draw() }
💡 해결 풀이
- 제어를 외부에서 넘기지 말고, 객체 다형성(Polymorphism) 으로 해결한다.
- 이렇게 하면 drawShape()는 확장에는 열려 있고, 수정에는 닫혀 있다.
- → “플래그 분기 대신 객체 다형성을 활용하라.”
4️⃣ Data Coupling (데이터 결합)
개념
모듈 간에 필요한 데이터만 주고받는 형태.
이것이 가장 바람직한 결합 형태다.
→ “명시적이고 독립적인 협력 관계.”
올바른 예제
data class User(val id: Int, val name: String) class UserRepository { fun saveUser(user: User) { println("Saved user: $user") } } class UserService(private val repository: UserRepository) { fun registerUser(id: Int, name: String) { val user = User(id, name) repository.saveUser(user) } }
해결 풀이
- UserService와 UserRepository는 “데이터(User)”라는 최소 단위로만 연결됨.
- 한쪽의 구현이 바뀌더라도 데이터 계약(Data Contract) 만 유지하면 상호 영향이 없다.
- 이 방식은 Kotlin의 data class 구조와 특히 잘 어울린다.
5️⃣ Stamp Coupling (도장 결합)
개념
객체 전체를 전달하지만, 실제로는 일부 데이터만 사용하는 경우.
즉, “도장을 찍듯 구조 전체를 전달하지만 일부만 참조”한다.
문제 예제
data class User(val id: Int, val name: String, val email: String) fun sendEmail(user: User) { // ❌ 전체 User를 받지만 email만 사용 println("Sending email to ${user.email}") }
문제점
- 함수가 실제로 필요한 데이터 범위가 불명확하다.
- User 구조가 변경되면 sendEmail() 도 재컴파일되어야 한다.
해결 예제
fun sendEmail(email: String) { println("Sending email to $email") }
💡 해결 풀이
- 필요한 데이터만 명시적으로 전달하라.
- “전체 모델 전달”은 간단해 보이지만, 의존 범위를 불필요하게 확장시킨다.
- 단, “함수 시그니처 단순화” 목적이라면 일부 허용되지만,
- 그 경우 반드시 문서화로 사용 목적을 명시해야 한다.
결합도 유형별 정리
유형 설명 문제점 개선 방향 Content Coupling 내부 구현 직접 접근 캡슐화 붕괴 private 보호 + API 노출 Common Coupling 전역 변수 공유 상태 일관성 깨짐 불변 객체 + 명시적 전달 Control Coupling 인자로 동작 제어 확장성 저하 다형성(Polymorphism) 활용 Data Coupling 필요한 데이터만 전달 없음 (이상적) data class 사용 Stamp Coupling 구조 전체 전달 의존 범위 과다 필요한 값만 인자로 전달
핵심 정리 (Coupling의 철학)
결합도가 낮다는 것은 “서로 모른다는 뜻”이 아니라,
“서로의 계약만 알고 내부를 모른다”는 뜻이다.
즉, 모듈 간 협력은 데이터로, 제어는 추상화로, 상태는 명시적으로 표현되어야 한다.
Part 2. Dependency Direction (의존성의 방향)
개념
“좋은 구조란 의존성이 한 방향으로만 흐르는 구조다.”
즉, 의존 관계에 순환(cycle) 이 없어야 한다.
이상적인 의존 방향
구분 방향 설명 호출 관계 caller → callee 호출자는 피호출자에 의존하지만 반대는 아님 추상화 수준 concrete → abstract 구현이 인터페이스에 의존 복잡도 complex → simple 복잡한 객체가 단순한 객체에 의존 변경 안정성 mutable → immutable 변동성 높은 코드가 안정적인 코드에 의존 책임 구분 algorithm → data model 로직이 데이터에 의존하되 반대는 금지
2-1. Caller → Callee 방향
문제 예제
class MediaViewPresenter { fun getVideoUri(): Uri = ... fun playVideo() { videoPlayerView.play(this) } } class VideoPlayerView { fun play(presenter: MediaViewPresenter) { val uri = presenter.getVideoUri() ... } }- VideoPlayerView가 MediaViewPresenter를 다시 참조하면서
- 서로가 서로를 호출하는 순환 구조 발생.
문제점
- 호출 순서를 파악하기 어렵다.
- 한쪽 변경이 다른 쪽에 바로 전파되어, 테스트가 어려워진다.
- Mock 객체 작성이 복잡해진다.
해결 예제
class MediaViewPresenter { fun getVideoUri(): Uri = ... fun playVideo() { videoPlayerView.play(getVideoUri()) } } class VideoPlayerView { fun play(videoUri: Uri) { ... } }
해결 풀이
- Presenter는 VideoPlayer에 URI라는 값만 전달하고, VideoPlayer는 Presenter의 내부를 알 필요가 없다.
- 이렇게 “값 기반”으로 바꾸면 의존 방향이 단방향(caller→callee) 이 된다.
- 즉, 역호출(callback) 을 없애면 테스트 가능성과 변경 안정성이 올라간다.
예외적 허용
- 비동기 콜백, 이벤트 리스너, 고차 함수(forEach) 등은 의도적 양방향 의존이지만,
- 반드시 수명(lifecycle)과 목적(scope) 을 명시적으로 제한해야 한다.
추가 개선 — 비동기 의존 완화
- Promise / Future 패턴, Coroutine, Actor, async/await 등을 활용하여 “함수가 호출자에 직접 의존하지 않도록” 구조화한다.
2-2. Concrete → Abstract 방향
문제 예제
open class IntList { fun addElement(value: Int) { if (this is ArrayIntList) { ... } else { ... } } } class ArrayIntList : IntList() { ... }
문제점
- “어떤 구현체인지”를 내부에서 스스로 검사하는 구조는 상속 변경에 매우 취약하다.
- 새 하위 클래스가 생길 때마다 조건문을 수정해야 한다.
해결 예제
open class IntList { open fun addElement(value: Int) { ... } } class ArrayIntList : IntList() { override fun addElement(value: Int) { ... } }
해결 풀이
- 구체 타입 대신 추상 인터페이스나 상위 클래스의 계약(contract) 에 의존해야 한다.
- 다운캐스팅은 “구현이 추상화를 침범하는 행위”로, OCP(Open-Closed Principle, 개방 폐쇄 원칙)를 위반한다.
- OCP
- 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해서는 개방적이어야 하지만, 수정에 대해서는 폐쇄적이어야 한다."
- 개방적 (Open for Extension): 새로운 요구사항이 발생했을 때, 기존 코드를 수정하지 않고 기능을 **추가(확장)**할 수 있어야 합니다.
- 폐쇄적 (Closed for Modification): 기존 코드를 수정하면 안 됩니다. 기존 코드를 수정하게 되면, 이미 잘 작동하고 있던 다른 부분에서 예기치 않은 버그가 발생할 위험(Side Effect)이 커지기 때문입니다.
- 추상화된 타입이 아니라 구현에 의존하게 되면 새로운 구현이 생길때마다 기존 코드를 수정해야 한다
- 이 부분이 OCP중 Closed를 위반하게 되는 부분이다.
- OCP
2-3. Complex → Simple 방향
문제 예제
class UserData( val userId: UserId, val requester: UserDataRequester ) class UserDataRequester { fun obtainFromServer(): UserData { ... } } fun getFollowerUserModel(userModel: UserModel): List<UserModel> { val requester = userModel.requeseter return userModel.followerIds .mapNotNull { followerId -> requester.query(followerId) } }→ 데이터 모델(UserData)이 복잡한 네트워크 객체(UserDataRequester)에 의존한다.
문제점
- 단순해야 할 객체가 복잡한 객체를 품고 있어 라이프사이클과 참조 범위를 관리하기 어렵다.
- 리소스 누수나 메모리 참조 오류 발생 가능.
해결 예제
class UserData(val userId: UserId) fun getFollowerUserModel( userModel: UserModel requester: UserModelRequester ): List<UserModel> = userModel.followerIds .mapNotNull { followerId -> requester.query(followerId) }→ 필요할 때만 UserDataRequester 가 주입되어 사용됨.
해결 풀이
- 데이터 모델은 단순해야 한다.
- 복잡한 객체(예: 네트워크, DB)는 호출 시점에만 외부에서 주입받는 것이 안전하다.
- 단, Mediator, Adapter, DataBinder 패턴처럼
- “다수의 단순 객체를 중재하는 역할”에서는 예외적으로 복잡성을 가질 수 있다.
Part 3. Redundancy (의존성의 중복)
개념
의존성 중복은 두 가지 형태로 나타난다:
- Cascaded dependency – 중첩되고 간접적인 의존
- Redundant dependency set – 동일한 클래스 집합에 다중 의존
3-1. Cascaded Dependency
문제 예제
class TimestampPresenter(val dataProvider: MessageDataProvider) { } class MessageTextPresenter(private val timestampPresenter: TimestampPresenter) { fun invalidateViews() { val messageData = timestampPresenter.dataProvider ... } }
문제점
- MessageTextPresenter가 MessageDataProvider를 직접 참조하지 못해 불필요하게 TimestampPresenter를 경유해야 한다.
- 즉, 의존성이 “중간 단계”를 거치며 꼬여 있다.
해결 예제
class TimestampPresenter(private val dataProvider: MessageDataProvider) { } class MessageTextPresenter(private val dataProvider: MessageDataProvider) { fun invalidateViews() { val messageData = dataProvider ... } }→ 직접 필요한 의존성만 가지도록 단순화.
해결 풀이
- 의존이 깊을수록 (A → B → C) 구조는 유지보수성을 급격히 떨어뜨린다.
- “불필요한 참조를 위해 다른 객체를 거치는 것”은 결합도를 감추는 형태의 복잡성이다.
- 필요한 객체는 직접 주입받는 것이 원칙이다.
3-2. Redundant Dependency Set
문제 예제
class MessageTextPresenter(val localProvider: LocalMessageDataProvider) class TimestampPresenter(val remoteProvider: RemoteMessageDataProvider)→ 동일한 데이터 계층(Local/Remote)을 여러 클래스가 중복 참조.
해결 예제
class MessageDataProvider( val local: LocalMessageDataProvider, val remote: RemoteMessageDataProvider ) class MessageTextPresenter(val provider: MessageDataProvider) class TimestampPresenter(val provider: MessageDataProvider)
해결 풀이
- 중간 계층(MessageDataProvider)을 두어 의존성을 하나의 진입점으로 통합한다.
- 단, 필요하지 않은 시점에서는 새로운 레이어를 추가하지 말 것.
- → YAGNI(You Ain’t Gonna Need It), KISS(Keep It Simple, Stupid) 원칙 준수.
Part 4. Explicitness (명시성)
개념
“좋은 코드란, 다이어그램만 봐도 의존 관계가 보이는 코드다.”
즉, 암묵적(implicit) 의존성은 반드시 제거해야 한다.
4-1. 불필요한 추상화 (Unnecessary Abstraction)
문제 예제
interface StringProvider { fun queryString(id: Int): String } class ProfileDataRepository : StringProvider { override fun queryString(id: Int): String = ... } class ProfilePresenter(val provider: StringProvider) { fun updateProfileView(userId: Int) { userNameView.text = provider.queryString(userId) } }
문제점
- 실제로는 ProfileDataRepository만 사용하는데 불필요한 인터페이스(StringProvider)를 추가함.
- 다른 구현(MessageDataRepository)이 주입되어도 컴파일 오류 없이 런타임 오류 발생 가능.
해결 예제
class ProfileDataRepository { fun queryUserName(userId: Int): String = ... } class ProfilePresenter(val repository: ProfileDataRepository) { fun updateProfileView(userId: Int) { userNameView.text = repository.queryUserName(userId) } }
해결 풀이
- “다형성(polymorphism)” 목적이 아니라면 추상화 계층을 만들지 말라.
- 필요 이상의 인터페이스는 의존 관계를 감추는 것처럼 보이지만,오히려 추적을 어렵게 만든다.
- 명확한 목적(모듈 분리, 외부 라이브러리 호환 등)이 있을 때만 인터페이스를 사용하라.
4-2. 암묵적 도메인 (Implicit Domain of Variable)
문제 예제
fun setViewBackgroundColor(colorString: String) { val colorCode = when(colorString) { "red" -> 0xFF0000 "green" -> 0x00FF00 else -> 0x000000 } view.setBackgroundColor(colorCode) }
문제점
- 허용 값의 범위("red", "green")가 암묵적(implicit) 으로 존재한다.
- 새로운 색상 추가 시, 모든 호출부 수정이 필요.
해결 예제
enum class BackgroundColor(val colorCode: Int) { RED(0xFF0000), GREEN(0x00FF00) } fun setViewBackgroundColor(color: BackgroundColor) { view.setBackgroundColor(color.colorCode) }
해결 풀이
- 도메인 모델(enum class)을 명시적으로 정의하면 허용 가능한 값의 범위가 컴파일 타임에 고정된다.
- 이는 타입 안정성과 변경 내성을 크게 높인다.
최종 요약 (OKR Table)
항목 원칙 이유 Coupling Content/Common/Control coupling 피하고 Data coupling 유지 모듈 간 독립성 강화 Direction Caller→Callee, Concrete→Abstract, Complex→Simple 순환 제거, 유지보수 용이 Redundancy 중첩·중복 의존 제거 계층 단순화, 변경 안정성 향상 Explicitness 숨겨진 의존 제거, 명시적 타입 사용 예측 가능성·타입 안정성 강화
핵심 결론
“의존성은 제거의 대상이 아니라, 관리의 대상이다.”
즉, 명시적으로 설계된 약한 의존 관계만이 유지보수 가능한 시스템을 만든다.
'kotlin > [Book] Code Readability' 카테고리의 다른 글
[CodeReadability] 8장 Code Reivew (0) 2025.11.09 [Code Readability] 5장 읽기 쉬운 함수(function) (0) 2025.11.09 [Code Readability] 3장 코드 가독성을 높이는 주석(Comment) 작성 원칙 (0) 2025.11.09 [Code Readability] 4장 상태(State) (0) 2025.11.09 [Code Readability] 6장 의존 관계와 결합도(Coupling) (0) 2025.10.31