🛠️Backend/Spring

[Spring Boot] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 11

뉴발자 2023. 1. 21.
728x90

 

 

 

 

 

 

 

 

 

 

 

 

 

 

naver with oauth2.0

 

 

어노테이션 기반으로 개선하기

같은 코드가 반복되는 부분은 일반적인 프로그래밍에서 개선이 필요한 나쁜 코드 중 하나이다.

 

같은 코드를 계속해서 복사 & 붙여넣기로 반복하게 만든다면

 

이후에 수정이 필요한 경우 모든 부분을 하나씩 찾아가며 수정해야만 한다.

 

 

이렇게 될 경우 유지보수성이 떨어질 수 밖에 없으며

 

혹시나 수정이 반영되지 않은 코드가 있다면 문제가 발생할 수 밖에 없다.

 

 

지금까지 작성한 코드 중에서 개선할만한 것은 무엇이 있을까?

 

책의 저자는 IndexController에서 세션값을 찾아오는 아래의 코드라고 생각한다.

 

SessionUser user = (SessionUser) httpSession.getAttribute("user");

 

index 메소드 외에 다른 Controller와 Method에서 세션값이 필요하면 그때마다 직접 세션에서 값을 가져와야만 한다.

 

같은 코드가 계속해서 반복되는 것은 불필요한 동작이다.

 

그래서 이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경하는 작업을 하겠다.

 

 

먼저 config.auth 패키지 안에 LoginUser 어노테이션 파일을 생성해 아래의 코드를 작성해준다.

 

어노테이션 생성 : [새로 만들기] -> [Java 클래스] -> ['어노테이션' 선택]

 

 

1. @LoginUser

package com.jojoIdu.book.springboot.config.auth;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

 


Target(ElementType.PARAMETER)

  • 어노테이션이 생성될 수 있는 위치를 지정한다.
  • PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있다.
  • 이 외에도 클래스 선언문에 쓸 수 있는 TYPE 등이 있다.

 

 

@interface

  • 이 파일을 어노테이션 Class로 지정해준다.
  • LoginUser라는 이름을 가진 어노테이션이 생성되었다고 보면 된다.

 

그리고 같은 패키지 위치 안에 LoginUserArgumentResolver Class 파일을 생성해준다.

 

LoginUserArgumentResolver라는 HandlerMethodArgumentResolver Interface를 구현한 Class이다.

 

 

HandlerMethodArgumentResolver는 한가지 기능을 지원한다.

 

바로 조건에 맞는 경우 메소드가 있다면 HandlerMethodArgumentResolver의 구현체가

 

지정한 값으로 해당 메소드의 파라미터로 넘길 수 있다.

 

 

2. LoginUserArgumentResolver

package com.jojoIdu.book.springboot.config.auth;

import com.jojoIdu.book.springboot.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {

        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;

        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());

        return isLoginUserAnnotation && isUserClass;

    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        return httpSession.getAttribute("user");

    }

}

 


supportsParameter()

  • 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단한다.
  • 여기서 파라미터에 @LoginUser 어노테이션이 붙어 있고, 파라미터 Class 타입이 SessionUser.class인 경우 true를 반환한다.

 

 

resolveArgument()

  • 파라미터를 전달할 객체를 생성한다.
  • 여기서는 세션에서 객체를 가져온다.

 

이제 @LoginUser를 사용하기 위한 환경은 구성되었다.

 

이렇게 생성된 LoginUserArgumentResolver가

 

Spring에서 인식될 수 있도록 WebMvcConfigure에 추가해준다.

 

config 패키지 안에 WebConfig Class를 생성해서 아래와 같이 설정을 추가해준다.

 

 

3. WebConfig

package com.jojoIdu.book.springboot.config;

import com.jojoIdu.book.springboot.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {

        argumentResolvers.add(loginUserArgumentResolver);

    }

}

 

HandlerMethodArgumentResolver항상 WebMvcConfigurer의

 

addArgumentResolvers()를 통해 추가해야만 한다.

 

다른 HandlerMethodArgumentResolver가 필요하다면 같은 방식으로 추가해 주면 된다.

 

