728x90
반응형

오늘은 Spring MVC(Sevlet)과 Webflux의 차이에 대해서 간략하게 정리해 보고자 한다. 

최근 API Gateway를 만들기 위해 Spring Cloud Gateway를 사용하였는데, 이 환경이 Webflux로 작성되어 있어서 기존에 내가 사용한 Spring MVC의 Servelt과 어떠한 차이가 있는지 궁금해서 이런저런 포스트를 찾아보았고, 이해한 내용을 정리해두고자 한다. 

 

우리의 서비스가 Concurrent하게 동작하기 위헤서는 서버 내부에서 동시성을 보장하는 기술이 필요하다. 간단하게 우리는 Thread라고 하는 녀석을 여러 개 만들어서 이것을 사용하여 여러 작업을 동시에 처리하게 할 수 있다. 이것은 Spring MVC에서 채택하고 있는 방법이고 이를 위해서 Thread pool를 만들어서 이 많은 Thread를 관리한다. Spring boot web에서 Request가 들어오면 해당 Request를 처리하기 위해서 Thread하나가 사용된다고 보면 될 것 같다. 

 

그에 반해, 적은 Thread 갯수로 이를 수행할 수 있는 방법이 존재한다. Thread 갯수보다 더 많은 작업이 있더라도 각 작업은 굉장히 적은 시간으로 쪼개어 하나씩 수행하도록 하는 방법이다. 이것을 Event-loop 방식이라하고, 이 방법을 채택해서 유명해 진 것이 우리가 아는 Node.js 이다. JavaScript의 거의 대부분의 수행은 메인 스레드 하나에서 실행된다. 그래서 Node.js는 싱글스레드 기반이라고 얘기하곤 한다. Spring Webflux는 이러한 컨셉을 가져와서 만들 것이라고 보면 된다. 

Webflux는 위에서 보는 것과 같이 기존 Servelt과 동일한 라이브리러리 이외에 Non-servlet으로 구현이 가능한 Netty와 Undertow 라이브러리를 가지고 있다. 이것으로 Non-blocking한 동작을 가능하게 한다. 둘다 동일하게 @Controller annotation을 사용하지만 내부적으로 수행되는 것은 다르다.

 

그럼 언제 Servlet을 사용하고 언제 Reactive를 사용해야 할까?

Spring boot의 Rossen Stoyanchev는 현재의 서비스가 Servlet으로 잘 구동되고 있다면 굳이 바꿀 필요가 없다고 말한다. 

그리고 Blocking Dependency를 사용하는 경우에는 Servlet이 더 어울린다. 그리고 Servlet API를 사용하게 된다면 Servlet이 더 어울린다. 왜나하면 Servlet API를 사용하는 순간 Blocking으로 바뀌게 되기 때문이다. 아래 그림을 보면 이해가 될 것이다. 

Servlet에서 Reactive를 사용하는 방법도 있다. 예를 들어 Microservice 환경에서 Restful API 요청이 들어왔을 때 다른 Service로 요청을 보내거나 데이터를 받아올 때 사용하면 좋을 것이다. 

 

Reactive가 반드시 Servlet 보다 빠른 것은 아니지만 Scalibility를 중시허가나 Resource를 효율적으로 사용하고 싶은 경우에는 고려하면 좋은 것임에는 틀림없다. 

 

미래의 나를 위해...

728x90
반응형
728x90
반응형

Kafka를 이용하는 Spring boot project에서 unit test를 수행할 때에 여러가지 어려움을 직면하게 된다. Kafka message send and receive 관련 Unit test를 추가하기 위해서는 Embedded kafka를 추가한 후 메세지가 제대로 전달되는지를 확인한다. 

 

이때, Kafka bootstrap server address를 아래와 같이 Embedded Kafka 의 Broker 주소 값으로 수행한다.

${spring.embedded.kafka.brokers}

하지만, 이 설정은 아래의 Annotation이 설정된 Spring boot test에서만 사용이 가능하다.

@EmbeddedKafka

그렇지 않은 Unit test에서는 아래와 같은 Exception이 발생하게 된다. Embedded Kafka의 AutoConfiguration이 적용되지 않아서 broker address를 찾을 수 없기 때문이다. 

 

