안녕하세요.! 회사와 함께 성장하고 싶은 KOSE입니다.

오늘은 크리스마스 동안 붙들었던 redis cache를 QueryDsl의  Page<T> 타입에 적용한 후기를 적어보고자 합니다.

(다들 연말 잘 보내세요!. 늦은 시간이지만, 너무나도 즐겁게 글을 작성할 수 있어서 행복합니다..ㅎㅎ!)

 

저는, Redis를  로그인에 사용되는 액세스 토큰 및 리프레쉬 토큰을 저장하는 저장소로, 두 번째는, 캐시화 처리를 위한 용도로 사용하였습니다. 토큰을 저장하는 용도의 사용은 다른 분들께서 자세히 적어주셔서 해당 과정은 에러 없이 해결했지만, 후자는 많은 에러가 발생했습니다. 다시 한번, 백엔드의 어려움과 경의로움을 경험할 수 있었습니다.

 

이 글은, Redis에 대한 정의, Local cache Vs global cache 비교, 에러 상황, 에러 해결 순으로 글을 작성하도록 하겠습니다.

글을 읽으시는 분들 중에 저와 비슷한 에러가 발생하신 분들이 있다면, 도움이 되셨으면 좋겠습니다.

1. Redis?

참조:&nbsp;https://devlog-wjdrbs96.tistory.com/374

 

Redis는 캐시 시스템으로 동일한 기능을 제공하면서 영속성,
다양한 데이터 구조와 같은 부가적인 기능을 지원합니다.

Redis는 모든 데이터를 메모리에 저장하고 조회하는 인메모리 데이터베이스입니다.

 

a. 인메모리 데이터베이스?

인메모리 데이터베이스는 보조 기억 장치를 사용하는 기존 데이터베이스에 비해 빠릅니다. (하드디스크 등) 

컴퓨터 중앙 처리 장치(CPU)는 주 메모리에 저장된 데이터만 직접 접속할 수 있습니다. 따라서, 주메모리 방식이 보조기억 장치보다 훨씬 빠르게 데이터를 처리할 수 있습니다.

 

b. 인메모리 데이터베이스와 디스크 기반 데이터베이스 차이점

디스크 기반 데이터베이스는 IO 작업을 수행하기 때문에, 디스크에서 쓰기/읽기 작업을 필요로 합니다. 디스크 기반 데이터 베이스는 데이터를 저장하는 구조가 복잡한데, 데이터 베이스에 데이터를 저장하기 전에 디스크 액세스가 효율적임을 먼저 확인합니다.

하지만, 인메모리 데이터베이스는 주 메모리에서 데이터를 무작위로 액세스 하는 것이 효율적이기 때문에 저장 구조가 간단합니다.

 

2. Local cache Vs Global cache

저는 이전 프로젝트에서 local cache의 일종인 caffeinCached를 사용한 경험이 있습니다. 그 당시에는 local cache와 global cache 차이점을 알지 못하고 redis는 토큰 저장소의 일종이라고만 생각을 했었습니다. 프론트분과 협업을 진행하는 과정에서 redis의 무궁한 기술을 접하게 되었고 두 캐시 방법의 차이점을 느끼게 되었습니다.

 

a. Local cache

 

참조:&nbsp;https://deveric.tistory.com/71

Local cache는 서버마다 캐시를 따로 저장합니다. 즉 여러 대의 서버가 존재한다면, 다른 서버의 캐시를 참조하기 어렵습니다. 이는 곧 정합성 문제와 직결되는데, 다중 서버를 운영한다면 같은 로직의 데이터 요청이 들어올 때, 서버마다 캐시가 참조가 어려우므로 데이터 정합성 문제가 발생할 수 있습니다.

이는 제가 경험해보지 못하여서 확실하지 않지만, 제 생각은 local cache는 사용자의 요청에 따라 캐시가 생성되고 인메모리에 저장되는데, 다른 서버에 저장된 캐시는 확인하기 어려우므로 다시 캐시가 생성될 수 있습니다. 

