Encryption - RSA

이전에 에서 해시(Hash)에 대해 설명하며 암호화(Encryption)와 다른점에 대해 간략히 알아보았다.
이번에는 암호화에 대해서 조금 더 자세히 알아보자.

2년차 LINE 서버 개발자의 2019년 회고

벌써 LINE에 입사한지 2년이 다 되어간다. 올해는 정말 시간이 어떻게 지나갔는지 모르겠다. 정신 차리고 보니 2020년이 코앞이다. 올해는 작년에 하지 못했던 회고를 해보려한다.

HMAC을 이용한 무결성 보장

지난번에 Hash에 대한 내용을 살펴보았다. 이번에는 Hash의 개념을 이용한 HMAC에 대해서 알아보자.

Hash - MD5와 SHA256

해시(Hash)와 암호화(Encryption)의 차이

먼저 혼동하기 쉬운 해시암호화의 차이에 대해서 알아보자.

Gradle에서 Dependency Pollution 문제 해결하기

지난번에 (Gradle dependency) api와 implementation 차이에 대해서 알아보았다.

NoSuchMethodException & NoSuchMethodError 해결하기

애플리케이션에서 사용하는 라이브러리의 버전을 업데이트하거나 혹은 새로운 라이브러리를 추가하는 과정에서 종종 NoSuchMethodError을 마주하게 된다.
이번에는 NoSuchMethodErrors 대한 내용과 해결하는 방법에 대해서 알아보자.

SpringBoot Application의 monitoring 시스템 구축하기

system
Spring Boot를 사용하고 있는 애플리케이션에서 이전에 살펴본 Micrometer를 이용해서 metric을 생성하고 Prometheus를 이용해 수집, 그리고 Grafana로 시각화하는 시스템을 만들어보자.

Prometheus는 metric을 수집하고 모니터링 및 알람에 사용되는 오픈소스 애플리케이션이다. time series database를 사용해 metric을 저장하고 flexible한 query를 사용하 metric을 조회할 수 있다.

Grafana는 데이터 시각화, 모니터링 및 분석을 위한 오픈소스 플랫폼이다. 사용자는 Grafana에서 패널(panel)을 사용해 설정된 기간 동안 특정 metric을 나타내는 dashboard를 만들 수 있다.
Grafana는 그래프, 테이블 같은 것들을 지원할 뿐만 아니라 시각화를 위한 별도의 플러그인을 추가해서 사용할 수도 있다.

Spring Boot Dependency

Spring Boot 2.0 이상부터는 애플리케이션의 metric 측정을 위해서 Micrometer를 제공한다. Micrometer는 Spring Boot 2의 Actuator에 포함되어 있기 때문에 spring-boot-starter-actuator를 dependency에 추가해주면 쉽게 사용할 수 있다.

추가적으로 micrometer-registry-prometheus dependency가 필요하다. 이 dependency는 Micrometer가 만들어내는 metric을 Prometheus 서버에서 사용할 수 있는 metric format으로 변경한다.

1
2
3
4
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
}

Actuator Endpoint 설정

Actuator는 Spring MVC 혹은 Spring WebFlux를 사용하는 경우, Micrometer를 통해 생성된 애플리케이션의 metric을 Prometheus 서버에서 가져갈(Pull)수 있도록 추가적인 endpoint를 제공해준다.

Spring Boot 2.0 이상부터 사용하는 Actuator는 1.x 버전에서 사용하던 것과는 달리 대부분의 endpoint가 disabled로 설정되어 있다. 기본적으로 /health/info 2가지 endpoint만 default로 사용 가능하다. 따라서 /Prometheus endpoint를 사용할 수 있도록 다음과 같이 application.yml에서 설정이 필요하다.

1
2
3
4
5
management:
endpoints:
web:
exposure:
include: health, info, prometheus

애플리케이션을 실행하고 http://localhost:8080/actuator 를 통해 Actuator가 제공하는 endpoint들을 확인할 수 있다.
endpoint

http://localhost:8080/actuator/prometheus 에서는 Micrometer를 통해 수집된 metric들을 확인할 수 있다.

prometheus_endpoint

Spring Boot 2는 기본적으로 다음과 같은 metric들을 제공하고 있다.

  • JVM, report utilization of:
    • Various memory and buffer pools
    • Statistics related to garbage collection
    • Thread utilization
    • Number of classes loaded/unloaded
  • CPU usage
  • Spring MVC and WebFlux request latencies
  • RestTemplate latencies
  • Cache utilization
  • Datasource utilization, including HikariCP pool metrics
  • RabbitMQ connection factories
  • File descriptor usage
  • Logback: record the number of events logged to Logback at each level
  • Uptime: report a gauge for uptime and a fixed gauge representing the application’s absolute start time
  • Tomcat usage

