우테코 마지막 미션 오목을 진행하면서 금수 알고리즘을 어떻게 해결해야 할지 고민이 많았다. 오목이 이렇게 규칙이 복잡한 게임인지 새삼 처음 알게되었다.. 어릴 때 하던 오목은 이런게 아니었는데..😢
Adapter
어탭터라는 단어는 잘 알고있을 것이다. 한국에서 사용하는 220V 충전기를 호주에서는 바로 사용할 수 없기 때문에 플러그 모양을 바꿔주는 어댑터가 필요하다. 조금 다르게 설명하면 어댑터는 소켓의 인터페이스를 플러그에서 필요로 하는 인터페이스로 바꿔준다고 할 수 있다.
기존 소프트웨어 시스템에 새로운 업체에서 제공한 클래스 라이브러리를 사용해야 한다고 가정해보자. 그 업체에서 사용하는 인터페이스가 기존 시스템에서 사용하는 인터페이스와 같을 수는 없을 것이다. 따라서 업체에서 제공하는 라이브러리를 사용하기 위해 기존 시스템에서 사용하는 형태를 업체 시스템에서 사용하는 형태로 바꿔주는 어댑터를 사용한다.
이런 방식으로 말이다. 이렇게 해서 얻을 수 있는 장점은 기존 시스템과 업체 시스템의 코드는 변경시키지 않고 어댑터의 코드만 수정하면 바로 적용할 수 있다는 점이다.
사실 업체에서 제공하는 알고리즘을 내 코드로 가져와 도메인에 맞게 수정해도 큰 문제가 없다 라고 생각할 수 있지만 이러한 경우 업체에서 제공하는 알고리즘이 수정될 경우 도메인에 맞게 수정한 내 코드도 바꿔줘야 하는 문제가 생긴다. 어댑터를 사용하면 알고리즘만 가져오면 다른 코드는 변경하지 않아도 된다는 이점이 있다.
어떻게 쓰는거야?
어떻게 사용하는지 책의 예제를 통해 알아보자.
interface Duck {
fun quack()
fun fly()
}
class MallardDuck : Duck {
override fun quack() {
println("꽥")
}
override fun fly() {
println("오리 날다!")
}
}
interface Turkey {
fun gobble()
fun fly()
}
class WildTurkey : Turkey {
override fun gobble() {
println("골골")
}
override fun fly() {
println("터키 날다!")
}
}
인터페이스인 Duck, Turkey 그리고 각각의 구현체 MallardDuck, WildTurkey가 있다.
fun testDuck(duck: Duck) {
duck.quack()
duck.fly()
}
그리고 Duck을 인자로 받는 testDuck 함수가 있다. testDuck은 인자로 Duck만 들어올 수 있기 때문에 Turkey는 들어갈 수 없는 것이 당연하다.
class TurkeyAdapter(val turkey: Turkey) : Duck {
override fun quack() {
turkey.gobble()
}
override fun fly() {
turkey.fly()
}
}
TurkeyAdapter는 Duck을 구현하는 클래스이며 Turkey를 프로퍼티로 가진다. Duck을 구현했기 때문에 인터페이스에 존재하는 모든 메서드를 오버라이드 해야한다. 어 그런데 뭔가 이상하다?
quack() 함수에는 turkey.gobble()이, fly() 함수에는 turkey.fly()가 들어가 있다. 밖에서는 Duck의 함수를 호출하는 것 같지만 실제로는 Turkey의 함수를 호출하도록 구현한 것이다.
Turkey인터페이스를 Duck으로 변환했다고 볼 수 있다.
fun main() {
val duck = MallardDuck()
val turkey = WildTurkey()
val turkeyAdapter = TurkeyAdapter(turkey)
println("오리 say")
testDuck(duck)
println("칠면조 어댑터 say")
testDuck(turkeyAdapter)
}
이 코드의 실행 결과를 보자.
testDuck은 Duck의 quack()과 fly()를 호출하는 함수임을 앞에서 봤다. 그래서 MallardDuck으로 된 duck 인스턴스가 인자로 넘어가면 "꽥", "오리 날다!"가 출력된 것을 볼 수 있다.
turkeyAdapter역시 Duck을 구현하는 클래스이기 때문에 testDuck의 인자로 넘겨줄 수 있고 마찬가지로 quack(), fly()를 호출할 것이다. 그런데 실제로 내부적으로는 turkey의 함수를 호출하고 있기 때문에 "골골", "터키 날다!"가 출력된 것을 볼 수 있다.
어댑터 패턴의 정의는 다음과 같다.
어댑터 패턴은 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환합니다. 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게 도와줍니다.
이 패턴을 사용하면 호환되지 않는 인터페이스를 사용하는 클라이언트를 그대로 활용할 수 있다. 인터페이스를 변환해주는 어댑터를 만들면 되기 때문이다. 그러면 클라이언트와 구현된 인터페이스를 분리할 수 있으며, 변경 내역이 어댑터에 캡슐화되기에 나중에 인터페이스가 바뀌더라도 클라이언트를 바꿀 필요가 없다.
오목 미션에 적용
이제 좀 더 깊이 들어가서 이 내용을 정리하게 된 계기와 사용한 방법을 정리해보려고 한다.
앞에서 말했던 것처럼 우테코 레벨1 마지막 미션은 오목을 구현하는 것이었다. 오목의 규칙 중에서 렌주룰이라는 규칙이 있는데, 간단히 이야기하면 흑목은 3*3 or 4*4에 둘 수 없고, 장목이 금지된다는 규칙이다.
금수를 찾는 알고리즘이 중요하지 않다는 말을 듣고 우리는 구현에 집중했다. 금수를 확인하기 전까지는 큰 어려움 없이 구현을 마쳤다. 이제 금수는 어떻게 찾지? 하는 벽을 만났다. 타이밍 좋게 아크가 금수를 찾는 알고리즘을 만들었고, 다른 크루들을 구제해주기 위해 공유해주었다.
사용하려고보니 어떻게 사용해야하나.. 하는 기분이 들었다. 우리가 오목판을 관리하는 방법과는 굉장히 달라 고민하고 있던 찰나 또 한 명의 구세주 제이슨이 어댑터 패턴을 던져주고 갔다.
먼저 아크가 금수 알고리즘에서 사용한 클래스들을 모두 가져와 rule 패키지 안에 넣어주었다. 앞의 개념을 살짝 첨가해보면 우리가 오목판을 관리하는 방법은 "기존 시스템"이고, 아크의 금수 알고리즘은 "업체에서 제공하는 시스템"이다. 하지만 서로 다른 오목판의 상태를 가지고 있기 때문에 업체에서 제공하는 시스템을 바로 가져와 사용할 수 없다.
interface OmokRule {
fun isForbidden(myboard: OmokBoard, stone: Stone): Boolean
}
그래서 우리는 OmokRule이라는 인터페이스를 만들었다. 이 친구는 isForbidden()
함수를 가지고 있다. 하는 일은 우리의 오목판과 돌을 놓을 위치를 받아 금수인지 아닌지를 반환해준다.
class OmokRuleAdapter : OmokRule {
// OurBoard -> ArkBoard
private fun boardConverter(myBoard: OmokBoard): ArkBoard {
return ArkBoard(
myBoard.board.value.reversed().mapIndexed { y, row ->
YCoordinate(y + 1) to OmokLine(
row.mapIndexed { x, state ->
XCoordinate(x.plus('A'.code).toChar()) to when (state) {
State.BLACK -> BlackStoneState
State.WHITE -> WhiteStoneState
State.EMPTY -> EmptyStoneState
}
}.toMap()
)
}.toMap()
)
}
// 3*3 or 4*4 check
override fun isForbidden(myBoard: OmokBoard, stone: Stone): Boolean {
val point = convertStoneToPoint(stone)
val convertedBoard = boardConverter(myBoard)
return Rule(convertedBoard).countOpenThrees(point) <= 1
&& Rule(convertedBoard).countOpenFours(point) <= 1
}
// Stone -> OmokPoint
private fun convertStoneToPoint(stone: Stone): OmokPoint {
val stoneString = stone.toString()
return OmokPoint(XCoordinate(stoneString[0]), YCoordinate(stoneString.substring(1).toInt()))
}
}
boardConvert()
메서드는 우리의 오목판을 아크의 오목판의 형태로 바꿔주는 일을 한다.
isForbidden()
메서드는 금수 위치인지를 반환하는 일을 한다.
convertStoneToPoint()
메서드는 우리 도메인에 맞게 변환 좌표인 Stone을 아크 도메인에 맞게 바꿔주는 일을 한다.
class Referee {
...
fun isMovable(myBoard: OmokBoard, stone: Stone, rule: OmokRule): Boolean {
return rule.isForbidden(myBoard, stone)
}
}
Referee의 isMovable()
메서드는 OmokRuleAdapter의 isForbidden()
메서드를 호출하고, 앞에서 본 것처럼 OmokRuleAdapter의 checkisForbidden()
메서드는 우리의 오목판, 좌표를 아크의 오목판, 좌표로 변경해주고 해당 위치가 금수인지 결과를 반환해준다.
순서를 정리하면
Referee.isMovable()
→ OmokRuleAdapter.isForbidden()
→ (OmokBoard ▶ ArkBoard) → ArkBoard.checkForbidden()
이 되는 것이다.
책의 내용처럼 딱딱하게 정리해보면 다음과 같다.
Client(우리의 코드)에서 OmokRule(인터페이스)의 isForbidden
함수를 호출하여 금수인지를 알고 싶은 상태다.
그런데 사실은 OmokRuleAdapter(OmokRule 인터페이스를 구현한 클래스)가 가지고 있는 isForbidden
함수를 호출한다.
근데 OmokRuleAdapter의 isForbidden
함수는 아크의 Rule을 사용하고 있다.
어댑터 덕분에 클라이언트에서 호출한 OmokRule인터페이스의 isForbidden
함수를 아크의 Rule의 함수를 호출해서 사용할 수 있는 것이다.(어렵다..)
사실 OmokRule인터페이스없이 바로 클래스로 구현해도 지금 코드에서는 크게 상관이 없다고 생각된다. 하지만 오목에는 렌주룰 외에도 다양한 룰이 있다. 만약 다른 룰을 가져와 적용하고 싶다면? 또 다른 룰에 대해 클래스를 만들게 되면 Referee가 또 그 클래스에 대해 의존성을 가지게 된다.
하지만 인터페이스를 만들고 그를 구현한 클래스들로 만들면 Referee의 isMovable()
메서드를 호출할 때 OmokRule 인터페이스를 인자로 받으면 어느 룰을 사용할지 동적으로 결정할 수 있다.
내가 가졌던 의문점과 힌트가 되었던 부분을 남기면서 글을 마친다.
외부의 함수를 호출할 때마다 convert를 진행하면 이것이 과연 효율적인 방법일까?
다음은 이 의문에 대한 답이 되었던 책의 내용이다.
어댑터 구현은 타깃 인터페이스로 지원해야 하는 인터페이스의 크기에 비례해 복잡해진다. 하지만 클라이언트에서 호출하는 부분을 새로운 인터페이스에 맞춰서 고치려면 정말 많은 부분을 고려해야 하고, 코드도 굉장히 많이 고쳐야 할 것이다. 이보다는 그냥 모든 변경 사항을 캡슐화할 클래스 하나만 제공하는 방법이 더 낫기 때문에 괜찮은 방법으로 여겨질 수 있다.
참고
[1] 헤드퍼스트 디자인패턴 272p