이러한 문제가 발생할 경우 application.yaml에 추가되어 있는 Embedded Kafka 의 broker 주소에 Default 값을 넣어주면 문제가 해결된다. 

 

즉, @EmbeddedKafka가 없는 Spring boot test의 경우 Default 주소로 동작을 하게 되고, 이 때 해당 주소에 실제 Kafka가 없더라도 Integration Test를 수행하는데는 문제가 발생하지 않는다. 

 

나의 경우 Kafka broker의 기본 Port를 사용하여 Default 값을 추가해 주었다. 

kafka:
  bootstrap:
    addresses: ${spring.embedded.kafka.brokers:localhost:9092}

(위 설정은 Config 파일을 통해서 직접 Bean을 등록하여 Spring에서 기본으로 사용하는 Configuration과는 차이가 있다. Default 값을 넣은 부분만 참고하다록 하자.)

728x90
반응형
728x90
반응형

https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#validator-customconstraints

 

Hibernate Validator 7.0.2.Final - Jakarta Bean Validation Reference Implementation: Reference Guide

Validating data is a common task that occurs throughout all application layers, from the presentation to the persistence layer. Often the same validation logic is implemented in each layer which is time consuming and error-prone. To avoid duplication of th

docs.jboss.org

Spring Project에서 Validation 설정 후에 사용을 하다보면 내가 원하는 형태의 Validation이 필요할 때가 있다. 기본적으로 제공하는 것으로 부족할 때가 반드시 발생하게 된다. 그때에는 Custom Annotation을 만들고, Validator를 연결하여 Contraint Annotation을 만들어서 사용하면 된다. 

 

나의 경우에 가장 먼저 필요한 Custom Validation Contraint는 Request Body에 전달되는 Session Values가 Json String format인지 확인이 필요하였다. (일반적으로 Session data는 Client가 입력하고 싶은 데이터를 입력하여야 하기 때문에 그 값을 Json 형태로 저장해 두면 활용성이 올라갈 것 같아 이렇게 만들었다.)

 

하지만, 기본으로 제공하는 Validation에서는 없는 기능이라 Custom Contraint를 작성하게 되었다. 

 

Annotation Interface

우선 Custom Annotation을 만들기 위해서는 Annotation Interface가 필요하다. 

@Documented			// Javadoc 문서에 Annotation이 포함된다.
@Constraint(validatedBy = JsonStringValidator.class)	// Contraint를 수행하는 Validator class
@Target({ElementType.FIELD})			// Target은 Field에서만 가능하다. (DTO field)
@Retention(RetentionPolicy.RUNTIME)		// 이 Annotation이 동작되는 범위 - Source(컴파일 이후 없어짐), Class(클래스 참조시까지), Runtime(컴파일 이후에도 가능)
public @interface JsonStringConstraint {
    String message() default "Invalid Json String type";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

위와 같이 JsonStringConstraint Annotation Interface를 작성하였다. 엄밀히 얘기하면 Custom Contraint용 Annotation이다. 

Contraint를 작성하기 위해서는 3가지 요소가 반드시 필요한데, message(), groups(), payload()가 그것이다. 

 

  • message - Validation이 실패하였을 경우 표시하는 메세지이다. 이것은 Message Source Accessor과 함께 사용할 수 있다. 
  • groups - Contraint를 Groupping 하는 기능이다. 그룹별로 Message Source를 다르게 사용할 때 등에 사용된다. 
  • payload - 이 값은 Validator에 전달하고 싶은 값을 넣는 곳이다. 예를 들면, Contraint 의 심각도 등을 보내어 심각도에 따라 다른 행위를 하도록 할 수 있다. 

 

JsonStringValidator class

@Slf4j
public class JsonStringValidator implements ConstraintValidator<JsonStringConstraint, String> {

