비지니스 로직은 어디에 작성해야할까? (Entity 의 설계는 어떻게 해야...

비지니스 로직은 어디에 작성해야할까? (Entity 의 설계는 어떻게 해야...

오류가 있는 부분은 지적해주시면 감사하겠습니다.

비지니스 로직이란?

프로그램이 작업을 수행할 때, 많은 작업이 있는데

그 중에 시스템 사용자의 작업과 관련이 깊은 로직을 뜻한다.

예를 들어서 사용자가 시스템을 통해서 입금을 할 때 다음과 같은 로직이 포함되어 있을 것이다.

DB의 connection 을 얻어온다.

다른 시스템 사용자의 계좌가 유효한지 확인한다.

유효하면 계좌 금액을 변경한다.

여기서, DB connection 을 얻어오는 부분을 제외하고 나머지 두 부분은 사용자가 "입금"을 하기 위해서 연관이 깊은 로직이기 때문에 비즈니스 로직이라고 할 수 있다.

비즈니스 로직은 어디에 위치해야할까?

보통 DB connection 등등은 프레임워크단에서 추상화 되어 우리가 직접하지 않는 작업들이다.

즉, 비즈니스 로직을 실행하기 위해 시스템이 준비해야할 코드는 이미 감춰져 있다는 말이다.

우리가 작성하는 많은 코드는 비즈니스 코드인데 보통 설계는 아래와 같은 구조로 가져간다.

Controller

Repository

Domain (entity)

Service (impl)

설명하자면 다음과 같다

Controller는 사용자 요청을 받아서 서비스를 호출하는 역할을 한다.

Repository 는 DB 와 밀접한 관련이 있으며 데이터를 조회하거나 insert 하는 동작을 한다.

Domain 은 Repository 로 부터 얻어올 수 있으며 DB의 데이터와 밀접한 관련이 있다.

이때, Service 가 문제인데, 많은 개발자가 대부분의 비즈니스 로직을 Service 에 작성하곤 한다.

그런데 이것이 맞을까?

최근에 "스프링 부트와 AWS로 혼자 구현하는 웹 서비스" 라는 책에서

"비즈니스 로직은 도메인에 가깝게 작성해야하고, 서비스는 오직 해당 비즈니스 로직의 호출에 대한 순서, 트랜잭션 보장의 역할만 해야한다" 라는 문구를 읽었다.

나도 Service 에 로직을 작성하면서, 수많은 어플리케이션의 서비스 부분만 비대해지는 것을 보며, 변화에 대응하기가 어렵고 내가 원하는 로직을 찾아갈때에도 몹시 보기 어렵다는 것을 느꼈다.

따라서 위 말에 동의한다. 만약 도메인 (엔티티)으로 비즈니스 로직을 이동시킬수 있다면

서비스 부분이 조금더 가벼워지리라 생각하기 때문이다.

어떻게 작성해야할까?

비즈니스 로직을 도메인으로 옮기기 위해서 고민을 좀 해봤다. 먼저, 작성하기에 앞서서 환경은 다음과 같다.

Spring Boot

JPA (Spring Data JPA)

다음과 같은 로직을 작성해야한다고 가정하자.

유저는 팀을 생성할 수 있는데, 이 때 오직 하나의 팀만 생성할 수 있다.

그럼 다음과 같이 코드를 작성할 수 있다.

// 인증이 성공한 환경으로 부터 principal (email) 을 추출한다. String email = SuccessAuthentication.getPrincipal(String.class); // userRepository 에서 email 로 user를 찾아온다. 없으면 Exception User user = userRepository.findByEmail(email) .orElseThrow(() -> new UserNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); // team 중에서 user 가 생성자인 팀을 모두 찾아온다. List teams = teamRepository.findByUser(user).get(); // 이미 생성한 팀이 있으면 Exception if (teams.size() > 0) throw new ResourceConflict(ErrorCode.RESOURCE_CONFLICT); // team 저장 teamRepository.save( Team.builder() .name(teamDTO.getName()) ... .build() );

딱 하나의 로직을 작성했을 뿐인데 벌써 서비스가 비대해졌다.

다행히, 조금이나마 아래와 같은 로직을 도메인으로 옮길 수 있다.

team 중에서 user 가 생성자인 팀을 모두 찾아온다

이미 생성한 팀이 있으면 Exception

이 로직을 Team Entity 에서 user 를 받아 이미 생성한 팀이 있는지 체크하는 식의 로직을 작성하기 위해서 다음과 같은 제약 조건을 걸었다.

Team Instance 가 없는 상태에서도 체크할 수 있도록 static 으로 작성한다.

repository 의 경우 Entity 에 있는 것이 맞지 않아보이므로 매개변수로 받아서 작성한다. (이 부분은 개인적인 견해)

user 가 생성하는 주체이므로 파라미터로 받는다.

고친 코드는 다음과 같다.

Service

// 인증이 성공한 환경으로 부터 principal (email) 을 추출한다. String email = SuccessAuthentication.getPrincipal(String.class); // userRepository 에서 email 로 user를 찾아온다. 없으면 Exception User user = userRepository.findByEmail(email) .orElseThrow(() -> new UserNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); // **변경** Team.duplicationCheck(user, teamRepository); // team 저장 teamRepository.save( Team.builder() .name(teamDTO.getName()) ... .build() );

Team

public static boolean duplicationCheck(User user, TeamRepository teamRepository) { List teams = teamRepository.findByUser(user).get(); if (teams.size() > 0) throw new ResourceConflict(ErrorCode.RESOURCE_CONFLICT); return true; }

이렇게 하니 이 코드를 재사용하기도 편해졌고 Service 부분도 조금 더 가벼워졌다.

다만, 위 로직의 경우 직접 Repository 를 조회하여야 했으나, 수정 작업이 일어나는 경우에는 그럴필요가 없다. JPA 가 마법같이 그런일을 해주기 때문이다. 덕분에 비즈니스 로직을 더 깔끔하게 분리해낼 수 있다. 다음 예제를 보자

비밀번호의 유효성 검사를 하는 로직

boolean isValidPass = RegularExpression.isValid(RegularExpression.pw_alpha_num_spe, userDTO.getPassword()); if (!isValidPass) throw new InvalidInputException(ErrorCode.INVALID_INPUT_VALUE);

userDTO 에서 비밀번호를 가져와 비밀번호 정규식을 검사한 뒤 유요하지 않으면 Exception 을 throw 하는 부분이다.

저는 이 코드를 코칠 때, 다음과 같은 원칙을 세웠습니다.

RegularExpression 클래스에서 정규식 검사를 할 수 있도록 하고 싶다.

여전히 RegularExpression 클래스에서 바로 참조하여 (static 메서드) 검사를 진행할 수 있게 하고 싶으나, RegularExpression 클래스가 아니라, 다른 이름을 가진 클래스 (중첩 클래스)에서 할 수 있도록 하고 싶다. ⇒ 이 코드를 읽었을 때, 정규식 검사하는 객체를 가져와 검사를 하는 구나. 라고 바로 읽을 수 있도록

public class RegularExpression { ... /* 중첩 클래스 static 으로 선언 */ public static class ValidationChecker { /* static 메소드 */ public boolean check(String regularExpression, String str) { if (!isValid(regularExpression, str)) throw new InvalidInputException(ErrorCode.INVALID_INPUT_VALUE); return true; } } /* 중첩 클래스 */ public static ValidationChecker getChecker() { return new ValidationChecker(); } /* private 으로 외부 접근을 막음 */ private static boolean isValid(String regularExpression, String str) { match = Pattern.compile(regularExpression).matcher(str); if (match.find()) return true; return false; } ... }

이 코드는 다음과 같이 사용할 수 있습니다.

RegularExpression.getChecker().check(RegularExpression.pw_alpha_num_spe, userDTO.getPassword());

RegularExpression 클래스에서 Checker 를 가져와, 내가 원하는 정규식으로 검사를 진행

정규식 검사를 실패할 경우 Exception

마지막으로 가장 적절하게 변경한 부분이 아닐까 생각이 드는데, 다음 예제를 보자

비즈니스 상황은 다음과 같다.

팀 생성자로서 팀을 생성할 수 있다.

단, 실제로 데이터를 저장하는 것이 아니라 데이터 상태만 "삭제" 상태로 바꾸어 조회할 때 조회가 되지 않도록 변경하였다.

// 이 요청을 날린 사용자 검색 User user = userRepository.findByEmail(SuccessAuthentication.getPrincipal(String.class)) .orElseThrow(() -> new UserNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); // 삭제하려는 Team 을 Repository 에서 조회 Team team = teamRepository.findById(teamDTO.getId()) .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); // 이 유저가 팀 생성자가 아니라면 Exception if (!user.equals(team.getUser())) throw new UnauthorizedException(ErrorCode.UNAUTHORIZED_VALUE); // 상태를 삭제로 변경해서 저장 return teamRepository.save( Team.builder() .id(team.getId()) .user(team.getUser()) .name(team.getName()) .description(team.getDescription()) .state(State.DELETED) .date(team.getDate()) .baseModifyEntity(BaseModifyEntity.now(user.getEmail())) .build() );

코드가 상당히 지저분하고 좋지 않다는 것을 알 수 있다.

먼저 위 코드의 문제점은 다음과 같다.

단순 삭제(업데이트) 코드를 구현하기 위해서 불필요한 코드가 길어졌다. ModelMapper 를 사용하면 Team.builder() 로 Team Entity 를 생성하는 로직을 줄일 수 있긴 하나, 그래도 여전히 불필요하게 코드가 길어짐 setter 를 사용하면 자동으로 JPA 가 Entity를 감지해서 객체가 수정되나, setter 는 데이터 무결성을 보장하기 위해서 사용하지 않았다.

코드가 객체지향스럽지 않다. 코드의 역할 분할이 완전히 이루어 지지 않았다. 객체지향스러운 코드에서 team의 삭제는 service 가 아니라 user가 해야하지 않을까?

코드를 변경하기 전에 나는 다음과 같은 원칙을 세웠다.

Team 또는 User 에 삭제 역할을 완전히 위임하여 코드를 객체지향스럽게 변경한다.

한눈에 읽히는 코드를 만든다.

위 원칙을 지키기 위해서 가장 먼저 작성한 서비스의 코드이다.

Service

Team team = teamRepository.findById(teamDTO.getId()) .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); User user = userRepository.findByEmail(SuccessAuthentication.getPrincipal(String.class)) .orElseThrow(() -> new UserNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); user.delete(team);

당연히, delete 메서드를 작성하지 않았기 때문에 위 코드에는 빨간줄이 가득했다.

그러나, 에러 표시를 제외하고 일단 읽으면 다음과 같이 읽힌다.

팀 저장소에서 팀을 조회해온다.

유저 저장소에서 유저를 조회해온다.

유저는 팀을 삭제한다.

매우 깔끔해졌다! 위 처럼 코드를 작성하고 User Entity 에 delete 메소드를 다음과 같이 작성했다.

User Entity

public Team delete(Team team) { // 이 팀을 삭제할 권한이 있는지 확인 if (!this.equals(team.getUser())) throw new UnauthorizedException(ErrorCode.UNAUTHORIZED_VALUE); // 팀의 상태를 변경 team.setState(State.DELETED); return team; }

그런데 작성하고 나니 두가지가 매우 마음에 들지 않았다.

이 팀을 삭제할 권한이 있는지 확인을 하는 것은 "Team" 의 역할이지 User 의 역할이 아닌 것 같았다.

team 의 상태를 변경하기 위해서 setState 라는 setter 를 생성해야 했다.

따라서, 나는 다음과 같이 코드를 변경하기로 했다.

팀을 삭제할 권한이 있는지 확인하는 로직은 Team 으로 이동시키기로 하였다.

setter 대신에 편의 메서드를 작성하고 해당 메서드에 상태를 삭제로 변경시키는 역할을 위임하기로 하였다.

User Entity refactoring

public Team delete(Team team) { team.deletedByUser(this); return team; }

한 눈에 봤을 때 코드가 매우 깔끔해졌다.

team 은 유저(this)에 의해서 삭제된다.

그리고 Team Entity의 내용은 다음과 같다.

Team Entity deletedByUser

public Team deletedByUser(User modifier) { if (!this.user.equals(modifier)) throw new UnauthorizedException(ErrorCode.UNAUTHORIZED_VALUE); this.state = State.DELETED; ... 중략 return this; }

이 팀을 삭제하려는 자가 생성자가 아니면 예외

이 팀의 상태를 "삭제"로 변경 (JPA 가 마법같이 필드 변경을 감지해서 update 를 해주므로 DB 에 update 쿼리를 날리는 로직을 작성할 필요가 없다!)

이렇게 하고 나니 setter를 사용할 필요도 없었고 (Team Entity 내부에서 Team 상태변화 로직을 작성하므로, 필드를 직접 변화시키면 된다.)

각각의 역할에 따라서 로직의 위치가 변경 되었다.

그리고 서비스에서는 Team의 상태를 확인하기 위해서 로직을 작성할 필요 없이 User Entity 에 Team의 삭제를 맞기면 된다.

추가적으로 위 코드 중에 권한 체크를 하는 부분을 Validator 클래스에 역할을 위임해서 코드를 작성하면 조금 더 깔끔하게 작성할 수 있을 뿐만 아니라, 혹여나 권한 체크를 위해 repository 를 조회할 필요가 있을 때, Entity 가 repository 에 대한 의존성을 가지지 않는다는 장점이 있다.

TeamValidator

class TeamValidator { // Validator 는 Repository 에 대한 의존성을 가질 수 있다. public void validate(User modifier, User teamOwner) { // 권한 체크를 진행한다. if (!teamOwner.equals(modifier)) { throw new UnauthorizedException(ErrorCode.UNAUTHORIZED_VALUE); } }

Team Entity deletedByUser

public Team deletedByUser(User modifier, TeamValidator validator) { // 유효성 검사에 관한 책임을 validator 에 위임한다. (깔끔하다!) validator.validate(modifier, this.user); this.state = State.DELETED; ... 중략 return this; }

from http://way-be-developer.tistory.com/281 by ccl(A) rewrite - 2021-12-08 19:01:19