이 글은 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 책을 참고하여 작성되었습니다.
세션 부분은 index 메서드 외에도 다른 컨트롤러와 메서드에서 사용 가능성이 있는데 현재는 그저 코드로만 구현되어 활용하려면 복사 붙여넣기를 해야한다. 코드 유지보수와 에러 발견에 어려움을 주기때문에 이를 개선해보자.
SessionUser user = (SessionUser) httpSession.getAttribute("user");
@LoginUser
어노테이션을 생성해개선해보자.
LoginUser.java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
@interface
: 해당 자바 파일이 어노테이션임을 나타낸다.@Target(ElementType.PARAMETER)
: 이 어노테이션이 생성될 수 있는 위치를 지정한다. PARAMETER로 지정했으므로 메서드의 파라미터로 선언된 객체에서만 사용가능하다. 이 외에도 여러 타입이 존재한다.HandlerMethodArgumentResolver는 조건에 맞는 경우 메서드가 있다면 구현체가 지정한 값으로 해당 메서드의 파라미터로 넘길 수 있게 해준다. 구현체를 작성해보자.
LoginUserArgumentResolver.java
@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
어노테이션과 SessionUser.class 타입이 모두 존재하는지 판단한다. 어노테이션은 바로 비교식으로 비교하고, 클래스는 equals 메서드로 비교한다.resolveArgument()
: 세션에서 객체를 가져와 파라미터에 전달할 객체를 생성한다.위에 작성된 코드를 통해 IndexController를 개선해보자.
IndexController.java
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if (user != null) {
model.addAttribute("loginUser", user.getName());
}
return "index";
}
...
}
@LoginUser SessionUser user
: 기존 코드를 개선한 부분이다. @LoginUser
만 사용하면 세션정보를 가져올 수 있다.현재 환경에서는 세션이 내장 톰캣의 메모리에 저장되어 실행 시 항상 초기화 때문에 애플리케이션 재시작시 로그인이 풀린다. 따라서 DB를 세션 저장소로 활용하여 사용한다. 서비스가 커지면 메모리 DB 방식을 사용한다고 한다.
build.gradle에 의존성을 추가해주자.
implementation('org.springframework.session:spring-session-jdbc')
그런 다음 세션 저장소를 jdbc로 사용하도록 application.properties
에 코드를 추가하자.
spring.session.store-type=jdbc
현재는 재시작하면 초기화되는 h2가 저장소라 마찬가지로 로그인이 풀리지만 추후 aws에서 rds를 사용하면 풀리지 않게된다.
책에 명시된대로 네이버 서비스 등록 링크로 들어가 서비스를 생성한다.
그후 application-oauth.properties에 id와 시크릿을 입력한다.
# 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
# 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
: 네이버는 JSON 형태로 회원 정보를 반환하고 유저 이름은 response 필드의 하위필드이다. 스프링 시큐리티는 하위 필드를 명시할 수 없기 때문에 response를 지정해주고 자바 코드에서 처리하도록한다.OAuthAttributes에서 네이버인지 판단하는 코드와 생성자를 추가하고, index.mustache에 네이버 로그인 버튼을 추가하면 완성된다.
OAuthAttributes.java
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("picture"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
...
}
response.get("name");
: response 필드에서 name 하위필드를 얻어 사용한다.index.mustache
...
{{^loginUser}}
<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>
{{/loginUser}}
...
/oauth2/authorization/...
: 마지막 Path에 각 소셜 로그인 코드를 사용한다.
시큐리티 옵션 활성화로 api 호출에 role에 따라 제약이 생기기 때문에 여러 테스트 코드가 실패하게된다. 이를 고쳐보자.
main에는 이미 생성했지만 test에는 적용되지 않는다. test는 application.properties까지만 자동으로 가져오기 때문이다. 따라서 테스트를 위해 src/test/resources/application.properties에 테스트용 설정 값을 추가하자.
spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.h2.console.enabled=true
spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb;MODE=MYSQL
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
ROLES_USER가 아닌 사용자는 글 등록 및 수정이 불가능하기 때문에 임의 사용자 인증을 추가하여 테스트를 진행하여야한다. 의존성을 추가하여 진행하자.
testImplementation("org.springframework.security:spring-security-test")
그리고 PostApiControllerTest의 각 Test 메서드에 @WithMockUser(roles = "USER")
어노테이션을 작성하여 임의 사용자 인증을 추가해주자. 그후 Mockmvc 환경에서 작동하게 @BeforeEach
로 매번 테스트마다 Mockmvc 인스턴스를 생성하여 mvc.perform 으로 테스트하자.
@WebMvcTest
스캔대상 예외 만들기HelloControllerTest는 @WebMvcTest
를 사용하므로 기본적으로 SecurityConfig를 스캔한다. 그래서 @Repository, @Service, @Component
는 읽지 않아 테스트 실패 문제가 발생한다. 따라서 SecurityConfig를 스캔 대상에서 제거하고 임의 사용자 인증을 추가하자.
@WebMvcTest(controllers = HelloController.class,
excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
}
)
그리고 WebMvcTest에서 @Entity
클래스가 없어서 @EnableJpaAuditing
오류가 나기때문에 application에서 해당 어노테이션을 삭제한다. 그리고 config 패키지에 JpaConfig 클래스를 만들어 @Configuration, @EnableJpaAuditing
어노테이션만 붙인 클래스를 만들어 문제를 해결한다.
이 부분은 index.mustache의 h1 부분을 스프링부트로 시작하는 웹 서비스 Ver.2
로 수정하고 Test의 테스트 문자열을 수정하지 않아서 발생한 단순한 오류이다. 간단하게 문자열만 고쳐주면된다.
//수정 전
assertThat(body).contains("스프링부트로 시작하는 웹 서비스");
//수정 후
assertThat(body).contains("스프링부트로 시작하는 웹 서비스 Ver.2");