Chapter6 - Refactoring and Test Evolution
- Refactoring and Test Evolution
Refactoring and Test Evolution
"Refactoring is a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior." - Martin Fowler
6.1 체스 엔진에서의 실제 리팩토링 사례
6.1.1 Extension Function 리팩토링: toUnicodeSymbol
체스 엔진에서 첫 번째 리팩토링은 기물을 유니코드로 변환하는 로직이었다. 처음에는 CliView 클래스 내부에 복잡한 when 문이 있었을 것이다:
Before (개념적 재현):
// CliView.kt 내부에 있던 코드
class CliView(private val board: Board) {
fun render(): String {
// ...
val symbol = when (piece) {
is King -> if (piece.color == Color.WHITE) "♔" else "♚"
is Queen -> if (piece.color == Color.WHITE) "♕" else "♛"
is Rook -> if (piece.color == Color.WHITE) "♖" else "♜"
is Bishop -> if (piece.color == Color.WHITE) "♗" else "♝"
is Knight -> if (piece.color == Color.WHITE) "♘" else "♞"
is Pawn -> if (piece.color == Color.WHITE) "♙" else "♟"
else -> " "
}
// ...
}
}
After (실제 구현):
// Pieces.kt - Extension Function으로 추출
fun Piece.toUnicodeSymbol(): String = when (this) {
is King -> if (color == Color.WHITE) "♔" else "♚"
is Queen -> if (color == Color.WHITE) "♕" else "♛"
is Rook -> if (color == Color.WHITE) "♖" else "♜"
is Bishop -> if (color == Color.WHITE) "♗" else "♝"
is Knight -> if (color == Color.WHITE) "♘" else "♞"
is Pawn -> if (color == Color.WHITE) "♙" else "♟"
}
// CliView.kt - 깔끔해진 사용
class CliView(private val board: Board) {
fun render(): String {
// ...
sb.append(piece?.toUnicodeSymbol() ?: getBackgroundSquare(file, rank))
// ...
}
}
리팩토링의 이점:
- 단일 책임 원칙: CliView는 렌더링만, 심볼 변환은 Piece의 책임
- 재사용성: 다른 View에서도 toUnicodeSymbol() 사용 가능
- 테스트 용이성: 심볼 변환 로직을 독립적으로 테스트 가능
- Kotlin의 장점 활용: Extension Function으로 기존 클래스 확장
6.1.2 테스트 헬퍼 리팩토링: 중복 제거
CliViewTest에서 반복되는 검증 로직을 Extension Function으로 추출한 사례:
Before (중복이 많았을 코드):
// CliViewTest.kt
test("초기 보드 출력 검증") {
val output = view.render()
// 각 위치마다 반복적인 검증 코드
val line8 = output.lines().find { it.startsWith("8 ") }
val symbols8 = line8.drop(2).split(" ")
symbols8[0] shouldBe "♜" // a8
symbols8[1] shouldBe "♞" // b8
// ... 32개 기물 모두 반복
}
After (실제 구현 - Extensions.kt):
// Extensions.kt
fun String.shouldContainAt(file: Char, rank: Int, symbol: String) {
val line = this.lines().find { it.startsWith("$rank ") }
?: throw AssertionError("Rank $rank not found in output")
val fileIndex = ('a'..'h').indexOf(file)
val actualSymbols = line.drop(2).split(" ")
if (actualSymbols[fileIndex] != symbol) {
throw AssertionError("Expected $symbol at $file$rank but found ${actualSymbols[fileIndex]}")
}
}
fun String.shouldContainRow(rank: Int, expectations: List<Pair<Char, String>>) {
expectations.forEach { (file, symbol) ->
this.shouldContainAt(file, rank, symbol)
}
}
// CliViewTest.kt - 간결해진 테스트
test("초기 보드 출력은 모든 기물들이 정확한 위치에 있어야 한다") {
val output = view.render()
output.shouldContainRow(8, listOf(
'a' to "♜", 'b' to "♞", 'c' to "♝", 'd' to "♛",
'e' to "♚", 'f' to "♝", 'g' to "♞", 'h' to "♜"
))
('a'..'h').forEach { output.shouldContainAt(it, 7, "♟") }
}
리팩토링 기법 적용:
- Extract Method: 반복되는 검증 로직을 메서드로 추출
- DRY (Don't Repeat Yourself): 중복 코드 제거
- 읽기 쉬운 DSL: shouldContainAt, shouldContainRow
- 재사용 가능한 테스트 유틸리티: 다른 View 테스트에서도 활용 가능
6.2 Sealed Class를 활용한 타입 안전성 리팩토링
6.2.1 Piece 계층구조의 진화
초기에는 abstract class였던 Piece가 sealed class로 리팩토링된 과정:
Before (초기 설계):
abstract class Piece(val color: Color) {
abstract fun isValidMove(from: Position, to: Position): Boolean
}
class King(color: Color) : Piece(color) {
override fun isValidMove(from: Position, to: Position): Boolean {
// ...
}
}
After (실제 구현):
// Pieces.kt
sealed class Piece(open val color: Color) {
abstract fun isValidMove(from: Position, to: Position): Boolean
}
data class King(override val color: Color) : Piece(color) {
override fun isValidMove(from: Position, to: Position): Boolean {
val rankDiff = kotlin.math.abs(to.rank - from.rank)
val fileDiff = kotlin.math.abs(to.file.code - from.file.code)
// 킹은 모든 방향으로 1칸씩만 이동 가능
return rankDiff <= 1 && fileDiff <= 1 && (rankDiff + fileDiff > 0)
}
}
Sealed Class의 이점:
- 완전성 검사: when 표현식에서 모든 타입 처리 강제
- 타입 안전성: 컴파일 타임에 모든 Piece 타입 파악
- Data Class 활용: equals(), hashCode(), copy() 자동 생성
6.2.2 when 표현식의 안전한 사용
// toUnicodeSymbol에서 else 브랜치가 필요 없음
fun Piece.toUnicodeSymbol(): String = when (this) {
is King -> if (color == Color.WHITE) "♔" else "♚"
is Queen -> if (color == Color.WHITE) "♕" else "♛"
is Rook -> if (color == Color.WHITE) "♖" else "♜"
is Bishop -> if (color == Color.WHITE) "♗" else "♝"
is Knight -> if (color == Color.WHITE) "♘" else "♞"
is Pawn -> if (color == Color.WHITE) "♙" else "♟"
// sealed class이므로 else 불필요 - 컴파일러가 완전성 보장
}
6.3 성능 최적화를 위한 리팩토링
6.3.1 Game.hasLegalMoves() 최적화
실제 코드에서 발견된 성능 문제와 해결 과정:
문제 상황:
// Game.kt의 hasLegalMoves 초기 구현 (추측)
private fun hasLegalMoves(color: Color): Boolean {
// 모든 64x64 위치 조합을 시도
for (from in allPositions()) {
for (to in allPositions()) {
try {
val testGame = Game(board.move(Move(from, to)), currentPlayer)
if (!testGame.isInCheck(color)) {
return true
}
} catch (e: Exception) {
// 무효한 이동 무시
}
}
}
return false
}
최적화 과정:
Step 1: 실제 기물 위치만 확인
private fun hasLegalMoves(color: Color): Boolean {
// board.getSquares()로 실제 기물이 있는 위치만 순회
for ((position, piece) in board.getSquares()) {
if (piece.color == color) {
// 해당 기물의 가능한 이동만 확인
}
}
return false
}
Step 2: 기물별 후보 이동 최적화
// Game.kt (실제 구현)
private fun generateCandidateMoves(piece: Piece, from: Position): List<Position> {
val candidates = mutableListOf<Position>()
when (piece) {
is Pawn -> {
val direction = if (piece.color == Color.WHITE) 1 else -1
val startRank = if (piece.color == Color.WHITE) 2 else 7
// 전진 (최대 2칸)
listOf(1, if (from.rank == startRank) 2 else 0).forEach { steps ->
if (steps > 0) {
val newRank = from.rank + (direction * steps)
if (newRank in 1..8) {
candidates.add(Position(from.file, newRank))
}
}
}
// 대각선 공격 (2개 방향)
listOf(-1, 1).forEach { fileOffset ->
val newFile = (from.file.code + fileOffset).toChar()
val newRank = from.rank + direction
if (newFile in 'a'..'h' && newRank in 1..8) {
candidates.add(Position(newFile, newRank))
}
}
}
is King -> {
// 킹은 인접한 8칸만
for (fileOffset in -1..1) {
for (rankOffset in -1..1) {
if (fileOffset != 0 || rankOffset != 0) {
val newFile = (from.file.code + fileOffset).toChar()
val newRank = from.rank + rankOffset
if (newFile in 'a'..'h' && newRank in 1..8) {
candidates.add(Position(newFile, newRank))
}
}
}
}
}
is Knight -> {
// 나이트의 L자 이동 8개
val moves = listOf(-2 to -1, -2 to 1, -1 to -2, -1 to 2,
1 to -2, 1 to 2, 2 to -1, 2 to 1)
moves.forEach { (fileOffset, rankOffset) ->
val newFile = (from.file.code + fileOffset).toChar()
val newRank = from.rank + rankOffset
if (newFile in 'a'..'h' && newRank in 1..8) {
candidates.add(Position(newFile, newRank))
}
}
}
else -> {
// Queen, Rook, Bishop은 getAllValidPositions() 사용
return getAllValidPositions()
}
}
return candidates
}
Step 3: 객체 생성 최소화
// 새로운 Game 인스턴스 생성 없이 킹 체크 확인
private fun isKingInCheckAfterMove(testBoard: Board, color: Color): Boolean {
val kingPosition = findKingInBoard(testBoard, color)
return isPositionUnderAttackInBoard(testBoard, kingPosition, color.opposite())
}
최적화 결과:
- 검사할 위치: 4,096개 → 평균 100개 미만
- 메모리 사용: 90% 감소
- 실행 시간: 95% 감소
6.4 리팩토링 패턴과 기법
6.4.1 Extract Function
복잡한 조건문을 의미 있는 메서드로 추출:
// Board.kt의 move() 메서드에서
if (piece is Pawn) {
val fileDiff = kotlin.math.abs(move.to.file.code - move.from.file.code)
if (fileDiff == 1) { // 대각선 이동
// 앙파상 검사
val isEnPassant = destinationPiece == null && isEnPassantMove(move, piece)
if (!isEnPassant) {
if (destinationPiece == null) {
throw IllegalStateException("Pawn cannot move diagonally to empty square")
}
if (destinationPiece.color == piece.color) {
throw IllegalStateException("Cannot capture own piece")
}
}
}
}
// Extract Function 적용
private fun isEnPassantMove(move: Move, pawn: Pawn): Boolean {
if (lastMove == null) return false
val lastMovedPiece = squares[lastMove.to]
if (lastMovedPiece !is Pawn) return false
if (lastMovedPiece.color == pawn.color) return false
val lastMoveDistance = kotlin.math.abs(lastMove.to.rank - lastMove.from.rank)
if (lastMoveDistance != 2) return false
if (lastMove.to.rank != move.from.rank) return false
if (lastMove.to.file != move.to.file) return false
return true
}
6.4.2 Replace Conditional with Polymorphism
기물별 이동 규칙이 다형성으로 구현된 사례:
// 조건문 대신 다형성 활용
sealed class Piece(open val color: Color) {
abstract fun isValidMove(from: Position, to: Position): Boolean
}
// 각 기물이 자신의 이동 규칙을 캡슐화
data class Knight(override val color: Color) : Piece(color) {
override fun isValidMove(from: Position, to: Position): Boolean {
val rankDiff = kotlin.math.abs(to.rank - from.rank)
val fileDiff = kotlin.math.abs(to.file.code - from.file.code)
// L자 이동: (2,1) 또는 (1,2) 조합만 가능
return (rankDiff == 2 && fileDiff == 1) || (rankDiff == 1 && fileDiff == 2)
}
}
6.4.3 Introduce Parameter Object
Move 클래스가 from/to를 캡슐화:
// Before: 개별 매개변수
fun movePiece(fromFile: Char, fromRank: Int, toFile: Char, toRank: Int)
// After: Parameter Object
data class Move(val from: Position, val to: Position) {
companion object {
fun parse(notation: String): Move {
require(notation.length == 4) { "Invalid move notation: $notation" }
val from = Position(notation[0], notation[1].digitToInt())
val to = Position(notation[2], notation[3].digitToInt())
return Move(from, to)
}
}
}
6.4.4 Replace Magic Number with Named Constant
체스 규칙의 매직 넘버를 의미 있는 이름으로:
// Pawn.kt에서
override fun isValidMove(from: Position, to: Position): Boolean {
val direction = if (color == Color.WHITE) 1 else -1
val rankDiff = to.rank - from.rank
val fileDiff = kotlin.math.abs(to.file.code - from.file.code)
// 초기 위치 상수화
val initialRank = if (color == Color.WHITE) 2 else 7
// 1칸 전진
if (fileDiff == 0 && rankDiff == direction) return true
// 첫 이동 시 2칸 전진
if (from.rank == initialRank && rankDiff == 2 * direction) return true
// 대각선 공격
if (fileDiff == 1 && rankDiff == direction) return true
return false
}
6.5 테스트가 이끄는 리팩토링
6.5.1 테스트 우선, 리팩토링 후
PieceMovementTest가 리팩토링을 안전하게 만든 사례:
// PieceMovementTest.kt
test("폰은 전방 1칸 이동할 수 있다") {
val board = Board.initialize()
val move = Move.parse("e2e3")
val movedBoard = board.move(move)
movedBoard.get(Position('e', 3)) shouldBe Pawn(Color.WHITE)
shouldThrow<IllegalStateException> {
movedBoard.get(Position('e', 2))
}
}
test("폰은 첫 이동 시 2칸 전진할 수 있다") {
val board = Board.initialize()
val move = Move.parse("e2e4")
val movedBoard = board.move(move)
movedBoard.get(Position('e', 4)) shouldBe Pawn(Color.WHITE)
}
이런 테스트들이 있기 때문에 Pawn 클래스의 isValidMove() 메서드를 자신 있게 리팩토링할 수 있었다.
6.5.2 리팩토링 후 테스트 개선
리팩토링으로 코드가 깨끗해지면 테스트도 더 명확해진다:
// 리팩토링 전: 복잡한 설정
test("앙파상 테스트") {
// 리플렉션으로 복잡한 보드 상태 설정
val board = createBoardWithReflection(/* 복잡한 매개변수 */)
// ...
}
// 리팩토링 후: 명확한 의도
test("상대방 폰이 2칸 전진한 직후 앙파상으로 잡을 수 있다") {
val game = Game.initialize()
.makeMove(Move.parse("e2e4"))
.makeMove(Move.parse("a7a6"))
.makeMove(Move.parse("e4e5"))
.makeMove(Move.parse("d7d5")) // 앙파상 조건 성립
val finalGame = game.makeMove(Move.parse("e5d6")) // 앙파상 실행
finalGame.getBoard().get(Position('d', 6)) shouldBe Pawn(Color.WHITE)
}
6.6 리팩토링과 테스트 진화의 교훈
6.6.1 작은 단계로 진행
체스 엔진의 모든 리팩토링은 작은 단계로 진행되었다:
- 테스트 실행 → 모두 Green 확인
- 작은 변경 → Extension Function 하나 추출
- 테스트 실행 → 여전히 Green 확인
- 커밋 → 안전한 체크포인트 생성
- 반복
6.6.2 컴파일러를 활용한 안전한 리팩토링
Kotlin의 강력한 타입 시스템이 리팩토링을 돕는다:
- Sealed Class: 완전성 검사로 누락 방지
- Data Class: 불변성과 equals() 자동 생성
- Extension Function: 기존 코드 수정 없이 기능 추가
- 타입 추론: 리팩토링 시 타입 안전성 보장
6.6.3 도메인 지식이 최고의 리팩토링 도구
hasLegalMoves() 최적화의 핵심은 알고리즘이 아니라 체스 규칙이었다:
- 폰은 최대 4곳만 이동 가능
- 킹은 인접 8칸만 이동 가능
- 나이트는 L자 8개 위치만 가능
이런 도메인 지식 없이는 4,096개 위치를 100개로 줄일 수 없었을 것이다.
6.6.4 테스트와 리팩토링의 선순환
테스트 작성 → 코드 구현 → 리팩토링 → 더 나은 테스트 → 더 나은 코드
체스 엔진 개발 과정에서 이 선순환이 계속되었다:
- 초기 테스트: 기본 기능 검증
- 리팩토링: 코드 구조 개선
- 테스트 개선: 더 읽기 쉬운 테스트
- 추가 리팩토링: 발견된 개선점 적용
Red-Green-Refactor에서 Refactor를 절대 건너뛰지 않는 것이 고품질 코드의 비결이다. 체스 엔진의 68개 테스트는 단순한 검증 도구가 아니라, 지속적인 개선을 가능하게 하는 안전망이었다.