    @Override
    public void initialize(JsonStringConstraint constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        try {
            if (s != null) {
                final ObjectMapper mapper = new ObjectMapper();
                mapper.readTree(s);
            }
            return true;
        } catch (IOException e) {
            LOGGER.debug("String is not json format. {}", s);
            return false;
        }
    }
}

Validator class 는 ContraintValidator Interface를 구현한다. Override되는 메서드는 initailize() 와 isValid() 가 있다. 

여기에서 중요한 것은 isValid() 이다. Contraint가 값을 확인 후에 Validation 결과를 Return 해주어야 한다. 

 

Json String이 유요한지 여부는 Jackson 에 있는 ObjectMapper를 이용하여 확인하였다. 

이 Contraint는 String Field에서만 사용이 가능하다. 

 

사용방법

사용방법은 다른 Validation annotation과 동일하게 사용가능하다. 

@Getter
@Setter
@Accessors(chain = true)
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "Session Creation Request")
public static class SessionCreateRequest {
    @Schema(example = "1L")
    @NotNull(message = "{account.id.empty}")
    private Long accountId;
    @Schema(example = "{\"orderCount\":1}")
    @NotBlank(message = "{session.value.empty}")
    @JsonStringConstraint(message = "{session.value.not.json}")
    private String values;
}

Validation messsage는 Message Source 기능을 사용하여 가져오도록 하였다. 그리고, 다른 Validation Annotation과 함께 사용할 수 있는 것을 볼 수 있다. 

 

아래와 같이 Validation Fail 일 경우 Error response를 받을 수 있다. 

...
"errors": [
        {
            "codes": [
                "JsonStringConstraint.sessionCreateRequest.values",
                "JsonStringConstraint.values",
                "JsonStringConstraint.java.lang.String",
                "JsonStringConstraint"
            ],
            "arguments": [
                {
                    "codes": [
                        "sessionCreateRequest.values",
                        "values"
                    ],
                    "arguments": null,
                    "defaultMessage": "values",
                    "code": "values"
                }
            ],
            "defaultMessage": "String is not JSON format.",
            "objectName": "sessionCreateRequest",
            "field": "values",
            "rejectedValue": "test",
            "bindingFailure": false,
            "code": "JsonStringConstraint"
        }
    ],
    "path": "/api/v1/session"
...

 

728x90
반응형
728x90
반응형

Spring boot 에서 Validation은 보통 Entity와 Request body의 정보가 제대로 들어왔는지 확인하는 경우에 사용한다.

예를 들면, 아래와 같이 Request body의 Variable 의 값이 Null이거나 Blank 일 때를 확인해서 이러한 경우에는 Client에게 Error Reponse 를 보내거나, log 상에 에러를 발생시키게 된다.

@Getter
@Setter
@Accessors(chain = true)
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Schema(description = "Session Creation Request")
public static class SessionCreateRequest {
    @Schema(example = "1L")
    @NotNull(message = "{account.id.empty}")
    private Long accountId;
    @Schema(example = "{\"orderCount\":1}")
    @NotBlank(message = "{session.value.empty}")
    private String values;
}

 

이러한 Validation 작업은 Server의 완성도를 높여줄 수 있고 Client에게 Request 상의 문제를 알려줄 수 있기 때문에 굉장히 유용한 기능이라고 할 수 있다. 셋팅은 완료하고 나면 간단하게 Annotation을 붙이면 체크를 해주니 사용성 또한 아주 간단하다. 

나중에 포스팅을 하겠지만, Custom annotation을 만들어서 체크도 할 수 있다. 

 

그럼 설정방법에 대해서 알아보자

 

Dependency 설정

Spring boot 에서 Validation Dependency를 제공한다. 아래를 pom.xml에 추가하도록 하자.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

 

Configuration setting

Validation Configuration

아주 기본적인 Configuration이다. Configuration이 없어도 Default로 셋팅이 되지만 Validation Message Source를 넣고자 Configuration file을 만들었다. Validation Message Source는 Validation error가 발생되었을 때 그 정보를 정의해둔 메세지 형태로 Client로 보내고자 할 때 사용한다. 아래에 설정 방법을 추가하도록 하겠다. 

@Configuration
public class ValidationConfig {
    private final MessageSource validationMessageSource;

    @Bean
    public LocalValidatorFactoryBean getValidator() {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(validationMessageSource);

        return bean;
    }
}

Message Accessor Configuration

Validation Message configuration은 ReloadableResourceBundleMessageSource를 생성하여 몇가지 옵션을 제공한다. 

@Configuration
public class MessageConfig {
    private static final String VALIDATION_MESSAGE_SOURCE_PATH = "classpath:/messages/validation_message";