Prometheus 설치 및 설정

애플리케이션에서의 설정은 끝났으니 애플리케이션에서 생성하는 metric을 수집하기 위한 Prometheus Server를 준비해보자.

테스트를 할 때는 역시나 docker(prometheus image)를 이용하면 간편하다. 만약 실제 로컬 환경 혹은 별도의 서버 환경에서 설치해서 사용하고 싶다면 https://prometheus.io/download/#prometheus 에서 다운받아 설치하자.

Prometheus Server는 기동시 /etc/prometheus/prometheus.yml 설정 파일을 사용한다. docker volume mount를 이용해 Prometheus Server에서 사용할 설정 prometheus.yml 파일을 만들어보자.

1
2
3
4
5
6
7
8
global:
scrape_interval: 10s # 10초 마다 Metric을 Pulling
evaluation_interval: 10s
scrape_configs:
- job_name: 'spring-boot-app'
metrics_path: '/actuator/prometheus' # Application prometheus endpoint
static_configs:
- targets: ['host.docker.internal:8080'] # Application host:port

docker에서 host.docker.internal은 특별한 DNS name으로 사용되며 docker를 실행하는 host를 가리킨다. 개발용으로만 사용해야 하며, Docker Desktop(Mac) 외부의 환경에서는 동작하지 않는다.

파일 생성을 완료했다면 prom/prometheus 이미지를 이용해 docker로 prometheus를 실행한다.

1
2
3
4
$ pwd
/Users/user/work/prometheus

$ docker run -p 9090:9090 -v /Users/user/work/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml --name prometheus -d prom/prometheus --config.file=/etc/prometheus/prometheus.yml

문제 없이 실행되었다면 http://localhost:9090 에 접속해보자. 다음과 같이 Prometheus main 화면을 볼 수 있다.
prometheus_main.png

docker로 Prometheus를 실행하면서 설정 파일이 잘 적용되었는지도 확인해보자.
prometheus_configuration.png

Status -> Targets 메뉴에서는 Application의 상태를 확인할 수 있다.
prometheus_targets.png

Application의 상태(Status)가 DOWN인 경우에는 Application이 현재 기동중인지, prometheus.yml에서 targets의 값이 제대로 되어있는지 확인이 필요하다.
prometheus_targets_2.png

여기까지 문제가 없다면 아래와 같이 수집된 metric 중 하나를 선택해 값이 잘 나오는지 확인해보자.
prometheus_graph.png

Prometheus에서 수집한 metric을 Grafana로 시각화하기

Prometheus의 웹 페이지에서 쿼리를 실행해 원하는 metric을 그래프로 시각화할 수 있다. 하지만 매번 모니터링을 위해 수동으로 쿼리를 실행하는 것은 비효율적이고 기본적으로 제공하는 대시보드 또한 간단하게 그래프를 볼 수 있는 정도이다.
Prometheus가 제공하는 것만으로는 시각화하는데 한계가 있기 때문에 보통 별도의 시각화 도구를 이용해서 metric들을 모니터링한다.

이번에는 별도의 시각화 도구로 Grafana를 사용해보자. 역시나 docker(grafana/grafana)를 사용한다.

1
$ docker run -d --name=grafana -p 3000:3000 grafana/grafana

실행 후 http://localhost:3000 에 접속해보자. 다음과 같이 Grafana login 화면을 볼 수 있다.
기본 설정된 ID/PW인 admin/admin 으로 로그인할 수 있다.
grafana_login.png

Home Dashboard에서 Add data source를 클릭해 Data Source 추가하자.
grafana_add_datasource.png

Grafana에서 시각화할 데이터로서 Prometheus에 수집되고 있는 metric을 사용할 것이기 때문에 Prometheus를 선택한다.
grafana_add_datasource_2.png

Name과 URL(Prometheus Server)을 설정하고 Save & Test를 클릭한다.
grafana_add_datasource_3.png

Prometheus가 Data Source로 추가되었다.
grafana_add_datasource_4.png

다음으로는 Data Source를 이용해 Dashboard를 생성해보자.
grafana_new_dashboard.png

그래프를 이용해볼 것이기 때문에 Choose Visualization을 클릭한다.
grafana_new_dashboard_2.png

Metrics로 이전에 Prometheus에서도 확인해보았던 jvm_memoruy_used_bytes를 선택한다.
grafana_new_dashboard_3.png

