Spring
연관관계 설정, 페이징, @ExceptionHandler를 적용한 일정 관리 서버 리팩토링
김예나
2025. 2. 4. 13:46
ERD 에서 일정과 작성자를 분리
- 일정 테이블과 회원 테이블을 분리
- 회원 : 일정 = 1 : N 관계
- pk는 aoto increment로 자동 증가되도록 관리
- 테이블 생성 쿼리
일정 엔티티와 회원 엔티티 생성
회원 엔티티
@Getter
@AllArgsConstructor
public class Member {
private Long id;
private String name;
private String email;
private LocalDateTime createDate;
private LocalDateTime updatedDate;
}
일정 엔티티
@Getter
@AllArgsConstructor
public class Schedule {
private Long id;
private String content;
private Long memberId;
private String password;
private LocalDateTime createDate;
private LocalDateTime modifiedDate;
}
연관관계 적용 - 저장
- 일정 저장시에 memberId값도 함께 추가하여 등록 (scheule table에 들어갈 member table의 pk값)
@Getter
public class ScheduleSaveRequestDto {
private Long memberId;
// NotBlank를 사용해야 null, 빈 문자열(""), 공백(" ") 모두 허용하지 않음
@NotBlank
@Max(value = 200)
private String content;
@NotBlank
private String password;
}
- 리포지토리에서 해당 값을 함께 담아서 schedule table에 insert!
@Override
public ScheduleResponseDto saveSchedule(Schedule schedule) {
SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
jdbcInsert.withTableName("schedule").usingGeneratedKeyColumns("id");
Map<String, Object> parameters = new HashMap<>();
parameters.put("content", schedule.getContent());
parameters.put("member_id", schedule.getMemberId());
...
Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
return new ScheduleResponseDto(key.longValue(), schedule.getContent(), schedule.getMemberId(), LocalDateTime.now(), LocalDateTime.now());
}
연관관계 적용 - 조회
- 일정 조회시에 작성자의 이름을 함께 가져오기
- 작성자의 이름은 Member 테이블에 있기 때문에 join하여 조회!
@Override
public List<SchedulePagingResponseDto> findSchedules(Long offset, int size) {
return jdbcTemplate.query("select s.id, s.content, m.name, s.created_at, s.updated_at from schedule as s inner join member as m on s.member_id = m.id limit ?, ?", scheduleRowMapperV4(), offset, size);
}
- 작성자의 이름이 응답객체에 포함되기 때문에 이름을 포함한 전용 Mapper 생성
public RowMapper<SchedulePagingResponseDto> scheduleRowMapperV4(){
return new RowMapper<SchedulePagingResponseDto>() {
@Override
public SchedulePagingResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
return new SchedulePagingResponseDto(
rs.getLong("id"),
rs.getString("content"),
rs.getString("name"),
rs.getTimestamp("created_at").toLocalDateTime(),
rs.getTimestamp("updated_at").toLocalDateTime()
);
}
};
}
페이징 적용
고민했던 점
- LIMIT의 개수는 고정적이기에 상관없지만 OFFSET의 경우는 어떤 식으로 지정해야 할지 고민됐음
- OFFSET의 공식을 사용해서 해결해야 하나 고민이 많이 됐는데 Pageable객체에서 사용할 수 있는게 뭐가 있는지 찾아보다 getOffet을 발견하고 해결!
페이징 쿼리
- schdule테이블의 행을 10번째부터 시작해서 20개를 가져오겠다는 의미
- LIMIT : 가져올 행의 개수
- OFFSET : 건너뛸 행의 개수
SELECT *
FROM schedule
LIMIT 10, 20;
구현
- 페이지의 값과 각 페이지별 크기를 쿼리 파라미터로 입력받음
@GetMapping("/paged")
public ResponseEntity<Page<SchedulePagingResponseDto>> list(@RequestParam("page") int page, @RequestParam("size") int size){
Page<SchedulePagingResponseDto> pagedList = scheduleService.findSchedulePageList(page, size);
return new ResponseEntity<>(pagedList, HttpStatus.OK);
}
- Pageable : 페이징 정보를 담는 인터페이스
- PageRequest : Pageable구현체
- PageImpl<T> : List<T>를 Page<T>로 변환
- PageRequest를 이용하여 page와 size값을 세팅한 후에 Pageable로 생성
@Override
public Page<SchedulePagingResponseDto> findSchedulePageList(int page, int size) {
int count = scheduleRepository.findScheduleSize();
Pageable pageable = PageRequest.of(page, size);
return new PageImpl<>(scheduleRepository.findSchedules(pageable.getOffset(), pageable.getPageSize()), pageable, count);
}
- PageImpl객체를 생성할 때 데이터의 전체 값을 인자로 함께 넣어줘야 정확한 페이징이 이루어짐
new PageImpl<>(scheduleRepository.findSchedules(pageable.getOffset(), pageable.getPageSize()), pageable, count);
- 리포지토리에서 Pageable객체를 이용하여 offset값과 size값으로 페이징 쿼리 실행 후 반환
@Override
public List<SchedulePagingResponseDto> findSchedules(Long offset, int size) {
return jdbcTemplate.query("select s.id, s.content, m.name, s.created_at, s.updated_at from schedule as s inner join member as m on s.member_id = m.id limit ?, ?", scheduleRowMapperV4(), offset, size);
}
요청
http://localhost:8080/schedules/paged?page=2&size=3 (GET)
응답
입력값 검증
고민했던 점
- 원래 파라미터의 id타입을 long으로 지정
- 사용자가 잘못된 경우로 입력했을 때 MethodArgumentTypeMismatchException 발생
- 해당 예외가 발생했을 때 직접 만든 에러 객체를 던지고 싶었기에 파라미터를 string으로 받고 검증 로직을 따로 추가해서 잘못된 경우로 입력했을 때InvalidRequestException(직접만든 예외)가 발생하도록 함
쿼리 파라미터 검증
- ValidationUtils class를 만들어서 쿼리 파라미터의 값을 모두 stirng형태로 받고, 검증을 수행
- 검증이 완료되면(사용자가 숫자만 입력한 경우) 해당 값을 Long으로 바꾸고 반환
// 사용자의 입력값이 숫자로만 이루어 지지 않았을 경우에, 검증에 사용하는 Util Class
public class ValidationUtils {
static final String NUMBER_REG = "[0-9]+";
public static Long validNumberInputValue(String number) throws InvalidRequestException {
if (!number.matches(NUMBER_REG)){
throw new InvalidRequestException(INVALID_REQUEST_EXCEPTION);
}
return Long.parseLong(number);
}
}
Dto 검증
- @Valid를 사용하여 검증
- @Email을 사용하는 경우에는 공백이 입력 가능하기 때문에 정규표현식으로 이메일 형식 적용
- NotBlank를 사용해야 null, 빈 문자열(""), 공백(" ") 모두 허용하지 않음
@Getter
public class ScheduleSaveRequestDto {
private Long memberId;
// NotBlank를 사용해야 null, 빈 문자열(""), 공백(" ") 모두 허용하지 않음
@NotBlank
@Max(value = 200)
private String content;
@NotBlank
private String password;
}
@Getter
public class MemberSaveRequestDto {
private String name;
// @Email을 사용하는 경우에는 공백이 입력 가능하기 때문에 정규표현식으로 이메일 형식 적용
@Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식이 올바르지 않습니다.")
private String email;
}
예외 발생 처리
- 에러 객체와 관련된 패키지
- 에러 객체가 발생한 경우 사용하는 응답 객체를 공통으로 관리하기 위해 생성
@Getter
@Builder
public class ErrorResponse {
private HttpStatus status;
private String message;
public ErrorResponse(HttpStatus status, String message){
this.status = status;
this.message = message;
}
public static ResponseEntity<ErrorResponse> toResponseEntity(ErrorCode e) {
return ResponseEntity.status(e.getStatus().value())
.body(ErrorResponse.builder()
.status(e.getStatus())
.message(e.getMessage())
.build());
}
}
- 에러 객체의 코드를 관리하기 위한 enum class
@Getter
@AllArgsConstructor
public enum ErrorCode {
INVALID_REQUEST_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 요청 정보입니다."),
INVALID_PASSWORD_EXCEPTION(HttpStatus.UNAUTHORIZED, "올바르지 않은 비밀번호입니다."),
SCHEDULE_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."),
MEMBER_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다.");
private final HttpStatus status;
private final String message;
}
- 컨트롤러에서 발생하는 에러를 관리하는 ExceptionController
@RestControllerAdvice(assignableTypes = ScheduleController.class)
@Slf4j
public class ScheduleExceptionController {
/*
* 일정이 없는 경우 발생하는 에러객체를 처리
*/
@ExceptionHandler(ScheduleNotFoundException.class)
public ResponseEntity<ErrorResponse> handleScheduleNotFoundException(ScheduleNotFoundException e) {
log.error("[ScheduleExceptionHandler] ScheduleNotFoundException = {}, class = {}", e.getErrorCode().getMessage(), e.getClass());
// return ResponseEntity.status(e.getErrorCode().getStatus()).body(new ErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage()));
return ErrorResponse.toResponseEntity(e.getErrorCode());
}