💡 Intro
우테코 네 번째 미션이자 레벨1 마지막 미션 오목! 미루고 미루다 이제야 글을 쓰게 됐다. 시작하기 전에 4단계로 나뉘어있어서 겁을 좀 먹었지만 걱정했던 것만큼 어렵지는 않았던 미션이다. 렌주룰에서 금수를 찾는 알고리즘을 내가 직접 구현하지 않았기 때문에 그렇게 느꼈을 수도 있다. 만약 내가 직접 구현해야 했다면 ... 시간안에 끝낼 수 있었을까?
오목
우리가 익히 알고있는 오목을 콘솔에서 할 수 있도록 구현하는 미션이다. 어려운 요구사항은 없었다. 이전에 배운 것들을 최대한 적용해보는 시간으로 생각하라고 하셨다.
❗️ 페어 프로그래밍 (Step1&2)
해시와 페어가 되었다. 해시와는 온보딩을 함께 하기도 했고 레벨1 데일리 미팅도 같은 조였기 때문에 이미 꽤나 친한 사이다. (맞지?) 덕분에 친해지는 시간, 서로에 대해 알아가는 시간을 줄이고 미션에 들어갈 수 있었다.
미션에 들어가기전 진행한 수업에서 구현에 집중하지 않고 객체가 어떤 책임을 갖게 될 것인가에 초점을 맞춰 설계하는 방법인 책임 주도 설계에 대해 학습했다. 여기서 CRC(Class-Responsibility-Collaboration) card를 작성해보는 시간을 가졌는데, CRC카드는 클래스와 책임과 협력을 기재하는 카드다. A4용지를 8등분하고 한 장의 카드에는 하나의 객체에 대해 기술하며, 작은 카드를 사용하여 디자인의 복잡성을 최소화하도록 했다.
우리는 오목 1단계에 대해 클래스의 이름을 적고 그 클래스가 해야하는 일을 기술하는 방식으로 진행했다.
1단계에서는 오목 게임만 진행하면 됐고 2단계에서는 흑턴에서 금수 조건을 추가해야 했다. 때문에 다음과 같이 클래스를 분리했다.
1. 2차원 오목판을 관리하는 OmokBoard 클래스
2. 흑돌만 놓여있는 오목판을 들고 있는 BlackTurn 클래스
3. 백돌만 놓여있는 오목판을 들고 있는 WhiteTurn 클래스
4. BlackTurn, WhiteTurn 의 공통 기능을 추상화한 Turn 인터페이스
5. BlackTurn, WhiteTurn 을 갖고 전체적인 게임을 관리하는 OmokGame 클래스
BlackTurn과 WhiteTurn을 분리한 이유는 위에서 말한 것처럼 흑턴만 금수를 판단하기 때문이다. 흑턴, 백턴이 각자의 돌을 어디 두었는지를 가지고 있도록 하면 BlackTurn 에서만 금수를 판단하는 로직을 넣으면 될 것이라고 생각했다.
구현하다 알게 된 사실이지만 사실 흑턴과 백턴을 나눌 필요가 없었다. 아니 나누면 안되었다. 이유는 금수를 찾기 위해서는 흑, 백 모두의 상태를 알고 있어야 했던 것이다.. 😂😂
Step1 구현을 완료하고 Step2로 넘어가면서 위에서 언급한 부분을 수정했고, 금수를 찾는 알고리즘은 다른 크루가 짠 코드를 어댑터 패턴을 사용해서 가져왔다. 내용은 꽤나 기니 링크로 첨부한다.
🦾 Step1&2 Refactoring
질문
먼저 미션을 구현하면서 생겼던 몇 가지 의문점에 대해 정리하고 넘어가려고 한다.
- 코드를 작성할 때 디미터의 법칙을 준수해서 omokBoard.board.isEmpty() 와 같은 방식으로접근하는 것이 좋지 않다고 생각이 듭니다. 그래서 omokBoard에서 isEmpty함수를 호출하면 board의 isEmpty함수를 호출하도록 만들게 되는데 결국 코드의 중복이 아닌가 하는 생각이 들고 좋은 방법인지 의문이 드네요😢
- 또 위와 같이 작성했을 때 테스트 코드에서 해당 함수의 동작을 확인해야 하는가도 의문이 듭니다!
1번 질문에 대해서는
각 클래스의 책임이 무엇인가를 고민해보세요!
라고 말씀해주셨다.
우리가 만든 클래스는 2차원 리스트를 일급 컬렉션으로 하는 Board 클래스, Board를 들고있는 OmokBoard 클래스였다. OmokBoard를 만든 이유는 Board가 일급 컬렉션이기 때문에 이를 가지고 있어야 하는 클래스가 필요하다고 느꼈기 때문이다. 하지만 정보를 갖고 있는 Board 클래스에 메시지를 넘겨 결과를 반환 받을 수 있고, OmokBoard 클래스에서는 따로 하는 일이 없었다. 결과적으로 OmokBoard의 필요성이 불분명해졌고, Board를 삭제하고, 2차원 리스트를 일급 컬렉션으로 하는 클래스를 OmokBoard로 바꾸었다.
2번 질문에 대해서는
해당 클래스의 책임에 대한 테스트를 작성하지만 책임이 없기 때문에 무의미한 것처럼 보여지네요! 만약 구조상 위와 같은 구조라면, 저는 테스트를 작성하지 않아도 될거라 생각들어요!
라고 말씀해주셨다.
2번 질문 역시 1번 질문과 연결되는 의문점이었다. 제출 당시 코드에서는 OmokBoard 클래스의 책임이 전혀 없었고, 위와 같이 수정하면서 해결했다.
의존성 주입(Referee.kt)
OmokRuleAdapter의 객체 생성을 Referee내에서 하게 된다면 Referee는 OmokRuleAdapter에 대한 강한 의존성이 생기게 됩니다. OmokRuleAdapter는 생성 시점에 주입받아보면 어떨까요?
fun checkForbidden(myBoard: OmokBoard, stone: Stone): Boolean {
return OmokRuleAdapter().checkForbidden(myBoard, stone)
}
(OmokRuleAdapter는 다른 크루의 금수 탐색 알고리즘을 어댑터 패턴으로 구현한 클래스다.)
이 피드백을 받고 로또 미션에서 받았던 피드백이 생각났다. 의존성 주입 방법은 생성자 주입, 필드 주입, 함수 파라미터 주입 세 가지다. 로또 미션 당시 함수 파라미터로 주입받도록 해서 사용하는 곳에서 결정하도록 했었다. (자동으로 로또를 발급할 것인지 수동으로 로또를 발급할 것인지를 사용하는 곳에서 결정)
fun isMovable(myBoard: OmokBoard, stone: Stone, rule: OmokRule): Boolean {
return rule.checkForbidden(myBoard, stone)
}
그래서 코드를 위와 같이 수정했다. 가독성을 높이기 위해 함수명을 checkForbidden 에서 isMovable로 변경했다. 또 isMovable 함수의 인자로 OmokRule을 추가했다.
OmokRule은 추상 메서드 checkForbidden() 가지고 있는 인터페이스고, OmokRuleAdapter는 OmokRule을 구현한 클래스다. 따라서 Referee의 isMovable 함수에 OmokRule을 추가함으로써 isMovable을 호출하는 곳에서 어떤 룰을 사용할지 동적으로 결정할 수 있다.
Referee 클래스의 위치
Referee는 현실 세계에서는 오목판과는 다른 객체로 볼 수 있지만, 프로그래밍 구조 내에서는 Referee는 사실 Board에 의존적인 구조예요, Referee는 Board에게 책임을 위임 받았다면, 어디에 위치하는게 좋을까 한 번 고민해보셔도 좋을 것 같아요! (정답은 없는 부분이니 고민만 해보셔도 좋아요!)
OmokGame 클래스가 OmokBoard와 Referee를 갖고있는 형태였다.
또, Referee 클래스는 어떤 결과를 판단하기 위해 현재 상태가 저장되어 있는 OmokBoard가 필수로 필요하다. 이 때문에 외부에서 Referee의 함수를 실행시키면 항상 인자로 OmokBoard를 넘겨줘야 한다. 만약 OmokBoard가 Referee를 프로퍼티로 가지도록 변경하면 OmokBoard에게 금수 위치인지를 확인하거나 승패를 확인하는 것도 메시지를 넘겨 결과를 반환받도록 수정할 수 있을 것 같다는 생각이 들었다.
class OmokGame(
private val omokBoard: OmokBoard = OmokBoard()
//private val referee: Referee = Referee()
)
class OmokBoard(
initialState: List<List<State>> = List(BOARD_SIZE) { List(BOARD_SIZE) { State.EMPTY } }
) {
private val referee: Referee = Referee()
private val _state = initialState.map { it.toMutableList() }.toMutableList()
val value
get() = _state.map { it.toList() }.toList()
fun isForbidden(stone: Stone): Boolean {
return !referee.isMovable(this, stone, OmokRuleAdapter())
}
fun isVictory(state: State): Boolean {
return referee.isWin(this, state)
}
}
그래서 위와 같이 OmokGame 클래스에 있던 Referee를 OmokBoard로 옮겼고, OmokBoard에서 Referee를 이용한 isForbidden, isVictory 함수를 추가했다.
함수의 접두사
check라는 접두사는 boolean을 리턴할거라고 보기 힘들 것 같아요! is, has 같은 접두사를 사용하면 좋을 것 같아요
이러한 이유로 앞에서 checkForbidden 함수명을 isMovable으로 변경했다. boolean의 결과를 반환하는 함수명은 is, has 같은 접두사를 사용하면 더욱 가독성이 높아질 것 같다.
함수 순서
함수 순서에 대한 이야기입니다!
개발자들은 보통 코드를 작성하는 시간보다 작성을 위해 코드를 읽는 시간이 훨씬 많다고 해요!
코드를 읽는 사람의 가독성을 위해, 위에서 아래로 흐름이 흐르도록 순서를 바꿔보면 어떨까요?
class OmokRuleAdapter : OmokRule {
private fun boardConverter(myBoard: OmokBoard): ArkBoard {
...
}
override fun checkForbidden(myBoard: OmokBoard, stone: Stone): Boolean {
...
}
private fun convertStoneToPoint(stone: Stone): OmokPoint {
...
}
}
코틀린 공식 컨벤션에는 다음과 같이 나와있다.
관련 항목을 함께 배치하여 위에서 아래로 읽는 사람이 무슨 일이 일어나고 있는지를 논리를 따를 수 있도록 합니다. 순서를 선택하고(높은 수준의 항목을 먼저 또는 그 반대로) 이를 고수하십시오.
class OmokRuleAdapter : OmokRule {
override fun isForbidden(myBoard: OmokBoard, stone: Stone): Boolean {
...
}
private fun convertStoneToPoint(stone: Stone): OmokPoint {
...
}
private fun boardConverter(myBoard: OmokBoard): ArkBoard {
...
}
그래서 위처럼 높은 수준의 함수를 먼저 위로 올린 뒤, 나눠진 함수를 아래로 배치했다. 어떤 방법으로 함수를 배치해야 하는지 명확하게 정해져 있지는 않지만 개인적인 생각으로는 높은 수준의 함수를 먼저 배치하는 것이 내려가면서 자연스럽게 읽을 수 있지 않나 해서 개인적인 기준을 정했다.
리스너 (OmokGame.kt)
runGame() 에서 콜백 함수들을 모두 받아서 처리하는 대신 OmokGame 자체에 Listener를 추가하는 방식으로 변경해보세요.
Listener 방식으로 변경된 함수의 경우 다른 함수들에서도 매개변수가 사라져야 합니다.
페어의 피드백이었지만 좋은 방법인 것 같아 나도 적용했다.
class OmokGame(
val omokBoard: OmokBoard = OmokBoard(),
private val listener: Listener
) {
fun runGame(
getStone: () -> Stone,
onMove: (OmokBoard, State, Stone) -> Unit,
onMoveFail: () -> Unit,
onForbidden: () -> Unit,
onFinish: (State) -> Unit
) {
...
}
}
class Controller {
fun run() {
val omokGame = OmokGame()
OutputView.printStart()
omokGame.runGame(
InputView::readPosition,
OutputView::printOmokState,
OutputView::printDuplicate,
OutputView::printForbidden,
OutputView::printWinner
)
}
}
OmokGame 클래스의 runGame 함수에서 총 5개의 람다를 받고 있는 형태다. 이렇게 만든 이유는 runGame 함수를 실행할 때 넘겨주는 함수들이 뷰에서 오는 것인지 어디에서 오는 것인지 알 수 없도록 하기 위함이었다. 이렇게 함으로써 도메인과 뷰의 의존성을 더욱 분리할 수 있다.
덕분에 runGame을 호출하는 controller에서 여러 개의 함수를 전달해줘야 한다.
interface Listener {
fun onStoneRequest(): Stone
fun onMove(omokBoard: OmokBoard, state: State, stone: Stone)
fun onMoveFail()
fun onForbidden()
fun onFinish(state: State)
}
class OmokGame(
val omokBoard: OmokBoard = OmokBoard(),
private val listener: Listener
)
runGame 함수를 실행할 때 넘겨주던 5개의 함수들을 추상 메서드로 가지고 있는 인터페이스 Listener를 만들었다. 그리고 OmokGame 클래스의 생성자에 Listener를 받도록 했다. 이런 식으로 변경하면 runGame함수에 5개의 함수를 넘겨주던 방식에서 Listener가 가지고 있는 함수들을 실행시키는 방식으로 변경할 수 있다.
FakeListener (OmokGameTest.kt)
개인적으로는 FakeListener를 만들기 위해서는 내부의 모든 함수를 override 해줘야 하는 단점이 있어요, FakeListener를 잘 쓰기 위해 아무것도 하지않는 FakeListener를 만들고, FakeListener를 상속받아 해당 테스트에 필요한 함수만 재정의하곤해요. 참고만 해주셔도 좋아요!
현재 Listener는 5개의 추상 메서드를 가지고 있기 때문에 리뷰어님이 말씀해주신 것처럼 테스트 코드에서 FakeListener를 만들 때 역시 5개의 함수를 모두 오버라이드 해줘야한다. 5개의 함수를 모두 사용하는 상황이라면 큰 부담은 아니겠지만 하나의 메서드만 사용하는 상황이라면 조금은 부담이 될 수 있을 것 같다.
open class FakeListener : Listener {
override fun onStoneRequest(): Stone {}
override fun onMove(omokBoard: OmokBoard, state: State, stone: Stone) {}
override fun onMoveFail() {}
override fun onForbidden() {}
override fun onFinish(state: State) {}
}
val fakeListener = object: FakeListener() {
override fun onFinish(state: State)() {
~~
}
}
이를 위해 Listener를 구현하는 FakeListener를 만들고 실제 함수의 내부는 구현해놓지 않는다. 그리고 테스트 코드에서 FakeListener를 상속받는 리스너를 만들고, 테스트에 사용하는 메서드만 오버라이드하면 테스트 코드를 보다 간결하게 작성할 수 있다.
또, 같은 리스너를 여러 곳에서 사용한다면 더 효과적으로 사용할 수 있을 것 같다는 생각도 들었다.
테스트
isMovable의 테스트지만, 테스트 준비단계에서 myBoard.move가 여러번 실행되고 있어요. 해당 테스트는 myBoard.move 함수에 의존적인 테스트라고 할 수 있을 것 같아요! move 함수를 실행하는 방법이 아닌 myBoard 생성시 초기값을 넣어주는 것은 어떨까요?
@Test
fun `3*3 test4`() {
// given
val referee = Referee()
val myBoard = OmokBoard()
myBoard.move(Stone.create('J', 9), State.BLACK)
myBoard.move(Stone.create('M', 10), State.BLACK)
myBoard.move(Stone.create('N', 9), State.BLACK)
myBoard.move(Stone.create('M', 12), State.BLACK)
// when
val stone = Stone.create('L', 11)
val actual = referee.isMovable(myBoard, stone, OmokRuleAdapter())
OutputView().printOmokState(myBoard, State.BLACK, stone)
// then
assertThat(actual).isFalse
}
위 테스트 코드는 isMovable을 테스트하는 코드다. isMovable은 놓으려는 돌의 위치가 비어있지 않거나 금수 조건인 경우 false를, 이외의 경우 true를 반환하는 함수다. 이를 테스트하기 위해서는 돌이 놓여있는 상태가 필요했다. 우리가 만든 오목판에 돌을 놓기 위해 정상적으로 동작하는 것을 확인한 move함수를 사용했다.
물론 move함수가 정상적으로 동작하는 것을 확인했지만 혹시라도 move함수에 우리가 확인하지 못한 오류가 있거나 코드가 변경되는 경우 위 테스트코드도 100% 통과하리란 보장이 없게된다.
@Test
fun `3*3 test4`() {
// given
val referee = Referee()
val myBoard = OmokBoard()
val board = MutableList(OmokBoard.BOARD_SIZE) { MutableList(OmokBoard.BOARD_SIZE) { State.EMPTY } }
board[6][9] = State.BLACK
board[5][12] = State.BLACK
board[6][13] = State.BLACK
board[3][12] = State.BLACK
val myBoard = OmokBoard(board)
val stone = Stone.create('L', 11)
// when
val actual = referee.isMovable(myBoard, stone, OmokRuleAdapter())
OutputView().printOmokState(myBoard, State.BLACK, stone)
// then
assertThat(actual).isFalse
따라서 위 코드처럼 직접 board에 index로 접근하여 값을 변경해주는 방식으로 변경했다.
이 피드백도 앞으로 테스트 코드를 작성하면서 계속 상기하면 좋을 것 같은 내용이라고 생각됐다.
오목 Step1&2 PR
Step 3&4
Step3의 요구사항은 콘솔 UI와 더불어 모바일 앱으로 오목게임이 가능해야 한다. 앞에서 만든 domain을 안드로이드로 올리는 것이었고,
Step4의 요구사항은 모바일 앱을 재시작하더라도 이전에 하던 오목 게임을 다시 시작할 수 있어야 한다. 데이터베이스를 사용해서 앱을 종료하기 전에 놓여있던 돌들을 저장하고, 앱을 재시작했을 때 데이터베이스에 저장된 정보를 가져와 이전 상태와 같이 만드는 것이었다.
안드로이드를 해보긴 했지만 아무것도 모르는 상태에서 구글링해서 코드를 그대로 가져와 붙여넣는 방식으로 했기 때문에 알고있는 것이 거의 없다고 봐도 무방한 상태다. 그나마 다행이었던 것은 코치님들이 기본적인 UI는 제공해주셨다는 것...! 이제까지 만들었던 도메인 코드를 안드로이드에 올리면 되는 것이다.
1, 2단계는 인텔리제이에서 진행했고 3, 4단계는 안드로이드 스튜디오를 사용해야 했다. 그래서 1, 2단계에서 작성한 코드를 안드로이드 스튜디오에 옮겼어야 했는데, 이것부터 큰 난관이었다.
앱 UI를 적용할 때 도메인 객체의 변경을 최소화해야 한다는 요구사항이 있었다.
🦾 Step3&4 Refactoring