티스토리 뷰
💡 넥스트 스탭의 TDD, 클린 코드 with Kotlin 8기 강의를 수강하며 정리한 내용입니다.
목차
배경
TDD, 클린 코드 미션을 진행하며 객체지향 설계에 대한 많은 고민을 하고 있다. 이번에는 잘못된 설계로 인해 발생한 양방향 의존성 사례를 살펴보고, 양방향 의존성으로 인해 발생하는 문제와 해결 방법에 대해 정리하였다.
내용
미션을 진행하며, 양방향 의존관계가 발생하고 있다는 피드백을 받았다. 기능 구현에만 신경쓰다보니, 설계를 놓쳤는데 이번 기회에 양방향 의존성에 대한 문제점과 해결 방안을 알아볼 수 있었다.
기존 코드와 문제점
양방향 의존성이 발생한 코드는 다음과 같다.
Rows.kt
class Rows(
val rows: List<Columns>,
) {
fun updateMineCounts(): Rows =
Rows(rows.map { it.updateMineCounts(rows = this) })
}
Columns.kt
class Columns(
val columns: MutableList<Point>,
) {
fun updateMineCounts(rows: Rows): Columns =
Columns(
columns
.map { it.updateMineCounts(rows = rows) }
.toMutableList(),
)
}
Point.kt
data class Point(
val point: Pair<Index?, Index?>,
val element: Element = Cell.ready(),
) {
fun updateMineCounts(rows: Rows): Point {
val adjacentMineCount = countAdjacentMines(rows)
return this.update(adjacentMineCount.toString())
}
private fun countAdjacentMines(rows: Rows): Int {
if (rowIndex == null || columnIndex == null) {
return 0
}
val position = Position(row = rowIndex, column = columnIndex)
return Direction.entries.count { isMineInDirection(direction = it, position = position, rows = rows) }
}
private fun isMineInDirection(
direction: Direction,
position: Position,
rows: Rows,
): Boolean {
val head = position.move(direction = direction, rowSize = rows.rowSize, columnSize = rows.columnSize)
if (head.row == null || head.column == null) {
return false
}
return rows.rows
.getOrNull(head.row.value)
?.columns
?.getOrNull(head.column.value)
?.isMine() ?: false
}
private fun update(element: String): Point =
Point(
point = this.point,
element = this.element.updateValue(newValue = element),
)
fun isMine(): Boolean = element is Mine
}
지도 정보를 구현하는데 의존 관계가 Map.kt -> Grid.kt -> Rows.kt -> Columns.kt -> Point.kt로 설계되어 있다. 이 때 Rows가 Columns의 updateMineCounts(rows: Rows) 메서드를 호출하면서, Columns의 이 메서드는 Rows 정보를 파라미터로 받고 있다.
fun updateMineCounts(): Rows =
Rows(rows.map { it.updateMineCounts(rows = this) })
fun updateMineCounts(rows: Rows): Columns =
Columns(
columns.map { it.updateMineCounts(rows = rows) }.toMutableList()
)
Rows는 Columns의 메서드를 호출하고, 그 메서드는 다시 Rows를 참조한다. 이로 인해 Rows와 Columns간 양방향 의존성이 발생하고, 서로 강하게 결합한다. (Rows와 Points간에도 마찬가지이다.)
여기서 더 나아가 updateMineCounts 메서드는 Streams의 map을 호출하여 객체의 상태까지 변경하고 있다.
양방향 의존성의 문제점
양방향 의존성으로 인해 발생하는 문제로는 가독성 저하, 테스트 코드 작성 어려움 등이 있다.
먼저 위 로직을 예시로 들어보면, Rows가 Columns를 호출하고, Columns는 Rows의 정보를 받아 처리한다. 즉 한 클래스의 코드만으로 코드의 동작 방식을 한 번에 이해하기 어려워진다. Columns의 동작 방식을 파악하려면 Rows의 동작까지 파악해야한다. 또한, 코드 이해가 어려우니 테스트도 어려워진다. Rows를 테스트하려면 Columns의 동작을 이해하고 테스트해야 하고, 반대로 Columns를 테스트하려면 Rows의 정보를 알아야 한다
이로인해 결국 유지보수가 어려워진다. 두 클래스가 강하게 결합되어 있고 심지어 서로의 상태를 조작하므로, 한쪽을 수정하면 다른 쪽에서 예상치 못한 영향을 받을 가능성이 높아진다.
일반적인 해결책
이처럼 양방향 의존성은 가독성 저하와 유지보수 어려움을 야기한다. 이러한 양방향 의존성을 해결하는 방안으로는 크게 의존성 주입(Dependency Injection)과 객체가 아니라 데이터 처리 방식인 함수를 전달하는 방안이 있다.
먼저 의존성 주입은, 객체가 필요한 의존성을 스스로 생성하지 않고, 외부에서 제공받는다. 이는 객체 간의 결합도를 낮추고, 객체의 책임을 단순화하며, 코드의 테스트 가능성과 유연성을 높인다.
두번째로 함수를 전달하는 방법이 있다. 이는 두 객체가 서로 직접적으로 참조하지 않고, 필요한 데이터 처리 방식만 외부에서 제공받아 실행한다. 이 방식은 객체 간 결합도를 낮추고, 단일 책임 원칙(SRP)을 준수하며, 테스트도 더 쉽게 만든다.
문제 해결 방안
함수형 인터페이스를 전달하는 자바와 달리 코틀린은 함수를 일급 시민(first-class citizen)으로 취급한다. 즉, 함수 자체를 전달할 수 있다. 의존성 주입과 함께 코틀린의 이러한 특성을 활용해보고자 함수를 전달하는 방안도 적용해보았다.
먼저 의존관계가 단방향으로 흐르도록 개선하였다. 기존 코드에서는 Rows가 Columns의 메서드를 호출하면서 Columns 내부에서 다시 Rows 정보를 참조하면서 발생하는 양방향 참조를 제거한다.
Map.kt
class Map(
val grid: Grid,
) {
fun updateMineCountByCell(): Map = Map(grid = grid.updateMineCountByCell())
companion object {
fun create(
height: Height,
width: Width,
element: Element = Cell.ready(),
): Map {
val rows = Rows.ready(height = height, width = width, element = element)
// Grid 객체 생성시 MineCountStrategy를 의존관계 주입한다.
return Map(grid = Grid(points = rows, mineCountStrategy = SurroundingMines(points = rows)))
}
}
}
객체간 결합도를 낮추기 위해, 정보를 제공하는 객체와 데이터를 처리하는 객체를 명확히 분리한다. 따라서 Map과 Grid 객체 생성시 중간계층 역할을 하는 MineCountStrategy 인터페이스를 의존관계 주입하였다.
Rows.kt
class Rows(
val rows: List<Columns>,
) {
fun updateMineCount(countMines: (Index?, Index?) -> Int): Rows {
val updatedRows = rows.map { it.updatePoints(countMines) }
return Rows(updatedRows)
}
또한 Rows, Columns, Points 클래스가 Rows 객체를 매개변수로 받는것이 아닌 중간 계층인 MineCountStrategy의 함수를 전달받도록 수정하였다. (예시 코드는 Rows.kt만 작성하였다. Columns와 Points도 수정 방안은 동일하다.)
Grid.kt
class Grid(
val points: Rows,
// MineCountStrategy를 의존관계 주입받는다.
private val mineCountStrategy: MineCountStrategy
) {
fun updateMineCountByCell(): Grid {
// calculate() 함수를 람다 표현식으로 전달한다.
val updateRows =
points.updateMineCount { rowIndex, columnIndex -> mineCountStrategy.calculate(rowIndex, columnIndex) }
return Grid(points = updateRows, mineCountStrategy = mineCountStrategy)
}
}
두가지 방안(의존관계 주입과 함수 전달)이 적용된 Grid.kt 코드는 다음과 같다. MineCountStrategy를 의존관계 주입받으면서 Rows 대신 caculate() 함수 자체를 전달한다. 함수 전달은 코틀린의 람다 표현식인 {}을 활용하였다.
결과
객체를 쪼개며 개발하는 동안 발생했던 양방향 의존성을 의존관계 주입과 함수 전달을 통해 해결하였다. 함수를 일급 시민으로 취급하는 코틀린의 특성을 활용하여 문제를 해결할 수 있었다.
양방향 의존성을 해결하면서 객체간 책임이 명확해졌다. Grid는 전체적인 데이터 구조의 순회, Rows와 Columns는 개별 행과 열의 상태 업데이트만을, MineCountStrategy 인터페이스는 데이터 처리 방식만을 담당한다. 객체간 강하게 결합되는 대신 데이터를 처리하기 위한 정보(calculate() 함수)만을 전달하고 전달 받는다. 결과적으로 Rows와 Columns, Rows와 Points는 자신의 상태만 변경하며, 외부 객체와 양방향으로 의존되지 않는다. 외부에서 데이터 처리 방식을 전달받아 로직을 처리한다. 즉, 단방향 의존성을 갖는다.
또한 객체의 책임을 명확히 분리하고 의존성을 낮추면서 테스트도 용이해였다. 이번 글에서 테스트 코드 작성은 포함하지 않았지만, 이 과정을 통해서 Grid, Rows, Columns, Point를 독립적으로 단위 테스트를 작성할 수 있었다.
'시스템 디자인' 카테고리의 다른 글
[함수형 프로그래밍] 불변 객체와 장점과 단점 (1) | 2024.12.09 |
---|---|
[객체지향 프로그래밍] -er, -or로 끝나는 이름을 사용하지 마세요 (0) | 2024.11.23 |
[시스템 디자인] 실습으로 배우는 선착순 이벤트 시스템 (번외) - DLT(DeadLetterTopic)을 이용한 메시지 Consume 재처리 (3) | 2024.09.11 |
[시스템 디자인] 실습으로 배우는 선착순 이벤트 시스템 (3/3) - 요구사항 변경과 쿠폰 발급 실패 예외처리 (0) | 2024.09.06 |
[시스템 디자인] 실습으로 배우는 선착순 이벤트 시스템 (2/3) - Kafka로 시스템 안정성 향상하기 (4) | 2024.09.05 |