또한, a라는 서버에 부하가 생길 시 로드밸런서가 a 서버 요청을 b 서버로 위임하게 된다면, 로그인을 다시 해야 하는 상황이 생길 수 있다고 생각합니다. 하지만, 서버 내에 캐시가 저장되므로 속도가 빠르다는 장점이 있습니다.

 

b. Global cache 

 

https://deveric.tistory.com/71

global cache는 여러 서버에서 캐시 서버를 참조하는 방식입니다. 이는 네트워크 트래픽을 사용하므로 로컬 캐시보다 느리다는 단점이 있으나, 서버 간 데이터 공유가 쉽습니다.

먼저, global cache는 외부 캐시 저장소에 접근하여 데이터를 가져오므로 네트워크 I/O 비용이 발생합니다. 하지만, 서버 인스턴스가 추가되더라도, 동일한 비용만 요구하므로 서버가 고도화될수록 더 높은 효율을 발휘됩니다.

또한, 위의 local cache와는 다르게 모든 서버의 인스턴스가 동일한 캐시 저장소에 접근하므로 데이터의 정합성이 보장됩니다.

(참조: https://souljit2.tistory.com/72)

 

 

3. Redis 캐싱 에러 발생

저는 Redis를 토큰 저장소와 자주 사용하지만 변할 가능성이 적은 데이터에 활용하고자 하였습니다. 학교 찾기와 같은 로직은 데이터가 잘 변하지 않지만, 매번 요청마다 데이터베이스에서 데이터를 가져와 클라이언트에 제공하는 과정이 서버에 많은 부하를 주리라 생각하였습니다. 따라서, 학교 찾는 서비스 로직에 Redis를 적용하고자 하였습니다.

 

먼저 Redis를 사용하기 위해선, bulild.gradle에 redis 관련 라이브러리를 의존받고, yml에 포트 및 호스트 등록, redis를 스프링 빈으로 사용하기 위해 RedisConfig 등록이 필요합니다.

// build.gradle

//redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
//application.yml

spring:
  redis:
    host: localhost
    port: 6379
@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @SuppressWarnings("deprecation")
    @Bean
    public CacheManager cacheManager() {
        RedisCacheManagerBuilder builder = fromConnectionFactory(redisConnectionFactory());
        RedisCacheConfiguration configuration = defaultCacheConfig()
                .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(30));
        builder.cacheDefaults(configuration);
        return builder.build();
    }

}

serializeValuesWith(fromSerializer(new GenericJackSon2JsonRedisSerializer())) 는  객체를 Json 타입으로 직렬화/역직렬화를 수행하여 binary 데이터 형태를 String으로 혹은 그 반대로 전환하는 역할을 수행해 줍니다.

entryTtl은 해당 캐시의 지속시간을 설정할 수 있습니다.

 

SchoolSearchDto로 클라이언트에게 학교 찾는 요청을 받는 Dto입니다.

@Data
@NoArgsConstructor
public class SchoolSearchReqDto extends BaseDefaultPageable {

    private String schoolName;
    private String schoolAddress;

    @Builder
    public SchoolSearchReqDto(String schoolName, String schoolAddress, Integer page, Integer size) {
        super(page, size);
        this.schoolName = schoolName;
        this.schoolAddress = schoolAddress;
    }
    
}

해당 소스는, 학교 이름 혹은 학교 주소를 동적으로 입력받아 쿼리를 생성하는 쿼리메서드입니다.

