Today's Codekata
// 체육복
class Solution {
public int solution(int n, int[] lost, int[] reserve) {
Arrays.sort(lost);
Arrays.sort(reserve);
for (int i = 0; i < lost.length; i++) {
for (int j = 0; j < reserve.length; j++) {
if (lost[i] == reserve[j]) {
lost[i] = -1;
reserve[j] = -1;
break;
}
}
}
int answer = n;
for (int i : lost) {
if (i != -1) answer--;
}
for (int i = 0; i < reserve.length; i++) {
for (int j = 0; j < lost.length; j++) {
if (reserve[i] == lost[j] + 1 || reserve[i] == lost[j] - 1) {
answer++;
lost[j] = -1;
break;
}
}
}
return answer;
}
}
-- 특정 기간동안 대여 가능한 자동차들의 대여비용 구하기
SELECT CAR_ID, CAR_TYPE, FEE
FROM (
SELECT C.CAR_ID, C.CAR_TYPE,
ROUND(C.DAILY_FEE * 30 * (1 - D.DISCOUNT_RATE / 100)) AS FEE
FROM CAR_RENTAL_COMPANY_CAR C
JOIN (
SELECT CAR_TYPE, DISCOUNT_RATE
FROM CAR_RENTAL_COMPANY_DISCOUNT_PLAN
WHERE CAR_TYPE IN ('세단', 'SUV')
AND DURATION_TYPE = '30일 이상'
) D ON C.CAR_TYPE = D.CAR_TYPE
WHERE NOT EXISTS (
SELECT C.CAR_ID
FROM CAR_RENTAL_COMPANY_RENTAL_HISTORY H
WHERE C.CAR_ID = H.CAR_ID
AND H.START_DATE <= '2022-11-30'
AND H.END_DATE >= '2022-11-01'
)
) T
WHERE FEE >= 500000 AND FEE < 2000000
ORDER BY FEE DESC, CAR_TYPE, CAR_ID DESC;
-- 자동차 대여 기록 별 대여 금액 구하기
SELECT H.HISTORY_ID,
ROUND(H.DAYS * C.DAILY_FEE *
(1 - IFNULL(P.DISCOUNT_RATE, 0) / 100)
) AS FEE
FROM (
SELECT HISTORY_ID, CAR_ID, DATEDIFF(END_DATE, START_DATE) + 1 AS DAYS,
CASE WHEN DATEDIFF(END_DATE, START_DATE) + 1 >= 90 THEN '90일 이상'
WHEN DATEDIFF(END_DATE, START_DATE) + 1 >= 30 THEN '30일 이상'
WHEN DATEDIFF(END_DATE, START_DATE) + 1 >= 7 THEN '7일 이상'
ELSE NULL END AS DURATION_TYPE
FROM CAR_RENTAL_COMPANY_RENTAL_HISTORY
) H
JOIN CAR_RENTAL_COMPANY_CAR C ON H.CAR_ID = C.CAR_ID
LEFT JOIN CAR_RENTAL_COMPANY_DISCOUNT_PLAN P
ON H.DURATION_TYPE = P.DURATION_TYPE AND C.CAR_TYPE = P.CAR_TYPE
WHERE C.CAR_TYPE = '트럭'
ORDER BY FEE DESC, H.HISTORY_ID DESC;
SQL문제도 점점 난이도가 올라가고 있다. 아래 문제에서 할인율을 하드코딩해서 만들었다가 당장에 답만 구할 게 아니라 변화에 대응할 수 있는 구조를 설계해야 한다는 생각에 `DURATION_TYPE` 컬럼을 추가로 만들어줌으로 해결했다. `IFNULL()`과 `LEFT JOIN`으로 할인율이 없는 경우에도 계산이 가능하도록 대비했다.
일정 관리 앱
페이지 네이션
// 일정 목록 조회
@GetMapping
public ResponseEntity<List<ScheduleResponse>> getAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "modifiedAt"));
Page<ScheduleResponse> pages = scheduleService.findAllPaged(pageable);
List<ScheduleResponse> schedules = pages.getContent(); // 페이징된 내용만 추출
return new ResponseEntity<>(schedules, HttpStatus.OK);
}
@Transactional(readOnly = true)
public Page<ScheduleResponse> findAllPaged(Pageable pageable) {
return scheduleRepository.findAll(pageable).map(ScheduleResponse::from);
}
Spring Data JPA는 `Pageable`을 통해 페이징을 자동으로 처리해준다. `PageRequest.of(page, size, sort)`를 사용하면 간단하게 페이징 요청 객체를 생성할 수 있으며, `findAll(pageable)` 메서드는 `Page<T>` 타입의 결과를 반환한다. 이때 내부적으로는 SQL의 `LIMIT`, `OFFSET`을 활용해 필요한 범위의 데이터만 조회한다. `Page<T>`는 실제 데이터뿐 아니라 전체 페이지 수, 현재 페이지 번호, 정렬 정보 등 다양한 메타데이터를 포함하고 있으며, `getContent()`를 통해 순수한 데이터 리스트만 추출할 수 있다. 만약 이러한 메타데이터가 필요하지 않다면, `List<T>`로 변환하여 필요한 정보만 응답에 포함시킬 수 있다.

이번에 쿼리 파라미터를 통해 페이징을 적용해보니, 다음 페이지도 정상적으로 조회되는 것을 확인할 수 있었다. 다만, 코드를 하나하나 수정하면서 전체적인 테스트를 충분히 하지 않아 놓친 부분이 있었다. 페이징 구현을 확인하기 위해 일정을 생성하던 중, `getComments()` 호출 시 NPE가 발생했다. 그 이유는 `Schedule` 객체 생성 시 `comments` 필드가 초기화되지 않았기 때문임을 확인했다.
@OneToMany(mappedBy = "schedule", cascade = CascadeType.REMOVE)
private List<Comment> comments = new ArrayList<>();
위처럼 필드를 초기화해주니 정상적으로 작동했다. 지금은 아주 작은 규모의 프로젝트라 큰 문제가 되진 않았지만, 그때그때 꼼꼼히 점검하면서 코드를 수정해야 한다는 교훈을 얻었다.
마치며

그리고 클래스가 한 곳에 몰려 있으니 점점 관리가 어려워졌다. 지금 프로젝트 규모에선 꼭 필요한 건 아니지만, 연습 삼아 도메인별로 디렉토리를 나눠봤다. 처음엔 과한 분리처럼 느껴졌지만, 기능 단위로 구조를 정리하는 경험 자체가 꽤 유익했다. 이런저런 자료들을 살펴보다보니 많은 개발자들이 `ApiResponse`같은 클래스를 만들어서 공통 응답 포맷으로 사용하고 있음을 알게 되었다. 추가적으로 만들어서 적용해보려고 한다. 또 오늘은 만든 코드들을 그대로 다시 구현해보는 것도 한번 해봤는데, 기본적인 구조에는 많이 익숙해졌음을 느낄 수 있었지만, 제대로 이해하지 못한 부분들도 상당하다는 걸 알게 되었다. 주어진 시간을 더 효율적으로 쓰는 개발자이자 내가 되도록 더 노력해야겠다.