PHP로 연간 주차를 계산해 전년도 데이터와 비교하기

사용자가 선택한 연도의 데이터를 월별·주차별로 집계하고, 같은 기준으로 계산한 전년도 데이터와 그래프에서 비교해야 하는 경우가 있습니다. 예를 들어 주문이나 문의 건수를 주 단위로 표시하면 어느 시기에 데이터가 늘거나 줄었는지 한눈에 파악할 수 있습니다.
구현 자체는 복잡한 알고리즘이 아니지만, 연도마다 시작 요일과 마지막 요일이 다르고 윤년이 존재하기 때문에 날짜 경계를 명확하게 처리해야 합니다. 특히 단순히 주차 번호만 사용하면 연초와 연말에서 예상하지 못한 결과가 나올 수 있습니다.
기존 방식에서 불편했던 점
기존 코드는 전년도와 선택 연도의 날짜를 각각 반복문으로 계산했습니다. 두 연도의 주차 구성이 다를 수 있으므로 별도로 계산한다는 방향은 타당하지만, 거의 같은 코드가 두 번 등장해 수정할 부분도 함께 늘어났습니다.
- 전년도와 선택 연도에 동일한 날짜 계산 코드가 반복됩니다.
date('W')가 반환하는 ISO 주차와 일요일부터 시작하는 자체 주차 기준이 섞일 수 있습니다.- 연초와 연말의 한 주가 다른 연도까지 포함될 수 있습니다.
- 월을 넘기는 주차를 어느 달에 포함할지 기준이 분명해야 합니다.
- 그래프의 두 연도에서 주차 수가 다를 때 누락된 값을 처리해야 합니다.
따라서 먼저 서비스에서 사용할 주차의 정의를 정한 뒤, 날짜 범위 생성과 데이터 조회를 분리하는 편이 안전합니다.
주차 계산 기준 정하기
이 글에서는 기존 코드의 의도를 유지해 일요일부터 토요일까지를 한 주로 사용합니다. 연초와 연말처럼 한 주의 일부가 다른 연도에 걸치면 조회 대상 연도의 범위까지만 잘라서 사용합니다.
- 선택 연도와 그 전년도를 각각 계산합니다.
- 한 주는 일요일에 시작하고 토요일에 끝납니다.
- 연도 밖으로 나간 날짜는 1월 1일 또는 12월 31일에서 제한합니다.
- 월을 넘기는 주차는 실제 조회 시작일이 포함된 달에 배치합니다.
- 비교 대상에 대응하는 주차가 없으면 그래프 데이터에는
null과 같은 빈 값을 사용합니다.
조직에서 ISO 주차를 사용한다면 월요일부터 일요일까지를 한 주로 계산해야 합니다. 화면, 집계 쿼리, 보고서가 모두 같은 기준을 사용하도록 먼저 합의하는 것이 중요합니다.
중복을 줄인 핵심 구현 구조
전년도와 선택 연도에 같은 반복문을 두 번 작성하는 대신, 연도를 전달하면 월별 주차 범위를 반환하는 함수로 분리할 수 있습니다. 다음 예시는 날짜 구간을 만드는 핵심 부분입니다.
<?php
function buildWeeklyRanges(int $year, DateTimeZone $timezone): array
{
$yearStart = new DateTimeImmutable(
sprintf('%04d-01-01', $year),
$timezone
);
$yearEnd = new DateTimeImmutable(
sprintf('%04d-12-31', $year),
$timezone
);
$cursor = $yearStart;
$monthWeeks = [];
while ($cursor <= $yearEnd) {
$dayOfWeek = (int) $cursor->format('w');
$weekStart = $cursor->modify("-{$dayOfWeek} days");
$weekEnd = $weekStart->modify('+6 days');
$rangeStart = $weekStart < $yearStart ? $yearStart : $weekStart;
$rangeEnd = $weekEnd > $yearEnd ? $yearEnd : $weekEnd;
$month = (int) $rangeStart->format('n');
$monthWeeks[$month][] = [
'sdate' => $rangeStart->format('Y-m-d'),
'edate' => $rangeEnd->format('Y-m-d'),
];
$cursor = $weekEnd->modify('+1 day');
}
return $monthWeeks;
}
$targetYear = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT);
if ($targetYear === false || $targetYear === null) {
throw new InvalidArgumentException('올바른 연도를 입력해 주세요.');
}
$timezone = new DateTimeZone('Asia/Seoul');
$graphDateArr = [];
foreach ([$targetYear - 1, $targetYear] as $year) {
$graphDateArr[$year] = buildWeeklyRanges($year, $timezone);
}
실제 서비스에서는 고정된 시간대 문자열을 그대로 사용하기보다 애플리케이션에 설정된 시간대를 전달하는 것이 좋습니다. 허용할 수 있는 연도 범위도 서비스 정책에 맞게 추가로 검사해야 합니다.
데이터가 그래프로 전달되는 흐름
- 사용자가 비교할 연도를 선택합니다.
- 서버가 입력된 연도를 검증합니다.
- 선택 연도와 전년도의 주차별 시작일·종료일을 생성합니다.
- 각 날짜 구간에 해당하는 데이터 건수를 조회합니다.
- 연도, 월, 월 내 주차 순서에 따라 결과를 배열에 저장합니다.
- 두 연도의 주차 수를 맞춘 뒤 그래프 데이터로 변환합니다.
기존 코드의 item과 detail 값은 이 과정에서 조회한 집계 결과에 해당합니다. 날짜 계산 함수는 기간만 반환하고, 실제 건수 조회는 별도의 함수나 저장소 계층에서 담당하도록 나누면 코드의 역할이 명확해집니다.
날짜 조회는 종료 시각을 포함하지 않는 방식으로
하루의 마지막 시간을 23:59:59로 고정하면 밀리초나 마이크로초가 저장되는 데이터에서 일부 항목이 빠질 수 있습니다. 조회 시작일은 포함하고, 종료일의 다음 날은 포함하지 않는 반개구간을 사용하면 이 문제를 피할 수 있습니다.
SELECT COUNT(*)
FROM target_table
WHERE created_at >= :start_at
AND created_at < :end_at_exclusive
예를 들어 1월 1일부터 1월 7일까지 조회한다면 시작값은 1월 1일 00시, 종료값은 1월 8일 00시로 전달합니다. 테이블과 열 이름은 실제 데이터 구조에 맞게 바꿔야 합니다.
월별 주차와 전년도 데이터 맞추기
연도마다 달력 구성이 달라 같은 달이라도 주차 배열의 길이가 다를 수 있습니다. 따라서 현재 연도의 첫째 주와 전년도의 첫째 주를 같은 그래프 위치에 배치하되, 어느 한쪽에 대응하는 값이 없다면 임의로 0을 넣기보다 null로 표시할지 먼저 결정해야 합니다.
0은 해당 기간의 실제 집계 결과가 0건이라는 의미로 사용합니다.null은 비교할 날짜 구간 자체가 없다는 의미로 사용할 수 있습니다.- 그래프 라이브러리가 빈 값을 처리하는 방식도 함께 확인해야 합니다.
이 구분을 지키면 데이터가 없었던 경우와 비교 가능한 주차가 없는 경우를 화면에서 다르게 표현할 수 있습니다.
사용자가 이용하는 방법
사용자는 검색 화면에서 확인할 연도를 선택한 뒤 조회를 실행하면 됩니다. 서버는 선택된 연도와 바로 전년도의 주차별 집계 결과를 만들고, 화면은 두 값을 같은 위치에 선이나 막대로 표시합니다.
그래프의 각 지점에는 월, 해당 월의 주차 순서, 실제 시작일과 종료일을 함께 제공하는 것이 좋습니다. 단순히 ‘3월 2주차’라고만 표시하는 것보다 ‘3월 2주차, 3월 5일~3월 11일’처럼 기간을 알려주면 사용자가 집계 기준을 더 쉽게 이해할 수 있습니다.