최종적으로 패키지 구조는 아래와 같다.

 

config 패키지 구조

 

JpaConfig Class 파일은 뒤에 작성하는 파일이니 신경쓰지 않아도 된다.

 

모든 설정이 끝났으니 IndexController의 코드에서 반복되는 부분들을 모두 @LoginUser로 개선하겠다.

 

 

4. IndexController

package com.jojoIdu.book.springboot.web;

...

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());
        if(user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }

    ...

}

 


LoginUserSessionUser user

  • 기존 코드에서 (User) httpSession.getAttribute("user")로 가져오던 세션 정보 값이 개선되었다.
  • 이제는 어느 Controller든지 @LoginUser 어노테이션만 사용하면 세션 정보를 가져올 수 있게 되었다.

 

애플리케이션을 실행시켜 로그인 기능이 정상적으로 작동하는 것을 확인해준다.

 

로그인 기능이 정상적으로 동작한다.

 

 

 

 

세션 저장소로 데이터베이스 사용하기

추가로 개선을 진행하겠다.

 

지금 우리가 만든 서비스는 애플리케이션 서버를 재실행하면 로그인이 풀린다.

 

이는 세션이 내장 톰캣의 메모리에 저장되기 때문이다.

 

 

기본적으로 세션은 실행되는 WAS의 메모리에서 저장되고 호출된다.

 

메모리에 저장되다 보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화가 된다.

 

즉, 배포할 때마다 톰캣이 재시작되는 것이다.

 

 

이 외에도 한가지 문제가 더 있다.

 

2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야만 한다.

 

그래서 실제 현업에서는 세션 저장소에 대해 아래의 3가지 중 한 가지를 선택해서 사용한다.

 


(1) 톰캣 세션을 사용한다.

  • 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식이다.
  • 이렇게 될 경우 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요하다.

 

(2) MySQL과 같은 데이터베이스를 세션 저장소로 사용한다.

  • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법이다.
  • 많은 설정이 필요없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있다.
  • 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용한다.

 

(3) Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용한다.

  • B2C 서비스에서 가장 많이 사용하는 방식이다.
  • 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요하다.

 

여기서는 두 번째 방식인 데이터베이스를 세션 저장소로 사용하는 방식을 선택해서 진행한다.

 

이유는 설정이 간단하고 사용자가 많은 서비스가 아니며 비용 절감을 위해서이다.

 

 

이후 AWS에서 이 서비스를 배포하고 운영할 때를 생각하면

 

Redis와 같은 메모리 DB를 사용하기에는 부담스럽다.

 

 

왜냐하면, Redis와 같은 서비스(엘라스틱 캐시)에 별도로 사용료를 지불해야 하기 때문이다.

 

사용자가 없는 현재 단계에서는 데이터베이스로 모든 기능을 처리하는게 부담이 적다.

 

만약 본인이 운영 중인 서비스가 커진다면 한번 고려해 보면 된다.

 

 

1. spring-session-jdbc 등록

먼저 build.gradle에 아래와 같이 의존성을 등록한다.

 

1-1. build.gradle

...

dependencies {

...

    compile('org.springframework.session:spring-session-jdbc')

}

 

spring-session-jdbc 역시 현재 상태에선 바로 사용할 수 없다.

 

spring web, spring jpa를 사용했던 것과 마찬가지로 의존성이 추가되어 있어야 사용할 수 있다.

 

그리고 application.properties에 세션 저장소를 jdbc로 선택하도록 아래의 코드를 추가해준다.

 

1-2. application.properties

spring.session.store-type=jdbc

 

모두 변경했다면 다시 애플리케이션을 실행해서 로그인을 테스트한 뒤 h2-console로 접속한다.

 

h2-console을 보면 세션을 위한 테이블 2개( SPRING_SESSION, SPRING_SESSION_ATTRIBUTES )가

 

생성된 것을 확인할 수 있다.

 

JPA로 인해 세션 테이블이 자동 생성되었기 때문에 별도로 해야할 일은 없다.

 

방금 로그인했기 때문에 아래의 사진처럼 한 개의 세션이 등록돼있는 것을 볼 수 있다.

 

