어노테이션 기반으로 개선하기
📌 일반적인 프로그래밍에서 개선이 필요한 나쁜 코드는?
→ 같은 코드가 반복되는 부분! ⇒ 유지보수성 ↓, 수정 반영되지 않은 반복 코드가 있으면 문제 발생
IndexController에서 세션값을 가져오는 부분이 반복
SessionUser user = (SessionUser) httpSession.getAttribute("user");
index 메소드 외에 다른 컨트롤러와 메소드에서 세션값이 필요하면 그때마다 직접 세션에서 값을 가져와야 한다.
이 부분을 메소드 인자로 세션값을 바로 받을 수 있게 변경
config.auth 패키지에 LoginUser 어노테이션 생성
package com.jojoldu.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로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용 가능
- @interface
- 해당 파일을 어노테이션 클래스로 지정
- LoginUser라는 이름을 가진 어노테이션이 생성
같은 위치에 LoginUserArgumentResolver를 생성
Login-UserArgumentResolver라는 HandlerMethodArgumentResolver라는 인터페이스를 구현한 클래스
HandlerMethodArgumentResolver
→ 조건에 맞는 경우 메소드가 있다면 HandlerMethodArgumentResolver의 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 있음
config/auth/LoginUserArgumentResolver
package com.jojoldu.book.springboot.config.auth;
import com.jojoldu.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 어노테이션이 붙어 있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true를 반환
- resolveArgument()
- 파라미터에 전달할 객체 생성
- 여기서는 세션에서 객체를 가져온다
LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigure에 추가
config 패키지에 WebConfig 클래스 생성
package com.jojoldu.book.springboot.config;
import com.jojoldu.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()를 통해 추가해야 한다.
IndexController의 코드에서 반복되는 부분들을 모두 @LoginUser로 개선하기
@RequiredArgsConstructor
@Controller
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("userName", user.getName());
}
return "index";
}
- @LoginUser SessionUser user
- 기존에 (User) httpSession.getAttribute(”user”)로 가져오던 세션 정보 값이 개선
- 이제는 어느 컨트롤러든지 @LoginUser만 사용하면 세션 정보 가져올 수 있게 된다
세션 저장소로 데이터베이스 사용하기
문제 1
지금까지 구현한 서비스는 애플리케이션을 재실행하면 로그인이 풀린다.
→ 세션이 내장 톰캣의 메모리에 저장되기 때문!
기본적으로 세션은 실행되는 WAS의 메모리에 저장되고 호출된다.
메모리에 저장되다 보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화
→ 배포할 때마다 톰캣이 재시작
문제 2
2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야 한다.
실제 현업에서는 세션 저장소에 대해 3가지 방법 중 한 가지 선택
- 톰캣 세션 사용
- 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식
- 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정 필요
- MySQL과 같은 데이터베이스를 세션 저장소로 사용 ✅
- 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법
- 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈 발생 가능성 있음
- 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용
- Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용
- B2C 서비스에서 가장 많이 사용
- 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요
spring-session-jdbc 등록
build.gradle에 의존성 등록
implementation('org.springframework.session:spring-session-jdbc')
application.properties에 세션 저장소를 jdbc로 선택하도록 코드 추가
spring.session.store-type=jdb
→ 스프링을 재시작하면 세션이 풀림
→ h2 기반으로 스프링이 재실행될 때 h2도 재시작되기 때문
⇒ AWS로 배포하면 AWS의 데이터베이스인 RDS를 사용하게 됨 → 세션 풀리지 X
네이버 로그인
네이버 API 등록
생성된 키값들을 application-oauth.properties에 등록
*네이버에서는 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력해야 함
# registration
spring.security.oauth2.client.registration.naver.client-id=clientid
spring.security.oauth2.client.registration.naver.client-secret=clientsecret
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
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://openai.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
- spring.security.oauth2.client.provider.naver.user-name-attribute=response
- 기준이 되는 user-name의 이름을 네이버에서는 response로 해야 함
- 이유는 네이버 회원 조회 시 반환되는 JSON 형태 때문
*스프링 시큐리티에선 하위 필드를 명시할 수 X, 최상위 필드만 user_name으로 지정 가능
하지만 네이버 응답값 최상위 필드는 resultCode, message, response
→ response를 user_name으로 지정하고 이후 자바 코드로 response의 id를 user_name으로 지정
스프링 시큐리티 설정 등록
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에 네이버 로그인 버튼 추가
{{^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}}
- /oauth2/authorization/naver
- 네이버 로그인 URL은 application-oauth.properties에 등록한 redirect-uri 값에 맞춰 자동 등록
- /oauth2/authorization/ 까지는 고정이고 마지막 Path만 각 소셜 로그인 코드를 사용
기존 테스트에 시큐리티 적용하기
기존 테스트에 시큐리티 적용으로 문제가 되는 부분들 해결하기
문제가 되는 부분
기존에는 바로 API를 호출할 수 있어 테스트 코드에서도 바로 API 호출했으나
시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있다.
기존의 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정!
인텔리제이에서 Gradle 탭 클릭 → Tasks → verification → test : 전체 테스트 수행
문제 1. CustomOAuth2UserService 찾을 수 없음
- CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없기 때문에 발생
- → 분명 application-oauth.properties에 설정값 추가했는데 왜 없다고 할까?
⇒ src/main 환경과 src/test 환경의 차이 때문!
test에 application.properties가 없으면 main의 설정들을 그대로 가져온다 - 이때 자동으로 가져오는 옵션의 범위는 application.properties 파일까지이고, application-oauth.properties는 가져오지
🔥 테스트 환경을 위한 application.properties를 만들어준다 (가짜 설정값으로)
src/test/resources/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
문제 2. 302 Status Code
- 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문
- 임의로 인증된 사용자를 추가하여 API만 테스트해 볼 수 있게 한다
- 스프링 시큐리티 테스트를 위한 여러 도구를 지원하는 spring-security-test를 build.gradle에 추가
testImplementation('org.springframework.security:spring-security-test')
PostsApiControllerTest의 2개 테스트 메소드에 임의 사용자 인증 추가
@WithMockUser(roles = "USER")
- 인증된 모의 사용자를 만들어 사용
- roles에 권한 추가 가능
- 이 어노테이션으로 인해 ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가지게 된다
- → @WithMockUser는 MockMvc에서만 작동
PostsApiControllerTest는 @SpringBootTest로만 되어있으며 MockMvc를 전혀 사용하지 X
- @SpringBootTest에서 MockMvc를 사용하는 방법
package com.jojoldu.book.springboot.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
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;
// For mockMvc
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@BeforeEach
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@AfterEach
public void tearDown() throws Exception {
postsRepository.deleteAll();
}
@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
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;
//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);
}
}
- @BeforeEach
- 매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성
- mvc.perform
- 생성된 MockMvc를 통해 API 테스트
- 본문 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환
문제 3. WebMvcTest에서 CustomOAuth2UserService를 찾을 수 없음
- @WebMvcTest는 CustomOAuth2UserService를 스캔하지 않기 때문
- @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") - 다시 테스트 돌리면 추가 에러 발생
- @EnableJpaAuditing로 인해 발생 → 사용하기 위해 최소 하나의 @Entity 클래스가 필요
- Application.java에서 @EnableJpaAuditing을 제거
- JpaConfig를 생성하여 @EnableJpaAuditing 추가
package com.jojoldu.book.springboot.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing // JPA Auditing 활성화
public class JpaConfig {
}
'Group Study (2024-2025) > Spring 입문' 카테고리의 다른 글
[Spring 입문] 5주차 - AWS 서버 환경을 만들어보자 - AWS EC2 (4) | 2024.11.03 |
---|---|
[Spring 입문] 4주차 - 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (1) (2) | 2024.10.29 |
[Spring 입문] 3주차 - 머스테치로 화면 구성하기 (5) | 2024.10.14 |
[Spring 입문] 2주차 - 스프링부트에서 JPA로 데이터베이스 다뤄보기 (0) | 2024.10.07 |
[Spring 입문] 1주차 - 인텔리제이 환경 설정과 테스트코드 (1) | 2024.09.29 |