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());
    }