@Override
    public Page<SchoolSearchDto> searchSchoolsPageComplex(SchoolSearchCondition condition, Pageable pageable) {

        List<SchoolSearchDto> content = query
                .select(
                        new QSchoolSearchDto(
                                school.id,
                                school.schoolName,
                                school.schoolAddress))
                .from(school)
                .where(
                        schoolNameContains(condition.getSchoolName()),
                        schoolAddressContains(condition.getSchoolAddress())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = query
                .select(school.count())
                .from(school)
                .where(
                        schoolNameContains(condition.getSchoolName()),
                        schoolAddressContains(condition.getSchoolAddress())
                );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

 

    /**
     *
     * 학교 검색
     * schoolSearchReqDto를 입력받아, name, address로 동적 쿼리 생성
     * schoolSearchReqDto의 page 관련 파라미터를 받아 pageable 생성
     *
     * @param reqDto
     * @return
     */
    @Cacheable(value = "Page<SchoolSearchDto>", key = "#reqDto.schoolName.hashCode()", cacheManager = "cacheManager", unless = "#reqDto.schoolName.hashCode() == ''")
    public Page<SchoolSearchDto> getSchoolSearchDto(SchoolSearchReqDto reqDto) {

        SchoolSearchCondition condition = SchoolSearchCondition.of(reqDto.getSchoolName(), reqDto.getSchoolAddress());
        Pageable pageable = PageRequest.of(reqDto.getPage(), reqDto.getSize(), Sort.by("id"));
        return schoolQueryRepository.searchSchoolsPageComplex(condition, pageable);

    }

SchoolQueryRepositoryImpl의 의존성 주입을 받은 SchoolService는 클라이언트의 SearchReqDto를 파라미터로 받아서 Page<SchoolSeachDto>를 리턴하는 메서드입니다. 

 

    @GetMapping("/search")
    public ResponseEntity getSchoolsSearchRes(@RequestBody SchoolSearchReqDto reqDto) {
        Page<SchoolSearchDto> schoolSearchDto = schoolService.getSchoolSearchDto(reqDto);
        return new ResponseEntity(new RestPage<>(schoolSearchDto), HttpStatus.OK);
    }

컨트롤러에서는 해당 데이터와 상태코드를 제공합니다.

 

이후, postman에서 해당 로직을 검증한 결과, 아래와 같은 문제가 발생하였습니다.

 

4. 에러 해결 과정

서비스 로직에 놓았던 @Cacheable 어노테이션을 컨트롤러에 위치하는 등 다양한 방법을 사용했지만, 이와 같은 상황이 발생했습니다. 구글링에 검색한 결과, 직렬화 과정에서 SchoolSearchDto와 PageIm의 기본생성자가 없어서 발생한 문제였습니다.

 

따라서, PageIm <T>를 상속받는 RestPage <T>를 생성하였습니다.

@JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"})
public class RestPage<T> extends PageImpl<T> {
    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public RestPage(@JsonProperty("content") List<T> content,
                    @JsonProperty("number") int page,
                    @JsonProperty("size") int size,
                    @JsonProperty("totalElements") long total) {
        super(content, PageRequest.of(page, size), total);
    }

    public RestPage(Page<T> page) {
        super(page.getContent(), page.getPageable(), page.getTotalElements());
    }
}

또한, SchoolSearchDto 역시 기본 생성자 어노테이션을 추가하였습니다.

@Data
@ToString
@NoArgsConstructor
public class SchoolSearchDto {

    private Long id;
    private String schoolName;
    private String schoolAddress;

    @Builder
    @QueryProjection
    public SchoolSearchDto(Long id, String schoolName, String schoolAddress) {
        this.id = id;
        this.schoolName = schoolName;
        this.schoolAddress = schoolAddress;
    }
}

그 결과, postman의 응답결과와 redis-cli의 value가 잘 전달되고 저장된 것을 확인할 수 있습니다.

 

5. Why 기본 생성자?

 

과거에 이펙티브 자바를 공부할 때, 리플렉션이라는 개념을 공부한 경험이 있습니다. 그 당시에 와닿지 않아서 넘겼는데, 리플렉션이 기본생성자와 많은 연관이 있음을 알게 되었습니다.

Reflection은 접근 제어자와 상관없이 클래스 객체를 동적으로 생성(런타임 시점)하는 자바 API입니다. 자바 리플렉션은 구체적인 클래스 타입을 알지 못해도, 클래스의 메서드, 타입, 변수들에 접근할 수 있도록 하는 기능을 수행하는데, 기본 생성자가 없다면 자바 리플랙션이 객체를 생성할 수 없습니다.

스프링 데이터 Jpa도 동적으로 객체를 생성하는 리플렉션을 사용하므로 Dto에 기본 생성자가 존재하지 않아서 발생한 에러입니다.


한 가지 의문점이 들었습니다. Dto에 기본 생성자를 추가했는데, "왜 다시 에러가 났을까?"
그리고, "RestPage <T> 클래스로 Page<SchoolSearchDto>를 감쌌을 때는 왜 에러가 나지 않았을까?"입니다. 
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)

이에 대한 해답은  어노테이션에 있었습니다.

Jackson의 @JsonCreator를 이용하면 인자가 없는 기본 생성자와 set메서드가 없이도 객체를 생성할 수 있는 경이로운 기능이 숨겨져 있었습니다.

따라서, 해당 어노테이션을 사용하면 기본 생성자 문제가 해결되므로, 이상 없이 이 문제를 해결할 수 있었습니다.

 

정말 배움에는 끝이 없는 것 같습니다. 

하지만, 배움으로 더 발전하고, 성장할 수 있어서 행복합니다.

긴 글 읽어주셔서 감사드립니다. 다들 좋은 하루 되십시오.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
velog를 사용하다, 체계적으로 글을 작성해보고 싶어서, tistory로 옮긴 후 이번이 두 번째 글입니다.

코드 리팩토링 과정에서 아무렇지 않게 사용했던, @RequestHeader를 보며, 순간 `아차` 하는 생각이 들었습니다.
HttpRequest 요청받을 때, 꼭 필요한 Header라는 것을 명시하고, 필터링하기 위해 사용했지만,
해당 필터는 이미 JwtFilter에 적용된 상태였습니다. Controller를 리팩토링하면서, 해당 어노테이션을 제거하며
해당 어노테이션의 쓰임새에 대해 고찰해보는 시간을 가지게 되었습니다.

1. @RequestHeader

@RequestHeader Annotation은 HTTP 요청 헤더 값을 컨트롤러 메서드의 파라미터로 전달한다(메서드 파라미터가 String가 아니라면 타입변환을 자동으로 적용한다)
만약 헤더가 존재하지 않으면 에러가 발생하며, required 속성을 이용해 필수여부를 설정할 수 있다.
또한 defaultValue 속성을 이용해 기본 값도 설정 가능하다.

@RequestHeader은 이처럼 사용자 환경에서 커스텀하게 사용할 수 있는 이점이 있습니다.

 

2. 해당 어노테이션을 삭제한 과정

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI();

        if (isLoginCheckPath(requestURI)) {

            log.info("jwt = {}", jwt);

            if (
                    StringUtils.hasText(jwt)
                    && tokenProviderImpl.validateToken(jwt)
                    && isNotLogoutAccessToken(jwt)
            ) {
                Claims claims = tokenProviderImpl.getClaims(jwt);
                log.info("claims.getExpiration() = " + claims.getExpiration());

                Authentication authentication = tokenProviderImpl.getAuthentication(jwt);

                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("authentication.getName() = {}", authentication.getName());

            } else {
                log.info("유효한 JWT 토큰이 없습니다, uri = {}", requestURI);
                return ;
            }

        }

        filterChain.doFilter(request, response);

    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        log.info("bearToken = {}", bearerToken);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }

 