안정성과 보안을 위해 확인할 사항
입력값 검증
요청으로 전달된 연도는 문자열 상태로 바로 날짜 계산에 사용하지 않아야 합니다. 정수 여부를 확인하고, 서비스가 제공하는 조회 가능 기간 안에 포함되는지도 검사해야 합니다. 이렇게 하면 잘못된 날짜 생성과 과도한 범위의 조회를 방지할 수 있습니다.
SQL 인젝션 방지
날짜와 다른 검색 조건은 SQL 문자열에 직접 붙이지 말고 준비된 문장과 바인딩 변수를 사용해야 합니다. 정렬 기준이나 열 이름처럼 바인딩할 수 없는 값은 미리 정의한 허용 목록에서만 선택해야 합니다.
시간대 통일
애플리케이션, 데이터베이스, 그래프에 전달되는 날짜가 서로 다른 시간대를 사용하면 날짜 경계에 있는 데이터가 다른 주차로 들어갈 수 있습니다. 저장 시간대와 조회 시간대를 명확히 정하고 필요한 경우 한 지점에서 변환해야 합니다.
출력 데이터 인코딩
그래프 데이터를 JSON으로 전달할 때는 문자열을 직접 이어 붙이지 말고 PHP의 JSON 변환 기능을 사용해야 합니다. 사용자 입력이 그래프 제목이나 설명에 포함된다면 HTML 출력 단계에서도 문맥에 맞게 이스케이프해야 합니다.
조회 비용 관리
주차마다 개별 쿼리를 실행하면 데이터가 많아질수록 조회 횟수가 늘어납니다. 필요한 경우 전체 기간을 한 번에 조회해 날짜별로 묶거나, 데이터베이스의 날짜 집계 기능을 이용할 수 있습니다. 조회에 사용하는 날짜 열에는 데이터 규모와 쿼리 형태에 맞는 인덱스도 검토할 필요가 있습니다.
정리
연간 주차 비교 기능의 핵심은 단순히 반복문을 실행하는 것이 아니라, 모든 데이터에 동일한 달력 기준을 적용하는 데 있습니다. 전년도와 선택 연도를 각각 계산하되 공통 함수를 사용하면 중복을 줄이고, 연초·연말과 윤년 같은 경계 조건도 한곳에서 관리할 수 있습니다.
여기에 입력값 검증, 준비된 SQL 문장, 시간대 통일, 빈 주차 처리까지 더하면 사용자는 선택한 연도의 흐름을 전년도와 안정적으로 비교할 수 있습니다. 완성된 주차 배열은 선 그래프, 막대그래프, 통계 표 등 필요한 화면 형식에 맞게 변환해 활용하면 됩니다.
댓글 0
댓글 남기기