5.4 어노테이션 기반으로 개선하기
개선이 필요한 나쁜 코드란?
같은 코드가 반복되는 부분. 수정시 모두 수정을 해야하기 때문! -> 수정이 반영되지 않을 수 있음.
앞에서 만든 로그인 관련 코드에서 개선할 부분이 있을까?
IndexController에서 세션값을 가져오는 부분이 그러하다.
SessionUser user = (SessionUser) httpSession.getAttribute("user");
해당 기능은 index 메소드가 아닌 다른 메소드에서도 충분히 활용될 수 있으므로 반복되지 않게 따로 분리해주는 것이 좋다.
@LoginUser 어노테이션 추가.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
@Target(ElementTYPE.PARAMETER)
- 해당 어노테이션(@LoginUser)이 생성될 수 있는 위치를 지정함. 즉, @LoginUser는 메소드의 파라미터로 선언된 객체에만 적용이 가능함.
(파라미터에만 해당 어노테이션을 생성할 수 있다.)
@interface : 해당 파일을 어노테이션 클래스로 지정한다는 뜻.
LoginUserArgumentResolver ( HandlerMethodArgumentResolver 인터페이스를 구현한 클래스 )
@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");
}
}
@Component : 프로그래밍에서 재사용할 수 있는 각각의 독립된 모듈
하나의 레고를 조립할때 나오는 여러 블록들을 이야기함.
- 부품이 고장났을때 다른 부품으로 교체하면 된다.라는 개념에서 차용됨.
-이렇게 작성된 코드는 프레임워크에 상관없이 상호 운용이 가능하다.
@Override
해당 클래스가 부모 클래스의 ( interface의 ) 메소드를 사용하였다고 명시적으로 선언하는 것.
supportParameter() : 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단함.
- isLoginUserAnnotation : 해당 메소드가 @LoginUser.class 이면 True
- isUserClass : 파라미터 클래스타입이 SessionUser.class인 경우 True
resolveArgument() : 파라미터에 전달할 객체를 생성함.
...?
이렇게 @LoginUser를 사용하기 위한 환경을 구축했다.
이제 이 LoginUserArgumentResolver가 스프링에서 인식되도록 WebMvcConfigurer를 추가하자.
WebConfig
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers){
argumentResolvers.add(loginUserArgumentResolver);
}
}
이렇게 WebConfigurer의 addArgumentResolvers()를 통해 HandlerMethodArgumentResolver가 추가된다.
@LoginUser를 통해 이제 User를 받아보자.
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user){
model.addAttribute("posts", postsService.findAllDesc());
//SessionUser user = httpSession.getAttribute("user");
if(user!=null){
model.addAttribute("userName",user.getName());
}
return "index";
}
httpSession.getAttribute("user")로 user를 가져오던 것을 @LoginUser 어노테이션 파라미터를 통해 가져왔다.
5.5 세션 저장소로 DB 사용하기.
현재 만든 어플리케이션은 서버를 한번 중지하고 재실행하면 기존의 데이터가 다 삭제된다. 로그인도 다시해야하고, 게시글도 삭제된다. 왜그럴까?
Why?
- 세션이 '내장 톰캣 메모리'에 저장되기 때문이다. 기본적으로 WAS의 메모리에 저장되니 실행 시 항상 초기화가 된다.
또 다른 문제로는 2대 이상 서버에서 서비스를 한다면 톰캣마다 세션 동기화 설정을 진행해야한다. 그로 인해 현업에선 세션 저장소로 3가지 중 하나를 선택한다.
(1) 톰캣 세션을 사용한다.
- 별다른 설정을 하지 않을때의 기본 값 (우리가 한 것)
- 톰캣(WAS)에 저장되기에 동기화 문제해결을 위한 별도의 설정이 필요함.
(2) MySQL과 같은 DB를 세션 저장소로 사용한다.
- WAS간의 공용 세션을 사용할 수 있음.
- 별다른 설정이 필요 없지만 요청마다 DB에 I/O(입출력) 요청이 발생 -> 요청이 많아질수록 성능 문제 야기 가능.
(3) Redis, Memcached와 같은 메모리를 세션 저장소로 사용한다.
- B2C (Business To Client) 서비스에서 가장 많이 사용함.
-
우리는 2번 방법으로 진행하겠다. ( 설정이 쉽고 , 사용자가 적으니 성능 우려 적음 )
build.gradle에 다음 코드를 추가한다
implementation 'org.springframework.session:spring-session-jdbc'
또한 application.properties에도 다음 코드를 추가한다.
spring.session.store-type=jdbc
위 두가지 설정을 통해 세션 저장소를 jdbc로 선택하도록 코드를 추가했다. 이후 h2-console을 보면 다음과 같다.
그러나, 아직도, 어플리케이션을 재시작하면 초기화가 된다. H2 기반으로 스프링이 재시작될때 H2도 재시작 되기 때문이다. AWS 배포시 RDS를 사용할 것이니 이 때부턴 세션이 풀리지 않는다.
5.6 네이버 로그인
네이버 로그인은 구글서비스와 다르게 기본 설정을 제공하지 않기 때문에 코드를 직접 쳐야한다고 했다. 진짜..?
https://developers.naver.com/apps/#/wizard/register
네이버 오픈 API로 이동해 서비스를 등록하자 우선.
환경을 웹 어플리케이션으로 설정하고 승인된 redirection url( = callback url)에 http://localhost:8080/login/oauth2/code/naver를 추가한다. 여기서 'naver'를 제외하곤 구글서비스와 동일하다.
application-oauth.properties에 네이버 관련 코드를 추가한다.
#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
*user_name_attribute=response : 네이버 회원 조회시 반환형이 JSON 객체이기 때문에 기준이 되는 user_name의 이름을 네이버는 response로 해야한다.
*오류 : uri -> rui로 쳐서 Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration$EnableWebMvcConfiguration': Unsatisfied dependency expressed through method 'setConfigurers' parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration': Unsatisfied dependency expressed through method 'setClientRegistrationRepository' parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'clientRegistrationRepository' defined in class path resource [org/springframework/boot/autoconfigure/security/oauth2/client/servlet/OAuth2ClientRegistrationRepositoryConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository]: Factory method 'clientRegistrationRepository' threw exception; nested exception is java.lang.IllegalArgumentException: authorizationUri cannot be empty 이런 오류가 나왔는데 검색도 안됐고 이해도 어려웠음. 오류 잘 찾아보자.
여기까지 naver login 등록이었다.
5.7 기존 테스트에 시큐리티 적용하기
기존 테스트에 시큐리티 적용으로 문제가 되는 부분을 해결해보자.
어디가 문제가 될까?
기존 : API를 바로 호출할 수 있어 테스트 코드 또한 바로 API를 호출하게 구성함.
현재 : 시큐리티 옵션 추가로 인증된 사용자만 API를 호출할 수 있음.
즉, 테스트 코드에게 인증된 사용자가 호출한 것처럼 작동하도록 수정해주자.
우선, 현재 전체 테스트를 돌려보면 다음과 같은 메시지가 나온다.
No qualifying bean of type 'com.jojoldu.book.springboot.config.auth.CustomOAuth2UserService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
CustomOAuth2UserService를 생성시 필요한 소셜로그인 설정값들이 없다는 뜻인데 application-oauth.properties가 있는데 왜 없다고 할까?
src/main이 아닌 src/test 환경의 차이 때문인데 application.properties는 src/test에서 자동으로 가져오지만 그 이상은 안 가져오기때문. 즉 ,테스트환경을 위한 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
구글 연동을 실제로 하는 것은 아니기에 가짜 설정 값 ( = id, secret )을 설정하자.
이렇게 수정하고 나면 두번째 문제가 발생한다.
2. 302 Status Code
정상 응답은 200인데 302가 등장했다.
302 : 리다이렉션 응답
- 인증되지 않은 사용자의 요청은 이동시키기 때문임.
즉, 임의로 인증된 사용자를 추가하면 api만 테스트를 할 수 있음.
@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);
}
@WithMockUser(roles = "USER") : 임의의 유저를 사용하자. roles를 활용해 권한을 추가하자.
그러나, 위 어노테이션은 MockMvc에서만 작동하기 때문에 @SpringBootTest에선 작동하지 않는다. 따라서, PostsApiContollerTest에서 MockMvc 를 사용하려고 코드를 수정해야한다.
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@After
public void tearDown() throws Exception{
postsRepository.deleteAll();
}
@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);
mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.contentType(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);
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);
}
}
새로운 postsApiControllerTest
@Before : 매 테스트 시작전에 수행되는 메소드 -> 여기선 MockMvc 생성 역할
mvc.perform
이제 HelloController의 두개 테스트만 실패한다.
'BE > 6기 코테이토 - Spring Study' 카테고리의 다른 글
4회. Ch7 - AWS에 데이터베이스 환경을 만들어보자. - AWS RDS (0) | 2023.02.14 |
---|---|
4회. ch6 AWS 서버 환경을 만들어보자 - AWS EC2 (0) | 2023.02.11 |
3회 - Ch.5 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (0) | 2023.02.07 |
3회 - Ch4 머스테치로 화면 구성하기 (0) | 2023.02.03 |
2회 Ch3. 스프링부트에서 JPA로 데이터베이스를 다뤄보자 (0) | 2023.01.27 |