Octoping의 블로그
article thumbnail

시작하기 전에

계층형 아키텍처에서 개발을 진행할 때 우리는 보통 Controller, Service, Repository 이렇게 3개의 계층의 구조로 짜게 된다.

이 3개의 계층은 Controller에서 Service로, Service에서 Repository로 의존성이 흐르는데, 이 중에서도 Repository 부분을 보통 JpaRepository나 Mybatis mapper 같은 클래스로 직접 의존성으로 사용하는 경우가 많다.

하지만 Service 계층에서 바로 이런 클래스들에 의존하게 될 경우 몇 가지 문제가 생기게 된다.

이 글에서는 쉬운 예제 작성을 위해 JpaRepository를 사용하겠다.

Service에서 바로 JpaRepository를 의존할 때 생기는 문제

Service 계층이 세부 사항에 의존하게 된다

Service 계층의 입장에서, JPA라는 세부 기술에 의존하는 것은 썩 즐거운 상황이 아니다.

Service 계층이 Repository 계층에게 사실 진정으로 바라는 것은, 영속성이 있는 그 어떤 무언가에서 내가 원하는 데이터를 가져오는 것이다. 다시 말해, Service 계층은 Repository가 데이터를 JPA를 통해 DB에서 가져오든, Mybatis를 통해서 가져오든, 심지어 txt 파일을 읽어서 가져오든 전혀 상관할 바가 아니다.

Service 계층은 JPA에 의존성을 가짐으로써, 추후 Repository 계층을 Mybatistxt 파일로 바꿔야 할 때 간단하게 Repository의 구현체만 바꿔 끼울 수 없게 되고 Repository를 아예 전부 들어내고 다시 만들어야 하는 상황에 놓인다.

테스트 코드를 짤 때 곤란함이 생긴다

Service 계층에서 Repository 계층으로써 곧바로 JpaRepository에 의존할 경우, Service 계층의 테스트를 작성할 때 Repository를 모킹하는 것이 무지 어려워진다.

JPA를 사용해서 Repository를 구현하려면, JpaRepository라는 인터페이스를 상속받는 인터페이스를 만들어야 한다.

이런 인터페이스의 가짜 클래스를 만드려면, 우리는 우리가 정말 테스트에 필요한 메소드만 가짜로 구현하는 것만으로만 해결할 수가 없고, JpaRepository에 존재하는 모든 메소드를 구현해야만 한다.

@Repository
interface PostRepository extends JpaRepository<Post, Long> {

}

다음과 같은 클래스가 있다고 하자. 이 PostRepository의 가짜 구현체인 FakePostRepository를 구현하기 위해서는 다음과 같이 어마어마한 개수의 메소드를 모두 구현해야 한다.

ㅎㄷㄷ

이렇게 되면 나는 테스트에 PostRepository::find만 필요했지만, 너무나도 많은 양의 메소드를 억지로 구현해야만 한다.

그렇게 된다면 테스트 코드 베이스의 복잡도가 굉장해지므로, 테스트 코드의 유지보수가 어려워진다. 또, 어떤 메소드가 진짜 제대로 모킹되었는지 알기가 어렵기 때문에 추후 테스트 코드를 추가로 작성할 때 버그가 발생할 가능성이 높다.

물론 보통 이렇게 모킹할 메소드가 많다는 것을 눈으로 보면, 마음을 싹 접고 Mockito와 같은 모킹 툴을 이용해서 모킹 클래스를 만들어버리고 만다.

@Test
public void test() {
    // given
    PostRepository mockedRepository = Mockito.mock(PostRepository.class);
    PostService postService = new PostService(mockedRepository);

    // when
    // ...
}

하지만 이게 정말 합리적인 선택일까? 내 눈에는 Mockito가 너무 강력한 툴이어서, 프로젝트에 있는 아키텍처의 문제점을 그저 눈에 보이지 않도록 덮어준 것에 불과하다고 생각한다.

도메인 객체와 JPA Entity 객체가 다를 경우 Service 계층이 매핑의 역할를 가진다

엄격한 도메인 주도 개발의 관점에서, 도메인 객체와 JPA Entity는 분리되어야 한다. 왜냐하면 JPA Entity는 DB의 테이블을 객체화한 클래스 그 자체이기 때문에 소프트웨어에 있어서 엄연히 세부 사항에 불과하다.

