본문 바로가기
ETC

다양한 Application Service 설계 방식들

by devson 2022. 8. 14.

한 서브 도메인의 기능을 담당하는 하나의 Service 클래스

가장 일반적인 방법으로 서브 도메인 관련 기능을 하나의 Service 클래스에서 처리하는 것이다.

@Service
class PostingService(
  private val postingRepository: PostingRepository,
) {
  @Transactional
  fun postPosting(command: PostPostingCommand): Posting {
    val posting: Posting = command.toEntity()
    return this.postingRepository.save(entity = posting)
  }
  
  @Transactional
  fun modifyPosting(command: ModifyPostingCommand): Posting {
    val posting = this.postingRepository.getById(id = command.postingId)
    posting.update(command.toUpdateData())
    return this.postingRepository.save(entity = posting)
  }
  
  @Transactional(readOnly = true)
  fun getPostings(query: GetPostingsQuery): List<Postings> {
    return this.postingRepository.find(query)
  }
  
  // 추가 기능...
}

 

서브 도메인 하나의 응집도 측면에서는 괜찮을 순 있지만 기능이 많아지면 많아질수록 메서드가 계속 추가되어 클래스의 크기가 그만큼 비대해지고,

기능이 많아지면서 해당 Service class가 의존하는 객체들이 계속해서 추가될 것이다.

 

초반에는 간단하고 코드를 읽거나 관리할 때 오버헤드가 가장 적은 방법이지만
길게 보았을 때는 수많은 메서드와 의존성을 가진 하나의 거대한 클래스가 될 것이고
하나의 클래스가 많은 메서드를 가지면 그만큼 코드가 이리저리 꼬일 확률이 높아지며 클래스 내에 특정 기능을 찾는 것도 힘들어지기 때문에
이 방법은 서비스가 커지면서 리팩터링의 대상이 되는 설계 방식이라고 생각한다.

 

operation 타입을 분리한 Service class

앞서 살펴본 하나의 Service class에서 관련된 모든 기능을 처리하는 대신에, operation의 타입에 따라 Service class를 나누는 방식이다.

예를 들자면 아래와 같 CRUD Service를 분리할 수 있을 것이다.

  • PostingCreateService: 데이터 생성 과 관련된 작업을 한다.
  • PostingReadService: 데이터 조회 와 관련된 작업을 한다.
  • PostingUpdateService: 데이터 수정 과 관련된 작업을 한다.
  • PostingDeleteService: 데이터 삭제 와 관련된 작업을 한다.

 

또는 Command, Query로 Service class를 나눌 수 있다.

  • PostingCommandService: 데이터 변경 과 관련된 작업을 한다.
  • PostingQueryService: 데이터 조회 와 관련된 작업을 한다.

 

위와 같이 Service를 나누면 class는 어느정도 작아져 유사한 기능들끼리 응집성이 생긴다.

단일 Service class에서 Service가 무거워지게되면 이런 식으로 operation 타입에 따라 클래스를 분리하는 방식의 리팩터링을 고려할 수 있다.

하지만 해당 도메인 관련 operation이 많아지면 클래스가 비대해질 수 있는 가능성이 여전히 있다.

 

하나의 메서드를 갖는 Service 클래스

주로 Clean Architecture의 예제를 보면 볼 수 있는 방식인데,
서브 도메인이 트랜잭션 내에서 처리해야할 유스케이스를 기준으로 서비스 클래스를 만드는 방식이다.

 

@Service
class PostPostingService(
  private val postingRepository: PostingRepository,
) {
  @Transactional
  fun post(command: PostPostingCommand): Posting {
    val posting: Posting = command.toEntity()
    return this.postingRepository.save(entity = posting)
  }
}

@Service
class ModifyPostingService(
  private val postingRepository: PostingRepository,
) {
  @Transactional
  fun modify(command: ModifyPostingCommand): Posting {
    val posting = this.postingRepository.getById(id = command.postingId)
    posting.update(command.toUpdateData())
    return this.postingRepository.save(entity = posting)
  }
}

@Service
class GetPostingService(
  private val postingRepository: PostingRepository,
) {
  @Transactional(readOnly = true)
  fun get(query: GetPostingsQuery): List<Postings> {
    return this.postingRepository.find(query)
  }
}

 

하나의 유스케이스를 기준으로 클래스가 생성되기 때문에 하나의 메서드를 가지며 그로인해 클래스의 크기 자체가 굉장히 작아진다.

또한 클래스 이름을 보면 어떤 유스케이스를 처리하는지 바로 알 수 있기 때문에 특정 기능을 찾아보기도 쉬워진다.

 

