네로개발일기

개발자 네로의 개발 일기, 자바를 좋아합니다 !

'web'에 해당되는 글 82건


반응형

1. Spring Security Config 리팩토링

Spring Security 버전업이 되면서 기존 프로젝트의 문제점이 생기기 시작했다. 해당 문제점에 대해 알아보고 리팩토링을 하자.

 

  • WebSecurityConfigurerAdapter 상속 제거
  • Resource Filter Chain 설정

2. 개발 환경

  • Spring Boot 2.7.6
  • Spring Security 5.7.6
  • Java 11
  • Gradle 7.5.1

3. 기존 SecurityConfig

일반적으로 많이 사용하는 SecurityConfig이다.

@Configuration
@EnableWebSecurity // Spring Security 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    public void configure(WebSecurity web) {
        // resource 에 대해 Spring Security FilterChain 제외
        web.ignoring().antMatchers("/resources/**");
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/", "/login", "/sign-up", "/check-email-token"
                        , "/email-login", "/check-email-login", "/login-link", "/login-by-email").permitAll()
                .mvcMatchers(HttpMethod.GET, "/profile/*").permitAll()
                .anyRequest().authenticated().and()
            .formLogin()
                .loginPage("/login").permitAll()
            .logout()
                .logoutSuccessUrl("/");
    }
}

ant pattern을 이용한 ignore 처리가 권장되지 않는다.

해당 설정으로 실행시 하단과 같은 WARN 로그가 발생한다.

You are asking Spring Security to ignore Ant [pattern='/resource/**']. This is not recommended -- please use permitAll via HttpSecurity#authorizeHttpRequests instead.

이 로그는 Spring Security 5.5.x에 추가되었으며 Spring Security Github Issue에서 확인할 수 있다. 간략히 요약해보자면,

web.ignoring()은 Spring Security 가 해당 엔드포인트에 보안헤더 또는 기타 보호 조치를 제공할 수 없다. 대신에 permitAll() 메서드를 사용하여 권한 검증 없이 요청을 보호할 수 있다. 이러한 이유때문에 해당 경고메시지를 알려주었다.

또한, static resource에 대해 SecurityFilterChain을 추가하는 방법에 대해서 알려주었는데 해당 방안은 밑에서 알아보겠습니다.

 

Spring Security 5.7.x 부터 WebSecurityConfigurerAdapter Deprecate 문제

Spring Security 5.7.x 부터 WebSecurityConfigurerAdapter가 Deprecated 되었다.

https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter

 

4. SecurityConfig 리팩토링

WebSecurityConfigurerAdapter를 상속하기않고 적용

WebSecurityConfigurerAdapter를 상속하지 않고 필터체인을 구성하는 방법으론 SecurityFilterChain을 Bean으로 선언하는 방법이 있다. 이때 HttpSecurity를 주입받아 사용하면 된다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean   
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().antMatchers("/resources/**");   
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/", "/login", "/sign-up", "/check-email-token"
                        , "/email-login", "/check-email-login", "/login-link", "/login-by-email").permitAll()
                .mvcMatchers(HttpMethod.GET, "/profile/*").permitAll()
                .anyRequest().authenticated();

        http.formLogin()
                .loginPage("/login")
                .permitAll();

        http.logout()
                .logoutSuccessUrl("/");

        return http.build();
    }
}

WebSecurityConfigurerAdapter 상속을 제거하고 WebSecurityCustomizer 선언과 HttpSecurity의 build() 호출 후, 리턴하여 Bean으로 등록한다.

HttpSecurityConfiguration을 확인해보면 HttpSecurity에 기본적인 설정을 한 후, prototype으로 Bean을 등록한다. 따라서 매번 주입받을 때 마다 새로운 인스턴스를 주입받을 수 있다.

 

➤ 주의사항

WebSecurityConfigurerAdapter 상속과 SecurityFilterChain Bean 등록을 동시에 사용할 경우 아래와 같은 로그가 발생한다.

Caused by: java.lang.IllegalStateException: Found WebSecurityConfigurerAdapter as well as SecurityFilterChain. Please select just one.

 

Resource 용 SecurityFilterChain 적용