이와 같은 이유로 도메인 객체와 JPA Entity를 분리하여 사용할 경우, JpaRepositoryfindfindAll과 같은 조회 연산의 결과로 JPA Entity를 반환하게 되고, 반대로 save같은 추가/수정 연산의 매개변수로 JPA Entity를 넣어주어야만 한다.

이렇게 될 경우 Service 계층이 직접 도메인 객체와 JPA Entity를 매핑하는 책임을 가지게 되는데, 아까 말했듯이 JPA Entity는 그저 세부 사항에 불과하다. 그러므로 이런 행위는, Service 계층이 우리의 DB의 테이블이 어떻게 생겼는지 간접적으로 알게 되는 행위이다.

어떻게 개선해야 할까

간단하다. ServiceJpaRepository의 사이에 새로운 계층을 넣어주면 된다.

Service가 바로 JPA나 mybatis mapper 등에 의존하는 것이 아니라, interface에 의존하면 된다.

@Service
@RequiredArgsConstructor
class PostService {
    private final PostRepository postRepository;
    // ...
}

@Repository
interface PostRepository {
    Optional<Post> findById(Long id);
    void save(Post post);
}

다음은 게시판에서 글에 대한 로직을 다루는 Service와 Repository 코드다.

PostServicePostRepository라는 인터페이스에 의존하도록 작업하였다.

이렇게 변경한 것만으로 어떤 일이 벌어지는지 알아보자.

Service가 사용하는 Repository의 기술을 쉽게 교체할 수 있다

/* JPA */
@Repository
@RequiredArgsConstructor
class PostJpaRepositoryImpl implements PostRepository {
    private final PostJpaRepository postJpaRepository;

    @Override
    public Optional<Post> findById(Long id) {
        return postJpaRepository.findById(id);
    }

    @Override
    public void save(Post post) {
        postJpaRepository.save(post);
    }
}

@Repository
interface PostJpaRepository extends JpaRepository<Post, Long> {

}

/* Mybatis */
@Repository
class PostMybatisRepository implements PostRepository {
    private final PostMapper postMapper;

    @Override
    public Optional<Post> findById(Long id) {
        return Optional.ofNullable(postMapper.findById(id));
    }

    @Override
    public void save(Post post) {
        postMapper.save(post);
    }
}

Post를 가져오는 Repository는 PostRepository라는 인터페이스의 메소드인, findByIdsave만 가지고 있다면 그 어떤 Repository든 자리를 대신할 수 있다.

그렇기 때문에, PostJpaRepository든, PostMybatisRepository든 무엇이든 간에 주입될 Bean만 바꾸어준다면 코드 베이스의 수정 없이도 언제든 의존성을 교체할 수 있다.

테스트 코드를 작성할 때 쉽다

Service가 JpaRepository를 직접 의존할 때에는 JpaRepository의 모든 메소드를 구현해주어야 하지만, 지금은 PostRepository가 갖고 있는 2개의 메소드만 구현해주면 된다.

class PostFakeRepository implements PostRepository {
    @Override
    public Optional<Post> findById(Long id) {
        return Optional.of(new Post(id, "제목", "본문"));
    }

    @Override
    public void save(Post post) {
        // do nothing
    }
}

아주 아주 간단하게 Mockito 없이도 PostRepository의 가짜 클래스를 만들어낼 수 있었다.

도메인 객체와 JPA Entity 객체의 매핑을 Repository가 할 수 있다

PostJpaRepository는 JPA Entity를 반환하겠지만, PostJpaRepositoryImpl이 그 반환된 JPA Entity를 받아서 매핑해주면 된다.

@Repository
@RequiredArgsConstructor
class PostJpaRepositoryImpl implements PostRepository {
    private final PostJpaRepository postJpaRepository;
    private final PostMapper postMapper;

    @Override
    public Optional<Post> findById(Long id) {
        return postJpaRepository.findById(id)
                .map(postMapper::mapToDomain);
    }

    @Override
    public void save(Post post) {
        PostEntity postEntity = postMapper.mapToEntity(post);
        postJpaRepository.save(postEntity);
    }
}

@Repository
interface PostJpaRepository extends JpaRepository<PostEntity, Long> {

}

