service와 respository는 1:1로 매핑하여 가능한 책임의 분할로 다른 비즈니스 로직의 영향을 받지 않게 하는 것이 좋다.
간단한 게시판을 생각해보자
하나의 게시글은, 여러 댓글을 가질 수 있다. 따라서 게시글과 댓글은 1:N으로 매핑되어 있다.
댓글을 추가하고자 한다면, Post의 정보를 가져와야 하므로 CommentService에서, PostRepository를 참조하게 된다.
그냥 다른 Repository를 의존하는 방법이다.
class PostService(
private val postRepository: PostRepository,
private val memberRepository: MemberRepository,
private val boardRepository: BoardRepository,
) {
fun createPost(postRequestDto: PostRequestDto) : PostResponseDto? {
val foundBoard = boardRepository.findByIdOrNull(postRequestDto.boardId) ?: throw RuntimeException("존재하지 않는 게시판입니다.")
val username = (SecurityContextHolder.getContext().authentication.principal as CustomUser).username
val foundMember = memberRepository.findByEmail(username)
return postRepository.save(postRequestDto.toEntity(foundBoard, foundMember)).toResponseDto()
}
}
위 코드에서는 post를 저장하기 위해 board와 member 정보를 필요로 한다. 간단한 예제라 문제가 없어 보이지만 service가 추가되어 여러 repository를 참조해야하는 경우 코드가 복잡해질 수 있고, 테스트 코드를 구현하는 경우에는 의존중인 모든 repository를 넣어줘야한다.
위와 같이 비즈니스 로직이 없다면 Repository를, 비즈니스 로직이 필요하다면 코드 중복 제거를 위해 Service를 의존해 사용해도 괜찮다였다.
다른 Repository에서 가져온 domain을 쉽게 사용가능 하기 때문에 작은 프로젝트에서는 오히려 개발하는데 용이하다.
다른 service를 의존하는 방법이다.
class PostService(
private val postRepository: PostRepository,
private val memberService: MemberService,
private val boardService: BoardService,
) {
fun createPost(postRequestDto: PostRequestDto) : PostResponseDto? {
val foundBoard = boardService.findById(postRequestDto.boardId) ?: throw RuntimeException("존재하지 않는 게시판입니다.")
val username = (SecurityContextHolder.getContext().authentication.principal as CustomUser).username
val foundMember = memberService.findByEmail(username)
return postRepository.save(postRequestDto.toEntity(foundBoard, foundMember)).toResponseDto()
}
}
이 경우도 여러 service가 필요해지는 경우, 해당 service를 모두 주입해야 하고 그러면 순환 참조가 발생할 가능성이 커진다.
또한 하나의 Service가 다른 domain도 관리하게 되어 Service의 책임이 불분명해지기 때문에 역할이 커질 수 있고, 불필요한 코드 중복이 발생할 수 있다.
위와 같이 서비스에서 서비스를 주입받아도 된다고 답변하고 있다. 서비스를 쪼개서 순환 참조가 되지 않도록 방지하면 된다.
만약 서비스 계층 간 순환참조가 일어나게 되면 그것은 잘못된 설계를 하고 있다는 것을 내포한다고 생각하고 적절히 책임을 이전하여 개선해야 한다.
class PostController(
private val postService: PostService,
private val memberService: MemberService,
privale val boardService: BoardService,
) {
@GetMapping("/")
fun getPosts(@RequestBody postRequestDto: PostRequestDto): BaseResponseDto<PostResponseDto>? {
val member = memberService.getMember(postRequestDto.memberId)
val board = memberService.getMember(postRequestDto.boardd)
val post = postService.getPost(postRequestDto.memberId, postRequestDto.boardId, postRequestDto.postId) ?: return null
return BaseResponseDto(data = post)
}
}
위 구조에는 치명적인 결함이 있다. 여러 서비스를 하나의 트랜잭션으로 묶을 수 없다는 것이었다.
조회의 경우에는 큰 문제가 없을 수 있겠지만, 여러 개의 서비스를 통해 저장하는 로직이 연속적으로 수행되는 경우 문제가 발생할 수 있다.
그러므로 Controller에서 여러 Service를 의존하는 것은 트랜잭션이 보장되지 않으므로 추천하지 않는 방법이다.
class PostManageService(
private val postService: PostService,
private val memberService: MemberService,
private val boardService: BoardService,
) {
fun createPost(postRequestDto: PostRequestDto) : PostResponseDto? {
val foundBoard = boardService.findById(postRequestDto.boardId) ?: throw RuntimeException("존재하지 않는 게시판입니다.")
val username = (SecurityContextHolder.getContext().authentication.principal as CustomUser).username
val foundMember = memberService.findByEmail(username)
return postService.save(postRequestDto.toEntity(foundBoard, foundMember)).toResponseDto()
}
}
관련된 여러 Service를 하나의 Service에서 관리하여 순환 참조 및 코드 중복을 모두 해결할 수 있는 방법이다.
규모가 큰 프로젝트에서 적합하다고 볼 수 있다.
파사드 패턴(Pacade Pattern)을 사용하여 적용할 수 있다.
퍼사드 패턴은 클라이언트는 복잡하게 얽혀있는 서브시스템은 모른 채 Facade 객체에만 의존하는것이다.
우리 예시로 따지면, 하나의 서비스가 의존할 여러 Service를 가지고 우리가 만들 Service에서 필요한 메소드를 가지게 되는, 일종의 조합 전용 객체를 만드는 방법이다.
따라서 서비스는 Facade 객체(PostManageService)만 의존하게 되고, Facade 객체는 사용할 Service들을 주입받는 형태로 구성된다.
정답은 없다.
큰 프로젝트에각 모듈의 책임 분리 없이 구현한다면 나중에 유지보수에 많은 비용을 들일 것이며, 작은 프로젝트에 파사드 패턴을 적용하는 것은 배보다 배꼽이 커진다고 생각한다.
각 프로젝트 상황 및 규모에 맞게 방법을 선택해서 적용하면 된다.
출처