h2 Session 스키마

 

세션 저장소를 데이터베이스로 교체했다.

 

물론 지금은 기존과 동일하게 Spring 서버를 재시작하면 세션이 풀리게 된다.

 

이유는 H2 기반으로 Spring 서버가 재실행될 때 H2도 같이 재시작되기 때문이다.

 

 

이후 AWS로 배포하게 되면 AWS의 데이터베이스 서비스인

 

RDS(Relational Database Service)를 사용하게 되니 이때부터는 세션이 풀리지 않게 된다.

 

 

그 기반이 되는 코드를 작성한 것이니 걱정하지말고 다음 과정을 진행하면 된다.

 

 

 

 

네이버 로그인

마지막으로 네이버 로그인을 추가해주겠다.

 

 

1. 네이버 API 등록

먼저 네이버 오픈 API로 접속한다.

 

 

네이버 오픈 API

https://developers.naver.com/apps/#/register?api=nvlogin 

 

애플리케이션 - NAVER Developers

 

developers.naver.com

 

 

아래와 같이 각 항목을 채워준다.

 

네이버 오픈 API 애플리케이션 등록

 

네이버 오픈 API 애플리케이션 등록

 

회원이름, 이메일, 프로필 사진은 필수이며 추가 정보는 필요한 경우 선택할 수 있다.

 

아래로 내려가서 구글에서와 마찬가지로 URL을 등록해주면 된다.

 

서비스 URL은 필수이다. 여기서는 localhost:8080으로 등록한다.

 

Callback URL은 구글에서 등록한 리디렉션 URL과 같은 역할을 한다.

 

여기서는 /login/oauth2/code/naver로 등록한다.

 

등록을 완료하고 등록하기 버튼을 누르면 별다른 알람창 없이바로 서비스가 등록되고

 

왼쪽 내 애플리케이션에 서비스의 서비스명을 누르면 ClientIdClient Secret을 확인할 수 있다.

 

애플리케이션 로그인 서비스 등록

 

해당 키값들을 application-oauth.properties에 등록한다.

 

네이버에서는 Spring Security를 공식 지원하지 않기 때문에

 

그동안 CommonOAuth2Provider에서 해주던 값들도 전부 수동으로 입력해야 한다.

 

# registration
spring.security.oauth2.client.registration.google.client-id=구글 클라이언트 ID
spring.security.oauth2.client.registration.google.client-secret=구글 클라이언트 비밀 보안 코드
spring.security.oauth2.client.registration.google.scope=profile,email
spring.security.oauth2.client.registration.naver.client-id=네이버 클라이언트 ID
spring.security.oauth2.client.registration.naver.client-secret=네이버 클라이언트 비밀 보안 코드
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization_grant_type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

# provider
spring.security.oauth2.client.provider.naver.authorization_uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token_uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user_name_attribute=response

 


user_name_attribute=response

  • 기준이 되는 user_name의 이름을 네이버에서는 response로 해야 한다.
  • 이유는 네이버의 회원 조회 시 반환되는 JSON 형태 때문이다.

 

네이버 오픈 API의 로그인 회원 결과는 아래와 같다.

 

{
    "resultcode: "00",
    "message": "success",
    "response": {
        "email": "openapi@naver.com",
        "nickname": "OpenAPI",
        "profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif",
        "age": "40-49",
        "gender": "F",
        "id": "32742776",
        "name": "오픈API",
        "birthday": "10-01"
    }
}

 

Spring Security에선 하위 필드를 명시할 수 없다.

 

최상위 필드들만 user_name으로 지정 가능하다.

 

하지만 네이버의 응답값 최상위 필드는 resultCode, message, response이다.

 

이러한 이유로 Spring Security에서 인식 가능한 필드는 저 3개 중에서 골라야 한다.

 

본문에서 담고 있는 response를 user_name으로 지정하고

 

이후 자바 코드로 response의 id를 user_name으로 지정하겠다.

 

 

2. Spring Security 설정 등록

Google 로그인을 등록하면서 대부분 코드가 확장성 있게 작성되었다 보니 네이버는 쉽게 등록 가능하다.

 