WebSecurityCustomizer 설정을 제거하고 @Order(0) 을 추가하여 먼저 FilterChain 을 타도록 지정한다. resources(css, js 등) 의 경우 securityContext 등에 대한 조회가 불필요 하므로 disable 합니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/", "/login", "/sign-up", "/check-email-token"
                        , "/email-login", "/check-email-login", "/login-link", "/login-by-email").permitAll()
                .mvcMatchers(HttpMethod.GET, "/profile/*").permitAll()
                .anyRequest().authenticated();

        http.formLogin()
                .loginPage("/login")
                .permitAll();

        http.logout()
                .logoutSuccessUrl("/");

        return http.build();
    }

    @Bean
    @Order(0)
    public SecurityFilterChain resources(HttpSecurity http) throws Exception {
        return http.requestMatchers(matchers -> matchers.antMatchers( "/resources/**"))
                .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll())
                .requestCache(RequestCacheConfigurer::disable)
                .securityContext(AbstractHttpConfigurer::disable)
                .sessionManagement(AbstractHttpConfigurer::disable).build();
    }
}

 

 

 

 출처 

https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter

 

Spring Security without the WebSecurityConfigurerAdapter

<p>In Spring Security 5.7.0-M2 we <a href="https://github.com/spring-projects/spring-security/issues/10822">deprecated</a> the <code>WebSecurityConfigurerAdapter</code>, as we encourage users to move towards a component-based security configuration.</p> <p

spring.io

https://github.com/spring-projects/spring-security/issues/10938

 

WARN when ignoring antMatchers - please use permitAll · Issue #10938 · spring-projects/spring-security

When I use web.ignoring().antMatchers() I'd like to see a DEBUG message instead of a WARNING for each ignored pattern. I'm confused by the message saying it's not recommended and I shou...

github.com

https://velog.io/@csh0034/Spring-Security-Config-Refactoring

 

[Spring Security] Config Refactoring

WebSecurityConfigurerAdapter Deprecated, Lambda DSL 적용, Resource Filter Chain 설정

velog.io

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

스프링은 애플리케이션 전 계층에서 도메인 객체를 검증할 수 있는 인터페이스를 제공한다. 스프링의 bean validation을 통해 controller의 파라미터를 비즈니스 로직을 추가하지 않고 검증할 수 있는지 알아보자.

 

interface Validator

Spring은 도메인 객체를 검증할 수 있도록 Validator 인터페이스를 도입했다. Validator 인터페이스는 객체를 검증하는데 실패하면 Errors 객체에 에러를 등록한다.

Validator 인터페이스는 아래의 두가지 메서드를 가지고 있다.

- supports(Class): 매개변수로 전달된 클래스를 검증할 수 있는지 여부를 반환

- validate(Object, org.springframework.validation.Errors): 매개변수로 전달된 객체를 검증하고 실패하면 Errors 객체에 에러를 등록한다.

 

아래 코드는 Person 객체가 어떤 식으로 Validator 인터페이스를 구현하는지 보여준다.

@Getter @Setter
public class Person {
    private String name;
    private int age;
}
public class PersonValidator implements Validator {

    public boolean supports(Class clazz) {
        return Person.class.equals(clazz);
    }
    
    public void validate(Object obj, Errors e) {
        ValidationUtils.rejectIfEmpty(e, "name", "name.empty");
        
        Person p = (Person) obj;
        if (p.getAge() < 0) {
            e.rejectValue("age", "negative.value");
        } else if (p.getAge() > 110) {
            e.rejectValue("age", "too.old");
        }
    }
}

validate 함수를 보면, 검증에 실패한 경우 Errors 객체의 rejectValue 함수를 호출하는 것을 볼 수 있다.

rejectValue의 파라미터는 필드이름, 에러코드로 구성된다.

ValidationUtils 클래스를 이용하여 필드 검증을 하는데, 값이 비어있거나 공백문자가 있는 경우를 쉽게 확인할 수 있다.

 

위 코드를 테스트 해보자.

@Autowired
private PersonValidator personValidator;

@GetMapping("/person/validate")
public boolean directlyValidatePerson(@ModelAttribute Person person, BindingResult result) {
    logger.debug("validate directly. {}", person);
    
    personValidator.validate(person, result);
    
    return !result.hasErrors();
}
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = { WebAppConfig.class, ValidatorConfig.class })
public class PersonValidateControllerTest {
    
    @Autowired 
    private PersonValidator personValidator;
    
    private MockMvc mockMvc;
    
    @Before
    public void setUp() {
        this.mockMvc = MockMvcBuilders.standaloneSetup(new PersonValidateController(personValidator)).build();
    }
    
