도메인 주도 개발 시작하기 (6) - 응용 서비스와 표현 영역

Jaewoo Ha·2022년 12월 11일
0

6.1 표현 영역과 응용 영역

  • 도메인 영역을 구현을 잘 한다고 소포트웨어 구현이 끝나는 것은 아니다.
  • 도메인이 제 기능을 하려면 사용자와 도메인을 연결해 주는 매개체가 필요하다.

표현 영역

  • 사용자의 요청을 해석한다.
  • 요청을 받은 표현 영역은 URL, 요청 파라미터, 쿠키, 헤더 등을 이용해서 사용자가 실행하고 싶은 기능을 판별하고 그 기능을 제공하는 응용 서비스를 실행한다.
  • 응용 서비스가 요구하는 형식으로 사용자 요청을 변환한다.
  • 응용 서비스를 실행한 뒤에 표현 영역은 실행 결과를 사용자에게 알맞은 형식으로 응답한다.
  • 사용자와 상호작용을 처리한다.

사용자가 원하는 기능을 제공하는 것은 응용 영역에 위치한 서비스다. 회원 가입을 요청했다면 실제 그 요청을 위한 기능을 제공하는 주체는 응용 서비스에 위치한다.

응용 영역

  • 기능을 실행하는 데 필요한 입력 값을 메서드 인자로 받고 실행 결과를 리턴한다.
  • 표현 영역에 의존하지 않는다.
  • 기능 실행에 필요한 입력 값을 받고 실행 결과만 리턴한다.

6.2 응용 서비스의 역할

  • 사용자가 요청한 기능을 실행한다.
  • 사용자의 요청을 처리하기 위해 레포지터리에서 도메인 객체를 가져와 사용한다.
  • 표현 영역에서 보았을 때 응용 서비스는 도메인 영역과 표현 영역을 연결해 주는 창구 역할을 한다.

주로 도메인 객체 간의 흐름을 제어하기 때문에 단순한 형태를 갖는다.

fun doSomeFunc(req: SomeReq): Result {
	// 1. 레포지터리에서 애그리거트를 구한다.
	val agg = someAggRepository.findById(req.id)
    checkNull(agg)
    
    // 2. 애그리거트의 도메인 기능을 실행한다.
    agg.doFunc(req.value)
    
    // 3. 결과를 리턴한다.
    return createSuccessResult(agg)
}

새로운 애그리거트를 생성하는 응용 서비스 또한 간단하다.

fun doSomeCreation(req: CreateSomeReq): Result {
	// 1. 데이터 유효성 검사
    validate(req)
    
    // 2. 애그리거트 생성
    val newAgg = createSome(req)
    
    // 3. 레포지터리에 애그리거트 저장
    someAggRepository.save(newAgg)
    
    // 4. 결과 리턴
    return createSuccessResult(newAgg)
}

역할

  • 트랜잭션 처리. 도메인의 상태 변경을 트랜잭션으로 처리한다.
  • 접근 제어와 이벤트 처리를 한다.

주의할 점

  • 응용 서비스가 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 있다. 도메인 로직을 일부 구현하면 코드 중복, 로직 분산 등 코드 품질에 안 좋은 영향을 줄 수 있다.

6.2.1 도메인 로직 넣지 않기

  • 도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않는다.
class ChangePasswordService(private val memberRepository: MemberRepository) {
    
    fun changePassword(memberId: String, oldPw: String, newPw: String) {
        val member = memberRepository.findById(memberId)
        checkMemberExists(member)
        member.changePassword(oldPw, newPw)
    }
}

위 코드처럼 응용 서비스는 Member 애그리거트와 관련 레포지터리를 이용해서 도메인 객체 간의 실행 흐름을 제어한다.

class Member(
    var pw: String
) {

    private var password: String = pw

    fun changePassword(memberId: String, oldPw: String, newPw: String) {
        if (!matchPassword(oldPw)) throw BadPasswordException()
        setPassword(newPw)
    }

    fun matchPassword(pwd: String): Boolean {
        return passwordEncoder.matches(pwd)
    }

    fun setPassword(newPw: String) {
        if (newPw.isEmpty()) throw IllegalArgumentException("no new password")
        this.password = newPw
    }
}

Member는 암호를 변경하기 전에 기존 암호를 올바르게 입력했는지 확인한다.

암호를 올바르게 입력했는지를 확인하는 것은 도메인의 핵심 로직이기 때문에 응용 서비스에서는 로직을 구현하면 안된다.

도메인 로직이 포함되면 안되는 이유

  • 코드의 응집성이 떨어진다.
  • 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.
  • 변경 용이성이 떨어진다.

6.3 응용 서비스의 구현

  • 도메인 영역과 표현 영역을 연결하는 매개체 역할을 하는 응용 서빗는 디자인 패턴에서 파사드와 같은 역할을 한다.

6.3.1 응용 서비스의 크기

  • 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
  • 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기

회원과 관련된 기능을 한 클래스에서 모두 구현하면 다음과 같은 모습을 갖는다.

class MemberService(
    private val memberRepository: MemberRepository
) {
    
    fun join(joinRequest: MemberJoinRequest) {...}
    
    fun changePassword(memberId: String, curPw: String, newPw: String) {...}
    
    fun initializePassword(memberId: String) {...}
    
    fun leave(memberId: String, curPw: String) {...}
}

장점

  • 동일 로직에 대한 코드 중복을 제거할 수 있다.
class MemberService(
    private val memberRepository: MemberRepository
) {

    fun join(joinRequest: MemberJoinRequest) {
        val member = findExistingMember(memberId)
        member.changePassword(currentPw, newPw)
    }

    fun changePassword(memberId: String, curPw: String, newPw: String) {
        val member = findExistingMember(memberId)
        val newPassword = member.initializePassword()
        notifier.nofiryNewPassword(member, newPassword)
    }

    fun leave(memberId: String, curPw: String) {
        val member = findExistingMember(memberId)
        member.leave()
    }

	// 중복 코드 제거
    private fun findExistingMember(memberId: String): Member {
        return memberRepository.findById(memberId).orElseThrow {throw NotFoundException()}
    }
}

단점

  • 한 서비스 클래스의 크기가 커진다. 연관성이 적은 코드가 한 클래스에 함께 위치할 가능성이 높아지게 되면서 관련 없는 코드가 뒤섞여 코드를 이해하는 데 방해가 된다.
  • 습관적으로 기존에 존재하는 클래스에 억지로 끼워 넣기 되면서 코드 품질을 낮춘다.

개선 방법

  • 서비스 클래스를 구현하는 방식은 한 응용 서비스 클래스에서 한 개 내지 2~3개의 기능을 구현한다.
class ChangePasswordService(private val memberRepository: MemberRepository) {

    fun changePassword(memberId: String, oldPw: String, newPw: String) {
        val member = memberRepository.findById(memberId).orElseThrow { throw NotFoundException() }
        member.changePassword(memberId, oldPw, newPw)
    }
}
  • 기능 별로 응용 서비스를 나눈다면 클래스 개수는 많아지지만 한 클래스에 관련 기능을 모두 구현하는 것과 비교해서 코드 품질을 일정 수준으로 유지할 수 있다. 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.

6.3.2 응용 서비스의 인터페이스와 클래스

interface ChangePasswordService {
    fun changePassword(memberId: String, curPw: String, newPw: String)
}

class ChangePasswordImpl: ChangePasswordService {...}

인터페이스가 필요한 상황

  1. 구현 클래스가 여러 개인 경우
  2. TDD로 먼저 개발을 하고 표현 영역부터 개발을 시작하는 경우
  3. 도메인 영여깅나 응용 영역의 개발을 먼저 시작한다면 표현 영역의 단위 테스트를 위해 응용 서비스 클래스의 가짜 객체가 필요한 경우