OAuthAttributes에 아래와 같이 네이버인지 판단하는 코드와 네이버 생성자를 추가해준다.

 

 

2-1. OAuthAttributes

package com.jojoIdu.book.springboot.config.auth.dto;

...

@Getter
public class OAuthAttributes {

    ...

    public static OAuthAttributes of(String registrationId, String userNameAttributeName
            , Map<String, Object> attributes) {

        if("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }

        return ofGoogle(userNameAttributeName, attributes);

    }

    ...

    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {

        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();

    }

    ...

}

 

그리고 index.mustache에 네이버 로그인 버튼을 추가해준다.

 

 

2.2 index.mustache

{{>layout/header}}

    <h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>
    <div class="col-md-12">
        <!-- 로그인 기능 영역 -->
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
                {{#userName}}
                    Logged in as: <span id="user">{{userName}}</span>
                    <a href="/logout" class="btn btn-info active" role="button">Logout</a>
                {{/userName}}
                {{^userName}}
                    <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>

                    <!-- Naver 로그인 버튼 -->
                    <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
                {{/userName}}
            </div>
        </div>
        <br>
        <!-- 목록 출력 영역 -->
        ...

    </div>


{{>layout/footer}}

 


/oauth2/authorization/naver

  • 네이버 로그인 URL은 application-oauth.properties에 등록한 redirect-uri 값에 맞춰 자동으로 등록된다.
  • /oauth2/authorization/ 까지는 고정이고 마지막 Path만 소셜 로그인 코드를 사용하면 된다.
  • 여기서는 naver가 마지막 Path가 된다.

 

이제 메인 화면을 확인해 보면 네이버 로그인 버튼이 활성화된 것을 볼 수 있고

 

네이버 로그인 버튼을 누르면 로그인 동의 화면이 나오고 로그인이 성공하는 것을 확인할 수 있다.

 

네이버 로그인 버튼 활성화

 

네이버 로그인 성공 화면

 

 

 

 

기존 테스트에 시큐리티 적용하기

마지막으로 기존 테스트에 Security 적용으로 문제가 되는 부분들을 해결해 보겠다.

 

문제되는 부분들은 대표적으로 다음과 같은 이유 때문이다.

 

기존에는 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성하였다.

 

하지만, Security옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있게 된다.

 

 

기존의 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로,

 

테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정한다.

 

IntelliJ 오른쪽 위에 [Gradle] 탭을 클릭해준다. 

 

Gradle 인증 설정

 

그리고 [Tasks -> verification -> test] 를 찾아서 더블클릭해 실행시켜준다.

 

[Gradle -> Tasks -> verification -> test] 실행

 

test를 실행해보면 다음과 같이 롬복을 이용한 테스트 외에

 

Spring을 이용한 테스트는 전부 실패하는 것을 확인할 수 있다.

 

text 실행 시 전체 테스트 실패

 

그 이유를 하나씩 확인해보겠다.

 

 

문제1. CustomOAuth2UserService를 찾을 수 없음

첫 번째 테스트인 "hello가_리턴된다"의 오류 메세지는 아래와 같다.

 

"No qualifying bean of type 'com.jojoldu.book.springboot.config.auth.CustomOAuth2UserService'"

 

이는 CustomerOAuth2UserService를 생성하는데 필요한

 

소셜 로그인 관련 설정값들이 없기 때문에 발생한다.

 

 

하지만 분명히 application-oauth.properties에 설정값들을 추가했는데

 

왜 설정값이 없다는 오류가 뜨는 것일까?

 

이는 src/main 환경과 src/test 환경의 차이 때문이다.

 

 

둘은 본인만의 환경 구성을 가진다.

 

다만, src/main/resources/application.properties가

 

테스트 코드를 수행할 때도 적용되는 이유는

 

test에 application.properties 파일이 없으면 main의 설정을 그대로 가져오기 때문이다.

 

 

하지만 자동으로 가져오는 옵션의 범위는 application.properties 파일까지이다.

 

application-oauth.properties 파일이 test에 없다고 가져오는 파일이 아니라는 점이다.

 

이 문제를 해결하기 위해 테스트 환경을 위한 application.properties를 만들어준다.

 

실제로 구글 연동까지 진행할 것은 아니므로 가짜 설정값을 등록해준다.

 

 

1-1. /src/test/resources/application.properties

/src/test/resources/application.properties 생성

 

application.properties 파일에 아래의 코드를 입력해준다.

 

spring.jpa.show_sql = true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.h2.console.enabled=true
spring.session.store-type=jdbc

# Test OAuth
# spring.security.oauth2.client.registration.google.client-id=test
# spring.security.oauth2.client.registration.google.client-secret=test
# spring.security.oauth2.client.registration.google.scope=profile,email

 

다시 Gradle로 test를 실행해 보면 아래와 같이 7개의 실패 테스트가 4개로 줄어들게 된다.

 

application.properties 추가 후 테스트 실행 결과

 

 

 

 

문제2. 302 Status Code

두 번째로 "Posts_등록된다" 테스트 로그를 확인해보면 아래와 같은 오류 코드가 발생한다.

 

expected:<[200 OK]> but was:<[302 FOUND]>
필요:200 OK
실제:302 FOUND

 

응답의 결과로 200(정상 응답) Status Code를 원했는데

 

결과는 302(리다이렉션 응답) Status Code가 와서 실패했다는 오류이다.

 

이는 Spring Security 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문이다.

 

 

그래서 이런 API 요청은 임의로 인증된 사용자를 추가하여 API만 테스트해 볼 수 있게 하겠다.

 

이미 Spring Security에서 공식적으로 방법을 지원하고 있으므로 바로 적용해 보겠다.

 

 

Spring Security 테스트를 위한 여러 도구를 지원하는 spring-security-test 의존성을 build.gradle에 추가해준다.

 

...

dependencies {

    ...

    compile('org.springframework.security:spring-security-test')

}

 

그리고 PostsApiControllerTest의 2개 테스트 메소드에 다음과 같은 임의 사용자 인증을 추가해준다.

 

 

2-1. PostsApiControllerTest

package com.jojoIdu.book.springboot.web;

...

public class PostsApiControllerTest {

    ...

    @Test
    @WithMockUser(roles="USER")
    public void Posts_등록된다() throws Exception {
        
        ...

    }

    @Test
    @WithMockUser(roles="USER")
    public void Posts_수정된다() throws Exception {
        
        ...

    }

}

 


WithMockUser(roles="USER")

  • 인증된 모의(가짜) 사용자를 만들어서 사용한다.
  • roles에 권한을 추가할 수 있다.
  • 즉, 이 어노테이션으로 인해 ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가지게 된다.

 

위의 코드를 작성해도 동작하진 않는다.

 

@WithMockUser가 MockMvc에서만 동작하기 때문이다.

 

 

현재 PostsApiControllerTest는 @SpringBootTest로만 되어있으며 MockMvc를 전혀 사용하지 않는다.

 

그래서 @SpringBootTest에서 MockMvc를 사용할 수 있게 적용해주어야 한다.

 

코드를 아래와 같이 변경한다.

 

package com.jojoIdu.book.springboot.web;

...
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

// 아래의 코드는 직접 import 해주어야한다.
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    ...

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
    }

    ...

    @Test
    @WithMockUser(roles="USER")
    public void Posts_등록된다() throws Exception {
    
        ...

        //when
        mvc.perform(post(url).contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);

    }

    @Test
    @WithMockUser(roles="USER")
    public void Posts_수정된다() throws Exception {
    
        ...

        //when
        mvc.perform(put(url).contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);

    }

}

 


@Before