작업한 Dashboard를 저장한다.
grafana_new_dashboard_4.png

추가된 Dashboard를 확인할 수 있다.
grafana_new_dashboard_5.png

Spring Boot Application에서 생성하는 metric을 Prometheus를 통해 수집하고, Grafana로 시각화하는 것까지 마무리했다.
실제로는 애플리케이션에서 기본적으로 제공하는 Metric 뿐만 아니라 Micrometer를 이용해 직접 필요한 Metric을 추가할 수도 있다.
또한 Grafana에는 소개하지 않은 더 많은 유용한 기능들이 있다. 필요한 기능은 문서를 통해 찾아가며 사용해보자.

Micrometer

Micrometer란?

micrometer.io에서는 Micrometer에 대해서 다음과 같이 소개하고 있다.

Micrometer provides a simple facade over the instrumentation clients for the most popular monitoring systems, allowing you to instrument your JVM-based application code without vendor lock-in. Think SLF4J, but for metrics

Micrometer는 JVM 기반의 애플리케이션에서 다양한 모니터링 도구가 제공하는 클라이언트 라이브러리에 대한 facade를 제공한다. 로깅 관련된 시스템에서는 SLF4J가 있다면 모니터링(metric) 시스템에서는 Micrometer가 있는 것이다.

즉, 모니터링 시스템을 만드는 vendor들은 Micrometer 인터페이스를 따르기 때문에 Micrometer를 사용하면 애플리케이션 내의 코드 상에서는 모니터링 시스템 클라이언트로 어떤 것을 사용할지에 대한 고민에서 벗어나 Micrometer를 이용해 애플리케이션 metric을 수집하기만 하면 된다.
(모니터링 시스템을 선택하는 것은 런타임 시점에 정해진다고 생각하면 된다.)

예를 들어, 모니터링 시스템으로 Prometheus를 사용한다고 하면 아래와 같이 표현할 수 있다.
micrometer

Micrometer는 다음과 같은 모니터링 시스템을 지원한다.

  • AppOptics, Azure Monitor, Netflix Atlas, CloudWatch, Datadog, Dynatrace, Elastic, Ganglia, Graphite, Humio, Influx/Telegraf, JMX, KairosDB, New Relic, Prometheus, SignalFx, Google Stackdriver, StatsD, Wavefront

Micrometer에 의해서 기록된 애플리케이션의 metric 정보는 시스템의 이상 유뮤룰 판단하기 위한 모니터링(알람) 용도로 사용된다.

Supported monitoring systems

Micrometer는 코어 모듈과 측정 SPI(Service Provider Interface)를 포함하고 있다. (각각 Registry라고 부르는 다양한 모니터링 시스템에 대한 구현을 포함하고 있다.)
모니터링 시스템에는 중요한 3가지 특징이 있다.

Dimensionality

Dimensionality는 수집하는 metric 이름에 tag(key-value)를 붙일 수 있도록 지원하는 것을 말한다. 반면 일부 모니터링 시스템의 경우에는 tag 형태가 아닌, flat한 metric name만 사용이 가능하다. 이를 hierarchical system이라고 한다.
Micrometer는 hierarchical system에 metric을 보낼 때는 tag를 metric name에 추가한다.

  • Dimensional: AppOptics, Atlas, Azure Monitor, Cloudwatch, Datadog, Datadog StatsD, Dynatrace, Elastic, Humio, Influx, KairosDB, New Relic, Prometheus, SignalFx, Sysdig StatsD, Telegraf StatsD, Wavefront
  • Hierarchical: Graphite, Ganglia, JMX, Etsy StatsD

Rate aggregation

모니터링 시스템 사용자들은 일정 시간 간격 동안의 특정 지표에 대한 평균값을 필요로 하는 경우가 많다. 어떤 모니터링 시스템은 애플리케이션에서 평균값을 직접 구해서 보내주기를 기대한다. 반면에 어떤 모니터링 시스템은 애플리케이션이 누적된 값을 보내주기를 기대하며, 모니터링 시스템이 직접 평균값을 구하는 경우도 있다.

  • Client-side: AppOptics, Atlas, Azure Monitor, Datadog, Elastic, Graphite, Ganglia, Humio, Influx, JMX, Kairos, New Relic, all StatsD flavors, SignalFx
  • Server-side: Prometheus, Wavefront

Publishing