    @Test
    public void directlyValidateEmptyNameTest() throws Exception {
        this.mockMvc.perform(get("/person/validate")
                             .param("name", "")
                             .param("age", "25")
                     .andDo(print())
                     .andExpect(status().isOk())
                     .andExpect(content().string("false"));
    }
    
    @Test
	public void directlyValidateWrongAgeTest() throws Exception {
		// 음수 나이
		this.mockMvc.perform(get("/person/validate")
				.param("name", "test")
				.param("age", "-1"))
			.andDo(print())
			.andExpect(status().isOk())
			.andExpect(content().string("false"));

		// 100살을 초과하는 나이
		this.mockMvc.perform(get("/person/validate")
				.param("name", "test")
				.param("age", "101"))
			.andDo(print())
			.andExpect(status().isOk())
			.andExpect(content().string("false"));
	}
}

 

directlyValidatePerson 메서드를 보면 파라미터에 Person 객체 외에 BindingResult 객체가 있다.

BindingResult 객체는 모델의 검증 작업에서 나타난 에러를 저장하는 역할을 하며, 이를 이용해 메서드 실행을 분기할 수 있다.

 

MessageSource

위 Validator의 validate 함수 내에서 검증 실패시 reject 함수를 호출하는 것을 살펴봤다. 이 때 reject 함수 파라미터 중 에러코드를 지정했는데, 이는 에러메시지와 관련이 있다.

에러 메시지는 보통 messages.properties와 같은 properties 파일에서 읽어오도록 구현한다. Spring에서는 MessageSource를 이용해 properties 파일로부터 에러 메시지를 가져오도록 할 수 있다. 

 

ValidationUtils.rejectIfEmpty(errors, "name", "name.empty");

위 코드에서 name.empty는 에러코드로 messages.properties 파일에 존재하는 키 값이다.

messages.properties에는 다음과 같이 선언이 되어있다.

name.empty=The name is empty

이 값을 가져오기 위해 MessageSource를 사용하는데, MessageSource의 구현에는 두가지 방법이 있다.

- StaticMessageSource: 코드로 메시지를 등록한다.

- ResourceBundleMessageSource: 리소스 파일로부터 읽어와 등록한다.

 

properties 파일을 읽어와 에러 메시지를 읽어올 것이므로 ResourceBundleMessageSource 클래스를 이용하자.

@Bean
public MessageSource messageSource() {
    ResourceBundleMessageSource resourceBundleMessageSource = new ResourceBundleMessageSource();
    resourceBundleMessageSource.setBasename("messages");
    resourceBundleMessageSource.setDefaultEncoding("UTF-8");
    
    return resourceBundleMessageSource;
}
@Autowired
private MessageSource messageSource;

@GetMapping("/person/validate")
public List<String> validateAndGetErrorMessages(@ModelAttribute Person person, BindingResult result) {
    logger.debug("validated. {}", person);
    
    personValidator.validate(person, result);
    
    return result.getFieldErrors().stream()
                            .map(e -> messageSource.getMessage(e, Locale.getDefault()))
                            .collect(Collectors.toList());
}
@Test
public void validateAndGetBindingResultTest() throws Exception {
    mockMvc.perform(get("/person/validate")
                    .param("name", "")
                    .param("age", "99")
           .andDo(print())
           .andExpect(status().isOk());
}

이 테스트에 대한 결과는 아래와 같다.

MockHttpServletResponse:
               Status = 200
        Error message = null
              Headers = [Content-type: "application/json;charset=UTF-8"]
         Content Type = application/json;charset=UTF-8
                 Body = ["The name is empty"]
        Forwarded URL = null
       Redirected URL = null
              Cookies = []

 

Spring Validation

Spring 3 부터 Bean Validation API를 제공한다. Spring Bean Validation은 Validator 인터페이스를 이용하여 직접 도메인 객체를 검증하는 방법을 표준화하고 어노테이션을 이용해 표현할 수 있도록 도와준다.

Bean Validation 명세는 구현체로 Hibernate Validator를 지원한다.

Hibernate Validator는 자주 쓰이는 몇가지 검증 어노테이션을 built-in으로 제공한다.

@Getter @Setter
public class Car {

    @NotBlank(message = "The manufacturer must not be empty.")
    private String manufacturer;
    
    @Range(min = 0, max = 10, message = "The seat count must be between 0 ~ 10")
    private int seatCount;
    
    @Range(min = 0, max = 300, message = "The speed must be between 0 ~ 300")
    private int topSpeed;
}

세가지 필드를 가지고 있는 간단한 도메인 클래스이다. 검증 조건을 어노테이션으로 선언하여 표현하였다. 이를 검증하는 Validator를 가지고 와야하는데 Bean Validation API에서 LocalValidatorFactoryBean 객체를 기본으로 제공해줘 이를 이용해 Validator 인터페이스의 bean을 생성할 수 있다. 이 객체는 org.springframework.validation.Validator 인터페이스 뿐만 아니라, javax.validation.ValidatorFactory, javax.validation.Validator 인터페이스를 모두 구현하고 있다. 이 bean을 이용해 애플리케이션 전 계층에서 객체를 검증할 수 있다. 아래는 Validator를 등록하는 과정이다.

@Bean
public Validator jsrValidator() {
    return new LocalValidatorFactoryBean();
}

이 Validator를 통해 Car 객체를 검증하는 컨트롤러 메서드를 작성하자.

@Autowired
@Qualifier("jsrValidator")
private Validator validator;

@GetMapping("/car/validate")
public boolean directlyValidateCar(@ModelAttribute Car car, BindingResult result) {
    logger.debug("validate directly. {}", car);
    
    validator.validate(car, result);
    logger.debug("errors: {}", result.getFiledErrors());
    
    return !result.hasErrors();
}
@Test
public void directlyValidateTest() throws Exception {
    this.mockMvc.perform(get("/car/validate")
                        .characterEncoding("utf-8")
                        .param("manufacturer", "kook")
                        .param("seatCount", "4")
                        .param("topSpeed", "200"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("true"));
}

@Test
public void directlyValidateInvalidParamTest() throws Exception {
    this.mockMvc.perform(get("/car/validate")
                        .characterEncoding("utf-8")
                        .param("manufacturer", "kook")
                        .param("seatCount", "4")
                        .param("topSpeed", "301"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("false"));
    this.mockMvc.perform(get("/car/validate")
                        .characterEncoding("utf-8")
                        .param("manufacturer", "kook")
                        .param("seatCount", "-1")
                        .param("topSpeed", "301"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("false"));
    this.mockMvc.perform(get("/car/validate")
                        .characterEncoding("utf-8")
                        .param("manufacturer", "")
                        .param("seatCount", "4")
                        .param("topSpeed", "301"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("false"));
}

위 코드에서 검증 후, 검증 에러의 로그를 찍도록 했는데 Car 클래스의 검증 어노테이션의 message 속성으로 정의한 에러메시지가 표시된다.

 

Spring MVC 3 Validation

Spring3부터 Spring MVC 컨트롤러 메서드의 파라미터를 자동으로 검증하는 어노테이션의 @Valid를 제공해준다. 지금까지는 직접 도메인 객체를 검증하는 방법이었지만, @Valid 어노테이션을 사용하면 검증하는 로직이 없어도 자동으로 검증해준다.

@Autowired
private MessageSource messageSource;

@GetMapping("/validate")
public boolean automaticallyValidateCar(@ModelAttribute @Valid Car car) {
    logger.debug("validate automatically. {}", car);
    
    return true;
}

@ExceptionHandler({BindException.class})
public ResponseEntity<String> paramValidateError(BindException ex) {
    logger.error(ex.getMessage());
    return ResponseEntity.badRequest()
                         .body(messageResource.getMessage(ex.getFielderror(), Locale.getDefault()));
}

위 코드에서 컨트롤러 메서드에서 BindingResult 객체를 받지 않는 것을 볼 수 있다. @Valid 애노테이션을 붙인 컨트롤러 메서드도 마찬가지로 검증 에러를 BindingResult 객체에 저장을 하는데, 만약 컨트롤러 메서드가 BindingResult 객체를 받지 않는다면 검증에러 발생시, BindException을 내보낸다.

이 예외는 HTTP 상태코드 400으로 처리되는데, 위 코드에서는 BindException을 400으로 처리는 그대로 하지만, body에 에러메시지를 담아서 내보내기 위해 ExceptionHandler 메서드를 정의하였다.

 

 

 

 

 

 

 출처 

https://lazymankook.tistory.com/86

 

Spring Validation

스프링은 애플리케이션 전 계층에서 도메인 객체를 검증할 수 있는 인터페이스를 제공한다. 이번 글에서는 spring의 bean validation을 통해 어떻게 controller의 파라미터를 controller에 비즈니스 로직을

lazymankook.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

MapStruct

  • Mapstruct란?
  • Dependency 설정
  • 사용법
    • Entity, Dto 클래스를 만들어보자.
    • Mapper를 만들자.
    • 빌드를 진행하자.

MapStruct란?

MapStruct is a code generator that greatly simplifies the implementation of mappings between Java bean types based on a convention over configuration approach.

MapStruct는 구성접근법에 대한 규약에 근거하여 Java Bean 종류 간의 매핑 구현을 크게 단순화한 code generator이다.

 

 참고  아래는 Java Mapping 프레임 워크 성능을 비교한 글이다.

https://www.baeldung.com/java-performance-mapping-frameworks

 

Performance of Java Mapping Frameworks | Baeldung

Compare the performance of the most popular Java mapping frameworks.

www.baeldung.com

 참고  아래는 ModelMapper 라이브러리에 대해 정리한 글이다.

https://frogand.tistory.com/180

 

[Java] ModelMapper 라이브러리

ModelMapper 라이브러리 의존성 추가 build.gradle implementation 'org.modelmapper:modelmapper:2.4.2' 변환 클래스들 정의 // 모든 클래스 Constructor, Getter, Setter 생략 class Address { String street; String city; } class Name { String

frogand.tistory.com

 

* MapStruct는 시간당 처리량이 높다.

 

Dependency 설정 (의존성 설정)

 참고  MapStruct 가이드

https://mapstruct.org/documentation/spring-extensions/reference/html/

 

MapStruct Spring Extensions 0.1.2 Reference Guide

MapStruct Spring Extensions is a Java annotation processor based on JSR 269 and as such can be used within command line builds (javac, Ant, Maven etc.) as well as from within your IDE. Also, you will need MapStruct itself (at least version 1.4.0.Final) in

mapstruct.org

1. maven 설정

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.5.2.Final</version>
</dependency>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.5.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>1.5.2.Final</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

2. gradle 설정

dependencies {
    implementation 'org.mapstruct:mapstruct:1.5.2.Final'
    annotationProcessor "org.mapstruct:mapstruct-processor:1.5.2.Final"
    annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
	
	compileOnly 'org.projectlombok:lombok:1.18.22'
    annotationProcessor 'org.projectlombok:lombok:1.18.22'
}

 

사용법

1. 예시를 위한 Entity와 Dto 클래스를 만들어보자.

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;

import java.time.LocalDateTime;

@AllArgsConstructor
@Getter
@Builder
@EqualsAndHashCode
public class Order {

    private Long id;
    private String name;
    private String product;
    private Integer price;
    private String address;
    private LocalDateTime orderedTime;
}
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@Data
@AllArgsConstructor
@Builder
class OrderDto {

    private String name;
    private String product;
    private Integer price;
    private String address;
    private String img;
    private LocalDateTime orderedTime;
}

* OrderDto 클래스에는 Long id 필드가 존재하지 않고, String img 필드가 만들어졌다.

* 변환해서 저장하고자 하는 객체에는 Builder 혹은 모든 필드를 담을 수 있는 생성자가 있어야 한다.

 

2. 이를 매핑하는 OrderMapper를 만들어보자.

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;

@Mapper // (1)
public interface OrderMapper {
    OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class); // (2)

    @Mapping(target = "id", constant = "0L") // (3)
    Order convertOrderDtoToEntity(OrderDto orderDto);

    @Mapping(target = "img", expression = "java(order.getProduct() + \".jpg\")") // (4)
    OrderDto convertOrderToDto(Order order);
}

(1) @Mapper 어노테이션이 있어야 Mapstruct를 사용할 수 있다.

(2) 해당하는 Instance가 OrderMapper를 상속받아서 orderMapperImpl를 구현하게 될 것이다. 

(3) 일반적인 경우에 @Mapping 어노테이션을 붙이면 된다. Order 에는 id 필드가 존재하기 때문에 id 필드를 0L로 지정하겠다는 의미이다. (ignore 파라미터를 사용하면 0L로 지정하는 것이 아니라 해당 필드를 무시할 수 있다.)

(4) OrderDto에는 img 필드가 존재하기 때문에 expression을 기반으로 img 필드를 지정할 수 있다.

 

3. 빌드 진행 후, MapperImpl 클래스가 생성된다.

import java.time.LocalDateTime;
import javax.annotation.processing.Generated;

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2022-11-04T15:46:35+0900",
    comments = "version: 1.5.2.Final, compiler: javac, environment: Java 1.8.0_311 (Oracle Corporation)"
)
public class OrderMapperImpl implements OrderMapper {

    @Override
    public Order orderDtoToEntity(OrderDto orderDto) {
        if ( orderDto == null ) {
            return null;
        }

        String name = null;
        String product = null;
        Integer price = null;
        String address = null;
        LocalDateTime orderedTime = null;

        name = orderDto.getName();
        product = orderDto.getProduct();
        price = orderDto.getPrice();
        address = orderDto.getAddress();
        orderedTime = orderDto.getOrderedTime();

        Long id = (long) 0L;

        Order order = new Order( id, name, product, price, address, orderedTime );

        return order;
    }

    @Override
    public OrderDto orderToDto(Order order) {
        if ( order == null ) {
            return null;
        }

        String name = null;
        String product = null;
        Integer price = null;
        String address = null;
        LocalDateTime orderedTime = null;

        name = order.getName();
        product = order.getProduct();
        price = order.getPrice();
        address = order.getAddress();
        orderedTime = order.getOrderedTime();

        String img = order.getProduct() + ".jpg";

        OrderDto orderDto = new OrderDto( name, product, price, address, img, orderedTime );

        return orderDto;
    }
}

 

해당 코드를 보면 나는 interface로 메소드만 만들어줬을 뿐인데, Mapper 클래스가 만들어진 것을 확인할 수 있다.

OrderMapper orderMapper = Mappers.getMapper(OrderMapper.class);
// .. 생략
Order order = orderMapper.convertOrderDtoToEntity(orderDto);

위와 같이 OrderMapper를 활용해서 변환작업을 쉽게 처리할 수 있다.

 

 출처 

https://huisam.tistory.com/entry/mapStruct

 

Spring Mapstruct - Java Entity DTO 매핑을 편하게 하자!

MapStruct? 안녕하세요~! ㅎㅎ 오늘은 Spring을 쓰면서 자주 쓰게 되는 라이브러리를 하나 소개할까 합니다! 바로 그것이 MapStruct 인데요! 이 Mapstruct란? MapStruct is a code generator that greatly simpli..

huisam.tistory.com

https://mapstruct.org/documentation/stable/reference/html/

 

MapStruct 1.5.2.Final Reference Guide

If set to true, MapStruct in which MapStruct logs its major decisions. Note, at the moment of writing in Maven, also showWarnings needs to be added due to a problem in the maven-compiler-plugin configuration.

mapstruct.org

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

3-2. 첫 페이지 조회 결과 캐시하기

처음 검색 시 조회된 count 결과를 응답 결과로 내려주어 JS에서 이를 캐싱하고 매 페이징 버튼마다 count 결과를 함께 내려주는 것이다.

Repository에서는 요청에 넘어온 항목 중 캐싱된 count 값이 있으면 이를 재사용하고, 없으면 count 쿼리를 수행한다.

 

- 첫 1페이지 요청 시 count 쿼리를 날려서 totalCount를 가져온다. 

- 2페이지 이상 요청 시 JS에서 캐싱한 totalCount를 같이 보내서 count 쿼리를 날리지 않고 기존의 totalCount를 같이 보내서 count 쿼리를 날리지 않는다.

 

이 방식은 다음과 같은 상황에서 도움이 된다.

- 조회 요청이 검색 버튼과 페이지 버튼 모두에서 골고루 발생하고

- 실시간으로 데이터가 적재되지 않고, 마감된 데이터를 사용할 경우

 

물론 JS에서 캐싱하고 있기 때문에 브라우저를 새로고침하게 되면 count는 다시 초기화가 되어 첫 조회시 다시 쿼리가 수행되게 된다.

 

3-2-1. 구현 코드

기존 페이징 쿼리는 동일하다.

public Page<BookPaginationDto> paginationCount(Pageable pageable, String name) {
    JPQLQuery<BookPaginationDto> query = querydsl().applyPagination(pageable,
            queryFactory
                    .select(Projections.fields(BookPaginationDto.class,
                            book.id.as("bookId"),
                            book.name,
                            book.bookNo,
                            book.bookType
                    ))
                    .from(book)
                    .where(
                            book.name.like(name + "%")
                    )
                    .orderBy(book.id.desc()));

    List<BookPaginationDto> items = query.fetch(); // 데이터 조회
    long totalCount = query.fetchCount(); // 전체 count
    return new PageImpl<>(items, pageable, totalCount);
}

private Querydsl querydsl() {
    return Objects.requireNonNull(getQuerydsl());
}

검색 / 페이징 버튼 클릭시 cache 된 count를 사용하도록 개선하기 위해서는 다음의 코드가 추가되어야 한다.

- 프론트 영역에서 넘겨준 count 값이 요청 필드에 포함시킨다.

- Repository에서는 해당 count 값이 있을 경우엔 그대로 페이징 결과에 포함시키고, 없으면 실제 count 쿼리를 실행한다.

 

public Page<BookPaginationDto> paginationCountCache(Long cachedCount, Pageable pageable, String name) {
    
    // cacheCount는 프론트 영역에서 넘겨준 count 값이다.
    JPQLQuery<BookPaginationDto> query = querydsl().applyPagination(pageable,
            queryFactory
                    .select(Projections.fields(BookPaginationDto.class,
                            book.id.as("bookId"),
                            book.name,
                            book.bookNo,
                            book.bookType
                    ))
                    .from(book)
                    .where(
                            book.name.like(name + "%")
                    )
                    .orderBy(book.id.desc()));

    List<BookPaginationDto> elements = query.fetch(); // 데이터 조회
    long totalCount = cachedCount != null ? cachedCount : query.fetchCount(); // 전체 count: cacheCount가 없으면 실제 count 쿼리를 수행시킨다.
    return new PageImpl<>(elements, pageable, totalCount);
}

private Querydsl querydsl() {
    return Objects.requireNonNull(getQuerydsl());
}

3-2-2. 결론

한번 조회된 동일 조건의 count에서는 클라이언트 영역에서 저장 후 요청시마다 재사용하는 방식을 사용하면 추가 쿼리 요청이 최소화된다.

단점은

- 첫번째 페이지 조회가 대부분일 경우 효과가 없다.

- 실시간으로 데이터 수정이 필요해서 페이지 버튼 반영이 필요한 경우 사용할 수 없다.

결국 새로고침(또는 버튼 클릭을 통한 페이지 이동)을 하기 전까지 실시간성이 떨어진다.

 

 

 출처 

https://jojoldu.tistory.com/531

 

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

 

페이징 기능을 구현하는데 있어, 페이징 쿼리 자체를 개선하는 방법도 있지만, 그 외 다른 기능을 개선하는 방법도 함께 할 수 있다.

 

여기서 말하는 기능은 count 쿼리이다.

일반적인 페이징 기능에 있어 데이터 조회와 함께 매번 수행되는 것이 count 쿼리이다. 해당 조건으로 조회되는 총 건수를 알아야만 아래와 같이 pageNo들을 노출시킬 수 있다.

총 몇건인지 확인하기 위해 전체를 확인해야 하기 때문에 데이터 조회만큼 오래 걸린다.

 

이 문제를 개선할 수 있는 방법은 크게 2가지가 있다.

1. 검색 버튼 사용시 페이지 건수 고정하기

2. 첫 페이지 조회 결과 cache 하기

 

3-1. 검색 버튼 사용시 페이지 건수 고정하기

굳이 사용율이 떨어지는 페이지 버튼을 위해 매번 전체 count 쿼리가 수행될 필요가 있을까를 한번 고민해야한다.

 

즉, 다음과 같은 상황에서 이 방법을 고려하는 것이 좋다.

- 대부분의 조회 요청이 검색 버튼(즉, 첫 조회)에서 발생하고

- 페이지 버튼을 통한 조회 요청이 소수일 경우

이럴 경우 검색 버튼을 클릭한 경우에만 Page 수를 고정하는 것이다.

 

즉, 다음 페이지로 이동하기 위해 페이지 버튼을 클릭했을 때만 실제 페이지 count 쿼리를 발생시켜 정확한 페이지 수를 사용하고, 대부분 요청이 발생하는 검색 버튼 클릭시에는 count 쿼리를 발생시키지 않는 것이다.

3-1-1. 구현 코드

아래는 기존 페이징 쿼리다.

public Page<BookPaginationDto> paginationCount(Pageable pageable, String name) {
    
    JPQLQuery<BookPaginationDto> query = querydsl().applyPagination(pageable,
                                queryFactory
                                    .select(Projections.fields(BookPaginationDto.class,
                                                                book.id.as("bookId"),
                                                                book.name,
                                                                book.bookNo,
                                                                book.bookType))
                                    .from(book)
                                    .where(book.name.like(name + "%"))
                                    .orderBy(book.id.desc());
                                    
    List<BookPaginationDto> items = query.fetch();
    long totalCount = query.fetchCount(); // 전체 count
    return new PageImpl<>(items, pageable, totalCount);
}

private Querydsl querydsl() {
    return Objects.requireNonNull(getQuerydsl());
}

이 코드를 검색 버튼 클릭시에는 10개 페이지를 고정으로 노출하도록 개성하기 위해서는 다음의 코드가 추가되어야 한다.

1. 검색 버튼을 클릭한 경우(useSearchBtn) 10개 페이지가 노출되도록 TotalCount (fixedPageCount)를 반환한다.

2. 페이지 버튼을 클릭한 경우 실제 쿼리를 수행해 결과를 반환한다.

3. 페이지 버튼을 클릭하였지만, 전체 건수를 초과한 페이지 번호로 요청이 온 경우에는 마지막 페이지 결과를 반환한다.

 

이를 적용한 코드는 다음과 같다.

public Page<BookPaginationDto> paginationCountSearchBtn(boolean useSearchBtn, Pageable pageable, String name) {

    JPAQuery<BookPaginationDto> query = queryFactory
                                        .select(Projections.fields(BookPaginationDto.class,
                                                book.id.as("bookId"),
                                                book.name,
                                                book.bookNo,
                                                book.bookType))
                                        .from(book)
                                        .where(book.name.like(name + "%"))
                                        .orderBy(book.id.desc())
    
    JPQLQuery<BookPaginationDto> pagination = querydsl().applyPagination(pageable, query);
    
    if (useSearchBtn) { // 검색 버튼 사용시
        int fixedPageCount = 10 * pageable.getPageSize();
        return new PageImpl<>(pagination.fetch(), pageable, fixedPageCount);
    }
    
    long totalCount = pagination.fetchCount();
    Pageable pageRequest = exchangePageRequest(pageable, totalCount); // 데이터 건수 초과시 보정
    return new PageImpl<>(pagination.fetch(), pageRequest, totalCount);
}

private Pageable exchangePageRequest(Pageable pageable, long totalCount) {

    int pageNo = pageable.getPageNumber();
    int pageSize = pageable.getPageSize();
    long requestCount = (pageNo - 1) * pageSize;
    
    if (totalCount > requestCount) {
        return pageable;
    }
    
    int requestPageNo = (int) Math.ceil((double)totalCount / pageNo);
    return PageRequest.of(requestPageNo, pageSize);
}

객체 지향적으로 분리하기 위해 별도의 Dto 클래스로 추출할 수 있다.

// DTO
public class FixedPageRequest extends PageRequest {

    protected FixedPageRequest(Pageable pageable, long totalCount) {
        super(getPageNo(pageable, totalCount), pageable.getPageSize(), pageable.getSort());
    }

    private static int getPageNo(Pageable pageable, long totalCount) {
        int pageNo = pageable.getPageNumber();
        int pageSize = pageable.getPageSize();
        long requestCount = pageNo * pageSize; // pageNo:10, pageSize:10 일 경우 requestCount=90

        if (totalCount > requestCount) { // 실제 건수가 요청한 페이지 번호보다 높을 경우
            return pageNo;
        }

        return (int) Math.ceil((double)totalCount/pageNo); // 실제 건수가 부족한 경우 요청 페이지 번호를 가장 높은 번호로 교체
    }
}

// repository
public Page<BookPaginationDto> paginationCountSearchBtn2(boolean useSearchBtn, Pageable pageable, String name) {
    JPAQuery<BookPaginationDto> query = queryFactory
            .select(Projections.fields(BookPaginationDto.class,
                    book.id.as("bookId"),
                    book.name,
                    book.bookNo,
                    book.bookType
            ))
            .from(book)
            .where(
                    book.name.like(name + "%")
            )
            .orderBy(book.id.desc());

    JPQLQuery<BookPaginationDto> pagination = querydsl().applyPagination(pageable, query);

    if(useSearchBtn) {
        int fixedPageCount = 10 * pageable.getPageSize(); // 10개 페이지 고정
        return new PageImpl<>(pagination.fetch(), pageable, fixedPageCount);
    }

    long totalCount = pagination.fetchCount();
    Pageable pageRequest = new FixedPageRequest(pageable, totalCount);
    return new PageImpl<>(querydsl().applyPagination(pageRequest, query).fetch(), pageRequest, totalCount);
}

 

 출처 

https://jojoldu.tistory.com/530 

 

3-1. 페이징 성능 개선하기 - 검색 버튼 사용시 페이지 건수 고정하기

모든 코드는 Github에 있습니다. 앞서 포스팅에서 실질 페이징 쿼리 성능을 올리는 방법들을 소개 드렸는데요. 1. 페이징 성능 개선하기 - No Offset 사용하기 2. 페이징 성능 개선하기 - 커버링 인덱

jojoldu.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !