많은 서비스에서 구글, 네이버등 소셜 로그인 기능을 많이 사용합니다.
우선 소셜로그인을 사용하지 않으면 구현해야 하는 것들을 알아보겠습니다.
소셜 로그인을 사용하면 위의 기능들을 직접구현하지 않아도 되니 서비스 개발에 집중할 수 있습니다.
소셜 로그인을 사용하기 위해서는 우선 해당 서비스에서 인증 정보를 발급받아야 합니다. 구글 서비스 등록은 이 글을 참고해 주세요.
application.properties와 같은 위치에 application-oauth.properties 파일을 생성합니다.
# Google Login
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
scope를 별도로 등록하지 않았을 때 기본값은 openid, profile, email입니다. openid를 빼고 등록한 이유는 openid가 있으면 OpenId Provider로 인식되어 OpenId Provider인 서비스와 그렇지 않은 서비스로 나눠서 OAuth2Service를 만들어야 하기때문에 하나의 OAuth2Service로 사용하기 위해 email, profile을 따로 등록해줍니다.
스프링 부트에서는 properties의 이름을 application-xxx.properties로 생성하면 xxx라는 이름의 profile이 생성되어 해당 profile의 설정들을 가져와 사용할 수 있습니다. application-oauth.properties의 설정을 application.properties에서 사용하기 위해 다음 코드를 application.properties에 추가합니다.
spring.profiles.include=oauth
또한 본인의 클라이언트 ID와 클라이언트 시크릿이 깃허브에 올라가는것을 방지하기위해 gitignore에 다음 코드를 추가합니다.
### Properties ###
application-oauth.properties
우선 사용자 정보를 담당할 User 클래스를 생성합니다. domain 패키지 안에 user 패키지를 생성합니다.
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
JPA로 데이터베이스에 저장할 때 Enum 값을 어떤 형태로 저장할지 결정합니다(기본값 int 숫자). 하지만 숫자로 저장되면 데이터베이스로 확일할 때 그 값이 무슨 코드를 의미하는지 알 수 없기때문에 문자열로 저장될 수 있도록 선언합니다.
사용자의 권한을 관리할 Enum 클래스를 생성합니다.
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
스프링 시큐리티에서는 권한 코드앞에 ROLE_이 있어야합니다. 그래서 권한을 설정할 때 ROLE_Guest, ROLE_USER 등으로 지정합니다.
User의 CRUD를 책임질 UserRepository를 생성합니다.
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
소셜 로그인으로 반환되는 값 중 이메일을 통해 이미 생성된 사용자인지 처음 가입하는 사람인지 판단합니다.
build.gradle에 의존성을 추가합니다.
implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
시큐리티 관련 클래스를 담을 config.auth 패키지를 생성합니다.
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOauth2Service;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout().logoutSuccessUrl("/")
.and()
.oauth2Login().userInfoEndpoint().userService(customOauth2Service);
}
}
시큐리티 설정들을 활성화합니다.
URL별 권한 관리를 설정하는 옵션의 시작점입니다. authorizeRequests 가 선언되어야만 aantMatchers 옵션을 사용할 수 있습니다.
권한 관리 대상을 지정하는 옵션입니다. URL, HTTP 메서드별로 관리가 가능합니다.
설정한 값들 외 나머지 URL들을 나타납니다. authenticated을 추가하여 인증된 사용자 즉, 로그인한 사용자들에게만 허용합니다.
로그아웃 기능에 대한 설정의 진입점입니다. 로그아웃 성공시 / 주소로 이동합니다.
OAuth2 로그인 기능에 대한 설정의 진입점입니다.
OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당합니다.
소셜 로그인 성공 후 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록합니다. 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있습니다.
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = SaveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())), attributes.getAttributes(), attributes.getNameAttributeKey());
}
private User SaveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현되었습니다. 사용자의 정보가 변경되면 User 엔티티에도 반영됩니다.
현재 로그인 진행중인 서비스를 구분하는 코드입니다. 이후 네이버 로그인 적용 후 구분하기 위해 사용합니다.
OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스입니다.
세션에 사용자 정보를 저장하기 위한 Dto 클래스입니다.
@Getter
public class OAuthAttributes1 {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes1(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
public static OAuthAttributes1 of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes1 ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes1.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
OAuth2Usesr 에서 변환하는 사용자 정보는 Map이기 때문에 값들을 하나하나 변환합니다.
처음 가입하는 시점에 User 엔티티를 생성합니다. 가입할 떄의 기본권한을 GUEST로 주기위해 role 빌더값에 Role.GUEST를 사용합니다.
@Getter
public class SessionUser implements Serializable {
private String name;
private String email;
private String picture;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
SessionUser에는 인증된 사용자 정보만 필요하기에 name, email, profile만 필드로 선언합니다.
User 클래스를 사용하면 안되는 이유
User 클래스를 그대로 사용하게되면 직렬화를 구현하지 않았다는 에러가 발생합니다. User 클래스는 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될지 모릅니다. 따라서 User 클래스에 직접 직렬화를 하지 않고 직렬화 기능을 가진 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움이 됩니다.
스프링 시큐리티 적용 확인을 위해 index.mustache에 로그인 버튼을 추가합니다.
<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>
{{/userName}}
</div>
</div>
머스테치는 if문을 제공하지 않고 true/false 여부만 판단합니다. 그래서 항상 최종값을 넘겨줘야합니다.
스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL입니다.
머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용합니다.
스프링 시큐리티에서 기본적으로 제공하는 로그인 URL입니다.
index.mustache에서 userName을 사용할 수 있도록 userName을 model에 저장합니다.
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if (user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
...
CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했습니다. 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있습니다.
세션에 저장된 값이 있을때만 model에 userName으로 등록합니다.
프로젝트를 실행하여 테스트를 진행합니다.
Google Login 버튼을 클릭해 로그인을 하면 등록된 이름이 노출됩니다.
저는 이 부분에서 이름이 출력되지 않고 제 윈도우 사용자 이름이 출력되는 문제가 있었습니다. 찾아보니 변수명에 IndexController에서 설정한 변수명에 문제가 있었습니다. 저와 같은 문제가 있으신분은 깃허브 이슈를 참고해주세요.
h2-console에 접속하여 데이터베이스에 정상적으로 회원정보가 들어갔는지 확인합니다.
하지만 이 상태에서 게시글 등록을 하면 사용자의 권한이 GUEST이기 때문에 403에러가 발생합니다.
다시 h2-console로 돌아가서 role을 USER로 변경해줍니다.
로그아웃 후 다시 로그인하여 글을 등록하면 정상적으로 등록되는 것을 확일할 수 있습니다.
프로그래밍에서 개선이 필요한 부분은 대표적으로 같은 코드가 반복되는 부분입니다. 코드가 반복되면 수정이 필요할 시, 하나씩 찾아가며 수정해야하기때문에 유지보수성이 떨어지고, 수정이 반영되지 않은 부분에서 문제가 발생할 여지가 있습니다. 앞서 만든 코드에서도 반복되는 부분을 개선해보도록 하겠습니다.
SessionUser user = (SessionUser) httpSession.getAttribute("user");
위의 코드는 IndexController에서 세션값을 가져오는 부분입니다. index 메서드 외에 다른 컨트롤러와 메서드에서도 세션값이 필요한 경우 그때마다 직접 세션에서 값을 가져와야합니다. 그래서 이 부분을 메서드 인자로 세션값을 바로 받을 수 있도록 변경하겠습니다.
config.auth 패키지에 @LoginUser 어노테이션을 생성합니다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
이 어노테이션이 생성될 수 있는 위치를 지정합니다. PARAMETER로 지정했으니 메서드의 파라미터로 선언된 객체에서만 사용할 수 있습니다.
이 파일을 어노테이션 클래스로 지정합니다.
LoginUser 클래스와 같은 위치에 LoginUserArgumentResolver 클래스를 생성합니다.
@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");
}
}
LoginUserArgumentResolver 클래스는 HandlerMethodArgumentResolver 인터페이스를 구현한 클래스입니다. HandlerMethodArgumentResolver는 조건에 맞는 경우 메서드가 있다면 HandlerMethodArgumentResolver 구현체가 지정한 값으로 해당 메서드의 파라미터로 넘길 수 있습니다.
컨트롤러 메서드의 특정 파라미터를 지원하는지 판단합니다.
파라미터에 전달할 객체를 생성합니다.
LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가합니다. config 패키지에 WebConfig 클래스를 생성합니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(loginUserArgumentResolver);
}
}
HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가해야 합니다. 다른 HandlerMethodArgumentResolver가 필요하면 같은 방식으로 추가할 수 있습니다.
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if (user != null) {
model.addAttribute("loginUserName", user.getName());
}
return "index";
}
다시 애플리케이션을 실행해 로그인 기능이 정상적으로 작동하는 것을 확인합니다.
지금까지 만든 서비스는 로그인시 세션이 내장 톰캣의 메모리에 저장되기 때문에 애플리케이션을 재실행하면 로그인이 풀립니다. 기본적으로 세션은 실행되는 WAS의 메모리에 저장되고 호출되는데, 그러다보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에서는 항상 초기화 됩니다.
이 외에 2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야만 합니다. 그래서 실제 현업에서는 다음 3가지 중 한가지를 선택합니다.
톰캣 세션을 사용한다.
MySQL과 같은 데이터베이스를 세션 저장소로 사용한다.
Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용한다.
설정이 간단하고 사용자가 많은 서비스가 아니며 비용 절감을 위해 데이터베이스를 세션 저장소로 사용하는 방식을 선택하겠습니다.
build.gradle에 다음과 같이 의존성을 등록합니다.
implementation('org.springframework.session:spring-session-jdbc')
그리고 application.properties에 세션 저장소를 jdbc로 선택하도록 코드를 추가합니다.
spring.session.store-type=jdbc
다시 애플리케이션을 실행하고 로그인을 진행한 뒤, h2-console로 접속하면 SPRING_SESSION, SPRING_SESSION_ATTRIBUTES 두 개의 테이블이 생성된 것을 볼 수 있습니다. 이 테이블들은 JPA로 인해 자동 생성되었기 때문에 별도로 해야 할 일은 없습니다.
방금 로그인 했기때문에 한 개의 세션이 등록되있는것을 확인할 수 있습니다.
하지만 지금은 H2를 사용하기 때문에 스프링이 재실행될 때 기존과 동일하게 세션이 풀리게 됩니다. 지금까지 설정한 것들은 이후 AWS에 배포시 RDS를 사용하기 위한 코드를 작성한 것입니다.
마지막으로 네이버 로그인을 추가하겠습니다. 네이버 로그인도 마찬가지로 서비스 등록을 해주어야 합니다. 서비스 등록은 이 글을 참고해주세요.
네이버 서비스 등록 후 Client Id와 Client Secret을 application-oauth.properties에 등록합니다.
# Naver Login registration
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
# Naver Login 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
네이버에서는 스프링 시큐리티를 공식 지원하지 않기 때문에 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력합니다.
네이버의 회원 조회 시 반환되는 JSON 형태 때문에 user_name의 이름을 response로 해야 합니다.
네이버 오픈 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",
"birthyear": "1900",
"mobile": "010-0000-0000"
}
}
스프링 시큐리티에서는 하위 필드를 명시할 수 없습니다. 하지만 네이버의 응답값 최상위 필드는 resultCode, message, response 입니다. 본문에서 담고 있는 response를 user_name으로 지정하고 이후 자바 코드로 response의 id를 user_name 으로 지정하겠습니다.
해당 클래스에 네이버인지 판단하는 코드와 네이버 생성자를 추가해 줍니다.
@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();
}
}
마지막으로 네이버 로그인 버튼을 추가합니다.
...
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
{{#loginUserName}}
Logged in as: <span id="user">{{loginUserName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/loginUserName}}
{{^loginUserName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
{{/loginUserName}}
</div>
</div>
...
애플리케이션을 다시 시작하고 접속하면 네이버 로그인 버튼이 활성화된 것을 확인할 수 있습니다.
네이버 로그인 버튼을 누르면 동의 화면이 나오고 동의하기 버튼을 누르면 로그인이 성공합니다.
기존 테스트에 시큐리티 적용으로 문제가 되는 부분들을 해결하겠습니다. 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있습니다. 기존 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정하겠습니다.
우선 전체 테스트를 진행합니다. 우측 상단에 [Gradle 탭] -> [Task] -> [verification] -> [test] 차례로 선택합니다.
test를 실행하면 롬복기능 테스트 외에 테스트는 모두 실패하는 것을 확인할 수 있습니다.
이 부분에서 테스트 메서드 명이 깨져서 나오시는 분은 이 글을 참고해 주세요.
"hello가_리턴된다"의 메세지를 확인하면 "No qualifying bean of type '패키지경로.CustomOAuth2UserService'"라는 메세지가 나옵니다. 이는 소셜 로그인 관련 설정값들이 없기 때문에 발생합니다.
src/main 과 src/test 는 본인만의 환경 구성을 가집니다. test에 application.properties가 없어도 main의 설정을 그대로 가져오지만, 자동으로 가져오는 범위는 application.properties 까지 이므로 application-oauth.properties에서 해준 설정을 추가해주어야 합니다.
src/test/resources 패키지에 생성합니다.
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb;MODE=MYSQL
spring.datasource.hikari.username=sa
spring.h2.console.enabled=true
spring.session.store-type=jdbc
# Test Outh
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
실제 구글 연동은 하지 않기때문에 가짜 설정값을 등록합니다.
"Post_등록된다" 테스트 로그를 확인해보면 302오류가 난 것을 확인할 수 있습니다. 이는 스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문입니다.
build.gradle에 다음 코드를 추가합니다.
testImplementation("org.springframework.security:spring-security-test")
...
@Test
@WithMockUser(roles = "USER")
public void Posts_등록된다() throws Exception {
...
@Test
@WithMockUser(roles = "USER")
public void Posts_수정된다() throws Exception {
...
@WithMockUser는 MockMvc에서만 작동합니다. 현재 PostApiControllerTest는 @SpringBootTest로만 되어있기에 @SpringBootTest에서 MockMvc를 사용하도록 코드를 변경하겠습니다.
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
...
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@BeforeEach
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);
}
}
매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성합니다.
생성된 MockMvc를 통해 API를 테스트합니다. 본문 영역은 문자열로 표현하기 위해 ObjectMapper 를 통해 문자열 JSON으로 변환합니다.
다시 테스트를 진행해 HelloControllerTest 테스트 메세지를 확인해보면 위에서 "Hello가_리턴된다"와 같은 메세지가 나오는 것을 볼 수 있습니다. HelloControllerTest는 @WebMvcTest를 사용합니다. @WebMvcTest는 @Repository, @Service, @Component는 스캔 대상이 아니기 때문에 CustomOAuth2UserService는 읽을수가 없습니다.
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = HelloController.class,
excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
}
)
public class HelloControllerTest {
@Autowired
private MockMvc mvc;
@WithMockUser(roles = "USER")
@Test
public void hello가_리턴된다() throws Exception {
...
}
@WithMockUser(roles = "USER")
@Test
public void helloDto가_리턴된다() throws Exception {
...
}
}
스캔 대상에서 SecurityConfig를 제거하고 @@WithMockUser를 사용해서 가짜로 인증된 사용자를 생성합니다.
다시 테스트를 진행해보면 다음과 같은 추가 에러가 발생합니다.
java.lang.IllegalArgumentException: JPA metamodel must not be empty!
@EnableJpaAuditing을 사용하기 위해서는 최소 하나의 @Entity 클래스가 필요한데, @WebMvcTest이다 보니 없습니다. @EnableJpaAuditing가 @SpringBootApplication과 함께 있어 @WebMvcTest에서도 스캔하게 되었습니다.
Application 클래스에 있던 @EnableJpaAuditing 어노테이션을 제거합니다.
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
config 패키지에 JpaConfig 클래스를 생성하여 @EnableJpaAuditing 을 추가합니다.
@Configuration
@EnableJpaAuditing
public class JpaConfig {}
앞의 과정을 잘 따라오셨다면 모든 테스트가 통과하는것을 확인할 수 있습니다.