일부 모니터링 시스템은 metric 정보를 애플리케이션으로부터 polling한다. 반면 일부 모니터링 시스템은 애플리케이션이 일정한 간격으로 metric 정보를 push하는 방식으로 사용된다.

  • Client pushes: AppOptics, Atlas, Azure Monitor, Datadog, Elastic, Graphite, Ganglia, Humio, Influx, JMX, Kairos, New Relic, SignalFx, Wavefront
  • Server polls: Prometheus, all StatsD flavors

Micrometer 어떤 Registry를 사용하는지에 따라, 위와 같은 요구사항을 충족하도록 metric 정보를 커스터마이징한다.

Registry

Meter는 애플리케이션의 metric을 수집하기 위한 인터페이스이다. Meter는 MeterRegistry에 의해 생성되어 등록된다. 지원되는 각 모니터링 시스템은 MeterRegistry 구현체를 갖고 있다.

SimpleMeterRegistry는 각 meter의 최신 값을 메모리에 저장한다. 그리고 metric 정보를 다른 시스템으로 내보내지 않는다. 따라서 만약 어떤 모니터링 시스템을 사용할지 결정하지 못했다면 SimpleMegerRegistry를 사용하면 된다.

1
MeterRegistry registry = new SimpleMeterRegistry();

Composite registries

Micrometer는 여러 Registry를 추가할 수 있는 CompositeMeterRegistry를 제공한다. 따라서 CompositeMeterRegistry를 사용하면 둘 이상의 모니터링 시스템에서 동시에 metric을 사용할 수 있다.

1
2
3
4
5
6
CompositeMeterRegistry compositeRegistry = new CompositeMeterRegistry();
SimpleMeterRegistry oneSimpleMeter = new SimpleMeterRegistry();
AtlasMeterRegistry atlasMeterRegistry = new AtlasMeterRegistry(atlasConfig, Clock.SYSTEM);

compositeRegistry.add(oneSimpleMeter);
compositeRegistry.add(atlasMeterRegistry);

Global registry

Micrometer는 static 변수로 Global MeterRegistry를 제공한다. Metrics.globalRegistry를 통해 static 변수에 접근할 수 있으며 Metrics 클래스에는 글로벌 MeterRegistry를 기반으로 Meter를 생성하는 정적 빌더 메소드가 있다.
Global MeterRegistry는 CompositeMeterRegistry 객체이다.

Meters

Micrometer는 Meter의 구현체로 다음의 것들을 지원한다. Meter의 type 별로 수집되는 metric 수가 다르다.

  • Timer, Counter, Gauge, DistributionSummary, LongTaskTimer, FunctionCounter, FunctionTimer, TimeGauge

Meter는 이름(name)태그(tag)로 고유하게 식별된다.

Naming

Micrometer는 소문자 단어를 ‘.’으로 구분하는 naming 규칙을 사용한다. 각각의 모니터링 시스템은 naming 규칙과 관련해 권장 사항을 갖고 있으며 일부 모니터링 시스템은 서로의 naming 규칙이 달라 호환되지 않을 수도 있다.

따라서 모니터링 시스템의 각 Micrometer 구현체는 소문자 단어와 ‘.’으로 구분된 이름을 각자의 모니터링 시스템 naming 규칙으로 변환하는 기능을 제공한다.

예를 들면 아래의 timer meter는 각각의 모니터링에서 다음과 같이 변경된다.

1
registry.timer("http.server.requests");

  • Prometheus: http_server_requests_duration_seconds
  • Atlas: httpServerRequests
  • Graphite: http.server.requests
  • InfluxDB: http_server_requests

그러므로 Micrometer의 소문자 단어를 ‘.’으로 구분하는 naming 규칙을 사용하면 모니터링 시스템 종류에 상관없이 metric name에 대해 이식성을 보장할 수 있다.

Counter

Counter는 애플리케이션에서 특정 속성에 대한 카운트를 기록한다. Build method 혹은 MetricRegistry의 helper method를 통해 custom counter를 생성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
Counter counter = Counter
.builder("instance")
.description("indicates instance count of the object")
.tags("dev", "performance")
.register(registry);

counter.increment(2.0);

assertTrue(counter.count() == 2);

counter.increment(-1); // 카운트는 증가만 가능하다.

assertTrue(counter.count() == 2);

Timers

시스템의 latency(지연 시간) 혹은 이벤트 빈도를 측정하기 위해서 Timers를 사용할 수 있다. Timer는 이벤트가 발생한 수와 총 시간을 기록한다.

1
2
3
4
5
6
7
8
9
10
11
12
SimpleMeterRegistry registry = new SimpleMeterRegistry();
Timer timer = registry.timer("app.event");
timer.record(() -> {
try {
TimeUnit.MILLISECONDS.sleep(1500);
} catch (InterruptedException ignored) { }
});

