본문으로 건너뛰기
2026. 6. 24·약 4분

그 로직은 어디에 살아야 할까요?

파문 한가운데에 놓인 상자 하나

첫 검증 로직을 쓰다가 한참을 멈춰 있었습니다. "복무 기간은 군필이거나 복무 중일 때만 의미가 있다"는 규칙 하나 때문이었습니다. 이걸 모델에 둘까, 서비스에 둘까, 컨트롤러에서 걸러낼까. 사실 프론트엔드에서도 같은 고민을 매일 했습니다. 이 계산은 컴포넌트에 둘까, 훅에 둘까, util에 둘까. 다만 백엔드에는 이 질문을 다루는 오래된 용어와 분류가 있었습니다.

어떤 로직이 모델에 살고, 어떤 로직이 서비스에 살아야 할까요?


이 글에서 다루는 내용​

지난 글까지가 구조와 방향의 이야기였다면 이번에는 그 구조 안에서 코드의 주소를 정하는 이야기입니다. 모델 안에 로직을 얼마나 둘 것인지(rich vs anemic), 서비스가 왜 두 종류로 나뉘는지(도메인 서비스 vs 애플리케이션 서비스), 그리고 권한 검사는 왜 모델에 두지 않는지를 다룹니다. 이번에도 순수 도메인 util, 오케스트레이션 훅, 라우트 가드라는 익숙한 대응물이 그대로 등장합니다.





모델 안의 로직, rich와 anemic​

모델이 순수하다는 것까지는 정리가 됐는데, 그 순수한 모델 안에 로직을 얼마나 둘 것인가는 별개의 질문입니다. 여기에는 두 학파가 있습니다.

  • Anemic(빈약한) 도메인 모델: 모델은 필드만 가진 데이터 봉투고 로직은 전부 서비스에 둡니다. Martin Fowler가 안티패턴이라고 이름 붙인 쪽이지만, 단순 CRUD 도메인에서는 실용적이라는 반론도 만만치 않습니다.
  • Rich(풍부한) 도메인 모델: 자기 데이터에 대한 규칙은 자기가 갖습니다. "엑셀을 밟으면 출발한다"가 자동차 모델 안에 있는 것입니다. 검증(validate)·정규화(normalize) 메서드가 모델에 붙습니다.

서두의 병역 규칙을 rich 스타일로 쓰면 이렇게 됩니다. 규칙과 데이터가 한 클래스에 함께 있습니다.

// 값객체: 자기 데이터의 정합성 규칙을 자기가 안다
data class MilitaryService(
val status: MilitaryStatus = MilitaryStatus.UNKNOWN,
val type: MilitaryType = MilitaryType.UNKNOWN,
val startDate: LocalDate? = null,
val endDate: LocalDate? = null,
) {
// 복무 종류와 기간은 군필/복무중일 때만 의미가 있다.
// 다른 상태로 바뀌면 남아 있는 값을 지운다
fun normalized(): MilitaryService =
if (status == MilitaryStatus.COMPLETED || status == MilitaryStatus.IN_SERVICE) this
else copy(type = MilitaryType.UNKNOWN, startDate = null, endDate = null)
}

이 규칙을 모델 밖의 검증 서비스에 두면 anemic 쪽으로 한 칸 이동한 것입니다. 팀의 선택은 한쪽 끝으로 고정되지 않았습니다. 레포마다 규칙을 두는 위치가 달랐습니다.

Anemic과 Rich 사이의 스펙트럼

프론트엔드에서 plain object와 별도 검증 함수를 둘지, .validate() 메서드를 가진 객체를 둘지 고민하는 것과 비슷합니다. 여기서 고민하는 것은 병역의 필드와 그 필드를 검사하는 코드를 한곳에 둘지입니다.




서비스는 두 종류입니다​

"로직은 서비스에"라고 말할 때의 서비스가 사실 한 종류가 아니라는 것이 이번 온보딩에서 배운 가장 유용한 구분이었습니다.

  • 도메인 서비스(domain service): 특정 엔티티 하나에 자연스럽게 귀속되지 않는 도메인 로직입니다. 교과서 예시는 두 계좌 사이의 이체 규칙입니다. 출금 계좌의 메서드로 두기도, 입금 계좌의 메서드로 두기도 애매한 규칙이라 별도의 서비스가 됩니다. 무상태이고 업무 언어로 이름이 지어집니다.
  • 애플리케이션 서비스(application service): 도메인 로직을 갖지 않습니다. 트랜잭션 경계, 권한 확인, 도메인 객체와 리포지토리를 부르는 순서, 다른 도메인 호출의 조율, 오케스트레이션이 전부입니다. 지난 글들의 "유즈케이스"가 대체로 이 층에 해당합니다.
