연관관계 맵핑 중에 ScheduleService의 update 메소드를 수정하는 과정에서
request.getUser()가 null인 상태에서 request.getUser().getUserId()를 호출하여 NullPointerException이 발생했다.
수정 API는 일정의 작성자를 변경하지 않는 기능이므로,
요청 본문의 user 정보를 받아오는게 아닌 기존 일정에 매핑된 user 정보를 사용하도록 수정했다.
기존:
@Transactional
public UpdateScheduleResponse update(Long scheduleId, UpdateScheduleRequest request) {
User user = userService.getUserById(request.getUser().getUserId());
Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(
() -> new IllegalStateException("해당 일정이 없습니다.")
);
schedule.updateSchedule(
user,
request.getTitle(),
request.getContent()
);
return new UpdateScheduleResponse(
schedule.getId(),
user,
schedule.getTitle(),
schedule.getContent(),
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
}
변경:
@Transactional
public UpdateScheduleResponse update(Long scheduleId, UpdateScheduleRequest request) {
Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(
() -> new IllegalStateException("해당 일정이 없습니다.")
);
schedule.updateSchedule(
schedule.getUser(),
request.getTitle(),
request.getContent()
);
return new UpdateScheduleResponse(
schedule.getId(),
schedule.getUser().getUserId(),
schedule.getTitle(),
schedule.getContent(),
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
}
🔥 이제 일정마다 해당 일정을 등록한 유저 id를 정상적으로 가져온다.
전체 조회 기능들에서 for loop로 엔티티 리스트를 DTO 리스트로 변환하고 있었으나,
현업에서는 일반적으로 stream을 활용해 컬렉션을 dto 리스트로 변환하는 방법을 많이 사용한다고 하여 리팩토링에 도전했다.
먼저 stream API를 활용해 getAll() 메서드의 가독성을 올렸고,
DTO 생성 규칙을 toGetUserResponse로 모아두고 나중에 DTO 필드가 바뀌어도 쉽게 수정할 수 있도록 분할했다.
@Transactional(readOnly = true)
public List<GetUserResponse> getAll() {
return userRepository.findAll().stream()
.map(this::GetUserResponse)
.toList();
}
private GetUserResponse toGetUserResponse(User user) {
return new GetUserResponse(
user.getUserId(),
user.getUserName(),
user.getEmail(),
user.getCreatedAt(),
user.getModifiedAt()
);
}
stream을 사용 한 후 가독성이 좋아진 코드를 보고 정적 팩토리 메서드를 사용해서 service단의 매핑 구문을 축약하고 싶어졌다. 이런 방법을 DTO 매핑 캡슐화라고 하며 실무에서도 많이 쓰인다고 한다.
이 방법을 선택한 이유는 코드 길이가 줄어서 가독성과 유지보수에 좋은 점도 있지만,
객체지향 프로그래밍에서 중요하게 생각하는 역할 분리 때문이다.
해결:
1. DTO에 from()을 추가
@Getter
public class GetUserResponse {
private final Long userId;
private final String userName;
private final String email;
private final LocalDateTime createdAt;
private final LocalDateTime modifiedAt;
public GetUserResponse(Long userId, String userName, String email,
LocalDateTime createdAt, LocalDateTime modifiedAt) {
this.userId = userId;
this.userName = userName;
this.email = email;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
}
public static GetUserResponse from(User user) {
return new GetUserResponse(
user.getUserId(),
user.getUserName(),
user.getEmail(),
user.getCreatedAt(),
user.getModifiedAt()
);
}
}
기존:
.map(this::toGetUserResponse)
변경:
@Transactional(readOnly = true)
public List<GetUserResponse> getAll() {
return userRepository.findAll().stream()
.map(GetUserResponse::from)
.toList();
}
🔥 변환 책임이 Service에서 DTO로 이동하며 완전히 깔끔해졌다.
같은 방식으로 Comment도 동일하게 적용했다.
프로젝트를 마무리해가며 기능 테스트중에,
댓글은 원래 로그인한 사용자만 작성 가능한 것이 정석인데 userId에 아무거나 입력하면 그 유저의 정보로 댓글이 달리는 위변조 문제를 발견했고 다음과 같은 순서로 해결했다.
기존:
@PostMapping
public ResponseEntity<CreateCommentResponse> saveComment(@Valid @RequestBody CreateCommentRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(commentService.save(request));
}
변경:
@PostMapping
public ResponseEntity<CreateCommentResponse> saveComment(
@SessionAttribute(name = "loginUser", required = false) SessionUser sessionUser,
@Valid @RequestBody CreateCommentRequest request
) {
if (sessionUser == null) {
throw new IllegalStateException("로그인이 필요합니다.");
}
return ResponseEntity.status(HttpStatus.CREATED).body(
commentService.save(sessionUser.getId(), request)
);
}
2. Service에서 userId 파라미터로 받기
기존:
User user = userService.getUserById(request.getUserId());
변경:
User user = userService.getUserByIdOrThrow(userId);
3. request DTO에서 userId 삭제
기존:
@NotBlank(message = "댓글 내용은 필수입니다.")
private String content;
@NotNull(message = "유저 ID는 필수입니다.")
private Long userId;
@NotNull(message = "일정 ID는 필수입니다.")
private Long scheduleId;
변경:
@NotBlank(message = "댓글 내용은 필수입니다.")
private String content;
@NotNull(message = "일정 ID는 필수입니다.")
private Long scheduleId;
🔥 이제 user정보는 서버에서 세션으로 강제하기 때문에 클라이언트에서 수정할 수 없게 되었다.
삭제 기능 테스트 중 exception 발생
java.sql.SQLIntegrityConstraintViolationException:
Cannot delete or update a parent row: a foreign key constraint fails (`schedule_develop`.`comments`
, CONSTRAINT `FKbef7m370enopdpf7yp6nmv0oo` FOREIGN KEY (`schedule_id`) REFERENCES `schedules` (`id`))
기존 ScheduleService.delete()는 존재 여부 확인 후
바로 scheduleRepository.deleteById(scheduleId)를 호출하는 구조여서 댓글 달린 일정 삭제 시 외래키 오류가 났다.
이제는 일정 삭제 전 해당 일정에 연결된 댓글을 먼저 삭제해서 제약 오류를 방지하도록 로직을 변경한다.
void deleteByScheduleId(Long scheduleId);
특정 일정에 달린 댓글을 먼저 전부 삭제하기 위한 메서드
변경:
@Transactional
public void delete(Long scheduleId) {
boolean existence = scheduleRepository.existsById(scheduleId);
if (!existence) {
throw new IllegalStateException("해당 일정이 존재하지 않습니다.");
}
commentRepository.deleteByScheduleId(scheduleId); // 추가 코드
scheduleRepository.deleteById(scheduleId);
}
public Schedule getScheduleById(Long scheduleId) {
return scheduleRepository.findById(scheduleId).orElseThrow(
() -> new IllegalStateException("해당 일정이 존재하지 않습니다.")
);
}
🔥 이제 일정 삭제 시 해당 일정을 바라보던 댓글들도 모두 삭제된 후 일정이 삭제된다.