timer.record(3000, MILLISECONDS);

assertTrue(2 == timer.count());
assertTrue(4510 > timer.totalTime(MILLISECONDS) && 4500 <= timer.totalTime(MILLISECONDS));

Gauge

Gauge는 Meter의 현재 값을 보여준다. 다른 Meter와 다르게 Guage는 데이터의 변경이 관찰 된 경우에만 데이터를 기록한다. 캐시 등의 통계를 모니터링 할 때 유용하다.

1
2
3
4
5
6
7
8
9
10
11
12
SimpleMeterRegistry registry = new SimpleMeterRegistry();
List<String> list = new ArrayList<>(4);

Gauge gauge = Gauge
.builder("cache.size", list, List::size)
.register(registry);

assertTrue(gauge.value() == 0.0);

list.add("1");

assertTrue(gauge.value() == 1.0);

Binders

Micrometer에는 JVM, 캐시, ExecutorService 및 로깅 서비스를 모니터링하기 위한 여러 내장 Binder가 있다.

  • JVM 및 시스템 모니터링: ClassLoaderMetrics
  • JVM memory pool: JvmMemoryMetrics
  • GC metrics: JvmGcMetrics
  • Thread 및 CPU 사용률: JvmThreadMetrics, ProcessorMetrics

Spring Boot에서의 Bean Validation (2)

해당 포스팅에서 사용된 예제 코드는 spring-boot validation example에서 확인 가능합니다.

Custom Validator

사용 가능한 constraint 어노테이션이 제공하는 제약 조건 외에 필요한 경우, 직접 만들어서 사용할 수 있다.

이전 포스팅에서 InPutRequestInputEntity 클래스에서 정규식(Regular Expression)을 사용하여 String이 유효한 PinCode 형식(6자리)인지 확인했었다. 이 부분을 별도의 Validator를 구현해 대체해보려고 한다.

먼저, custom constraint 어노테이션 PinCode를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = PinCodeValidator.class)
@Documented
public @interface PinCode {

String message() default "{PinCode.invalid}";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};
}

custom constraint 어노테이션에는 다음과 같은 것들이 필요하다.

  • parameter message: ValidationMessages.properties에서 특정 property key를 가리키는 메시지 (제약 조건 위반시 메시지로 사용된다.)
  • parameter groups: 유효성 검사가 어떤 상황에서 실행되는지 정의할 수 있는 매개 변수 그룹.
  • parameter payload: 유효성 검사에 전달할 payload를 정의할 수 있는 매개 변수.
  • @Constraint: ConstraintValidator interface 구현을 나타내는 어노테이션

PinCode validator는 다음과 같이 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PinCodeValidator implements ConstraintValidator<PinCode, String> {

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
final Pattern pattern = Pattern.compile("^[0-9]{6}$");
final Matcher matcher = pattern.matcher(value);

try {
return matcher.matches();
} catch (Exception e) {
return false;
}
}
}

이제 다른 constraint 어노테이션과 마찬가지로 @PinCode 어노테이션을 사용할 수 있다.

1
2
3
4
5
6
7
public class InputEntityWithCustomValidator {

@PinCode
private String pinCode;

// ...
}

직접 Validator를 생성해 Validation 하기

Spring이 지원하는 Bean Validator에 의존하지 않고 직접 Bean Validation을 하고자하는 경우가 있을 수 있다.