해당 코드는 JwtFilter.class 로 클라이언트 요청이 발생하면 수행되는 필터입니다.

아래 그림과 같이 클라이언트에게 Request 요청이 발생하면 필터를 거친 후, 디스패처 서블릿에 도달합니다.

 

Filter 작동 과정

a. Why ?

필터를 사용하는 이유는 디스패처 서블릿에 요청 과부하를 줄여주는 역할을 수행합니다. 사용자가 설정한 Filter 조건을 만족하지 않는다면 doFilter 메소드가 호출되지 않고, return 하여 해당 요청의 처리를 중단하여, 예외 처리가 이루어질 수 있도록 합니다.

스프링은 filter가 chain 형태로 복잡하게 이뤄진 것을 확인할 수 있습니다. 제가 진행하고 있는 토이 프로젝트에는 이처럼 다양한 필터가 존재하는 것을 확인할 수 있습니다. (현재, JwtFilter는 8번째 인덱스에 속해있습니다.)

따라서, 디스패처 서블릿의 부담을 덜어주어 서버 과부화를 줄이는데 많은 역할을 수행하는 것이 filter라고 할 수 있습니다.

 

b. How ?

필터를 사용하는 방법은,  OncePerRequestFilter 혹은 해당 클래스의 부모 클래스를 상속받거나, Filter 인터페이스를 구현함으로써 사용할 수 있습니다. 저는 OncePerRequestFilter를 상속받아 사용하는데, 이는 필터 작동 원리가