주의할 점

  • 응용 서비스는 런타임에 교체할 경우가 거의 없으며, 한 응용 서비스의 구현 클래스가 두 개인 경우가 드물어 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 선택은 아니다.

6.3.3 메서드 파라미터와 값 리턴

  • 응용 서비스가 제공하는 메서드는 도메인을 이용해서 사요자가 요구한 기능을 실행하는 데 필요한 값을 파라미터로 전달 받아야 한다.

파라미터로 값을 전달받을 수 있도록 데이터 클래스를 만들어 전달을 받을 수 있다.

data class ChangePasswordRequest(
    private val _memberId: String,
    private val _currentPassword: String,
    private val _newPassword: String
) {
    val memberId: String
        get() = _memberId.ifEmpty { "" }

    val currentPassword: String
        get() = _currentPassword.ifEmpty { "" }

    val newPassword: String
        get() = _newPassword.ifEmpty() { "" }
}

응용 서비스는 파라미터로 전달받은 데이터를 사용해서 필요한 기능을 구현한다.

class ChangePasswordServiceImpl(private val memberRepository: MemberRepository): ChangePasswordService {

    override fun changePassword(req: ChangePasswordRequest) {
        val memberId = req.getMemberId()
        val member = memberRepository.findById(memberId).orElseThrow { throw NotFoundException() }
        member.changePassword(memberId, req.getCurrentPassword(), req.getNewPassword())
    }
}

6.3.4 표현 영역에 의존하지 않기

  • 표현 영역과 관련된 타입을 사용하면 안 된다.
@Controller
@RequestMapping("/member/changePassword")
class MemberPasswordController {
    
    @PostMapping
    fun submit(request: HttpServletRequest): String {
        try {
            // 응용 서비스가 표현 영역을 의존하면 안된다.
            changePasswordService.changePassword(request)
        } catch (ex: NotFoundException) {
            // Error handling
        }
    }
}

표현 영역에 의존할 때 발생하는 문제점

  1. 응용 서비스에서 표현 영역에 대한 의존이 발생하면 응용 서비스만 단독으로 테스트하기가 어려워진다.
  2. 표현 영역의 구현이 변경되면 응용 서비스의 구현도 함께 변경해야 된다.
  3. 표현 영역의 응집도가 깨지면서 유지 보수 비용을 증가시킨다.

대처 방안

  • 서비스 메서드의 파라미터와 리턴 타입으로 표현 영역의 구현 기술을 사용하지 않는다.

6.3.5 트랜잭션 처리

  • DB에 데이터가 반영이 되지 않는다면 응용 서비스의 역할을 하지 않는 것이다.

스프링에서 제공하는 트랜잭션 관리 기능을 사용한다.

open class ChangePasswordServiceImpl(private val memberRepository: MemberRepository):
    ChangePasswordService {

    @Transactional
    override fun changePassword(req: ChangePasswordRequest) {
        val memberId = req.memberId
        val member = memberRepository.findById(memberId).orElseThrow { throw NotFoundException() }
        member.changePassword(memberId, req.currentPassword, req.newPassword)
    }
}

6.4 표현 영역

  • 사용자가 시스템을 사용할 수 있는 흐름을 제공하고 제어
  • 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공
  • 사용자의 세션을 관리

사용자가 시스템을 사용할 수 있도록 알맞은 흐름을 제공

  • 웹 서비스의 표현 영역은 사용자가 요청한 내용을 응답으로 제공. ex) 링크, 데이터 입력 폼
  • 요청을 처리하고 결과를 응답으로 전송

사용자의 요청에 맞게 응용 서비스에 기능 실행을 요청

  • 필요한 데이터를 읽거나 도메인의 상태를 변경해야 할 떄 응용 서비스를 사용
  • 사용자의 요청 데이터를 응용 서비스가 요구하는 형식으로 변환하고 응용 서비스의 결과를 사용자에게 응답할 수 있는 형식으로 변환

