+ ResponseDTO 객체가 맵핑이 잘 되지 않았던 문제를 해결하는 과정을 기록하고자 합니다.
구현 기능
- /v11/qnas 로 HTTP POST 요청을 body에 JSON 형태로 memberId , 질문제목, 질문내용 을 담아서 request body를 받음
- 그 데이터를 DTO에 받아서 Qna엔티티 객체로 맵핑
- JPA를 활용하여 H2 Inmemory DB에 저장
- 저장된 Qna 엔티티 객체를 반환 받아 ResponseDTO에 맵핑하여 ResponseEntity 객체로 HTTP 상태와 같이 응답
위와 같은 구현을 위해서 컨트롤러, 서비스 클래스, QnaPostDto, Qna 엔티티 객체를 만들고 MapStruct를 이용하여 mapper 구현과 QnaRepository를 통해서 JPA를 구현했다.
QnA 게시판 기능중에 회원만이 QnA를 등록 할 수 있다는 요구사항이 있는데 아직 Spring Security를 제대로 배우지 못한 상태라서 일단 서비스 단에서 memberID를 통해 회원이 존재하는지 확인하고 QnA를 등록하는 식으로 로직을 만들었다. memberID 보다는 email 같은 거로 확인하는 것도 괜찮은 것 같은데 그거는 나중에 다시 리팩토링을 하던지 해야겠다.
에러 상황
- 포스트맨으로 QnA를 POST 요청을 보냈을 때 응답으로 ResponseDto에 담긴 데이터와 201 Created 응답이 와야하는데 500 server error 응답이 왔다.
- 500 server error 를 해결했더니 ResponseDto에 createdBy (작성자) 필드가 null 로 응답이 왔다. 해당 필드는 질문을 작성한 memberId의 멤버 이름을 가져와서 맵핑하도록 설계하였는데 계속 누락이 되었다.
이 두가지 에러를 해결하는 과정을 남기려고 한다.
수정 전 코드
- QnaPostDto
@Getter
@Builder
public class QnaPostDto {
@NotBlank(message = "회원은 공백이 아니어야 합니다.")
private long memberId;
@NotBlank(message = "제목은 공백이 아니어야 합니다.")
private String title;
@NotBlank(message = "질문은 공백이 아니어야 합니다.")
private String body;
private Qna.QnaVisibility visibility;
}
해당 DTO 클래스는 Request body 를 통해 들어오는 데이터들과 맵핑되는 DTO 객체이다. memberId, title, body 는 필수 입력 조건이고 질문의 공개, 비공개 여부를 결정하는 visibility는 기본값은 공개로 비공개일 때만 입력을 받아 비공개 질문으로 처리한다.
- QnaBoardController
@RestController
@RequestMapping("/v11/qnas")
@Validated
@Slf4j
public class QnaBoardController {
private final QnaMapper mapper;
private final QnaService qnaService;
public QnaBoardController(QnaMapper mapper, QnaService qnaService) {
this.mapper = mapper;
this.qnaService = qnaService;
}
@PostMapping
public ResponseEntity postQnA(@RequestBody QnaPostDto qnaPostDto){
// TODO : 내부 로직 구현
Qna qna = qnaService.createQna(mapper.qnaPostDtoToQna(qnaPostDto));
QnaResponseDto response = mapper.qnaToQnaResponseDto(qna);
// URI location = UriCreator.createUri("/v11/qnas/", 1L);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
컨트롤러 클래스는 mapper와 QnaService 는 DI(Dependency Injection)를 통해 주입받아 사용했다. mapper를 통해 입력받은 qnaPostDto를 Qna 엔티티 객체로 변환해주어 서비스 클래스로 넘겨준뒤 멤버 조회 및 데이터 액세스를 거쳐 반환 받은 Qna 객체를 다시 ResponseDto 객체로 변환하여 반환한다.
나머지 서비스 클래스와 맵퍼 클래스들은 오류를 찾고 해결해 나가는 과정 안에서 코드를 보도록 하겠다.
첫번째 오류 해결
- Post요청시 500 에러가 떠서 서버 콘솔을 보니 NullPointException이 떠있었음.
- 해당 Exception 오류는 서비스 단에서 일어난 오류 였음.
NullPointException을 보자마자 ‘그래 너가 안 나올리가 없지’ 하는 생각을 하게 되었다. 언제나 개발자들에게 친구와도 같은 Null 익셉션.. ‘내가 무엇을 빼먹었는지 서비스 단에서 찾아보자’ 하면서 debug를 돌려보았다.
- QnaService 기존 코드
@Service
public class QnaService {
private final QnaRepository qnaRepository;
private final MemberService memberService;
public QnaService(QnaRepository qnaRepository, MemberService memberService) {
this.qnaRepository = qnaRepository;
this.memberService = memberService;
}
public Qna createQna(Qna qna) {
// 회원만 Qna 등록가능
// 회원이 존재하는지 확인
⏸️정지! Member findMember = memberService.findVerifiedMember(qna.getMember().getMemberId());
return qnaRepository.save(qna);
‘⏸️정지!’ 요 부분에 브레이크 포인트 를 걸고 디버그를 돌려보았더니 컨트롤러 측에서 QnaPostDto 를 Qna 엔티티 객체로 맵핑하는 과정에서 Member랑 연관관계 맵핑을 해둔 member 필드에 데이터가 하나도 들어가 있지 않았다. 그러니 getMember().getMemberId()를 해도 멤버가 null 인 상황이라 에러가 났던 것
우선 Qna 엔티티 객체를 먼저 보면 이렇게 생겼다.
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Qna {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long qnaId;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String body;
@Column(nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(nullable = false)
private LocalDateTime modifiedAt = LocalDateTime.now();
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private QnaStatus qnaStatus = QnaStatus.QUESTION_REGISTERED;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private QnaVisibility qnaVisibility = QnaVisibility.QUESTION_PUBLIC;
// 🔥 멤버와 연관관계 맵핑한 필드
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
// TODO: 답변 엔티티랑 연관관계 맵핑
@OneToOne(mappedBy = "qna")
private Reply reply;
public void setReply(Reply reply) {
this.reply = reply;
if (reply.getQna() != this){
reply.setQna(this);
}
}
public enum QnaStatus {
QUESTION_REGISTERED(1, "질문 등록"),
QUESTION_ANSWERED(2, "답변 완료"),
QUESTION_DELETED(3, "질문 삭제"),
QUESTION_DEACTIVATED(4, "질문 비활성화");
@Getter
private int stepNumber;
@Getter
private String status;
QnaStatus(int stepNumber, String status) {
this.stepNumber = stepNumber;
this.status = status;
}
}
public enum QnaVisibility {
QUESTION_PUBLIC("공개"),
QUESTION_SECRET("비공개");
@Getter
private String visibility;
QnaVisibility(String visibility) {
this.visibility = visibility;
}
}
}
보다시피 Qna 의 필드에는 해당 문제를 해결하기 위해서 QnaMapper 가 QnaPostDto에서 멤버를 가져오려고 할 때 QnaPostDto 필드에 있는 memberId 만 담긴 Member 객체를 리턴하도록 코드를 수정했다.
@Getter
@Builder
public class QnaPostDto {
@NotBlank(message = "회원은 공백이 아니어야 합니다.")
private long memberId;
@NotBlank(message = "제목은 공백이 아니어야 합니다.")
private String title;
@NotBlank(message = "질문은 공백이 아니어야 합니다.")
private String body;
private Qna.QnaVisibility visibility;
// 🔽 추가된 부분
public Member getMember() {
Member member = new Member();
member.setMemberId(memberId);
return member;
}
}
빈 멤버 객체를 만들어서 그 안에 입력 받은 memberId 를 주입해서 QnaService에서 이를 조회해 해당 멤버가 존재하는지 확인했다. 늘 항상 엔티티 객체와 데이터베이스와의 맵핑이 살짝 헷갈리 때가 있는 것 같다. mapping을 해줘도 타입이 다르면 실제 객체에는 안들어가게 되는데 그런 부분들을 다음부터 신경써야겠다.
오류해결 결과
{
"qnaId": 1,
"title": "질문있어요",
"body": "테스트하기위해서 여러가지 보내봅니다",
"createdBy": null, // 🔥이부분
"createdAt": "2023-05-15T15:31:15.4760595",
"modifiedAt": "2023-05-15T15:31:15.4760595",
"qnaStatus": "질문 등록",
"qnaVisibility": "공개"
}
500 server error 는 해결이 되었다. 나머지 responseDto 필드에는 다 들어갔는데 저 createdBy에 null 값이 들어가 있다. H2 데이터베이스에서 확인 했을 때는 맵핑이 잘되어서 데이터 테이블에 질문자의 memberId 도 잘 들어가는데 왜 그럴까? 하면서 또 디버깅을 시작했다.
두번째 오류 해결
- 첫번째 생각은 QnaMapper에서 아마 Qna 엔티티 객체에는 createdBy가 없고 responseDto 에만 있기 때문에 @Mapping을 통해서 member 객체의 name 필드를 createdBy에 맵핑하도록 지정하면 되겠다고 생각했다.
Mapstruct 의 @Mapping 어노테이션은 target은 리턴되는 객체의 맵핑 되어야 하는 필드명, source는 어떤 것으로 맵핑을 할 것인가?에 대한 것이다.
@Mapper(componentModel = "spring")
public interface QnaMapper {
// 🔽 해당 추가 내용
@Mapping(target = "createdBy", source = "member.name")
QnaResponseDto qnaToQnaResponseDto(Qna qna);
}
이렇게 수정했는데 .. 결과는 동일했다. 😅 다시 디버깅.. 저기서 맵핑이 계속 null로 나온다는 것은 source에 해당하는 member.name이 null이라는 것이라 생각이 들었고 디버깅을 통해 멈춰봤을 때 매개변수로 들어오는 Qna의 엔티티 객체의 member 필드에는 memberId만 들어있었다. 그렇다면 어차피 해당 멤버를 리파지토리에서 조회하면 해당 member 엔티티 객체를 반환해주니까 그거를 Qna 객체에 넣어주면 된다고 생가했다.
- memberId를 통해서 조회된 회원을 QnaService에서 Qna를 리턴해줄 때 주입해서 리턴해주면 mapping 때 해당 멤버의 이름이 맵핑 될 것이라 생각 되었다. 과연!!
@Service
public class QnaService {
private final QnaRepository qnaRepository;
private final MemberService memberService;
public QnaService(QnaRepository qnaRepository, MemberService memberService) {
this.qnaRepository = qnaRepository;
this.memberService = memberService;
}
public Qna createQna(Qna qna) {
// 회원만 Qna 등록가능
// 회원이 존재하는지 확인
Member findMember = memberService.findVerifiedMember(qna.getMember().getMemberId());
// 🔽 추가된 코드
qna.setMember(findMember);
return qnaRepository.save(qna);
}
반환받은 멤버를 qna에 넣어 주었다. 과연 결과는 ?
{
"qnaId": 1,
"title": "질문있어요",
"body": "테스트하기위해서 여러가지 보내봅니다",
"createdBy": "benjamin", // ✨성공!✨
"createdAt": "2023-05-15T16:00:08.1841736",
"modifiedAt": "2023-05-15T16:00:08.1841736",
"qnaStatus": "질문 등록",
"qnaVisibility": "공개"
}
예상대로 해당 맵핑이 정상적으로 진행되었다. 일단 지금은 스프링 시큐리티나 이러한 인증, 인가에 대한 내용들을 잘 모르기에 일단 이렇게 구현했지만 아무래도 멤버가 많아지면 많아질 수록 로그인 상태를 저장하지 않으니 데이터베이스에 조회하는 부담이 많이 들 것 같다는 생각이 들었다. 또 열심히 배워서 업그레이드 시켜봐야지 !
총평!
늘 에러, 오류 라는 것은 사실 개발을 하다보면 늘 항상 만나는 거고 어쩌면 평생의 동반자이다. 하지만 항상 당황스러운 것은 마찬가지, 왜냐면 이렇게 하면 구현 될 것이라 구현했는데 갑자기 생각지도 못한 곳에서 에러는 터진다.
그럼에도 에러의 지점 부터 차근 차근 시작점을 찾아 하나하나 실마리를 풀어나가는 것이 굉장히 몰입력을 주고 하나 하나 해결 될 때마다 짜릿함이 있다. 이 부분이 내가 개발에 매력을 느낀 부분이라 생각이 든다. 앞으로 다른 오류를 만나도 당황하지말고 로그, 콘솔부터 하나하나 차근 차근 실마리를 풀어나가보자!
'개발일지' 카테고리의 다른 글
[프로젝트일지 - 에러 해결] JPA 연관관계 맵핑 문제 오류 feat.클래스 이름 바꿀 때는 조심 또 조심! (0) | 2023.05.27 |
---|---|
[TIL] Cloud 배포에 대해서 feat. 코드스테이츠, AWS (0) | 2023.05.25 |
[KPT] Section3를 마치며 돌아보는 KPT 회고 feat.코드스테이츠 (1) | 2023.05.09 |
[TIL] Spring Framework 기본 개념 정리 (0) | 2023.03.31 |
[TIL] 데이터베이스 스키마 설계 (0) | 2023.03.30 |