코드의 개선 사항이 필요해보인다면 언제든 댓글로 지적해주세요 :)
이번 API는 각 년도별로 누적된 로또 번호의 통계치를 제공해주는 api이다. 크게 보면 Lotto정보에 속하는 것이기 때문에 service와 controller 코드를 같은 클래스에 작성하였었으나, 단일 책임 원칙에 따라 분리하여 작성하였다.
코드를 작성하면서 팀원이 작성해둔 예외처리메서드를 사용하여 예외처리를 진행해주었다. try-catch 사용은 최대한 지양하는게 좋다는 말을 들어왔기에 throw하여 @RestControllerAdvice에서 받게끔하였다.
Entity & DTO
@Getter
@Setter
@Mapping(mappingPath = "statnum-mapping.json")
@Setting(settingPath = "elastic-setting.json")
@Document(indexName = "lotto_statics")
public class StatNumDocument {
@Id
private String id;
@Field(type = FieldType.Integer)
private Integer cnt;
@Field(type = FieldType.Integer)
private Integer number;
@Field(type = FieldType.Integer)
private Integer year;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class StatNumDto {
private String id;
private Integer cnt;
private Integer number;
private Integer year;
}
Repository
@Repository
public interface StatNumRepository extends ElasticsearchRepository<StatNumDocument, String> {
Page<StatNumDocument> findByYear(Integer year, Pageable pageable);
}
이전에 로또 리스트 조회처럼 Pagination을 통해 정렬을 하는 방식을 택했다. 로또 통계 정보를 조회할때는 년도를 필수적으로 입력해야하기 때문에 년도별로 조회하는 쿼리를 추가했다.
Service
@Service
@RequiredArgsConstructor
public class LottoService {
private final LottoRepository lottoRepository;
public Page<LottoDto> getLottoList(int page, int size, String sortBy) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sortBy));
return lottoRepository.findAll(pageable)
.map(this::toLottoDTO);
}
public LottoDto getLottoByRound(Integer round) {
return lottoRepository.findByRound(round)
.map(this::toLottoDTO)
.orElseThrow(() -> new AppException(ExceptionCode.NON_EXISTENT_LOTTO));
}
private LottoDto toLottoDTO(LottoDocument doc) {
return new LottoDto(
doc.getId(),
doc.getActualWinnings(),
doc.getPrizeDate(),
doc.getRound(),
doc.getWinnerNum(),
doc.getWinnings(),
doc.getFinalNumbers()
);
}
}
@Service
@RequiredArgsConstructor
public class StatNumService {
private final StatNumRepository statNumRepository;
public Page<StatNumDto> getStatNumList(Integer year, String sortBy, int page, int size) {
Sort sort = Sort.by(
"number".equalsIgnoreCase(sortBy) ? Sort.Direction.ASC : Sort.Direction.DESC,
"number".equalsIgnoreCase(sortBy) ? "number" : "cnt"
);
Pageable pageable = PageRequest.of(page, size, sort);
Page<StatNumDocument> documents = statNumRepository.findByYear(year, pageable);
if (documents.isEmpty()) {
throw new AppException(ExceptionCode.NON_EXISTENT_LOTTO);
}
return documents.map(this::toStatNumDTO);
}
private StatNumDto toStatNumDTO(StatNumDocument doc) {
return new StatNumDto(
doc.getId(),
doc.getCnt(),
doc.getNumber(),
doc.getYear()
);
}
}
로또 통계 정보를 조회하면 값으로 들어온 년도까지 누적된 로또 번호 카운트 수를 제공하게된다. 단순 정렬과 필터링이기 때문에 pagination과 jpa를 섞어서 사용했다. 현재까지 작성한 API들은 단순 조회가 대부분이기 때문에 존재하지 않는 경우에 대한 예외만 처리하였다.
Controller
@RestController
@RequestMapping("/api/lotto")
@RequiredArgsConstructor
@Validated
public class LottoController {
private final LottoService lottoService;
@GetMapping("/list")
public ResponseEntity<Page<LottoDto>> getAllLotto(
@RequestParam(defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") int page,
@RequestParam(defaultValue = "10") @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") int size,
@RequestParam(defaultValue = "round") String sortBy) {
Page<LottoDto> lottoList = lottoService.getLottoList(page, size, sortBy);
return ResponseEntity.ok(lottoList);
}
@GetMapping("/{round}")
public ResponseEntity<LottoDto> getLottoByRound(
@PathVariable @Min(value = 1, message = "회차 번호는 1153 이상이어야 합니다.") Integer round) {
LottoDto lottoDto = lottoService.getLottoByRound(round);
return ResponseEntity.ok(lottoDto);
}
}
@RestController
@RequestMapping("/api/lotto")
@RequiredArgsConstructor
@Validated
public class StatNumController {
private final StatNumService statNumService;
@GetMapping("/statNum")
public ResponseEntity<Page<StatNumDto>> getStatNum(
@RequestParam(defaultValue = "2025") @Min(value = 2000, message = "년도는 2024 이상이어야 합니다.") Integer year,
@RequestParam(defaultValue = "cnt") String sortBy,
@RequestParam(defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") int page,
@RequestParam(defaultValue = "10") @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") int size) {
Page<StatNumDto> statNumList = statNumService.getStatNumList(year, sortBy, page, size);
return ResponseEntity.ok(statNumList);
}
}
service계층에서는 데이터, 비즈니스 로직과 관련된 예외처리를 하고, controller 계층에서는 입력값에 대한 검증과 관련된 예외처리를 해야한다. 따라서 입력값의 대략적인 활자로써는 이해가 되는데 막상 코드에 적용을 해보려하니 이게 입력값 유효성 검사에 해당하는건지 비즈니스 로직과 관련된 유효성 검사인지를 모르겠었다. 일단은 controller로 빼서 했다.
입력값 유효성 검사를 할때 원래는 각각 파라미터의 범위를 지정해서 if로 돌려서 걸리면 throw 하게끔 했었다. 그런데 아무리 코드를 봐도 이렇게 유치하게 검사를 하진 않을거같아서 찾아보니 @validated 라는 어노테이션이 존재했다. RequestParam이나 PathVariable에 어노테이션을 달아서 검사를 하게되면 에러시에 예외를 발생시킨다. 여기서 발생한 에러는 전역적으로 발생한 에러를 캐치하는 코드에서 처리하면 된다.
@RestControllerAdvice
public class ExceptionManager {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
String errorMessage = ex.getBindingResult().getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ApiResponse(ResultCode.FAILURE, errorMessage, null));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse> handleConstraintViolationExceptions(ConstraintViolationException ex) {
String errorMessage = ex.getConstraintViolations()
.stream()
.map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
.collect(Collectors.joining(", "));
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ApiResponse(ResultCode.FAILURE, errorMessage, null));
}
// exceptionHandelr를 통해 예외 전역 관리
@ExceptionHandler(AppException.class)
public ApiResponse handleAppException(AppException exception) {
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
if (exception.getExceptionCode() != null) {
// ExceptionCode를 사용하는 경우
final ExceptionCode exceptionCode = exception.getExceptionCode();
response.setStatus(exceptionCode.getResultCode().getHttpStatus().value());
return new ApiResponse(exceptionCode.getResultCode(), exceptionCode.getResultMessage(), null);
} else {
// 직접 코드 ,메시지 넣은 경우
response.setStatus(exception.getResultCode().getHttpStatus().value());
return new ApiResponse(exception.getResultCode(), exception.getResultMessage(), null);
}
}
}
AppException은 팀원이 커스텀하게 만든 예외처리를 위한 메서드이다.
validate 어노테이션을 통해 발생한 예외들은 MethodArgumentNotValidException과 ConstraintViolationException 핸들러가 각각의 예외에서 발생한 오류 메시지를 수집해서 적절한 HTTP 응답을 생성해 처리해준다.
사실 예외 처리하기 위해서 이것 저것 찾아보고 그랬는데 아직도 이해가 많이 안되는 부분이 많다. 추후에 공부해보고 관련된 내용 정리해서 포스팅 꼭 해봐야할 거 같다.
'Dev Tool > Spring boot' 카테고리의 다른 글
[Spring] 로또 API 단위 테스트 코드 작성 (0) | 2025.01.23 |
---|---|
[Spring] 테스트 코드 기본 개념 (0) | 2025.01.22 |
[Spring] Swagger 연동 (1) | 2025.01.22 |
[Spring] 엘라스틱 서치 연동 & API 구현 (0) | 2025.01.22 |
[Spring] 프로젝트 생성 기초 (1) | 2024.11.13 |