사용자의 연결 상태인 세션을 관리

  • 웹은 쿠키나 서버 세션을 이용해서 사용자의 연결 상태를 관리
  • 권한 검사

6.5 값 검증

  • 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행
  • 모든 값에 대한 검증은 응용 서비스에서 처리
open class JoinService(private val memberRepository: MemberRepository) {

    @Transactional
    open fun join(joinReq: JoinRequest) {
        checkEmpty(joinReq.id, "id")
        checkEmpty(joinReq.name, "name")
        checkEmpty(joinReq.password, "password")
        if (joinReq.password.equals(joinReq.getConfirmPassword()))
            throw IllegalArgumentException("confirmPassword")
    }

    private fun checkEmpty(value: String?, propertyName: String) {
        if (value.isNullOrEmpty()) throw IllegalArgumentException(propertyName)
    }
    
    private fun checkDuplicatedId(id: String) {
        val count = memberRepository.countsById(id)
        if (count > 0) throw IllegalArgumentException()
    }
}

표현 영역은 잘못된 값이 존재하면 이를 사용자에게 알려주고 값을 다시 입력받아야한다. 스프링 MVC는 에러 메세지를 보여주기 위한 용도로 ErrorsBindingResult를 사용하는데 컨트롤러에서 응용 서비스를 사용하면 폼에 에러 메세지를 보여주기 위해 번잡한 코드를 작성해야 된다.

대처 방안

  1. 에러 코드를 모아 하나의 익셉션으로 발생 시킨다.
  2. 표현 영역에서 필수 값을 검증한다.
  3. 값 검증을 위한 Validator 인터페이스를 사용하여 검증기를 따로 구현한다.

표현 영역과 응용 서비스의 값 검증 범위

  • 표현 영역: 필수 값, 값의 형식, 범위 등을 검증
  • 응용 서비스: 데이터의 존재 유무와 같은 논리적 오류 검증

6.6 권한 검사

  • 단순한 시스템은 인증 여부만 검사하면 되는데 반해, 관리자인지에 따라 사용할 수 있는 기능이 달라지기도 한다.

권한 검사 영역

  • 표현 영역
  • 응용 서비스
  • 도메인

표현 영역

  • 인증된 사용자인지 아닌지 검사
  • 인증된 사용자의 웹 요청만 컨트롤러에 전달
  • 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트

-> 서블릿 필터에서 사용자의 인증 정보를 생성하고 인증 여부를 검사한다.

응용 서비스

  • URL 만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사 수행.
  • 응용 서비스의 코드에서 직접 권한 검사를 해야 한다는 건은 아니다. 아래 코드처럼 AOP를 사용해 애너테이션으로 서비스 메서드에 대한 권한 검사 가능.
open class ChangePasswordServiceImpl(private val memberRepository: MemberRepository):
    ChangePasswordService {

    @Transactional
    @PreAuthorize("hasRole('ADMIN)")
    override fun changePassword(req: ChangePasswordRequest) {
        val memberId = req.memberId
        val member = memberRepository.findById(memberId).orElseThrow { throw NotFoundException() }
        member.changePassword(memberId, req.currentPassword, req.newPassword)
    }
}

도메인

  • 응용 서비스의 메서드 수준에서 권한 검사를 할 수 없기 때문에 직접 권한 검사 로직을 구현

6.7 조회 전용 기능과 응용 서비스

  • 응용 서비스에서 전용 기능을 만들면 서비스 코드가 단순히 전용 기능을 호출하는 형태가 된다.
class OrderListService {
    
    fun getOrderList(ordererId: String): List<OrderView> {
        return orderViewDao.selectByORderer(ordererId)
    }
}

사용 상황

  • 응용 서비스가 사용자 요청 기능을 실행하는 데 별다른 기여를 하지 못한다면 서비스를 만들지 않아도 된다.

profile
내일의 코드는 더 안전하고 깔끔하게

0개의 댓글