[Server] 비즈니스 정책과 입력 데이터, 서로 다른 데이터 검증 및 유효성 검사(Validation)
1. 비즈니스 정책과 입력 데이터, 서로 다른 데이터 검증 및 유효성 검사(Validation)
[ 입력 데이터의 검증 및 유효성 검사 ]
우리의 애플리케이션은 클라이언트로부터 받은 입력 데이터를 바탕으로 비즈니스 로직을 수행해야 한다. 이때 입력 데이터가 잘못되면 시스템에 문제를 일으킬 수 있으므로 클라이언트로부터 올바른 데이터가 전달되었는지 검증이 필요하다. 예를 들어 주식 주문을 위한 API가 존재하고, 다음과 같은 입력 데이터를 받고 있다고 하자.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OrderStockRequest {
private TradeType tradeType; // 거래 타입(매도, 매입)
private String isin; // 국제 주식 종목 코드
private BigDecimal price; // 가격
private Integer count; // 수량
}
이때 입력 데이터의 관점에서, 데이터는 기본적으로 다음과 같이 전달되어야 문제가 없다.
- 거래 타입이 반드시 존재해야 함
- 주식 코드가 반드시 존재해야 함
- 가격은 반드시 존재해야 하며 0원보다 작을 수 없음
- 수량은 반드시 존재해야 하며 0개보다 적을 수 없음
따라서 이러한 입력 데이터의 유효성 검사를 반영하기 위해 다음과 같은 유효성 검증 애노테이션(jakarta.validation)을 사용할 수 있다. 참고로 DecimgalMin 애노테이션에서 inclusive 옵션은 입력이 정확히 0일 경우도 유효하지 않도록 처리한다는 옵션이다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OrderStockRequest {
@NotNull
private TradeType tradeType; // 거래 타입(매도, 매입)
@NotNull
private String isin; // 국제 주식 종목 코드
@NotNull
@DecimalMin(value = "0.0", inclusive = false)
private BigDecimal price; // 가격
@NotNull
@Min(value = "0")
private Integer count; // 수량
}
해당 애노테이션을 스프링 프레임워크에서 적용하려면 다음과 같이 컨트롤러에서 적용 가능하다.
@RestController
@AllArgsConstructor
class OrderStockController {
private final OrderStockService orderStockService;
@PostMapping("/api/stocks/order/")
public ResponseEntity<Void> orderStock(
@StockUser User user,
@RequestBody @Valid OrderStockRequest orderStockRequest
) {
orderStockService.order(
OrderStockInput.builder()
.userId(user.id);
.tradeType(tradeType)
.isin(isin)
.price(price)
.count(count)
.build();
);
...
}
}
이렇듯 입력 데이터를 받는 시점에 유효성 검사를 진행하면 다음과 같은 장점이 있다.
- 해당 데이터로 전달되는 값들은 모든 계층에서(어디서든) 유효함을 보장받을 수 있음
- 입력 데이터를 가장 앞에서 처리하여 불필요한 로직의 실행을 최소화할 수 있음
예를 들어 위의 API를 처리하는 비즈니스 로직이 있다고 하자. 이때 앞 계층에서 유효성 검사를 처리하지 않았다면 다음과 같이 입력 데이터가 유효한지 검증하는 로직이 필요할 것이다.
문제는 아래의 계층은 비즈니스 로직을 처리하는 영역인데, 비즈니스 외적인 입력 데이터 검증을 진행하고 있다는 점이다. 이로 인해 클래스의 로직이 복잡해져서, 순수 비즈니스 로직 자체에 집중하기가 어려워졌다.
@Service
public class OrderStockService {
private final LoadUserPort loadUserPort;
private final LoadStockPort loadStockPort;
static class OrderStockInput {
private Long userId;
private TradeType tradeType;
private String isin;
private BigDecimal price;
private Integer count;
}
public void order(
OrderStockInput orderStockInput
) {
if (orderStockInput.getPrice() == null || orderStockInput.getPrice().intValue() <= 0) {
throw IllegalArgumentException("price가 없거나 0보다 작습니다.")
}
...
Stock stock = loadStockPort.findByIsbnOrThrow(orderStockInput.isbn);
}
}
그 외에도 만약 입력 데이터 검증을 누락한 경우라면, 불필요하게 리소스를 사용되는 문제가 발생할 수 있다. 예를 들어 가장 먼저 주식 코드(isbn)으로 주식 정보를 조회한다고 하자.
@Service
public class OrderStockService {
private final LoadStockPort loadStockPort;
static class OrderStockInput {
private Long userId;
private TradeType tradeType;
private String isin;
private BigDecimal price;
private Integer count;
}
public void order(
OrderStockInput orderStockInput
) {
Stock stock = loadStockPort.findByIsbnOrThrow(orderStockInput.isbn);
...
}
}
이때 isbn 입력 데이터에 대한 검증을 하지 않은 상황이라면, isbn이 공백값인 경우처럼 절대 데이터가 존재할 수 없는 상황에서도 항상 데이터베이스 등과 같은 외부 인프라 계층에 대한 네트워크 호출이 발생할 것이다. 이로 인해 불필요하게 시스템 리소스를 사용할 뿐만 아니라 외부 인프라의 장애를 유발할 수도 있다.
그 외에도 해당 컨트롤러로부터 받은 입력을 다른 서비스에서도 사용한다고 할 때, 컨트롤러와 다른 서비스 모두에 입력 데이터에 대한 검증을 진행하지 않는다면 예상치 못한 문제가 발생할 수도 있다.
이렇듯 입력 데이터에 대한 검증을 진행하게 되면 불필요한 문제를 사전에 예방하고 여러 가지 장점을 누릴 수 있다.
[ 비즈니스 정책의 검증 및 유효성 검사 ]
위에서 살펴보았던 검증은 “수량은 0보다 크다” 또는 “가격은 0보다 크다”와 같이 전달받은 입력 데이터에 대한 검증이다. 하지만 검증해야 하는 대상은 입력 데이터 외에도 도메인 정책도 존재한다.
예를 들어 “한 번에 주문할 수 있는 주식의 수량이 200개를 초과할 수 없다”는 정책이 내부적으로 세워졌다고하자. 이러한 도메인 또는 비즈니스 정책에 대한 유효성 검증 역시 필요한데, 해당 유효성 검증을 어느 계층에서 진행할 것인지에 대해 고민해볼 필요가 있다.
예를 들어 다음과 같이 주식 주문을 위한 엔티티가 존재한다고 하자.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OrderStock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long stockId;
@Enumerated(EnumType.STRING)
private TradeType tradeType;
private BigDecimal price;
private Integer count;
}
일반적으로 JPA를 사용한다는 가정 하에, 비즈니스 정책 역시 입력 데이터 유효성과 마찬가지로 다음과 같이 애노테이션 기반으로 적용할 수 있다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OrderStock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long stockId;
@Enumerated(EnumType.STRING)
private TradeType tradeType;
private BigDecimal price;
@Max(value = 200)
private Integer count;
}
위와 같이 애노테이션 기반으로 적용한 유효성 검증은 다음과 같은 2가지 문제가 있다.
- 데이터가 저장되는 시점에만 유효성 검증이 진행됨
- JPA가 아닌 도구로 데이터를 저장하는 경우에 유효성 검증이 되지 않음
먼저 위와 같은 애노테이션 기반의 유효성 검증은 데이터가 저장되는 시점에만 수행된다. 따라서 객체를 생성하고 전달하는 상황에서 유효한 데이터를 들고 있지 않은 객체로 인해 잘못 사용될 수 있다. 이를 방지하려면 해당 객체를 사용하는 여러 계층이나 클래스에 각각 도메인 정책에 대한 검증 로직을 넣어야 하는데, 이로 인해 응집도가 떨어지는 문제가 생길 수 있다. 또한 서비스를 운영하다 보면 수기로 데이터베이스에 데이터를 직접 입력해야 하는 상황이 자주 있다. 이러한 경우에도 시스템에 잘못된 데이터가 입력되었음을 보장받을 수 없다.
그 다음으로 JPA가 아닌 도구로 저장하는 경우에는 유효성 검증이 되지 않는다는 점이다. 서비스를 개발하다 보면 대량의 데이터를 추가(Bulk Insert)해야 하는 상황이 존재한다. 이때 JPA가 아닌 JdbcTemplate과 같은 도구를 활용하는 경우가 많은데, 이러한 경우에는 유효성 검증이 적용되지 않아서 문제가 생길 수 있다.
이러한 문제를 방지하기 위해 다음과 같이 도메인 정책에 대한 유효성 검사를 입력 데이터를 검증하는 곳에 함께 구현할 수 있다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OrderStockRequest {
@NotNull
private TradeType tradeType; // 거래 타입(매도, 매입)
@NotNull
private String isin; // 국제 주식 종목 코드
@NotNull
@DecimalMin(value = "0.0", inclusive = false)
private BigDecimal price; // 가격
@NotNull
@Min(value = "0")
private Integer count; // 수량
}
문제는 이러한 방식은 도메인 정책에 대한 표현력이 떨어진다는 것이다.
“우리 주문할 때 1회에 최대 몇 개까지 가능하죠?”라는 질문을 기획자로부터 받았다면, 누구나 빠르게(새롭게 팀에 합류한 개발자라도) 해당 정보를 비즈니스를 표현하는 엔티티로부터 얻어낼 수 있어야 한다. 하지만 위와 같이 DTO에 비즈니스 정책이 표현된다면, 인지 부하를 초래할 것이며 해당 정책의 위치를 찾느라 불필요한 시간이 소요될 것이다.
따라서 비즈니스 정책은 핵심 도메인 엔티티 안에서 객체가 생성되는 시점에 검증해주는 것이 좋다. 참고로 아래의 엔티티는 영속성 관련 역할도 함께 맡고 있는데, 이에 대해서는 일단 넘어가도록 하자.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OrderStock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long stockId;
@Enumerated(EnumType.STRING)
private TradeType tradeType;
private BigDecimal price;
@Max(value = 200)
private Integer count;
public static int MAX_ORDER_COUNT = 200;
public OrderStock() {
if (count > MAX_ORDER_COUNT) {
throw IllegalArgumentException("구매 수량을 초과하였습니다.")
}
}
}
해당 값을 참조하여 입력 유효성을 검증할 때 함께 사용하는 것은 괜찮다. 하지만 200이라는 매직 넘버를 하드 코딩하여 DTO에 재선언해주는 방식은 올바르지 않다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OrderStockRequest {
@NotNull
private TradeType tradeType; // 거래 타입(매도, 매입)
@NotNull
private String isin; // 국제 주식 종목 코드
@NotNull
@DecimalMin(value = "0.0", inclusive = false)
private BigDecimal price; // 가격
@NotNull
@Min(value = "0")
@Max(value = OrderStock.MAX_ORDER_COUNT, message = "구매 수량을 초과하였습니다.") // 참조하는 방식
private Integer count; // 수량
}
따라서 위와 같이 도메인 엔티티(Domain Entity) 혹은 비즈니스 엔티티(Business Entity)에 비즈니스 정책을 표현하게 되면 다음과 같은 장점을 얻을 수 있다.
- 생성된 모든 객체들이 비즈니스 정책을 만족함을 보장할 수 있음
- 비즈니스 정책에 대한 응집도와 표현력을 높일 수 있음
참고로 이렇게 객체를 설계하는 방법을 계약에 의한 설계라고 부른다. 계약에 의한 설계(Design By Contract)는 소프트웨어 구성 요소 간의 인터페이스를 계약으로 간주하고, 객체들이 정해진 제약을 만족하도록 하는 설계 기법이다. 서버는 자신이 처리할 수 있는 값들을 클라이언트가 전달할 것이라고 기대하며, 클라이언트는 자신이 원하는 값을 서버가 반환할 것이라고 예상한다. 이러한 부분을 일종의 계약으로 바라보고, 설게하는 방식이 바로 계약에 의한 설계이다. 현실과 마찬가지로 계약은 크게 전제 조건(precondition), 사후 조건(postcondition), 그리고 불변 조건(invariant)으로 구분된다.
- 전제 조건(precondition)
- 메서드가 호출되기 위해 만족돼야 하는 조건으로 요구사항에 해당함
- ex) 주문 수량이 MAX_ORDER_COUNT를 초과하지 않아야 함
- 사후 조건(postcondition)
- 메서드나 생성자가 실행된 후에 만족해야 하는 조건
- ex) 주문 수량이 MAX_ORDER_COUNT를 초과할 경우 예외를 던져, 원하는 상태를 보장함
- 불변 조건(invariant)
- 객체의 상태가 언제나 만족해야 하는 조건
- ex) count 필드는 항상 200 이하의 값을 유지함
이를 통해 협력에 참여하는 객체들이 지켜야 하는 제약 조건을 명시할 수 있다. 또한 계약에 의한 설계의 경우 비즈니스 정책이 바뀌면서 함께 적용되는 설계 기법이므로, 주석과 달리 시간이 지나도 항상 비즈니스 정책이 반영되어 신뢰할 수 있다. 일반적으로 구현이 변경되면 주석도 함께 변경해주어야 해서 번거로움이 존재하는데, 계약에 의한 설계를 문서 그 자체로 활용하여 불필요한 주석을 최소화하여 유지보수 비용을 줄일 수 있다.
[ 비즈니스 정책과 입력 데이터의 경계 ]
개발을 하다 보면 비즈니스 정책과 입력 데이터의 경계에 있는 애매한 상황에 마주하게 된다. 예를 들어 삼성전자과 같이 주식 isbn 코드는 KR7005930003인데, 12자가 아닌 isbn 코드를 입력 데이터로 볼 것인가 또는 비즈니스 정책으로 볼 것인가 하는 부분이다. (예시가 다소 부적절할 수 있는데, 이렇듯 경계에 놓이는 상황이 발생할 수 있음만 인지하도록 넘어가도록 하자.)
이러한 경우에는 앞서 살펴보았듯 비즈니스 정책과 입력 데이터 모두에 녹여낼 수 있다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String isin;
public static int ISBN_LENGTH = 12;
public Stock() {
if (isin == null || isin.length != ISBN_LENGTH) {
throw IllegalArgumentException("구매 수량을 초과하였습니다.")
}
}
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OrderStockRequest {
@NotNull
private TradeType tradeType; // 거래 타입(매도, 매입)
@NotNull
@Length(Stock.ISBN_LENGTH) // 주식 코드에 대한 입력 데이터 검사
private String isin; // 국제 주식 종목 코드
@NotNull
@DecimalMin(value = "0.0", inclusive = false)
private BigDecimal price; // 가격
@NotNull
@Min(value = "0")
@Max(value = OrderStock.MAX_ORDER_COUNT, message = "구매 수량을 초과하였습니다.") // 참조하는 방식
private Integer count; // 수량
}
하지만 이렇게 입력 데이터와 비즈니스 정책 모두에 녹여내는 것은 상당히 번거롭다. 따라서 해당 부분이 서비스에 있어 핵심적인 부분인지 판단하고, 문제가 생겨도 괜찮은 범위라면 타이트하게 모든 부분을 위와 같이 구현해줄 필요는 없다. 위와 같이 입력 데이터와 비즈니스 정책 모두에 녹여내는 것은 시스템의 안정성을 높이는 것이기도 하지만 한편으로는 시스템의 경직성을 높이는 행위이기도 하다. 따라서 항상 이러한 부분에 깊은 고민을 하고 가치 판단을 하여 적용 여부를 결정할 수 있어야 한다. 위에 작성한 입력 데이터와 비즈니스 정책을 구분하는 것은 하나의 도구일 뿐, 항상 적용할 필요는 없다.