[Spring] MVC @RestController 사용 시 발생한 문제와 해결

Hyeonsu Bang·2021년 8월 30일
0

spring

목록 보기
1/1
post-thumbnail

배경 : ajax 를 통한 목록 불러오기


마이페이지에서 회원이 자신이 쓴 글의 목록을 볼 수 있는 기능을 구현하고자 했다. ajax, GET 요청으로 목록을 불러오는 간단한 기능이었다.

Front endopint

getMyRcps : function(){
		
		const myRcpsTmpl = _.template($("#myRcpsTmpl").html());
		const $myRcpsUl = $(".mypage_myrecipe_tab.tab_wrap ul");
		
		$.ajax({
			url: '/mypage/ajax/myrecipes',
			type: 'GET',
			dataType:'json',
			error: function(){ alert('something went wrong')},
			success: function(json){
				console.log(json);
				$myRcpsUl.html(myRcpsTmpl({myRcps:json}));
			}
			
		});
	}

템플릿 엔진은 underscore.js를 썼다. 서버에서 얻은 데이터를 Spring이 json으로 리턴해주면 리턴한 json을 지정한 템플릿 영역에서 쓸 수 있다.

지정한 uri로 ajax 요청을 수행할 때 따로 회원번호를 파라미터로 넘기지 않는다. 컨트롤러 쪽에서 아규먼트 리졸버를 통해 유저 정보를 얻어올 수 있게 해놓았기 때문이다.


Api controller

@Slf4j
@RequiredArgsConstructor
@RestController
public class MypageApiController {

	private final RecipesService recipesService; 
	
	@GetMapping("/mypage/ajax/myrecipes")
	public List<Rcp> getMyRecipes(@LoginUser SessionUser user) {
		
		if(user!=null) {
			int memberNo = user.getNo();
		
			return recipesService.getRecipesByMemberNo(memberNo);
		}
		
		return null;
		
	}


코드 관리를 편하게 하기 위해 ajax를 사용하는 컨트롤러는 rest controller로 만들어 뷰만 리턴하는 controller와 분리했다.

앞서 말한 것처럼 아규먼트 리졸버를 통해 SessionUser 클래스가 메서드 파라미터로 있는 경우 세션으로부터 유저 정보를 얻어온다. Spring Security 프레임워크를 이용해 '/mypage/**' 로 매핑된 메서드는 ROLE_USER 권한을 가진 사용자만 접근 가능하게 해놓았기 때문에, 임의로 uri를 입력해 해당 페이지에 접근할 수는 없다.


SessionUser

@Getter
public class SessionUser {

	private int no, addressNo;
	private String email, name, nickname, profileImg;
	private char marketkeeperStep;
	
	@Builder
	public SessionUser(int no, int addressNo, String email, String name, String nickname, String profileImg, char marketkeeperStep) {
		super();
		this.no = no;
		this.addressNo = addressNo;
		this.email = email;
		this.name = name;
		this.nickname = nickname;
		this.profileImg = profileImg;
		this.marketkeeperStep = marketkeeperStep;
	}
	
}


유저 정보를 불러올 때 HttpSession을 활용할 수도 있지만, 매번 HttpSession을 파라미터로 넘기고 getSession() 메서드 수행 후 형 변환하는 코드를 쓰는 것보다 위와 같은 dto를 하나 만들어 유저 정보를 담을 수 있도록 했다. (책을 참고했다)

문제 : BindException, ConversionFailedException


기능을 구현하는 과정은 간단했지만, 문제는 테스트하는 과정에서 발생했다.
__ApiTestConfig.java__
@Configuration
@Import(MvcTestConfig.class)
@EnableTransactionManagement
@ComponentScan(basePackages = {"com.ktx.ddep.controller","com.ktx.ddep.service", "com.ktx.ddep.argumentresolver"})
public class ApiTestConfig {

	@Bean
    public PasswordEncoder encoder() {
        return new BCryptPasswordEncoder();
    }
    
	@Bean
	public HttpSession sessionCreator() {
		return new MockHttpSession();
	}
	
}


MvcTestConfig

@Configuration
public class MvcTestConfig implements WebMvcConfigurer{

	private LoginUserArgResolver loginUserArgResolver;
	
	// enable default servlet when requests other than that made for spring is made
		@Override
		public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
			configurer.enable();
		}

		// jsp view resolver
		@Override
		public void configureViewResolvers(ViewResolverRegistry registry) {
			registry.jsp("/WEB-INF/view/", ".jsp");
		}

		// redirect request "/" to "/main"
		@Override
		public void addViewControllers(ViewControllerRegistry registry) {
			registry.addRedirectViewController("/", "/main");
		}
		
		@Override
		public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
			resolvers.add(loginUserArgResolver);
		}
}

테스트하기 위해 필요한 설정만 모아놓은 TestConfig를 작성했다. PasswordEncoder와 HttpSession 빈 설정은 원래 다른 설정 파일에 있는 설정인데 컨트롤러와 아규먼트 리졸버에서 각각 의존성을 필요로 하고 있기 때문에, 테스트하는 과정에서 에러를 피하기 위해 실제 코드를 수정하기보다 테스트 설정 파일에서 따로 빈으로 등록해주었다.

MypageApiControllerTest.java

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {ApiTestConfig.class})
@Transactional
public class MypageApiControllerTest {

	// mock 객체를 생성하고 해당 mock 객체에 Mock으로 설정한 객체들을 주입
	@InjectMocks
	private MypageApiController apiController;
	
	@Mock
	private RecipesService rcpService;
	
	@Mock
	private List<Rcp> rcpList;
			
	private MockMvc mockMvc;
	