하지만 하나의 유스케이스에 집착을 하여 무턱대고 하나의 메서드 만을 갖는 서비스 클래스를 만든다면,
유사한 기능에 대해 도메인 객체를 묶는 로직이 중복될 수 있다.

예를 들어, 하나의 리소스에 대해 특정 기능을 수행하는 액터가 여럿 있을 수 있고, 각 액터 별로 기능을 수행하는 디테일이 약간씩만 다르다고 했을 때
(e.g. 어드민 유저가 데이터를 수정하는 기능일반 유저가 데이터를 수정하는 기능에 대해 처리할 수 있는 범위가 다를 수 있다)
메서드를 하나만 갖게하려고 서비스 클래스를 다 찢어놓으면 유사한 코드가 중복될 수 있다.

 

클린 아키텍처에서는 추상 유스케이스라는 방식을 얘기하는데, 상위 서비스 클래스를 만드는 방식으로 위 문제를 풀 수 있을 것이다.

(다만 그만큼 코드를 읽을 때 오버헤드가 생길 것이다)

 

같이 읽어보면 좋은 글: (기록) 한 개의 메소드만 갖는 계층형 컨트롤러/서비스 패키지 스타일

 

하나의 operation을 전문적으로 다루는 Service 클래스

딱 정해진 용어가 없는데 하나의 operation이라고 하면 특정 유스케이스와 관련된 작업들을 포함한 작은 상위 개념이라고 생각할 수 있다.

(앞서 살펴본 하나의 메서드를 갖는 Service 클래스 방식의 조금 무거운 버전이라고 볼 수 있다)

 

예를 들어 특정 포스팅을 삭제하는 기능이 있을 때, 글쓴이가 본인의 포스팅을 지울 수도 있지만 운영 상의 문제로 인해 관리자가 지울 수도 있다.

그런 경우 아래와 같이 포스팅 삭제와 관련된 operation을 하나의 Service 클래스에 두는 것이다.

@Service
class DeletePostingService(
  private val postingRepository: PostingRepository,
) {  
  @Transactional
  fun deleteByWriter(command: DeleteByWriterCommand): List<Postings> {
    val deletedPosting = this.deletePosting(id = command.postingId)
    // writer 삭제 특화 작업
    return deletedPosting
  }
  
  @Transactional
  fun deleteByAdmin(command: DeleteByAdminCommand): Postings {
    val deletedPosting = this.deletePosting(id = command.postingId)
    // admin 삭제 특화 작업
    return deletedPosting
  }
  
  private fun deletePosting(id: PostingId): Posting {
    val posting = this.postingRepository.getById(id)
    posting.getDeleted() // 상태 변경 등 삭제 처리
    return this.postingRepository.save(id)
  }
}

 

이렇게 함으로써 해당 operation 관련하여 공통된 로직을 공유하여 중복을 줄일 수 있고 여러 actor가 처리할 수 있는 여지를 남겨둔다.

 


그래서 나는?

나는 개인적으로 클래스는 가볍고 작게 하자는 주의이다.

대신 너무 역할을 작게 갖게 만들어서 불필요한 중복, 중복을 줄이기 위해 추가되는 복잡성을 줄이는 것도 중요하다고 생각을 한다.

 

그래서 나는 하나의 operation을 전문적으로 다루는 클래스를 선호한다.

 

추가로 특정 엔티티의 라이프 사이클이 조금 길어지거나, 상태값이 복잡해지면 관리 상의 편의를 위해 아래와 같이 라이프 사이클에 맞춰서 클래스 패키지를 나누기도 한다.

(하지만 엔티티의 라이프 사이클이 너무 긴 경우엔 하나의 엔티티에 너무 많은 역할과 정보를 담으려고 한 것은 아닌지, 분리할 필요는 없을지 등을 생각해볼 필요는 있다)

 

하지만 뭐든 딱딱 떨어지는 케이스는 없다.

위의 경우도 다른 라이프 사이클 끼리 동일한 기능을 공유해야 하는 경우도 있다. (대표적으로는 조회 기능)

또한 매일 다량의 데이터를 처리해야하는 배치 기능의 경우도 처리 효율을 위해 위의 케이스와는 다르게 접근할 필요가 있을 수 있다.

 

그렇기에 결국은 각자 상황에 맞게 복잡도를 낮추면서 관리가 편한 방식으로 설계 방향이 정해지는게 좋다고 본다.

(참고: Complexity is killing software developers - 원문, Hacker News)

 

또한 가장 중요한 것 중에 하나는 합의이다.

팀에서 설계에 대한 정책과 컨벤션(그게 본인 입맛에 맞든 아니든)이 있다면 이를 준수하고 프로젝트가 일관성있는 코딩 스타일을 지키는 것이 좋다고 본다.

 
 

댓글