이 경우, 직접 Validator를 생성하고 validation을 할 수 있다. Spring의 지원이 전혀 필요하지 않다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
@RequiredArgsConstructor
public class DirectlyValidateService {

public void validateInput(InputEntity inputEntity) {
final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
final Validator validator = factory.getValidator();
final Set<ConstraintViolation<InputEntity>> violations = validator.validate(inputEntity);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}

그러나, Spring Boot는 이미 사전에 설정되어 만들어진 Validator 인스턴스를 제공한다. 그렇기 때문에 위와 같이 별도의 Validator 인스턴스를 직접 생성하지 않고 서비스에서 주입받아 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
@RequiredArgsConstructor
public class DirectlyValidateService {

private final Validator validator;

public void validateInputWithInjectedValidator(InputEntity inputEntity) {
final Set<ConstraintViolation<InputEntity>> violations = validator.validate(inputEntity);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}

위의 Service는 Spring에 의해 Bean으로 생성될 때, Validator를 주입받게 된다.

위의 두 방법이 제대로 동작하는지 확인하기 위해 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@ExtendWith(SpringExtension.class)
@SpringBootTest
class DirectlyValidateServiceTest {

@Autowired
private DirectlyValidateService service;

@Test
void whenInputEntityIsInvalid_thenThrowsException() {
final InputEntity inputEntity = new InputEntity();
inputEntity.setNumberBetweenOneAndTen(50);
inputEntity.setNotEmptyString("");
inputEntity.setPinCode("1234");

assertThrows(ConstraintViolationException.class, () -> {
service.validateInput(inputEntity);
});
}

@Test
void givenInjectedValidator_whenInputEntityIsInvalid_thenThrowsException() {
final InputEntity inputEntity = new InputEntity();
inputEntity.setNumberBetweenOneAndTen(50);
inputEntity.setNotEmptyString("");
inputEntity.setPinCode("1234");

assertThrows(ConstraintViolationException.class, () -> {
service.validateInputWithInjectedValidator(inputEntity);
});
}
}

Validation Groups

종종 특정 객체(클래스)는 서로 다른 상황에서 공유되어 사용될 수 있다.

예를 들면, CRUD와 같은 작업에서 “Create”와 “Update”를 수행할 때 같은 객체(클래스)를 사용하는 것이다. 그러나 다음의 경우처럼 서로 다른 상황에서 실행되어야 하는 validation이 있을 수 있다.

  • “Create” 상황에서만 Validation
  • “Update” 상황에서만 Validation
  • 두 가지 상황 모두에서 Validation

위와 같이 Validation 규칙을 구현할 수 있는 Bean Validation 기능을 Validation Groups이라고 부른다.

이전에 custom constraint 어노테이션을 직접 만들면서 groups 필드가 반드시 있어야 하는것을 보았다. 이를 이용해서 Validation이 실행되어야 하는 특정 validation group을 명시할 수 있다.

CRUD 예제를 위해 OnCreateOnUpdate라는 2개의 marker interface를 정의한다.

1
2
3
interface OnCreate {}

interface OnUpdate {}

그 다음 marker interface를 다음과 같은 constraint 어노테이션과 함께 사용할 수 있다.

1
2
3
4
5
6
7
8
class InputEntityWithCustomValidator {

@Null(groups = OnCreate.class)
@NotNull(groups = OnUpdate.class)
private Long id;

// ...
}

위의 코드는 “Create” 상황에서는 id가 null일 수 있고, “Update” 상황에서는 not null이어야 함을 의미한다.

Spring은 @Validated 어노테이션을 이용해 validation group을 사용할 수 있도록 지원한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
@Validated
public class ValidateServiceWithGroups {

@Validated(OnCreate.class)
void validateForCreate(@Valid InputEntityWithCustomValidator input) {
// do something
}

@Validated(OnUpdate.class)
void validateForUpdate(@Valid InputEntityWithCustomValidator input) {
// do something
}
}

@Validated 어노테이션은 클래스에도 적용되어야 하며, validation group에 의한 Validation을 하기 위해서는 메서드에도 적용되어야 한다.

제대로 동작하는지 확인하기 위해 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@ExtendWith(SpringExtension.class)
@SpringBootTest
class ValidateServiceWithGroupsTest {

@Autowired
private ValidateServiceWithGroups service;

@Test
void whenInputIsInvalidForCreate_thenThrowsException() {
InputEntityWithCustomValidator input = new InputEntityWithCustomValidator();
input.setId(17L);
input.setNumberBetweenOneAndTen(5);
input.setNotEmptyString("not empty");
input.setPinCode("123456");

assertThrows(ConstraintViolationException.class, () -> {
service.validateForCreate(input);
});
}

@Test
void whenInputIsInvalidForUpdate_thenThrowsException() {
InputEntityWithCustomValidator input = new InputEntityWithCustomValidator();
input.setId(null);
input.setNumberBetweenOneAndTen(5);
input.setNotEmptyString("not empty");
input.setPinCode("123456");

assertThrows(ConstraintViolationException.class, () -> {
service.validateForUpdate(input);
});
}
}

Spring Boot에서의 Bean Validation (1)

Bean Validation은 Java 생태계에서 유효성(Validation) 검증 로직을 구현하기위한 사실상의 표준이다. Bean Validation은 Spring과 Spring Boot에도 잘 통합되어 있다.

해당 포스팅에서 사용된 예제 코드는 spring-boot validation example에서 확인 가능합니다.

Validation 설정

Spring Boot에서의 Bean Validation은 spring-boot-starter-validation를 추가함으로써 사용할 수 있다.

1
implementation('org.springframework.boot:spring-boot-starter-validation')

Spring Dependency Management Gradle 플러그인(“io.spring.dependency-management”)을 사용한다면 현재 사용중인 Spring Boot 버전에서 자동으로 의존성(버전)을 가져오기 때문에 별도로 버전을 명시할 필요가 없다.

만약 spring-boot-starter-web를 포함하고 있다면 Validation Starter도 포함되어 있기 때문에 따로 추가할 필요는 없다.

1
implementation('org.springframework.boot:spring-boot-starter-web')

Validation Starter는 Bean Validation Specification 구현체 중 가장 널리 사용되고 있는 hibernate validator를 포함하고 있다.

Bean Validation 기본

기본적으로 Bean Validation은 클래스 필드에 특정 어노테이션(annotation)을 달아 제약 조건을 정의하는 방식으로 동작한다. 그 후 해당 클래스 객체를 Validator를 이용해 제약 조건의 충족 여부를 확인한다.

Spring MVC Controller의 Validation

Spring RestController를 통해 Client로부터 전달받은 요청(request, input)에 대해 유효성을 검사하고자 한다. Client로부터 들어오는 다음의 3가지 요청 형식에 대해서 유효성 검사를 할 수 있다.

