이상(추상화 계층 분리) vs. 현실(기능)
체스 게임에서는 폰은 특별한 이동규칙을 가진다.
첫 이동에 2칸을 이동할 수 있고, 공격을 하는 경우는 대각선으로도 이동이 가능하다.
하지만 다른 기물들은 이렇게 특별한 이동규칙을 가지지 않는다. (나이트, 퀸, 비숍 등)
나는 모든 기물들을 하나로 추상화 해 다루고 싶었으나 폰을 추상화하는 과정에서 어려움을 겪었다.
아무리 생각해도 폰과 다른 기물들을 하나로 추상화 할 수 있는 수단을 찾지 못했었다.
따라서 폰의 이동규칙만 특별하게 상위 개념인 Board 객체에서 다뤘다.
이 부분에 대해서는 당연히 지적이 들어올 것이라 생각했고, 나도 개선해야 할 점이라 생각했다.
그리고 이것이 가지는 단점이 무엇인지 생각도 해보았는데, 아래와 같은 결론을 도출할 수 있었다.
즉, 하위 개념인 Pawn의 이동 로직을 상위 개념에서 다루는 것은 변경에 취약한 설계라고 생각했다.
변경에도 강한 설계는 추상화 계층을 잘 지키는 설계라고 생각한다.
잘 지켜진 추상화 계층은 일종의 격벽이 되어, 변경 여파를 격리시킨다.
하지만 해답을 안다고 해도 이를 어떻게 코드로 풀어낼지는 정말 막막했다.
따라서 비용을 지불하기로 했다.
public final class BlackPawn extends Pawn {
private static final int START_RANK = 6;
...
}
public final class WhitePawn extends Pawn {
private static final int START_RANK = 1;
...
}
BlackPawn, WhitePawn에서 내부적으로 시작 행(Rank)를 갖고 있게 했다.
그리고 이후에 기물을 움직일 때, START_RANK와 같은 행이라면 2칸을 이동시키는 것을 허용하면 된다.
이것도 Pawn이 Board의 정보에 대해 알고 있기에 추상화 계층의 파괴이다.
정말 좋은 설계라면 Pawn은 보드판, 체스 게임 자체에 대해 아무것도 몰라야 한다.
하지만 그런 설계 방법이 미션을 하는 내내 떠오르지 않았고, 이런 방식으로 비용을 지불하기로 한 것이다.
나는 상위 개념(Board)이 하위 개념(Pawn)을 아는 것보다는,
하위 개념(Pawn)이 상위 개념(Board)을 아는 것이 더 좋은 설계라고 생각해 위처럼 코드를 구성했다.
즉, 국소적인 추상화 계층의 파괴는 존재했으나 기능을 구현하기 위해 어쩔 수 없는 부분이었다고 생각된다.
이를 통해 느낀 점은, 기능 명세나 도메인 영역 자체가 복잡하다면 추상화 계층의 파괴도 trade-off의 대상이 될 수 있다는 것이다.
다만 어떤 설계가 변경에 강한지를 파악하는 것이 중요하다고 생각된다.
그리고 이것이 체스 미션의 핵심이 아니었을까 조심스레 생각해본다.
널 오브젝트 패턴
기물은 칸에 존재하지 않을 수도 있다.
보드 판을 생각해보면, 초기 상태에는 중간의 4줄은 아무런 기물이 존재하지 않는다.
아무런 기물이 존재하지 않는다를 무엇으로 표현해야 할까?
null은 잠재적인 위험성이 크다.
따라서 Null Object Pattern을 사용하기로 했고, 아래처럼 코드에서의 null 사용을 완전히 제거할 수 있었다.
객체지향은 표현할 수 있는 모든 것을 객체로 만드는 것이 중요하다고 느끼는 계기가 되었다.
public final class EmptyPiece extends Piece {
public EmptyPiece() {
super(Color.NEUTRAL);
}
@Override
public boolean isMovable(
final Coordinate start,
final Coordinate end
) {
return false;
}
@Override
public boolean isAttackable(
final Coordinate start,
final Coordinate end,
final Piece target
) {
return false;
}
}
부생성자 도입을 통한 테스트 개선?
조영호님의 <오브젝트>를 읽고 나서 나는 생성자의 정의를 다음과 같이 생각하게 되었다.
생성자란, 해당 객체의 의존성을 명시적으로 드러내기 위한 도구이다.
마찬가지로 <엘레강트 오브젝트> 에서는 좋은 객체란 생성자가 퍼블릭 메소드의 수보다 많아야 한다고 주장한다.
생성자는 의존성을 어떻게 결정하느냐에 관여하고 있기 때문에, 즉 객체의 생성 책임에만 관여하고 있기 때문에,
객체의 특징 자체를 바꿀 순 없다.
따라서 생성자는 많아도 문제가 되지 않는다는 주장이 팽배한 것이다.
그렇다면, 생성자를 통해 테스트를 용이하게 하는 것은 기회비용 측면으로 보았을 때 합당한 일이 아닌가?
라는 생각이 이번 체스 미션 중에 들게 되었다.
이에 대해 리뷰어님 답변은 '테스트를 위한 코드가 있는 것에는 대체로 반대하는 편, 다만 생성자는 조금 너그럽게 본다' 였다.
나도 테스트를 위한 프로덕션 코드는 지양해야 한다고 생각한다.
다만 생성자의 경우는 리뷰어님이 말씀하신 것처럼 팀 규약에 따라, 혹은 협업 대상에 따라 테스트를 위해 사용해도 되지 않을까,
라는 생각이 들게 되었다.
정리하다가 든 생각인데, 비슷한 논리로 테스트를 위한 메소드 오버로딩도 고려해볼 수 있을 듯 하다.
메소드 오버로딩도 어떻게 보면 의존 관계만 바꾸는 것에 불과하니까..!
아무튼, 정답은 없고 상황에 맞춰서 생성자도 테스트를 위한 도구로 사용해도 될 듯 하다.
물론 테스트를 하면서 얻는 이득이 더 크고, 오용의 여지가 없을 때 말이다.
상속의 단점을 마주하다
상속의 단점 중 하나는 상위 타입의 객체의 변경이 하위 타입의 객체로 이어질 수 있다는 점이다.
이것을 취약한 기반 클래스 문제라고 부르기도 하는데, 나는 이번 미션을 통해 직접적으로 이 문제를 겪었다.
다음은 리팩토링 하기 전, Pawn 객체의 구현 중 일부이다.
public abstract class Pawn extends Piece {
@Override
public boolean isMovable(
final Coordinate start,
final Coordinate end,
final Situation situation
) {
if (situation.meetNeutral() || situation.meetColleague()) {
return isMovableWhenMovingNotVariates(start, end);
}
return isMovableWhenMovingVariates(start, end);
}
...
}
우선은 isMovable 메소드를 오버라이드해 구현하고 있다는 것만 기억해두면 될 듯 하다.
그리고 Pawn의 부모 클래스인 Piece에는 다음과 같은 코드가 존재한다.
public abstract class Piece {
public boolean isMovable(
final Coordinate start,
final Coordinate end,
final Situation situation
) {
validateIsNotSameColor(situation);
return isMovableWhenMovingNotVariates(start, end);
}
...
}
Piece 역시도 isMovable 메소드를 구현하고 있다.
왜 이런 구조가 나왔을까?
다른 기물들과 다르게 Pawn에서는 isMovable 메소드의 구현부가 다르게 작성되어야 하기 때문이다.
이렇게 구현이 완료된 부모 클래스의 메소드를 오버라이드해 덮어씌우면 문제가 뭘까?
설명이 길어질 것 같기에 내가 미션 중 남겼던 코멘트를 첨부한다.
리뷰어님은 "왜 덮어씌우는 것이 캡슐화를 저해시킨다고 생각하시나요?"라고 여쭤봐주셨고, 나는 아래와 같은 답을 했다.
리뷰어님도 나의 답변에 어느정도 동의하셨고 위 문제를 해결하는 코드가 이상적인 코드일 수 있겠다, 라는 답을 주셨다.
이것이 조합을 사용하는 한 가지 이유가 될 것 같다.
레벨1을 하면서 가장 많이 들었던 키워드 중 하나가 '상속과 조합'이었는데,
구현 상속을 하지 말라고 하는 이유를 스스로 찾아낸 계기가 된 것 같아 기쁘다.
패키지 구조도 설계의 대상이다
나는 이번 체스 미션을 진행하면서 다음과 같은 생각을 했던 것 같다.
'컨트롤러로 들어오는 입력이든, 도메인에서 내보내는 데이터든.. 모두 순수하게 사용될 일이 있을까?'
즉, 각 레이어를 관통하는 데이터가 그대로 사용되는 일이 없이 항상 변환되서 사용된다는 이야기이다.
체스 미션을 예로 들어서, 사용자가 "move b2 b4"라는 정보를 입력했다고 해보자.
"move b2 b4" 라는 정보는 도메인이 이해할 수 있는 형태로 바꾸어야 한다.
"move"의 경우 담당 메소드를 호출하면 되겠지만.. "b2 b4"의 경우 적절한 좌표계로 바꾸는 연산이 필요했다.
즉, b2를 (1, 1)로, b4를 (1, 3)으로 변경해야 했다.
마찬가지로 도메인에서 데이터를 내보낼 때도
OutputView에서 이를 출력하게 하기 위해서는 적절한 String 형태로의 가공이 필요했다.
따라서 다음과 같은 패키지 구조를 고안했다.
inward, outward는 데이터의 방향을 표현한 것이다.
InputView에서 컨트롤러로 데이터가 들어오는 경우는 inward로,
OutputView로 컨트롤러가 데이터를 내보내는 경우는 outward로 표현했다.
그렇게 되면 컨트롤러는 오로지 흐름 제어만 맡는다.
나는 위와 같은 구조가 안정적인 구조라고 생각했고, 이 구조에 대한 리뷰어님의 코멘트는 조금 놀라웠다.
내가 고안한 패키지 구조는 헥사고날 아키텍쳐의 모습과 꽤 닮아있었다.
사실 내부 내용은 전혀 다르기에 닮았다고 하기도 그렇지만, 패키지 구조가 거의 비슷했다는게 놀라웠다.
아직은 클린 아키텍쳐를 읽을 역량은 안되지만, 설계에 관심이 많아서 나중에 기회가 된다면 제대로 읽어보고 싶다.
도메인의 생성 책임
이번 미션을 통해 데이터베이스를 처음으로 도입해봤다.
DAO, Service, Repository 등의 용어는 아직까지도 잘 와닿지 않는다.
그렇지만 한 가지 고민은 해볼 수 있었는데, '바로 도메인의 생성 책임을 영속성이 가져가는 것이 옳은가' 이다.
DAO, Service를 접목시키면서 도메인 객체들의 생성 책임이 영속성으로 옮겨갔다.
나는 이것이 좋은 설계인지에 대한 고민을 많이 했다.
일단 도메인이 영속성에 강하게 의존하고 있다는 느낌을 많이 받았고,
의존성을 제어하는 과정이 고정되는 것이 아닌가? 라고 생각했기 때문이다.
이것이 기존의 계층형 아키텍쳐가 가지는 문제점이라고 어디서 들은 적이 있는데,
보다 좋은 구조는 어떤 구조인지 고민해보는 계기가 되었던 것 같다.
이것을 이제 코드로 풀어내는 것은 아마 레벨 2에서 이뤄지지 않을까.. 싶다!
'우테코 5기' 카테고리의 다른 글
장바구니 미션) 도메인에서 영속성 개념 분리해보기 (6) | 2023.04.29 |
---|---|
우아한테크코스 레벨1 회고 (6) | 2023.04.06 |
[레벨 1 미션] 체스 학습 기록(1) (2) | 2023.04.04 |
[레벨 1 미션] 블랙잭 게임 학습 기록 (2) | 2023.04.04 |
레벨 1 레벨로그 (1) | 2023.03.30 |