본문 바로가기
개발자 준비/개발 공부

전략 패턴과 캡슐화 개념을 이용한 도메인과 검증 로직 최적 분리 과정

by osul_world 2022. 12. 2.
728x90

💫시작하기 전에

프로젝트에서 스케줄러 기능을 개발하면서 ‘검증 로직을 도메인 클래스에서 분리’한 과정을 기록하고자 합니다.

해결 과정은 아래와 같습니다.

  1. 검증 책임을 가진 클래스를 별도 생성하여 분리
  2. JPA 임베디드 타입과 전략 패턴을 이용한 분리(feat. 래퍼 클래스)
  3. JPA 임베디트 타입과 스프링 validation 혼합

시스템에서 발생한 에러를 적절히 처리해서 사용자의 불편을 최소화하는 것은 개발의 기본 덕목이지만, 보통 에러는 클라이언트의 잘못된 요청이나 입력에서 비롯되는 경우가 많았습니다.

 

에러 처리를 위한 코드가 늘어나다 보니 시스템 복잡도가 점점 증가했습니다.

때문에, 이 이상 에러 처리에 고집하기보다 에러 예방을 통해 위험 발생 가능성을 사전에 최대한 배제하는 것이 좋겠다고 생각했습니다.

 

아래는 사용하는 도메인 중 일부 입니다. url 이나 날짜를 입력 받을때 잘못된 양식으로 입력받을 가능성이 높아 보입니다.

때문에 잘못된 입력으로 데이터가 생성되지 않도록 검증하는 로직을 도메인 클래스에 추가하고자 했습니다.

 

잘못된 인풋값을 검증 스프링의 Validation 기능을 이용하여 검증 할 수 있지만, 보다 디테일한 검증 제어가 필요한 부분이 있었기 때문에 ‘직접’ 검증 로직을 구현할 필요가 있다고 생각해 아래와 같이 도메인에 검증 로직을 추가했습니다.

 

도메인 클래스에 검증 로직 추가

public class AnnouncementSchedule {
    @Id
    @Column(name = "id")
    private Long id;

    private String title;

    @Column(name = "original_url")
    private String originalUrl;

    @Column(name = "start_date")
    private String startDate;

    @Column(name = "end_date")
    private String endDate; 

	
		public AnnouncementSchedule(....){
			if(validateFieldValues()==false) throw new WrongInputException(..);
			....
		}

		private boolean validateFieldValues(){
			if(ValidateStartDate()==false) return false;
			...
		}

		//각 필드별 검증 로직
		private boolean ValidateStartDate(String startDate){
			...
		}

		...
}

생성자를 호출 할때 검증 절차를 밟은 뒤, 잘못된 입력이라면 예외를 발생시키도록 설계했습니다. (발생한 예외는 ExceptionHandler에서 처리 합니다.)

 

💡1) 검증 책임을 가진 클래스를 별도 생성하여 분리

하지만 위 코드는 클래스의 ‘역할을 한눈에 파악하기 힘들고 검증기능이 추가 될 수록 크고 복잡한 클래스’가 될것입니다.

또한, 단일 책임 원칙을 위반하고 있습니다.

 

때문에, 해당 도메인 검증 책임을 가진 클래스를 별도로 생성하여 도메인 클래스에서 검증로직을 분리했습니다.

public class AnnouncementScheduleValidator{

		public static validateFieldValues(Long id, String title , ... ,String endDate){
			validateFieldValues()			
		}

		private boolean ValidateTitle(String title){
			...
			throw new WrongInputException(...); //검증실패시

		}

		private boolean ValidateOriginalUrl(String originalUrl){
			...
		}

		private boolean ValidateStartDate(String startDate){
			...
		}

		private boolean ValidateEndDate(String endDate){
			...
		}
}
public class AnnouncementSchedule {
    @Id
    @Column(name = "id")
    private Long id;

    private String title;

    @Column(name = "original_url")
    private String originalUrl;

    @Column(name = "start_date")
    private String startDate;

    @Column(name = "end_date")
    private String endDate; 

	
		public AnnouncementSchedule(....){
			AnnouncementScheduleValidator.validateFieldValues(); //검증 클래스 호출!
			...
		}
}

AnnouncementSchedule 도메인의 검증을 담당하는 AnnouncementScheduleValidator 를 생성했습니다.