	@Before
	public void instantiate() {
		MockitoAnnotations.openMocks(this);
		mockMvc = MockMvcBuilders.standaloneSetup(apiController).build();	
        }
	
	@Test
	public void givenUser_whenGetRequest_thenIsStatusOk() throws Exception {
		
		//given
		SessionUser user = SessionUser.builder()
				.no(8)
				.addressNo(8)
				.email("wlgypark@gmail.com")
				.name("박지효")
				.nickname("짜릿한슬라임")
				.marketkeeperStep('i')
				.profileImg("profile.jpg")
				.build();
                
		//when
		when(apiController.getMyRecipes(user)).thenReturn(rcpList);
		
		//then
        mockMvc.perform(get("/mypage/ajax/myrecipes")).andExpect(status().isOk()).andDo(print());
		
		
	}
	
}

처음 코드를 짤 때는, 메서드의 수행 여부만 확인하면 된다고 생각했으므로 api 컨트롤러 메서드 호출 시 파라미터는 Mockito를 통해 해결하고자 했다. 그래서 처음에는 SessionUser 객체를 mock으로 만들어 apiController의 파라미터로 넣어주었다. 그런데

HttpMessageNotWritableException: nested Exception - jackson.databind.JsonMappingException: cannot invoke 'java.util.Iterator.hasNext()" because it is null

위와 같은 예외가 발생하며 테스트가 실패했다.

따라서 위와 같이 임의의 값을 담은 SessionUser 객체를 생성한 뒤 get 요청을 다시 수행하는 코드를 짠 뒤 테스트를 실행했다. 그러자

[org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult]
Field error in object 'sessionUser' on field 'no': rejected value [null];
[typeMismatch.sessionUser.no,typeMismatch.no,typeMismatch.int,typeMismatch];
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [sessionUser.no,no]; arguments []; default message [no]];
default message [Failed to convert value of type 'null' to required type 'int'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [null] to type [int] for value 'null'; nested exception is java.lang.IllegalArgumentException: A null value cannot be assigned to a primitive type]


와 같은 예외가 발생했다. 생성자를 통해 필드를 초기화하는 과정에서 null value가 기본 자료 타입 변수에 대입될 수 없다는 것이다. 한동안 원인을 찾지 못해 시간을 좀 보냈다. 왜 mock으로 설정했을 때는 jackson이 예외를 일으켰는지, 객체에 값을 넣어주어도 Spring에서 BindException이 발생했는지 의문이었다.


해결 : no arg constructor



DispatcherServlet에서 요청을 받으면, Multipart나 Locale같은 commons service를 처리한다. 그 뒤 HandlerMapping을 통해 메서드 체인이 호출되고, 요청된 uri와 매핑된 메서드가 호출된다. 이 과정에서 요청에 포함된 정보가 포맷되어 메서드에 바인딩 된다.

컨트롤러 메서드는 요청의 Accept와 Content-type이 포함된 header를 읽어들여 요청 정보의 미디어 타입을 확인하고, 이어 HttpMessageConverters를 통해 해당 미디어 타입을 핸들링할 수 있는 컨버터를 찾게 된다. 그리고 이 컨버터는 자바 엔티티를 클라이언트가 요청한 포맷으로 가공해주는 역할을 한다. 나는 @RestController를 사용할 계획이었으므로 json/xml로 응답하여야 했고, 컨버터로 jackson-databind 라이브러리를 사용했다.


위의 내용까지 정리한 뒤, 다시 json, rest controller, data bind와 같은 키워드로 계속 검색하다 직렬화에 대해 알게 되었다. 사실 직렬화에 대한 개념은 알고만 있었지 자세히는 몰랐던 터라, 이참에 구글링을 계속하며 api doc문서와 참고할 만한 글들을 들여다 보게 되었다.

During deserialization, the fields of non-serializable classes will be initialized using the public or protected no-arg constructor of the class. A no-arg constructor must be accessible to the subclass that is serializable. The fields of serializable subclasses will be restored from the stream.


역직렬화를 하는 과정에서는 non-serializable 한 클래스(Serializable 인터페이스를 구현하지 않는 클래스)는 기본 생성자를 통해 초기화를 하게 된다. 직렬화/역직렬화를 하는 과정은 Serializable 인터페이스를 구현하는 클래스를 모두 나열한 뒤에 순차적으로 스트림으로 바꾸는 과정을 거친다고 하는데, 좀 더 자세한 것은 다음 번에 다루는 시간을 가져야겠다.

저 글을 읽고 생각해보니 내가 이전에 설정해놓은 SessionUser 클래스에는 lombok 라이브러리의 `@Builder` 를 통해서만 생성되는 파라미터가 있는 생성자만 선언되어 있었다. 곧바로 기본 생성자를 생성하는 `@NoArgConstructor` 를 선언해주었고, 테스트는 정상적으로 통과되었다. 결국 응답을 출력하려는 과정에서 역직렬화를 해야 하는데 해당 객체의 기본 생성자가 없으니 예외가 발생했던 것이다.


자바의 기본 개념인데, 잘 몰랐던 탓에 한 줄의 코드를 통해 문제를 해결하기까지 꽤나 오랜 시간을 잡아먹었다. 하지만 또 하나를 배웠고, 앞으로도 차근차근히 학습하면서 문제를 해결해 나가야 겠다는 생각이 든다.



references:



** 제가 스스로 종합한 내용들을 정리하는 글이다보니 틀린 내용이 있을 수 있습니다. 그런 부분들에 대해서 충고, 지적해주시면 감사하겠습니다.
profile
chop chop. mish mash. 재밌게 개발하고 있습니다.

0개의 댓글