🧑⚖️ 체스 게임방 삭제 관련 기능은 도메인 규칙이다
우아한테크코스 레벨 2 첫 미션, 스프링 체스 2단계에서 도메인 요구사항이 추가되었습니다.
여러 게임을 게임 방으로 관리하되, 게임 종료 상태이고 비밀번호가 일치할 경우 삭제할 수 있다는 것이었습니다.
첫 고민은 이 요구사항과 관련된 도메인 객체를 만들어야 하느냐 였습니다.
이 규칙은 실제 체스 규칙과는 상관이 없지만,
해결하고자 하는 주요 문제 영역이자 관심사라고 판단했습니다.
또한 프리젠테이션 레이어나 인프라스트럭처 레이어가 변경되더라도,
게임방 관리, 비밀번호 확인 등의 도메인 규칙이 변경되지 않는다면 동일하게 사용되어야 한다고 판단했습니다.
그래서 도메인 객체 Room 추가했습니다.
📨 Room 에게 메시지를 보내기 위한 준비
사실도메인 객체 Room을 만들 생각을 처음부터 할 수 있었던 것은 아니었습니다.
방 제거 요청 시, 방의 ID와 Password가 전달되는데,
사실 이걸 DB에 조회해버리면 간단히 확인되기 때문입니다.
Dao에게 id를 주며 room 테이블에서 password를 조회해오라고 시킨 뒤,
입력된 비밀번호를 encode해서 비교한뒤, 서비스 레이어가 다시 삭제 요청을 보내버리면 그만입니다.
하지만 이런식으로 코드를 작성할 때의 가장 큰 문제는,
도메인 관련 주요한 규칙임에도 도메인 객체를 통한 단위 테스트가 어려워진다는 점입니다.
뿐만 아니라, 도메인 로직이 Dao와 서비스 레이어에 작성되어 절차지향적 코드가 된다는 점입니다.
처음엔 별거 아닌 것 같지만 마치 깨진 유리창 이론처럼, 서비스 레이어가 순식간에 더럽혀지고,
흐름을 파악하기 어려운 코드, 유지보수하기 어려운 코드가 되어버릴 것입니다.
또한, 이러한 요청이 발생할 때마다 Dao에 쿼리를 새로 작성하면,
매번 dao 테스트 코드가 추가되어야 하고, 로직이 분산되어버립니다.
그래서 Dao는 테이블 컬럼과 매핑되는 Entity 클래스를 반환하는 공통 메서드를 구성해두어,
이를 통해 도메인 객체를 생성한 뒤 공통으로 사용하게 구성하는 쪽으로 방향을 잡았습니다.
게임방 관련 처리는 일단 도메인 객체 Room을 만든 뒤에,
그 도메인 객체에게 메시지를 보내서 처리하고,
그 결과를 영속성 레이어에 전달하여 저장하게 한다.
이것이 큰 컨셉이었습니다.
🚥 비밀번호 검증은 Room의 책임인데, Encoder는 어떡하죠..
public class Room {
private final long id;
private final String name;
private final String password;
private final String white;
private final String black;
private final boolean finished;
// ...
}
게임방을 의미하는 Room에는 게임방 번호, 제목, 비밀번호 등이 상태값으로 존재합니다.
그런데 이 비밀번호는 이미 단방향 암호화 처리되어 저장되었던 String입니다.
따라서 단순히 문자열을 전달해서는 Room이 비밀번호 일치여부 검증 책임을 온전히 수행할 수 없습니다.
단방향 암호화에 사용했던 PasswordEncoder를 비밀번호 검증을 위해 재차 활용해야만 했는데,
문제는 상태값을 갖는 도메인 객체인 Room을 Spring Bean으로 선언할 수 없었기에
스프링의 DI를 이용해 Room 내부에 PasswordEncoder를 장착시킬 수 없었다는 점입니다.
Room으로부터 getPassword 로 값을 꺼내어 처리하는 것은
Room을 객체지향 세계 속 협력하는 객체로 다루는 게 아니라 데이터덩어리로만 취급하는 것 같아
분명히 선택할 옵션은 아니라고 생각했습니다.
그리고 이렇게 바라보고 나니, DB에서 비밀번호를 조회해온뒤, 입력값을 인코딩하여 비교하는 것도
getter 로 값을 꺼내 비교하는 것이나 마찬가지이고 둘다 절차지향적으로 보여졌습니다.
🔝 PasswordEncoder를 메서드 매개변수로 끌어올리기
public class Room {
// fields & constructor
public boolean matchPassword(String password, PasswordEncoder passwordEncoder) {
return passwordEncoder.matches(password, this.password);
}
}
class RoomTest {
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Test
@DisplayName("비밀번호 일치여부를 확인할 수 있다")
void matchPassword() {
final Room testRoom = new Room(1L, "test room", passwordEncoder.encode("1234"));
assertAll(
() -> assertThat(testRoom.matchPassword("1234", passwordEncoder)).isTrue(),
() -> assertThat(testRoom.matchPassword("1233", passwordEncoder)).isFalse()
);
}
}
최종적으로 구성한 모습은 위와 같습니다.
Service Layer에서 도메인 객체 Room에게 비밀번호 일치 여부를 물어볼 때,
그 때마다 매번 PasswordEncoder를 전달하도록 구성한 것입니다.
이 방법이 꺼려졌던 것은 이러한 모습이 매우 낯설었기 때문입니다.
물론 Service Layer는 Spring Bean으로 등록되어 PasswordEncoder 를 DI 받아
매번 재생성하지 않고 싱글턴 객체를 보내줄 수 있지만,
매 메시지를 보낼 때마다 협력할 대상을 매개변수로 보내는 모습은 익숙지 않았습니다.
추가적으로 Room이 비밀번호 검증이라는 자신의 책임을 수행하기에 충분하지 못한 정보를 지닌 것이 아닌가
하는 불편함도 있었습니다.
그럼에도 불구하고 위와 같은 선택을 한 이유는 다음과 같습니다.
자신의 책임을 수행하기에 자신이 충분한 정보나 수단을 지니지 못할 경우,
다른 또다른 객체에게 협력을 요청하는 것은 객체지향적으로 봤을 때 자연스러운 모습이라 할 수 있을 것입니다.
또한 레벨 1 자동차게임에서 사용되었던 전략 패턴 역시 비슷한 모습이라고 생각됐습니다.
@ParameterizedTest(name = "전진 - {0}")
@CsvSource(value = {"true:1", "false:0"}, delimiter = ':')
@DisplayName("자동차 전진 테스트")
void advanceTest(boolean isMove, int expected) {
// given
Car car = new Car("test");
// when
car.advance(() -> isMove);
// then
assertThat(car.getPosition()).isEqualTo(expected);
}
public class Car {
// fields & constructor
public void advance(MovableStrategy movableStrategy) {
if (movableStrategy.isMove()) {
this.position++;
}
}
}
분명 자동차 게임에서 테스트하기 어려운 랜덤을 인터페이스를 매개변수로 받게 해서 처리했었는데,
그걸 Service Layer에서 필드로 가지고 있는 PasswordEncoder를 매번 전달하려니 매우 낯설게 느껴졌었습니다.
그러나 이런 방식을 채택함으로써, 테스트하기 쉬운 구조가 되었음은 분명했습니다.
추가적으로 애플리케이션이 복잡해질수록, 이렇게 협력할 객체를 함께 전달하는 방식은
더 많이 만나게 될 거라는 네오의 조언도 있었습니다.
사실 이 한 마디 덕분에 아주 많이 편안해졌습니다 ㅋㅋ
👀 Service Layer에서 바라보기
@Service
@Transactional
public class SpringChessService implements ChessService {
private final RoomRepository roomRepository;
private final PasswordEncoder passwordEncoder;
// other fields & constructor & methods
@Override
public GameDeleteResponse deleteById(GameDeleteRequest gameDeleteRequest) {
validateFinished(gameDeleteRequest.getId());
validatePassword(gameDeleteRequest.getId(), gameDeleteRequest.getPassword());
roomRepository.deleteById(gameDeleteRequest.getId());
boardRepository.deleteById(gameDeleteRequest.getId());
return new GameDeleteResponse(true, String.valueOf(gameDeleteRequest.getId()));
}
private void validatePassword(long id, String password) {
final Room room = roomRepository.findById(id);
if (room.matchPassword(password, passwordEncoder)) {
return;
}
throw new PasswordNotMatchedException();
}
}
Service Layer는 Repository 로부터 id를 통해 찾아진 Room을 받아,
이 Room에게 비밀번호 일치여부를 물어봅니다.
물어볼 때 이번에 입력된 비밀번호를 암호화 해서 전달하는 것이 아니라,
입력값 그대로와 단방향 암호화 및 비교에 사용될 인터페이스 PasswordEncoder 를 같이 전달합니다.
이 코드를 지금 다시 돌아보니 순전히 검증의 책임을 위해서만 Room을 사용하고 있는 것으로 보입니다.
Room에게 메시지를 보낸 뒤, 그 Room을 그대로 다시 Repository에게 저장하되,
Repository에서는 Room의 삭제 상태가 변경되었을 때 이를 반영하도록 알아서 처리될 수 있다면
뭔가 더욱 객체지향적일 것 같다는 생각도 들기도 합니다.
또 한편으로는 비밀번호가 일치하지 않았을 때, 어떤 예외를 발생시킬지,
혹은 추가적으로 어떤 로직을 수행할지 Service Layer가 제어 흐름을 계속 가져가는 것도 이점이 있을 것 같네요.
결론
테스트하기 어려운 부분을 더 상위로 끌어올려 테스트 가능하게 만들어보자 입니다.
도메인 객체 Room에게 비밀번호 검증 책임을 맡기되,
책임을 수행하는 과정에서 협력할 인터페이스를 함께 전달하도록 구성하여,
Room이 비밀번호 암호화, 비교의 책임은 인터페이스에게 느슨하게 의존하였고,
이로써 도메인 객체 Room에 대한 테스트가 쉬워졌습니다.
추가로 서비스 레이어에서도 Room에게 메시지를 보내는 객체지향적 코드가 가능해졌습니다.
'우아한테크코스 4기' 카테고리의 다른 글
스프링 통합 테스트에 사용되는 도구와 설정들(Sql, Transactional, JdbcTestUtils) (6) | 2022.05.24 |
---|---|
find vs get (네이밍 컨벤션과 JPA에서의 내부 동작 차이) (2) | 2022.05.24 |
우당탕탕 Repository 제작기 (feat. Reflection) (2) | 2022.05.06 |
@Component 🆚 @Service 🆚 @ Repository (0) | 2022.04.23 |
Spring 의존성 주입 방법 중 생성자 주입을 사용해야 하는 이유 (0) | 2022.04.22 |