  • request body
  • path에 포함된 variables (ex. /foos/{id}의 id)
  • query parameters

Request Body

POST 혹은 PUT 요청에서 request body에 JSON 형식의 데이터를 전달하는 것은 일반적이며, Spring은 JSON 형식의 데이터를 Java 객체에 자동으로 매핑한다. 이 과정에서 매핑되는 Java 객체가 요구 사항을 충족하는지 확인하려 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class InputRequest {

@Min(1)
@Max(10)
private int numberBetweenOneAndTen;

@NotEmpty
private String notEmptyString;

@Pattern(regexp = "^[0-9]{6}$")
private String pinCode;
}

numberBetweenOneAndTen는 1에서 10 사이의 값을 가져야 하며, notEmptyString은 빈 문자열(“”)이 아니어야 하고, pinCode는 6자리의 숫자를 가져야 한다.

request body에서 InputRequest 객체를 가져와 유효성 검사를 하는 RestController는 다음과 같다.

1
2
3
4
5
6
7
8
@RestController
public class ValidateRequestBodyController {

@PostMapping("/validateBody")
ResponseEntity<String> validateBody(@Valid @RequestBody InputRequest request) {
return ResponseEntity.ok("valid");
}
}

@RequestBody 어노테이션을 사용하고 있는 Input 매개변수에 @Valid 어노테이션만 추가하면 된다. 이로인해 Spring은 다른 작업을 수행하기 전에 먼저 객체를 Validator에 전달해서 유효성을 검사하게 된다.

만약 InputRequest 클래스에 유효성을 검사해야하는 다른 객체가 필드로 포함된 경우, 이 필드에도 @Valid 어노테이션을 추가해야한다. 이러한 경우를 Complex Type이라고 부른다.

유효성 검사에 실패할 경우 MethodArgumentNotValidException 예외가 발생한다. 기본적으로 Spring은 이 예외에 대해서 HTTP status code 400(Bad Request)으로 변환한다.

아래의 테스트 코드를 통해 동작을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ValidateRequestBodyController.class)
class ValidateRequestBodyControllerTest {

@Autowired
private MockMvc mvc;

@Autowired
private ObjectMapper objectMapper;

@Test
void whenInputRequestIsInvalid_thenReturnStatus400() throws Exception {
final InputRequest request = new InputRequest();
request.setNumberBetweenOneAndTen(50);
request.setNotEmptyString("");
request.setPinCode("1234");

final String body = objectMapper.writeValueAsString(request);

mvc.perform(post("/validateBody")
.contentType("application/json")
.content(body))
.andExpect(status().isBadRequest());
}
}

Path Variables & Request Parameters

path에 포함된 variables과 query parameters에 대한 유효성 검사는 조금 다르게 동작한다.

Path Variable과 Request Parameter의 경우는 int와 같은 primitive type이거나 Integer 혹은 String과 같은 객체이기 때문에 복잡한 유효성 검사를 하지 않는다.

클래스 필드에 직접 어노테이션을 추가하지 않고 다음과 같이 Controller 메소드 매개 변수에 직접 제약 조건을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Validated
@RestController
public class ValidateParametersController {

@GetMapping("/validatePathVariable/{id}")
ResponseEntity<String> validatePathVariable(@PathVariable("id") @Min(5) int id) {
return ResponseEntity.ok("valid");
}

@GetMapping("/validateRequestParameter")
ResponseEntity<String> validateRequestParameter(@RequestParam("param") @Min(5) int param) {
return ResponseEntity.ok("valid");
}
}

@Validated 어노테이션을 클래스 레벨의 Controller에 추가해 Spring이 메서드 매개 변수에 대한 제한 조건 annotation을 평가하게 해야한다.

request body 유효성 검사와 달리 실패할 경우 MethodArgumentNotValidException 예외가 아닌 ConstraintViolationException 예외가 발생한다. 주의할 것으로는 Spring은 ConstraintViolationException 예외에 대해서는 기본적으로 exception을 handling 하지 않기 때문에 HTTP status code 500(Internal Server Error)로 처리한다.

만약 HTTP status code 400(Bad Request)으로 처리하고자 한다면, custom exception handler를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Validated
@RestController
public class ValidateParametersController {

// request mapping method omitted

@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
String handleConstraintViolationException(ConstraintViolationException e) {
return "not valid due to validation error: " + e.getMessage();
}
}

테스트 코드를 통해 동작을 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ValidateParametersController.class)
class ValidateParametersControllerTest {

@Autowired
private MockMvc mvc;

@Test
void whenPathVariableIsInValid_thenReturnStatus400() throws Exception {
mvc.perform(get("/validatePathVariable/1"))
.andExpect(status().isBadRequest());
}

@Test
void whenRequestParameterIsInvalid_thenReturnStatus400() throws Exception {
mvc.perform(get("/validateRequestParameter")
.param("param", "1"))
.andExpect(status().isBadRequest());
}
}