  • 매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성합니다.

 

mvc.perform

  • 생성된 MockMvc를 통해 API를 테스트한다.
  • 본문(Body) 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환한다.

 

다시 test를 실행시켜 전체 테스트를 수행해보겠다.

 

전체 테스트 실행 화면

 

위의 사진과 같이 Posts 테스트도 오류없이 정상적으로 수행되는 것을 확인할 수 있다.

 

 

 

 

문제3. @WebMvcTest에서 CustomOAuthUserService를 찾을 수 없음

제일 앞에서 발생한 "Hello가 리턴된다" 테스트를 확인해 본다.

 

그럼 첫 번째로 해결한 것과 동일한 오류 메시지가 나온다.

 

No qualifying bean of type 'com.jojoIdu.book.springboot.config.auth.CustomOAuth2UserService'

 

이 문제는 왜 발생했을까?

 

HelloControllerTest는 1번과는 조금 다른점이 있다.

 

바로 @WebMvcTest 를 사용한다는 점이다.

 

 

1번을 통해 Spring Security 설정은 잘 작동했지만,

 

@WebMvcTest는 CustomOAuth2UserService를 스캔하지 않기 때문이다.

 

 

@WebMvcTest는 WebSecurityConfigurerAdapter, WebMvcConfigurer를 비롯한

 

@ControllerAdvice, @Controller를 읽는다.

 

즉, @Repository, @Service, @Component는 스캔 대상이 아니다.

 

 

그러니 SecurityConfig는 읽었지만, SecurityConfig를 생성하기 위해 필요한

 

CustomOAuth2UserService는 읽을수가 없어 앞에서와 같이 에러가 발생한 것이다.

 

 

이 문제를 해결하기 위해 아래와 같이 스캔 대상에서 SecurityConfig를 제거해준다.

 

 

3-1. HelloControllerTest

package com.jojoIdu.book.springboot;

...

@WebMvcTest(controllers = HelloController.class,
            excludeFilters = {
            @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
            })

public class HelloControllerTest {

