소개 배경
최근 순환 참조 에러를 만나 이를 해결하기 위해 API Response Data를 DTO화 시키다가 만나게 된 에러이다.
난생 처음보는 오류와 함께 아래와 같은 ERROR도 아닌 WARN이 출력된다.
에러메시지
2023-03-13 15:14:09.835 WARN 11696 --- [nio-8080-exec-7] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation]
PostMan Response
개발을 하며 처음 겪는 에러였기 때문에 우선, 에러를 파악하고자 HTTP 응답 코드를 살펴보던 도중 406에러에 대한 설명을 보게되었다.
What is 406?
💡 406 Not Acceptable
이 응답은 서버가 서버 주도 콘텐츠 협상 을 수행한 이후, 사용자 에이전트에서 정해준 규격에 따른 어떠한 콘텐츠도 찾지 않았을 때, 웹 서버가 보냅니다.
일단 설명부터 직관적이지 못하다.. 서버 주도 콘텐츠 협상?
알아보니 서버에서 반환 값을 정의하여 사전 콘텐츠 협상 헤더에 정의 된 허용 가능한 값 목록과 일치하는 응답을 생성하여 반환값을 선택하는 것이라고 한다.
자세한 설명은 아래를 참고바란다.
다시 본론으로 돌아와서 저말의 뜻은 서버측에서 응답을 주기위해 시도했으나, Accept 등의 헤더에 적혀있는 형식을 생성해 낼 수 없을 경우 발생한다고한다.
스프링부트는 Content-Type에 선언된 형식으로 변환이 불가능 할 경우 위의 로그와 함께, 406 Not Acceptable 에러를 발생시킨다. Spring에서는 데이터를 HttpMessageConverter를 이용해서 변환한다.
해당 작업은 아래와 같은 순서로 동작한다.
- 응답하려는 MediaType을 식별한다.
- 등록된 HttpMessageConverter에서 MediaType으로 데이터를 변환 시킬 수 있는 녀석을 찾는다.
- 해당 HttpMessageConverter를 이용하여 데이터를 변환시킨다.
그럼 이걸 해결하기 위해서는 반환타입에 문제를 해결하거나 헤더의 생성이 막히는 이유를 찾아야하는데, 전자의 이유는 문제가 없을 것이라 생각해 그 이유를 구글링하였고 그 결과 , 다음 이유들이 원인이 될 수 있었다.
1. Jackson 라이브러의 부재
해당 원인이 문제를 일으키기에는 기본적으로 gradle에 추가하는 dependency중 spring-boot-starter 에 기본적으로 포함되어 있어 문제를 일으킬 것같진않았다.
implementation 'org.springframework.boot:spring-boot-starter'
2. 적절한 뷰 리졸버가 없는 경우
MVC 형태의 프로젝트였다면 한번 쯤 의심할 만한 원인이었으나 나는 현재 Restful Server를 구축 하였기에 이 또한 직접적인 원인은 아닐 것이라 판단했다.
3. 인증되지 않은 요청의 경우
이는 현재 구성중인 프로젝트가 JWT 토큰 전략을 사용하여 Custom Filter를 구성해 만약, 허가되지 않은 요청의 경우 401을 반환하도록 만들었기에 이 또한 의심할 여지가 없었다. (실제로 Filter에 logging 작업을 하며 확인한 결과 문제가 없었다.)
4. 반환하는 Respons Entity 혹은 DTO에 Getter가 없는 경우
맨 처음에는 당황했다.
"이런 단순한 이유로 오류가 난다고?" 생각했기 때문이다.
이 원인을 알 수 있었던 StackOverFlow 질문을 보면 도움이 되는 답변이 하나있었다.
I believe by default auto discovery is on and will try to discover your getters. You can disable it with
@JsonAutoDetect(getterVisibility=Visibility.NONE)
, and in your example will result in
[].
해석하자면 기본적으로 Jackson의 자동 검색이 켜져 있고 게터를 검색하려고 시도할 것이며 @JsonAutoDetect(getterVisibility=Visibility.NONE)를 사용하여 이를 비활성화할 수 있다는 뜻이다.
원인 분석
아까 Accept와 같은 헤더에 작성된 형식을 생성하지 못할 때 406에러를 리턴한다고 말했는데 Aceept가 application/json로 설정되어있어 서버는 반드시 JSON 형태의 Response를 반환해야한다.
그런데 이때 JSON 형태를 생성하는 과정에서 spring-boot-starter에 속해있는 Jackson 라이브러리가 이용되는데 이때,
반환 객체에 '@Getter' 어노테이션을 제거되어 있다면 JSON 객체를 생성하지 못하고 406에러를 반환하게 된것이다.
기본적으로 우리가 클라이언트에게 Response를 주기위해 Response Entity 또는 DTO를 사용하고, 이는 모두 application/json 타입이다. 이를 구성할 때 Getter를 통해 필드값을 가져와 재구성하여 최종적으로 JSON 객체를 만드는데 Getter가 제거되어있으니 당연히 되지 않았던 것이다.
문제코드
DTO 사용 컨트롤러
@GetMapping("/userInfo/one")
public ResponseEntity<?> getOneOfUserInfo(@RequestHeader("userInfo")String accessToken){
System.out.println("userInfoaccess="+accessToken);
String user_Nickname = jwtService.extractNickname(accessToken).get();
log.info("user_Nickname+"+user_Nickname);
MemberResponseDTO memberResponseDTO = memberService.getMemberInfoByNickname(user_Nickname).toDTO();
return new ResponseEntity<>(memberResponseDTO, HttpStatus.OK);
}
해당 코드를 보면 membrService라는 Service Layer에서 getMemberInfoByNickname이라는 메소드를 통해 Member Entity를 반환하고 MemberEntity 내부에 toDTO()라는 빌더를 만들어 이를 DTO화시킨다.
MemberSocial Entity 일부
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Builder
public class MemberSocial extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long member_seq;
...
public MemberResponseDTO toDTO(){
return MemberResponseDTO
.builder()
.member_seq(member_seq)
.authToken(authToken)
.member_imgUrl(member_imgUrl)
.email(email)
.nickname(nickname)
.oauthId(oauthId)
.socialType(socialType)
.refreshToken(refreshToken)
.role(role)
.build();
}
}
원래의 DTO 상태
@Builder
public class MemberResponseDTO {
private Long member_seq;
private String email;
private String nickname;
private String member_imgUrl;
private String oauthId;
private MemberRole role;
private SocialType socialType;
private String authToken;
private String refreshToken;
}
이를 Lombok 라이브러리의 @Getter 어노테이션을 붙여 해결하였다.
package project.mogakco.domain.member.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import project.mogakco.domain.member.entity.member.MemberRole;
import project.mogakco.domain.member.entity.member.SocialType;
@Builder
@Getter
public class MemberResponseDTO {
private Long member_seq;
private String email;
private String nickname;
private String member_imgUrl;
private String oauthId;
private MemberRole role;
private SocialType socialType;
private String authToken;
private String refreshToken;
}
결론
무한참조를 해결하다 우연히 만난에러이지만, 새삼스럽게 Spring Framework을 사용하면서도 정말 내부적 동작원리를 잘알지못하고 사용하는 것 같다.
역시나 명세와 문서는 항상 참고하며 공부를 해야하는 것 같다.
'Trouble-Shooting' 카테고리의 다른 글
Jackson InvalidDefinitionException (0) | 2023.05.17 |
---|---|
NoClassDefFoundError, Command is too long (0) | 2023.04.23 |
[JPA] Service Layer 간의 순환 참조 (1) | 2023.04.14 |
[JPA] @OneToOne - 주인 결정하기(양방향 참조) (0) | 2023.03.26 |
Refresh Token Rotation 이슈 해결 (0) | 2023.03.17 |