NovelService 관련 기능을 구현하다보니 값 정규화 이외의 추가적인 다른 약간의 문제가 초반에 있었는데, 바로 다른 도메인의 엔티티를 조회하는 문제였다.

예를 들어 소설을 생성 할 때 인자로 받은 userId 에 해당하는 User 엔티티를 가져와서 Novel 엔티티에 넣어줘야 했는데, 이때 User 조회가 실패한다면 UserNotFound 예외를 던져줘야 했다.

이 부분을 원래는 UserRepository 를 NovelService에서 직접 사용해 구현했었지만, 이는 두 도메인간의 결합도를 높이고 User 도메인의 예외를 사용해야 해서 나중에 예외가 변경된다면 변경 지점이 너무 많아진다고 판단하였다.

그래서 고민하다가 Reader(Finder) 라는 계층을 도입하였다. Reader 는 각 도메인별로 최대 하나씩 두고 다른 도메인이 내 도메인을 조회할 때 오로지 Reader 를 통해서만 조회하게 하여 예외 처리를 일관화 하고 중복을 최소화 할 수 있다.

예를 들어 아래와 같이 사용된다.

// novel/service 패키지의 NovelService
public NovelResponse createNovel(CreateNovelCommand command, long userId) {
        User user = userReader.findUserById(userId); // Reader 사용
        String title = command.title();

        boolean isDuplicateTitle = novelRepository.existsByTitleAndUserIdAndDeletedAtIsNull(title, userId);
        if (isDuplicateTitle) {
            throw new DuplicateNovelTitle(title);
        }

        Novel newNovel = NovelCommandMapper.toNovel(command, user);
        Novel savedNovel = novelRepository.save(newNovel);

        return NovelResponseMapper.toNovelResponse(savedNovel);
    }
    
// user/service/reader 패키지의 UserReader
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserReaderImpl implements UserReader {

    private final UserRepository userRepository;

    @Override
    public User findUserById(long userId) throws UserNotFound {
        return userRepository.findByIdAndDeletedAtIsNull(userId)
                .orElseThrow(() -> new UserNotFound(userId));
    }
}

이를 도입하고 나니 확실히 두 도메인간 결합이 줄어든 느낌을 받았다. 하지만 다음날에 다시 생각해보니 이는 내 프로젝트에서는 오히려 오버엔지니어링에 가까웠다. 이유는 아래와 같다.

그래서 이런 이유로 다시 한번 Reader 도입에 대해 깊게 생각해보게 되었고, 결론적으로 Reader 를 포기하고 NovelService 에서 직접 UserRepository 를 통해 조회하고 UserNotFound 예외도 직접 던지게 하였다.

물론 이렇게 되면 UserNotFound 라는 User의 도메인 예외를 Novel 도메인이 사용하게 되므로 두 도메인간 결합도가 높아진다고 볼 수 있다. 하지만 이는 이론적인 얘기고, 실용성을 생각해본다면 예외 클래스는 비즈니스 요구사항에 비해 잘 변하지 않으므로 도메인끼리 서로 공유하여도 큰 문제는 없다. 대신 예외 처리 일관성을 유지하기 위해 ‘유저가 존재하지 않는다면 UserNotFound 예외를 던진다’ 같은 컨벤션을 잘 정의해두는 것이 좋을 것이고, 혹시 모를 실수를 방지하기 위해 코드 리뷰 또한 꼼꼼히 하는 것이 좋을 것이다.

반대로 비즈니스 요구사항은 더 빠르게 변할 수 있는데, 만약 Reader 가 다른 도메인의 Repository 와 내 도메인의 Service 사이에 끼어있다면 비즈니스 요구사항에 따라 엔티티 조회 조건이 변경될 때 마다 Repository, Reader, Service 3곳을 수정해야 한다. 심지어 만약 수정 후 특정 Reader의 메서드를 아무도 사용하지 않게 된다면 그 메서드를 삭제까지 해줘야 한다. 이 삭제가 빈번하게 일어난다는 것 자체가 공통화에 실패했다는 증거이다.

그래서 이런 여러 단점을 감수할 만큼의 이익이 있는 지 따졌을 때, 사실 상 아무 이익도 없다고 판단되었다. 그나마 예외 처리가 공통화 된다 정도가 이익이겠지만 예외 클래스는 자주 변하지 않기 때문에 오히려 다른 단점이 더 크다고 보았다.

그래서 결국 Reader 는 도입을 포기하였다. 나중에 정말 자주 공통으로 쓰이는 조회 조건이 있다면 Reader를 도입해볼 가치가 있겠지만, 솔직히 도메인별로 언제나 조회 패턴은 달라질 수 있기 때문에 이런 Reader 계층을 추가로 도입하려면 그만큼의 충분한 복잡도가 있는 프로젝트여야 할 것으로 보인다.

후기

이번에 처음으로 기존 결정을 포기하는 경험을 하였다. 이를 통해 아직 백엔드에 대한 경험은 확실히 부족하다는 것을 느꼈고, 앞으로도 최대한 이런 경험을 많이 하면서 더 좋은 설계 능력을 키워가고 싶어졌다.

특히 트레이드 오프에 대해 더 확실하게 알 수 있는 경험이었고, 겉으로 좋아보이는 설계라고 해도 숨어있는 문제점이 있을 수 있다는 것도 확실하게 배울 수 있었다.