    @Bean
    public MessageSource validationMessageSource() {
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.setBasename(VALIDATION_MESSAGE_SOURCE_PATH);
        messageSource.setDefaultEncoding("UTF-8");

        return messageSource;
    }

    @Bean("validationMessageSourceAccessor")
    public MessageSourceAccessor validationMessageSourceAccessor() {
        return new MessageSourceAccessor(validationMessageSource());
    }
}

Basename은 Message 목록의 Path를 지정한다. 아래와 같이 Validation에는 message 옵션이 붙는데 Validation이 실패할 경우 보여줄 메세지를 지정한다. `{}`안에 있는 것이 key이며 파일에서 아래의 key를 찾아 메세지를 매핑하게 된다. 

@NotBlank(message = "{session.value.empty}")

Message source 를 생성한 후 그것에 접근할 수 있는 Accessor를 Bean에 등록한다. 

이렇게 설정할 경우에는 메세지를 더 효율적으로 관리할 수 있다. 

 

Message properties 파일

위의 설정의 Basename으로 들어간 파일의 경로에 message.properties 파일이 있어야 한다. 아래와 같이 key값에 대응되는 값을 가지고 있어야 한다. 한가지 주의할 점은 Unit test를 만들 경우에는 아래의 파일이 test/resources에도 있어야 한다. 

# JwtToken
account.id.empty=Account id is empty.
token.value.empty=Jwt token is empty.

# Session
session.value.empty=Session value is empty.

 

@Valid annotation

Vadilation check를 위한 Annotaiton을 붙였다라고 해서 Validation이 되지 않는다. 원하는 Request Body에 `@Valid` annotation을 붙여줘야 한다. 아래 Contoller source를 보면 `@RequestBody` 앞에 `@Valid` annotation이 붙은 것을 볼 수 있다. 

@PostMapping("/session")
public SessionDTO.SessionInfo createSession(@Valid @RequestBody SessionDTO.SessionCreateRequest request) {
    Session session = modelMapper.map(request, Session.class);
    Session createdSession = sessionService.create(session);

    return SessionDTO.SessionInfo.from(createdSession, modelMapper);
}

 

Validation Result

Validation 설정을 완료하고 테스트를 해보면 Validation error가 발생하였을 경우에는 아래와 같은 메세지가 나오게 된다.

{
    "timestamp": "2022-01-19T19:53:48.832+00:00",
    "status": 400,
    "error": "Bad Request",
    "trace": "org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public io.coolexplorer.session.dto.JwtTokenDTO$JwtTokenInfo io.coolexplorer.session.controller.JwtTokenController.createToken(io.coolexplorer.session.dto.JwtTokenDTO$JwtTokenCreateRequest): [Field error in object 'jwtTokenCreateRequest' on field 'jwtToken': rejected value []; codes [NotBlank.jwtTokenCreateRequest.jwtToken,NotBlank.jwtToken,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [jwtTokenCreateRequest.jwtToken,jwtToken]; arguments []; default message [jwtToken]]; default message [Jwt token is empty.]] \n\tat org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.resolveArgument(RequestResponseBodyMethodProcessor.java:141)\n\tat org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:179)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:146)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1067)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)\n\tat org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:681)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:764)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:197)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:540)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:135)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:895)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1732)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\n\tat java.base/java.lang.Thread.run(Thread.java:829)\n",
    "message": "Validation failed for object='jwtTokenCreateRequest'. Error count: 1",
    "errors": [
        {
            "codes": [
                "NotBlank.jwtTokenCreateRequest.jwtToken",
                "NotBlank.jwtToken",
                "NotBlank.java.lang.String",
                "NotBlank"
            ],
            "arguments": [
                {
                    "codes": [
                        "jwtTokenCreateRequest.jwtToken",
                        "jwtToken"
                    ],
                    "arguments": null,
                    "defaultMessage": "jwtToken",
                    "code": "jwtToken"
                }
            ],
            "defaultMessage": "Jwt token is empty.",
            "objectName": "jwtTokenCreateRequest",
            "field": "jwtToken",
            "rejectedValue": "",
            "bindingFailure": false,
            "code": "NotBlank"
        }
    ],
    "path": "/api/v1/token"
}

 

728x90
반응형

+ Recent posts