그림 <Filter 작동 과정>의 모습처럼 Request -> 처리 -> Response로 이어지는 과정에서 두 번 처리되는 것을 볼 수 있습니다. OncePerRequestFilter는 해당 요청이 두 번 실행되지 않도록 하는 Filter 구현체 입니다.

 

두 가지 방법을 활용하여 해당 메소드를 재정의 혹은 구현하게 된다면, doFilterInternal 메소드를 오버라이딩 하게 됩니다.

이 메소드에서 request에 대한 헤더를 파싱하거나 검증하는 과정을 수행할 수 있습니다.

이후, 해당 필터를 Bean 등록하여 사용할 수 있도록 Config 클래스를 생성합니다. 스프링 Security5 이상부터는, Security 설정하는 방법이 바뀌었는데, 이에 대한 글도 추가로 작성하도록 하겠습니다.

맨 아래의 .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class)는 UsernamePasswordAuthenticationFilter가 수행되기 전에 해당 필터를 적용하라는 것 입니다.

@Bean
    SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http,
                                                  TokenProviderImpl tokenProviderImpl) throws Exception {

        http
                .httpBasic().disable()
                .csrf().disable()

                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                .and()
                .authorizeRequests((requests) -> requests
                .antMatchers(
                        "/resources/**",
                        "/api/v1/register",
                        "/api/v1/login",
                        "/"
                )
                .permitAll()
                .anyRequest().authenticated())
                
                .formLogin().disable()

                .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(
                        userInfoEndpointConfig -> userInfoEndpointConfig
                                .userService(customOAuth2UserService)
                                .oidcUserService(customOidcUserService)))

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();

    }

c. Where ?

해당 JwtFilter에서는 resolveToken 메소드에 해당 검증 내용이 담겨 있습니다. AUTHORIZATION_HEADER 파이널 상수는 "Authorization" 헤더의 문자열을 할당하였습니다.  따라서, JwtFilter가 적용되는 과정에서 해당 토큰 값만 파싱한 후, 토큰 검증하여 해당 클라이언트의 요청이 유효한 유저의 요청인지 판단합니다. 따라서, Controller에서 해당 헤더를 판단하게 된다면 불필요한 체킹이 될 수 있습니다.

private String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

    log.info("bearToken = {}", bearerToken);

    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }

    return null;
}

 

3. @RequestHeader 사용처

그렇다면 해당 어노테이션은 어디에 사용할까요? 다양한 사용처가 있겠지만,

저는 RefreshToken을 헤더에 담아서 사용할 때, 해당 어노테이션이 중요한 역할을 수행할 수 있다고 생각합니다.