Spring Service의 Validation

Controller 레벨에서 입력을 검증하는것 뿐만 아니라 @Validated@Valid 어노테이션을 이용해 다른 Spring component에서도 입력에 대해 유효성을 검증할 수 있다.

1
2
3
4
5
6
7
@Service
@Validated
public class ValidateService {

void validateInputRequest(@Valid InputRequest input) {
}
}

@Validated 어노테이션은 클래스 수준에서만 평가되기 때문에 메서드에는 추가하지 말아야 한다.

테스트 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ExtendWith(SpringExtension.class)
@SpringBootTest
class ValidateServiceTest {

@Autowired
private ValidateService service;

@Test
void whenInputRequestIsInvalid_thenThrowException() {
final InputRequest request = new InputRequest();
request.setNumberBetweenOneAndTen(50);
request.setNotEmptyString("");
request.setPinCode("1234");

assertThrows(ConstraintViolationException.class, () -> {
service.validateInputRequest(request);
});
}
}

Spring Repository의 Validation

유효성 검사를 위한 가장 마지막 계층은 persistence layer이다. 기본적으로 Spring Data는 Hibernate를 사용하여 Bean Validation을 지원한다.

JPA Entities

InputEntity 클래스의 객체를 DB에 저장하려고 한다. 먼저 필요한 JPA 어노테이션들을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
@Entity
public class InputEntity {

@Id
@GeneratedValue
private Long id;

@Min(1)
@Max(10)
private int numberBetweenOneAndTen;

@NotEmpty
private String notEmptyString;

@Pattern(regexp = "^[0-9]{6}$")
private String pinCode;
}

위의 Entity를 관리하는 CRUE Repository도 생성한다.

1
public interface ValidateRepository extends CrudRepository<InputEntity, Long> {}

기본적으로 제약 조건을 위반한 InputEntity 객체를 저장할 때 ConstraintViolationException이 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@ExtendWith(SpringExtension.class)
@DataJpaTest
class ValidateRepositoryTest {

@Autowired
private ValidateRepository repository;

@Autowired
private EntityManager entityManager;

@Test
void whenInputEntityIsInvalid_thenThrowsException() {
final InputEntity inputEntity = new InputEntity();
inputEntity.setNumberBetweenOneAndTen(50);
inputEntity.setNotEmptyString("");
inputEntity.setPinCode("1234");

assertThrows(ConstraintViolationException.class, () -> {
repository.save(inputEntity);
entityManager.flush();
});
}
}

Bean Validation은 EntityManager가 flush된 후에 트리거 된다. Hibernate는 특정 상황에서 EntityManager를 자동으로 flush하지만 integration test의 경우에는 직접 수행해야한다.

만약 Spring Data Repository에서 Bean Validation을 비활성화하려면 Spring Boot property인 spring.jpa.properties.javax.persistence.validation.mode 값을 none으로 설정하면 된다.

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×