// 애플리케이션 서비스: 도메인 로직 없이 흐름만 지휘한다
class UpdateUserProfile(
private val repository: UserProfileRepository,
private val permissionChecker: PermissionChecker,
) {
@Transactional
fun execute(actorId: UserId, command: UpdateProfileCommand) {
permissionChecker.check(actorId, MANAGE_PROFILE) // 권한
val profile = repository.find(command.userId) ?: throw ProfileNotFound()
profile.update(command.changes) // 규칙은 모델에 위임한다
repository.save(profile) // 순서와 트랜잭션만 책임진다
}
}

둘을 가르는 판별 질문이 있습니다.

판별 질문

"이 로직을 트랜잭션·권한·API 같은 기술 용어 없이, 업무 언어만으로 설명할 수 있는가?"

업무 규칙이라면 먼저 도메인 모델이나 도메인 서비스에 둘지 검토합니다. 그중 특정 엔티티나 값객체에 자연스럽게 속하지 않는 규칙이 도메인 서비스의 후보입니다. 유즈케이스의 실행 순서와 기술적 조율은 애플리케이션 서비스가 맡습니다.

calculateProrated(salary, days)처럼 React나 API를 모르는 계산 함수는 도메인 서비스의 역할을 떠올리게 합니다. 데이터를 가져오고 검사를 거쳐 저장하는 순서를 묶는 코드는 애플리케이션 서비스에 가깝습니다. 계산 자체와 그 계산을 실행할 준비를 구분하면 두 역할을 나누기 쉽습니다.




모델은 페르소나를 모릅니다​

위의 애플리케이션 서비스 코드에서 권한 확인이 첫 줄에 있었습니다. 이 절에서 다루는 것은 보는 사람에 따라 어떤 필드를 공개할지 결정하는 인가(authorization)입니다. 병역 모델에는 병역의 개념과 규칙을 두고, 조회자의 권한에 따른 노출 제어는 API와 애플리케이션 서비스에서 처리합니다.

페르소나가 늘어날 때 이 원칙의 효과가 드러납니다. 평가 도메인처럼 보는 사람이 많은 곳(평가자, 피평가자, 관리자)을 상상해 보면, 페르소나별로 보이는 것이 달라도 모델은 하나로 유지됩니다. 차이는 전부 모델 바깥, API와 애플리케이션 서비스에서 표현됩니다. 조회 응답에서 권한 없는 필드를 서버가 지워서 내려주는 필드 단위 마스킹도 같은 층에서 처리합니다.

// 애플리케이션 레이어: 권한이 없으면 민감 필드를 빈값으로 교체해 내려준다
val masked =
if (canViewSensitive(actor)) profile
else profile.copy(military = MilitaryService()) // 모델은 이 사정을 모른다

프론트엔드에서도 상위 컨테이너가 노출할 데이터를 고르고 프레젠테이션 컴포넌트에는 표시할 값만 전달할 수 있습니다. 권한별 표시와 데이터 자체의 규칙을 나눈다는 점이 닮았습니다.

"작성자만 수정할 수 있다"처럼 업무 행위의 조건인 권한 규칙은 도메인에 둘 수도 있습니다. 필드 노출 제어와 함께 하나의 위치로 묶기보다, 해당 규칙이 어떤 작업을 제한하는지 보고 정합니다.

그런데 "권한을 어디에 거는가"를 더 따져 들어가면 API 자체의 종류 이야기가 됩니다. 고객이 부르는 API와 어드민이 부르는 API, 그리고 다른 서버가 부르는 API는 권한을 다르게 적용합니다. 다음 글에서 다루겠습니다.




FE ↔ BE 대응표​

이 글에서 다룬 대응입니다. 1편·2편의 표에 이어집니다.

BE 개념FE에서 가장 가까운 것대응의 핵심
rich 도메인 모델메서드를 가진 도메인 객체자기 데이터의 규칙은 자기가
anemic 모델 + 서비스plain object + util 함수들데이터와 로직의 분리
도메인 서비스순수 도메인 util프레임워크를 모르는 업무 규칙
애플리케이션 서비스컨테이너 · 오케스트레이션 훅조율만 하고 로직은 위임
필드 노출 인가라우트 가드 · 권한 훅서버에서 접근을 통제하고 화면에서는 표시를 조절



References​

도메인 모델과 로직의 자리

  1. Martin Fowler: AnemicDomainModel
  2. Eric Evans: Domain-Driven Design Reference

서비스 레이어

  1. Martin Fowler: Service Layer (P of EAA)


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자