만약, 사용자에게 만료 기간 전에 RefreshToken을 재발급 해야 하거나, 사용자가 먼저 재발급 요청을 하게 될 때 필터를 통과한 후, 컨트롤러 해당 RefreshToken이 담겨있다면, 이를 확인해서 토큰을 재발급하는 과정이 수행될 수 있습니다.

 

이처럼 토큰을 재발급해야하는 상황이 생긴다면, 해당 해더에 "RefreshToken"이라는 커스텀 헤더를 요청받아, 로직을 수행할 수 있습니다. Filter에 해당 요청을 받는 로직도 생각해보았지만, refreshToken은 매번 검증이 필요한 토큰이 아니라 판단하여, 재발급 요청 시에만 해당 토큰을 검증하면 된다고 생각하였습니다. 따라서, 효율성 측면에서 해당 요청에만 커스텀 헤더를 적용하였습니다.

 

아무렇지 않게 사용했던 것들을 다시 자세히 살펴보는 과정에서, 정말 배울 점이 많다는 것을 느끼게 되었습니다.

긴 글 읽어주셔서 감사드립니다.!

안녕하세요. 회사와 함께 성장하고 싶은 Kose입니다.

오늘은 크리스마스 이브입니다. 모두 행복한 크리스마스 보내시기 바랍니다.!
이번 글은 Spring에서 Mockito 라이브러리와, Postman을 활용한 Controller 테스트 코드 작성에 대해 논의해보고자 합니다.

Mock
모의 객체(Mock Object)란 주로 객체 지향 프로그래밍으로 개발한 프로그램을 테스트할 경우 테스트를 수행할 모듈과 연결되는 외부의 다른 서비스나 모듈들을 실제 사용하는 모듈을 사용하지 않고 실제의 모듈을 "흉내"내는 "가짜" 모듈을 작성하여 테스트의 효용성을 높이는 데 사용하는 객체입니다. 사용자 인터페이스(UI)나 데이터베이스 테스트 등과 같이 자동화된 테스트를 수행하기 어려운 때 널리 사용됩니다. 위키백과, Mock Object 인용

 

평소에 테스트 코드를 짤 때, 눈으로 직접 확인하고 계속 틀려보고 오류를 수정하는 작업을 하면서 많이 성장할 수 있다고 생각하였습니다. 하지만, 외부의 요청이 있어야 하는 경우, 직접 코드를 작성하는 데 한계가 있을 수 있습니다. 이럴 때, Mock 객체를 활용하여 가짜 요청을 생성한 후, 작성한 코드의 품질을 평가한다면 코드 작성의 효율이 높아질 수 있습니다.

저는, 학과 내 개발에 관심이 있는 학우와 팀을 구성하여 간단한 메신저 프로젝트를 진행하였습니다. 백엔드를 구성하는 과정에서 작성한 로직을 바탕으로 예시를 작성하겠습니다.

1. SchoolController 구성

@RestController
@Slf4j
@RequestMapping("/api/v1/schools")
@RequiredArgsConstructor
public class SchoolController {

    private final SchoolService schoolService;

    @GetMapping("/search")
    public ResponseEntity getSchoolsSearchRes(@RequestHeader("Authorization") String accessToken,
                                              @RequestBody SchoolSearchReqDto schoolSearchReqDto) {

        Page<SchoolSearchDto> schoolSearchDto = schoolService.getSchoolSearchDto(schoolSearchReqDto);

        return new ResponseEntity(schoolSearchDto, HttpStatus.OK);
    }

}

해당 컨트롤러는 AccessToken을 Authorization 헤더로 받고, School을 검색하기 위한 SearchReqDto를 json 형태로 받아, QueryDsl을 이용하여 페이징 처리 하여 값을 받아오는 컨트롤러입니다.

2. TestClass 작성

@SpringBootTest
@Transactional
@ExtendWith(MockitoExtension.class)
class SchoolControllerTest {

