일반적인 프로그래밍에서 같은 코드가 반복되는 부분은 대표적인 코드 개선의 대상이 된다
IndexController에서 세션값을 가져오는 부분은 index 메소드 외에 다른 컨트롤러와 메소드에서 세션값이 필요하다면 반복될 가능성이 있다
SessionUser user = (SessionUser) httpSession.getAttribute("user");
이 부분을 메소드 인자로 세션값을 바로 받아올 수 있도록 변경해보자
config.auth
패키지에 @LoginUser
어노테이션을 생성한다
LoginUser.java
package com.vencott.dev.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)
@interface
같은 위치에 LoginUserArgumentResolver를 생성한다
LoginUserArgumentResolver.java
package com.vencott.dev.springboot.config.auth;
import com.vencott.dev.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");
}
}
implements HandlerMethodArgumentResolver
supportsParameter()
@LoginUser
어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class
인 경우 true를 반환이렇게 생성된 LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가해야 한다
config 패키지에 WebConfig 클래스를 생성한다
WebConfig.java
package com.vencott.dev.springboot.config;
import com.vencott.dev.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()
를 통해 추가해야 한다
이제 IndexController에서 반복되는 부분을 모두 @LoginUser
로 개선한다
IndexController.java
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if (user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
public String index(Model model, @LoginUser SessionUser user)
httpSession.getAttribute("user")
로 가져오던 세션 정보 값이 @LoginUser
를 사용하는 방식으로 개선@LoginUser
만 사용하면 세션 정보를 가져올 수 있게 되었다여기서는 두번째 방식을 선택하여 진행한다
먼저 build.gradle에 의존성을 등록한다
compile('org.springframework.session:spring-session-jdbc')
application.properties에 세션 저장소를 jdbc로 선택하도록 추가한다
spring.session.store-type=jdbc
애플리케이션을 시작하고 h2-console을 보면 SPRING_SESSION
, SPRING_SESSION_ATTRIBUTES
2개의 테이블이 JPA로 인해 자동 생성된 것을 볼 수 있다
물론 지금도 스프링을 재시작하면 세션이 풀린다
→ H2 기반으로 스프링이 재실행 될 때 H2도 재시작하기 때문
이후 AWS로 배포하게 되면 AWS의 DB인 RDS를 사용하게 되니 이때부터는 세션이 풀리지 않는다
네이버 오픈 API에서 다음과 같이 애플리케이션을 등록한다
application-oauth.properties
# Google
spring.security.oauth2.client.registration.google.client-id=
spring.security.oauth2.client.registration.google.client-secret=
spring.security.oauth2.client.registration.google.scope=profile,email
# Naver
# registration
spring.security.oauth2.client.registration.naver.client-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
Client ID 와 Secret을 등록한다
네이버는 구글과 다르게 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력한다
spring.security.oauth2.client.provider.naver.user-name-attribute=response
resultCode, message, response
의 세 필드 중 response
필드에 로그인 정보를 담고 있으므로 이를 선택한다구글 로그인을 개발하면서 확장성을 염두해 두었으므로 OAuthAttributes에서 네이버인지 판단하는 코드와 네이버 생성자만 추가해준다
OAuthAttributes.java
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
if ("naver".equals(registrationId)) {
return ofNaver("id", attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
public 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();
}
return ofNaver("id", attributes);
index.mustache에 네이버 로그인 버튼을 추가한다
index.mustache
{{^userName}}
<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>
{{/userName}}
기존 테스트에 시큐리티 적용으로 문제가 되는 부분을 해결해본다
기존: 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성
→ 시큐리티 옵션이 활성화되면 인증된 사용자만 API 호출 가능
테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정
전체 테스트 실행: Gradle > Tasks > verification > test
테스트에 실패한 문제들을 하나씩 해결해본다
hello가_리턴된다
테스트에서 CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없어서 발생하는 문제
src/main
환경과 src/test
환경의 차이 때문에 발생
src/main/resources/application.properties
까지는 test에 해당 파일이 없을 시 main의 환경을 그대로 가져오기 때문에 테스트 수행이 가능하나, application-oauth.properties
는 test에 파일이 없다고 가져오는 파일이 아니다
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.profiles.include=oauth
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
Posts_등록된다
테스트
스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동
이런 API 요청은 임의로 인증된 사용자를 추가하여 테스트
이미 스프링 시큐리티에서 공식적으로 방법을 지원하고 있다
spring-security-test
를 build.gradle에 추가한다
build.gradle
testCompile('org.springframework.security:spring-security-test')
PostsApiControllerTest.java
그리고 PostsApiControllerTest의 2개의 테스트 메소드에 임의 사용자 인증을 추가한다
@Test
@WithMockUser(roles = "USER")
public void Posts_등록된다() throws Exception {
}
@WithMockUser(roles = "USER")
@WithMockUser
는 MockMvc에서만 작동하기 떄문에 @SpringBootTest
로만 되어있는 PostsApiControllerTest 에선 바로 사용할 수 없다
@SpringBootTest
에서 MockMvc를 사용하기 위해 다음과 같이 변경한다
PostsApiControllerTest.java
@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 {
//given
String title = "title";
String content = "content";
PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
.title(title)
.content(content)
.author("author")
.build();
String url = "http://localhost:" + port + "/api/v1/posts";
// //when
// ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);
//
// //then
// assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
// assertThat(responseEntity.getBody()).isGreaterThan(0L);
//
// List<Posts> all = postsRepository.findAll();
// assertThat(all.get(0).getTitle()).isEqualTo(title);
// assertThat(all.get(0).getContent()).isEqualTo(content);
//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 {
//given
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updateId = savedPosts.getId();
String expectedTitle = "title2";
String expectedContent = "content2";
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
// //when
// ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
//
// //then
// assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
// assertThat(responseEntity.getBody()).isGreaterThan(0L);
//
// List<Posts> all = postsRepository.findAll();
// assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
// assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
//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
mvc.perform
문제 1에서 여전히 문제가 발생한다
HelloControllerTest는 @WebMvcTest
를 사용한다
문제 1을 통해 스프링 시큐리티 설정은 잘 했지만, @WebMvcTest
는 CustomOAuth2UserService를 스캔하지 않는다
@WebMvcTest
는 WebSecurityConfigurerAdapter
, WebMvcConfigurer
를 비롯한 @ControllerAdvice
, @Controller
를 읽는다
하지만 @Repository
, @Service
, @Component
는 스캔의 대상이 아니다
SecurityConfig는 읽었지만 SecurityConfig 생성에 필요한 CustomOAuth2UserService는 읽을 수 없어서 발생한 문제이다
먼저, 스캔 대상에서 SecurityConfig를 제거한다
@WebMvcTest(controllers = HelloController.class,
excludeFilters = {@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)})
여기서도 마찬가지로 @WithMockUser
를 사용해서 가짜로 인증된 사용자를 생성
@WithMockUser(roles = "USER")
@Test
public void hello가_리턴된다() throws Exception {
}
@WithMockUser(roles = "USER")
@Test
public void helloDto가_리턴된다() throws Exception {
}
다시 테스트를 돌려보면 다음과 같은 에러가 발생한다
Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty!
이 에러는 @EnableJpaAuditing
으로 인해 발생한다
@EnableJpaAuditing
을 사용하기 위해선 최소 하나의 @Entity
클래스가 필요하나 @WebMvcTest
이다 보니 당연히 없다
@EnableJpaAuditing
가 @SpringBootApplication
과 같이 있으니 @WebMvcTest
에서도 스캔하게 되었다
그래서 @EnableJpaAuditing
가 @SpringBootApplication
을 분리한다
Application.java
package com.vencott.dev.springboot;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
// @EnableJpaAuditing
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Application.java에서 @EnableJpaAuditing
를 제거해주고 config 패키지에 JpaConfig를 생성해 @EnableJpaAuditing
를 추가한다
JpaConfig.java
package com.vencott.dev.springboot.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
출처: 이동욱 저, 『스프링 부트와 AWS로 혼자 구현하는 웹 서비스』, 프리렉(2019)