쇼츠 히스토리 전체 조회 기능 추가
목차
쇼츠 히스토리 기능은 개별 사용자 단위 조회는 이미 있었는데, 전체를 한 번에 보는 글로벌 뷰가 빠져 있었다. 운영 중에 "특정 기간 동안 어떤 쇼츠가 얼마나 재생됐는지" 흐름을 보고 싶어지는 시점이 생겼고, 그때 막상 조회 수단이 없다는 걸 알았다. 개별 조회를 반복 호출해서 합산하는 건 수단이 아니다. 그래서 이번에 히스토리 전체 조회 기능을 별도로 추가했다.
기능 규모는 크지 않다. 파라미터 받고, 쿼리 돌리고, 결과 뿌리는 전형적인 조회 플로우다. 근데 이런 단순해 보이는 기능일수록 세부 처리를 제대로 안 하면 운영에서 조용히 문제가 생긴다. 그 "조용히"가 제일 무섭다.
Spring MVC + MyBatis 구조에서 내부 클래스로 처리하는 방식
이 프로젝트는 Spring MVC + MyBatis 조합이다. 컨트롤러 내부 클래스가 요청 파라미터 바인딩과 비즈니스 처리를 담당하고, SQL은 XML 매퍼에서 따로 관리한다. JSP가 뷰 레이어를 맡는 구조.
내부 클래스(inner class)로 처리 로직을 묶는 방식은 같은 요청 흐름에서 쓰이는 DTO, 처리 결과, 유효성 검사 로직을 한 파일 안에 두는 데 유리하다. 코드 추적이 편하고, 이 기능 하나가 어디서 시작해서 어디서 끝나는지 한눈에 보인다. 반면 재사용 가능성이 없는 로직이 계속 쌓이면 클래스가 비대해지는 문제가 있다. 이번엔 기존 패턴을 따르되, 나중에 분리할 경계선을 머릿속에 그려두고 작업했다.
파라미터 검증은 빠짐없이 처리했다. null, 빈 문자열, 날짜 형식 오류 전부 앞단에서 걸러야 한다. SQL까지 흘러들어가면 에러 메시지가 DB 레이어에서 올라오고, 그때 원인 추적이 두 배로 번거로워진다.
// 파라미터 기본값 처리 및 날짜 범위 검증 예시
String startDate = StringUtils.defaultIfBlank(param.getStartDate(),
LocalDate.now().minusDays(30).toString());
String endDate = StringUtils.defaultIfBlank(param.getEndDate(),
LocalDate.now().toString());
if (!isValidDateRange(startDate, endDate)) {
return ResponseEntity.badRequest().body("날짜 범위가 올바르지 않습니다.");
}
히스토리 전체 조회는 데이터 범위가 넓어질 수 있기 때문에, 기간 필터를 필수 조건으로 받도록 설계했다. 조건 없이 전체를 끌어오는 형태로 두면 시간이 지날수록 응답이 느려지다가 어느 시점에 타임아웃 문제로 튀어나온다. MyBatis XML 쪽 쿼리는 동적 조건을 <if> 태그로 처리했다.
<select id="selectShortsHistoryAll" parameterType="map" resultType="ShortsHistoryDto">
SELECT
sh.user_id,
sh.shorts_id,
sh.played_at,
s.title
FROM shorts_history sh
JOIN shorts s ON sh.shorts_id = s.id
<where>
<if test="startDate != null and startDate != ''">
AND sh.played_at >= #{startDate}
</if>
<if test="endDate != null and endDate != ''">
AND sh.played_at <= #{endDate}
</if>
</where>
ORDER BY sh.played_at DESC
</select>
ORDER BY를 명시적으로 박아두는 건 사소해 보이지만 중요하다. 정렬 기준이 없으면 결과 순서가 DB 내부 구현에 따라 달라지고, 페이징이 붙으면 같은 레코드가 다른 페이지에 중복 노출되거나 누락되는 케이스가 생긴다. 특히 히스토리 조회는 나중에 통계 파이프라인 입력으로 붙는 경우가 많아서, 처음부터 정렬 기준을 명확히 잡아두는 게 낫다.
트랜잭션과 예외 처리 - 조회 기능인데도 짚어야 하는 이유
조회 기능이라도 트랜잭션 설정은 그냥 넘기면 안 된다. @Transactional(readOnly = true)로 선언해두면 Spring이 커넥션을 read 전용으로 처리하고, 일부 드라이버나 복제 환경에서는 read replica로 라우팅되기도 한다. 커넥션 풀이 빠듯한 환경에서 이게 체감 차이를 만든다.
예외 처리는 두 방향 중 하나를 골라야 했다.
| 방식 | 장점 | 단점 |
|---|---|---|
| RuntimeException 전파 | 처리 코드 적음, 스택 추적 명확 | 핸들링 안 되면 500 그대로 노출 |
| 잡아서 응답 변환 | API 응답 형태 통일 | 예외가 묻히면 디버깅 어려움 |
이번엔 후자를 택했다. 조회 기능에서 내부 에러가 사용자한테 스택 트레이스로 그대로 노출되는 건 좋지 않아서 공통 에러 형태로 변환해 내보냈다. 단, 로그에는 원본 예외를 반드시 그대로 남겼다. 예외를 변환한다고 묻어버리면 나중에 무엇이 터진 건지 알 수가 없다.
try {
List<ShortsHistoryDto> result = shortsService.findAllHistory(param);
log.info("[ShortsHistory] 전체 조회 완료 | startDate={}, endDate={}, resultCount={}",
param.getStartDate(), param.getEndDate(), result.size());
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("[ShortsHistory] 전체 조회 실패 | param={}", param, e);
return ResponseEntity.internalServerError().body("조회 중 오류가 발생했습니다.");
}
로그는 INFO 레벨로 중요 입출력값을 남겼다. 날짜 범위와 결과 건수 정도는 남겨둬야 운영에서 "왜 데이터가 이렇게 나왔지"를 뒤에서 추적할 수 있다. 이 로그가 없으면 재현 조건도 모르고 원인도 모른 채 맨눈으로 DB 뒤지는 상황이 된다.
기존 로직과의 호환성도 체크했다. 같은 테이블을 참조하는 다른 기능이 있었는데, 쿼리 조건이나 조인 방식이 충돌하지 않는지 확인하고, 수정한 내부 클래스가 기존 요청 흐름에 영향을 안 주는지 코드 레벨에서 추적했다. 이런 검토 없이 넘어가면 기존에 잘 되던 기능이 예상치 못한 타이밍에 깨진다.
배포 - 헬스체크 없으면 무중단이 아니다
로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 처리했다. 이 흐름 자체는 기본인데, 무중단 배포에서 자주 놓치는 포인트가 헬스체크 타이밍이다.
신규 버전 인스턴스가 프로세스로 뜨는 시점과, 실제로 트래픽을 정상 처리할 수 있는 시점은 다르다. 컨텍스트 로딩 중에 트래픽이 넘어오면 초기화가 덜 된 빈에 요청이 닿고 에러가 난다. 헬스체크 엔드포인트가 정상 응답을 내기 시작한 이후에 프록시가 트래픽을 전환하도록 설정이 돼 있어야 "무중단"이 의미가 있다. 설정이 빠져 있으면 배포 때마다 짧은 에러 구간이 생긴다.
롤백도 준비해뒀다. 이전 버전 JAR를 보관해두고 문제가 생기면 배포 스크립트에서 구버전으로 재기동할 수 있도록 해뒀다. 기능이 작다고 롤백 준비를 생략하면 정작 터졌을 때 복구 시간이 길어진다. 준비해두고 안 쓰는 게 낫다.
작업 규모는 크지 않았지만, 히스토리 조회류 기능은 나중에 다른 기능이나 파이프라인의 입력으로 붙는 경우가 많다. 그래서 조회 결과가 일관되게 나오는 것, 에러 상황이 조용히 묻히지 않는 것, 배포 타이밍에 에러 구간이 안 생기는 것 - 이 세 가지를 기준으로 닫아뒀다. 이런 것들이 쌓이면 운영에서 체감이 다르다.
댓글 0
첫 댓글 달아줘.