    @Autowired UserService userService;
    @Autowired AuthService authService;
    @Autowired AuthorityService authorityService;
    @Autowired PasswordEncoder passwordEncoder;
    @Autowired SchoolService schoolService;
    @Mock SchoolController schoolController;

    static Users user;
    static List<Authority> authorities;
    static TokenAuthDto tokenAuthDto;
    static String accessToken;

    private MockMvc mockMvc;

해당 컨트롤러는 로그인이 필요하므로,
유저 가입 - userService (유저 패스워드 인코딩 - passwordEncoder)
권한 부여 - authorityService
토큰 발급 - authService
학교 찾기 - schoolService
빈으로 등록된 클래스를 의존성 주입받습니다.

그리고, Mock 라이브러리로 테스트가 필요한 SchoolController를
Mock빈 주입 받도록 합니다.

3. BeforeEach method

@BeforeEach
public void dbInit() {
        userService.registerForm(FormRegisterUserDto.builder().email("k@naver.com").username("kose").password("1234").build(), passwordEncoder);
        user = userService.findByEmail("k@naver.com");
        authorities = authorityService.findAuthorityByUser(user);

        tokenAuthDto = authService.createFormTokenAuth("k@naver.com", authorities);
        accessToken = "Bearer " + tokenAuthDto.getAccessToken();

        mockMvc = MockMvcBuilders.standaloneSetup(schoolController).build();

        saveSchool();
    }
    
private void saveSchool() {

        String[] school = {"서울A대학교", "서울B대학교", "인천A대학교"};
        String[] address = {"서울시 A구", "서울시 B구", "인천시"};
        String[] regisNum = {"123", "124", "125"};

        for (int i=0; i< school.length; i++) {
            schoolService.save(new SchoolSaveDto(school[i], address[i], regisNum[i]));
        }
    }
    
    private SchoolSearchReqDto requestDto() {
        return SchoolSearchReqDto.builder().page(0).size(10).schoolName("서울").build();
    }    

@BeforeEach 빈은, 테스트 클래스에 작성된 개별 Test 메소드가 실행되기 전에 실행되어 각 테스트의 개별화를 돕습니다.

해당 dbInit() 회원가입 및 로그인 로직, 학교 데이터 저장을 위해 설정한 메소드입니다.
사용자가 ID/PASSWORD 로그인을 이용하면, 회원 가입 시 "ROLE_USER"을 부여받고 로그인에 성공하여 토큰을 부여받습니다. accessToken은 Http Get 요청에 Header로 사용하기 위해 "Beaer "를 포함한 문자열로 static accessToken에 할당합니다.

MockMvc는 웹 애플리케이션을 애플리케이션 서버에 배포하지 않고도 스프링 MVC의 동작을 재현할 수 있는 클래스입니다.
MockMvcBuilders.standaloneSetup().build()를 활용하여 MockMvc 클래스의 인스턴스를 static mockMvc에 할당합니다.

3. 학교_검색 method

@Test
    public void 학교_검색() throws Exception {

        //given
        SchoolSearchReqDto reqDto = requestDto();
        Page<SchoolSearchDto> resDto = schoolService.getSchoolSearchDto(reqDto);

        //when
        ResultActions resultActions = mockMvc.perform(
                MockMvcRequestBuilders.get("/api/v1/schools/search")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new Gson().toJson(reqDto))
                        .header("Authorization", accessToken)
        );

        //then
        resultActions.andExpect(status().isOk());

    }

해당 메소드는, 실제 테스트를 구현하는 메소드입니다.

Given
requestDto() 메소드를 호출하여 reqDto를 생성한 후, 의존성 자동 주입을 설정한 schoolService의 gerSchoolSearchDto() 메소드를 호출 합니다. 해당 메소드는 학교 이름, 학교 주소 파라미터를 동적으로 입력받아 학교 관련된 학교를 제공하는 QueryDsl을 활용한 동적 쿼리입니다.