다시 말해서 이런 식으로 PostJpaRepositoryImplPostJpaRepository에게 PostEntity를 받아서 Post로 반환한 뒤 return 해주면 된다.

이렇게 되면 매핑이라는 책임을 Service 계층이 가져갈 필요 없이, Repository가 처리할 수 있다.

조금 더 고쳐보자

하나의 큰 서비스를 유스케이스 별로 쪼갤 수 있다

게시판에는 글 조회, 추가도 있겠지만 글과 관련된 더 많은 유스케이스가 존재한다.

글 삭제, 수정 등등 여러가지 유스케이스가 더 추가되었다고 할 때, 이 모든 로직이 PostService 클래스에 담기는 것은 한 클래스의 책임이 너무 과중해지는 것이기도 하다.

따라서 유스케이스에 따라 Service 계층을 쪼개보도록 하자.

@Service
@RequiredArgsConstructor
class PostUpdateService {
    private final PostRepository postRepository;
    // ...
}

다음은 PostService를 유스케이스별로 쪼개서 나온 결과물 중에 하나인, PostUpdateService이다.

그런데 PostUpdateService는 글의 수정만 담당하지만 PostRepository는 글의 조회, 수정, 삭제 등 모든 로직이 들어가있을 것이다.

이 경우 PostUpdateService는 Repository의 메소드 딱 하나만 사용하더라도 하나의 넓은 인터페이스에 의존성을 가지게 된다. 코드에 불필요한 의존성이 생겼다는 뜻이다.

그렇다면 위에서 언급했던 테스트 코드 작성이 어렵다는 부분에서 발생했던 문제가 또 존재하는 셈이다.

인터페이스 분리 원칙으로 원치 않는 관심사를 분리할 수 있다

인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.
인터페이스 분리 원칙은 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다.

 

이미지 출처: https://hankookilbo.com/News/Read/201509020581680482

노인용 리모컨을 본 적 있는가? 일상적인 리모컨에서 통상적으로 필요하지 않은 버튼을 제거한 리모컨이다.

우리의 리모컨에는 너무 버튼이 많아서 전자기기에 서툰 노인 분들께서 사용에 큰 어려움을 겪고 있다. 기능이 너무 많다는 것은 오히려 사용자에게 있어 혼란을 가중시킬 수 있다는 의미이다.

우리의 문제는 PostRepository라는 인터페이스가 우리가 이용하지 않을 메소드도 제공하고 있다는 점이다. PostUpdateService에게 필요한 것은 오직 save 하나 뿐이다. 거대한 인터페이스 하나를 잘게 쪼개어, 클라이언트가 꼭 필요한 메소드가 존재하는 인터페이스들로 분리해보자.

@Repository
interface PostUpdateRepository {
    Optional<Post> findById(Long id);
    void save(Post post);
}

@Repository
interface PostDeleteRepository {
    Optional<Post> findById(Long id);
    void deleteById(Long id);
}

@Repository
interface PostRepository extends PostUpdateRepository, PostDeleteRepository {
    Optional<Post> findById(Long id);
    void save(Post post);
    void deleteById(Long id);
}

PostRepository라는 거대한 인터페이스를 잘게 쪼개서 PostUpdateRepository, PostDeleteRepository라는 작은 인터페이스 여러 개로 나누어주었다.

@Service
@RequiredArgsConstructor
class PostUpdateService {
    private final PostUpdateRepository postUpdateRepository;
    // ...
}

@Service
@RequiredArgsConstructor
class PostDeleteService {
    private final PostDeleteRepository postDeleteRepository;
    // ...
}

그렇다면 사용하는 Service 계층 입장에서는 본인이 필요한 인터페이스만 사용할 수 있다.

마치며

우리는 이렇게 Service 계층과 JpaRepository 계층 사이에 추가 계층을 덧씌움으로써 Service 계층이 세부사항에 의존하는 문제를 해결하였고, Service를 유스케이스로 분리함과 동시에 Repository도 유스케이스에 맞게 잘게 쪼갠 인터페이스로 만듬으로써 SRP와 ISP를 모두 만족하는 아름다운 아키텍처를 만들 수 있었다.

'개발 일반 > 아키텍처' 카테고리의 다른 글

유스콘22: Introduce to Clean Architecture 정리  (0) 2023.04.13
profile

Octoping의 블로그

@Octoping

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!