하지만, 역할과 책임에 맞게 코드를 분리했다는 장점에 반해, ‘도메인마다 검증 클래스가 생성’되게 됩니다.

 

또한, ‘코드 중복’ 문제가 있습니다. AnnouncementSchedule 도메인 뿐 아니라 PersonalSchedule , AnnouncementSubscription 등 다른 도메인들도 endDate, startDate 등 AnnouncementSchedule 가 사용하는 것과 동일한 필드를 사용합니다.

 

즉, 동일한 검증 코드를 도메인마다 중복해서 작성해야 했습니다.

 

마지막으로, ‘유연한 변경이 불가’하다는 문제가 있습니다. AnnouncementSchedule 의 필드구성이 변경되면 의존관계에 있는 AnnouncementScheduleValidator 도 도메인 필드구성에 맞게 변경되어야 합니다.

 

현재 문제점들을 정리해보면 다음과 같습니다.

  • 도메인마다 검증 클래스가 생성
  • 동일한 검증 로직 중복
  • 도메인 클래스 수정에 따른 검증 클래스 수정 불가피

💡 2) JPA 임베디드 타입과 전략 패턴을 이용한 분리(feat. 래퍼 클래스)

JPA 임베디드 타입을 활용하면 ‘해당 타입만의 의미있는 메서드’ 를 추가 할 수 있습니다. 필드의 타입을 별도의 클래스로 만들어 사용하기 때문입니다. 일종의 사용자 정의 래퍼 클래스라고 생각하면 좋을 것 같습니다.

public class AnnouncementSchedule {
    ...

		@Embedded
    private Title title;

		@Embedded
    @Column(name = "original_url")
    private OriginalUrl originalUrl;
		
		@Embedded
    @Column(name = "start_date")
    private StartDate startDate;

		@Embedded
    @Column(name = "end_date")
    private EndDate endDate; 

		public AnnouncementSchedule(Long id , String title , String originalUrl , ... , String endDate){
				...					
				this.title = Title.from(title);
				this.originalUrl = OriginalUrl.from(originalUrl);
				this.startDate = StartDate.from(startDate);
				this.endDate = EndDate.from(endDate);
	
		}

}
@Embeddable
public class OriginalUrl{

		private String originalUrl;

		...
			
		public static OriginalUrl from(String originalUrl){
				validate(originalUrl);
				return new OriginalUrl(originalUrl);
		}

	  private void validate(String originalUrl){
				//다양한 검증로직 수행
				throw new WrongInputException(...); //검증실패시
		}
}

각 필드를 임베디드 타입으로 변경하여 해당 타입만의 검증 로직 메서드를 추가합니다.

필드를 임베디드 타입으로 만드는 과정에서 각 필드와 관련된 유효성 검증 로직이 타입으로 정의한 클래스 내부로 들어갔기 때문에 AnnouncementScheduleValidator 같은 도메인 별 검증 클래스가 더 이상 필요하지 않습니다.

 

또한, 검증 로직을 수정할때 각 필드 타입에 해당하는 클래스만 수정하면 되고 도메인 필드도 자유롭게 수정할 수 있게 되었습니다.

그러나, 독립성이 증가했다는 장점에 반해, 검증 코드 중복이 일부 존재하고 이전보다 생성되는 클래스와 의존관계 수가 증가 했습니다.

 

startDate , endDate는 네임은 다르지만 Date 타입으로써 동일한 검증로직을 가지고 있기 때문입니다.

 

현재 문제점

  • 도메인마다 검증 클래스가 생성
  • 도메인 클래스 수정에 따른 검증 클래스 수정 불가피
  • 클래스 수 증가 , 의존관계 증가
  • 동일한 검증 로직 중복

임베디드 타입 검증 로직에 전략 패턴 적용

동일한 검증 로직 중복 문제점을 개선하기 위해 전략 패턴을 활용해 보았습니다. 예시의 startDate , endDate 외에도 startTime , endTime 과 같은 중복 로직을 가지는 타입들이 존재하고 검증 로직을 수정할때 일관성이 깨질수 있기 때문입니다.

 

Validator interface를 생성하고 이를 구현하는 하위 전략 클래스 DateTypeValidator , TimeTypeValidator 등을 생성합니다.

public interface Validator {
    void validate(String field) throws ValidatedException;
}

사용자 정의 예외 클래스 ValidatedException을 이용해 에러 원인(ERROR_MSG)을 전달합니다.

public class DateTypeValidator implements Validator{