When
MockRequestBuilders.Get()은 mockMvc를 수행할 때, 요청하는 Http method입니다. 컨트롤러에 GetMapping으로 설정하였으므로, MockMvcRequestBuilders의 httpMethod를 설정하는 메소드도 get으로 설정하여야 합니다.

contentType은 클라이언트에게 json으로 입력받기 때문에 MediaType.APPLICATION_JSON을 설정합니다.

중요한 부분은 Header입니다. 처음 Controller에서
@RequestHeader("Authorization) String accessToken 을 설정하였습니다.
해당 메소드가 실행되기 위해서는 header에 유효한 accessToken을 Authorization에 설정하여 HttpRequest 요청을 수행해야 합니다.

then
andExpect()는 해당 요청에 대한 상태 및 content 결과를 검증할 수 있는 결과 메소드 입니다. 저는 해당 요청에 대한 성공 상태코드로 200을 설정하였으므로 isOK()로 설정하였습니다. 그뿐만 아니라, andExpect()로 content 값도 확인할 수 있는데, 타입 에러가 발생하여…. 이 부분은 Mock을 더 공부한 후에 적용하도록 하겠습니다.

 

4. Postman 검증

서버를 가동한 후, 회원가입 -> 로그인 -> 학교 찾기 Http 요청 순서로 진행하였습니다.

먼저, json 형태로 회원 가입 요청을 수행합니다.


회원가입에 성공 후, 로그인을 요청합니다.


Tests 목록에 해당 요청에 대한 결과를 테스트 코드 형태로 작성할 수 있습니다.
저는 요청 결과 상태코드 pm.response.to.have.status(200)으로 검증하였습니다.

var data = JSON.parse(responseBody);
pm.environment.set("accessToken", data.accessToken)
pm.environment.set("refreshToken", data.refreshToken)

해당 코드는 서버에서 전달한 Token 결과를 클라이언트의 Header에 저장하기 위해 설정한 코드입니다. 백엔드에서 프론트로 Token을 제공하면, 클라이언트는 해당 토큰을 쿠키나 세션 로컬 스토리지 등에 저장합니다.
postman은 environment.set을 호출하여 key-value 형태로 저장하여 다음 요청에 이를 활용할 수 있습니다.

학교 찾기 요청을 위해 Authorization 목록의 Type을 Bearer Token으로 설정한 후 {{key}}를 설정하면 위에 설정한 accessToken을 jwt 토큰으로 이용합니다.


헤더 설정 후, json으로 page, size, schoolName을 설정하여 요청을 보냅니다.


postman에서 원했던 결과를 확인할 수 있습니다.

 

5. 추가 공부할 내용

mock 객체를 활용하여 테스트 코드를 작성하면서 에러가 발생했던 부분이 있습니다.

resultActions.andExpect(status().isOk());

이 부분에 다른 추가 검증 내용을 작성하고 싶은데, ResultActions 클래스의 메소드 요청 파라미터를 올바르게 이해한 후에 추가 검증을 구현하여 글로 남기겠습니다.

또한, @Mock을 적용한 서비스 로직, @Mock을 적용한 서비스를 주입받고자 하는 @InjectMocks을 선언한 컨트롤러에 doReturn().when()을 적용할 때, 아래 에러가 발생하였습니다.

please remove unnecessary stubbings or use 'lenient' strictness. more info: javadoc for unnecessarystubbingexception class.

해당 원인은 불필요한 스터빙을 하지 않도록 되어있는데, 현재 코드에 쓰이지 않는 스터빙을 해놨기 때문에 발생한 에러입니다. 이에 대한 해결책으로 Mock 객체를 활용하지 않아도 되는 Service는 의존성 주입을 받아 진행했습니다. 이 해결책이 올바른지 궁금하여 추가 공부를 더 진행하고자 합니다.

오늘도 즐거운 공부 시간이었습니다.
감사합니다.!

+ Recent posts