    ...

}

 

여기서도 마찬가지로 @WithMockUser를 사용해서 가짜로 인증된 사용자를 생성한다.

 

@WithMockUser(roles = "USER")
@Test
public void hello가_리턴된다() throws Exception {
        
    ...
        
}

@WithMockUser(roles = "USER")
@Test
public void helloDto가_리턴된다() throws Exception {
        
    ...

}

 

이렇게 한 뒤 다시 테스트를 돌려보면 다음과 같은 추가 에러가 발생한다.

 

java.lang.IllegalArgumentException: At least one JPA metamodel must be present!

 

이 에러는 @EnableJpaAuditing으로 인해 발생한다.

 

@EnableJpaAuditing을 사용하기 위해선 최소 하나의 @Entity Class가 필요하다.

 

@WebMvcTest이다 보니 당연히 Entity Class가 없다.

 

@EnableJpaAuditing가 @SpringBootApplication과 함께 있다보니 @WebMvcTest에서도 스캔하게 됐다.

 

 

그래서 @EnableJpaAuditing과 @SpringBootApplication 둘을 분리해주겠다.

 

우선 Application.java Class에서 @EnableJpaAuditing을 제거해준다.

 

 

3-2. Application

package com.jojoIdu.book.springboot;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// @EnableJpaAuditing
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

 

그리고 config 패키지에 JpaConfig를 생성하여 @EnableJpaAuditing을 추가해준다.

 

 

3-3. JpaConfig

package com.jojoIdu.book.springboot.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

 

그리고 다시 전체 테스트를 수행해본다.

 

전체 테스트 통과

 

 

모든 테스트를 정상적으로 통과한다.

 

앞의 과정을 토대로 Spring Security 적용으로 깨진 테스트를 수정할 수 있게 되었다.

 

 

우리는 앞서 IntelliJ로 Spring Boot 통합 개발 환경을 만들고

 

테스트와 JPA로 데이터를 처리하고 Mustache로 화면을 구성했으며

 

Security와 OAuth로 인증과 권한을 배워보며 간단한 게시판을 모두 완성했다.

 

 

예전만 하더라도 Spring Security를 사용하기가 쉽지 않았다.

 

하지만 계속 버전이 상향되어 최근 버전에서는 확장이 쉬워졌다.

 

꼭 저자의 선택인 Spring Boot Security 2.0을 쓰지않고 1.5를 사용해도 되지만, 언젠가는 업데이트를 해야만 한다.

 

 

 

다음엔 AWS를 이용해 나만의 서비스를 직접 배포하고 운영하는 방법을 포스팅하겠다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90

댓글