    private final static int MAX_DATE = 20501231;

    private final static String ERROR_MSG = "잘못된 양식의 날짜 입니다.";

    @Override
    public void validate(String field) throws ValidatedException {
        int numTypeFromStartDate = Integer.parseInt(field);

        if (field.length() != 8 ||
                (numTypeFromStartDate <= 0 && numTypeFromStartDate >= MAX_DATE))
            throw new ValidatedException(ERROR_MSG, HttpStatus.BAD_REQUEST);
    }
}

임베디드 타입 클래스에 DI하여 해당 전략을 활용하도록 합니다.

public class StartDate {

    private String startDate;

    private final static Validator validator = new DateTypeValidator();

    private StartDate(String startDate) {
        this.startDate = startDate;
    }

    public static StartDate from(String startDate) throws ValidatedException {
        validator.validate(startDate);
        return new StartDate(startDate);
    }

}

이제 동일한 검증을 거치는 타입들은 전략 클래스를 이용해 코드 중복 없이 검증 로직을 수행 할 수 있습니다. 또한, 검증 로직의 수정이 필요할때 전략 클래스만 수정하면 된다는 장점이 있습니다.

 

생성한 클래스와 의존관계가 오히려 늘어나긴 했지만 임베디드 타입 클래스와 검증 로직간에 ‘의존도를 최소화’ 했기 때문에 감수할 수 있다고 판단했습니다.

💡 3) 임베디트 타입과 스프링 validation 혼합

클래스의 증가는 관리의 어려움이 발생할 수 있기 때문에 모든 필드를 임베디드 타입으로 생성할 가치가 있는지 판단할 필요가 있었습니다.

스프링은 @Valid을 통해 도메인 클래스의 로직 식별을 해치지 않는 선에서 간단하게 검증로직을 적용할 수 있습니다.

 

검증 로직이 도메인 클래스에 로직을 식별하는데 방해가 되지 않게 하는게 목표였기 때문에, 다른 도메인에서도 중복적으로 사용되고 디테일한 검증이 필요한 필드는 래퍼클래스로 만들고 나머지는 스프링 validation을 이용하도록 수정했습니다.

(적용 전)

public class PersonalSchedule {
    @NotBlank(message = " 제목은 필수 입력값 입니다.")
    private String title;

		@NotBlank(message = " 내용은 필수 입력값 입니다.")
    private String body;

    @NotBlank(message = " 시작시간은 필수 입력값 입니다.")
    @Size(min=4,max=4, message = "유효하지 않는 시간")
    private String startTime;

    @NotBlank(message = " 종료시간은 필수 입력값 입니다.")
    @Size(min=4,max=4, message = "유효하지 않는 시간")
    private String endTime; 

    @NotBlank(message = " 날짜는 필수 입력값 입니다.")
    @Size(min=8,max=8,message = "유효하지 않는 날짜")
    private String day; 

		...
}

(적용 후)

public class PersonalSchedule {
    @NotBlank(message = " 제목은 필수 입력값 입니다.")
    private String title;
		@NotBlank(message = " 내용은 필수 입력값 입니다.")
    private String body;
    
    private StartTime startTime;

    private EndTime endTime; 
   
    private Date date;

		...
}

극명한 비교를 위해 PersonalSchedule 를 예시로 사용했습니다.

디테일한 검증절차가 필요하고 타 도메인에서도 사용되는 타입인 Time, Date 등 은 임베디드 타입으로 만들고 title 같은 간단한 검증은 @Valid를 사용했습니다.

결과적으로 기존 문제점들을 개선하고 검증 로직을 사용할 수 있게 되었습니다.

  • 도메인마다 검증 클래스가 생성
  • 동일한 검증 로직 중복
  • 도메인 클래스 수정에 따른 검증 클래스 수정 불가피
  • 클래스 수 증가 , 의존관계 증가 → 최소화

정리

  1. 도메인 필드는 대부분 수정이 발생하지 않도록 설계하기 때문에, 1번 방법이 가장 간단한 해결방법입니다.
  2. 수정이 발생하는 경우와 1번 방법의 코드 중복 문제를 해결하고, 유연한 수정을 위해선 2번 방법을 적용해야 합니다.
  3. 2번 방법으로 인한 클래스와 의존관계 증가가 부담스럽다면, 완전한 분리를 포기하더라도 3번 방법을 통해 최소화 해야 합니다.
728x90