Apache Access Log 기반 도메인별 방문자 카운터 구현
- 14:09
- 9 회
- 0 건
로그 기반 방문자 집계가 필요했던 배경
여러 도메인을 하나의 Apache 서버에서 운영하면서 도메인별 방문자 수를 분리해서 확인해야 했다. 초기에는 access_log를 직접 grep이나 awk로 조회하는 방식으로 대응했다. 문제는 로그가 커질수록 처리 시간이 선형적으로 증가한다는 점이었다. 하루 로그가 1GB 수준이 되면 단순 조회도 수 초에서 수십 초까지 늘어났다.
같은 데이터를 반복해서 읽는 구조도 비효율이었다. cron으로 주기 실행을 걸어도 매번 전체 로그를 다시 분석하게 된다. 이 문제를 해결하기 위해 변경된 부분만 처리하는 구조로 방향을 잡았다.
Apache Access Log에 도메인 포함시키기
기본 Access Log에는 도메인이 포함되지 않는 경우가 많다. VirtualHost 환경에서는 어떤 요청이 어떤 도메인으로 들어왔는지 구분이 필요하다. 이를 위해 %V 포맷을 추가했다.
# LogFormat 추가 및 CustomLog 변경
sed -i 's/CustomLog "logs\/access_log" combined/LogFormat "%V %h %l %u %t \\"%r\\" %>s %b \\"%{Referer}i\\" \\"%{User-Agent}i\\"" vhost\n CustomLog "logs\/access_log" vhost/' /etc/httpd/conf/httpd.conf
sed -i 's|CustomLog /var/log/httpd/access_log common env=!dontlog|#CustomLog /var/log/httpd/access_log common env=!dontlog|' /etc/httpd/conf.d/logging.conf
# 문법 확인 및 적용
httpd -t && systemctl reload httpd
이 설정 이후 로그의 첫 번째 필드에 도메인이 기록된다.
예를 들어 다음과 같은 형태가 된다.
example.com 127.0.0.1 - - [31/Mar/2026:12:00:00 +0900] "GET / HTTP/1.1" 200 1024
이 도메인 값이 이후 집계 기준이 된다.
gen-counter.sh 스크립트 전체 코드
로그를 읽어 도메인별로 today, yesterday, total 값을 계산하는 스크립트다. 약 140줄 정도이며 상태 파일을 이용한 증분 처리가 포함되어 있다.
cat > /usr/local/bin/gen-counter.sh << 'EOF'
#!/bin/bash
LOG="/var/log/httpd/access_log"
OUTDIR="/home/www/example.com/wp-content/counter"
STATE_FILE="/var/lib/gen-counter.state"
LOCK_FILE="/tmp/gen-counter.lock"
exec 200>$LOCK_FILE
flock -n 200 || exit 1
TODAY=$(date +%d/%b/%Y)
YESTERDAY=$(date -d "yesterday" +%d/%b/%Y)
TODAY_KEY=$(date +%Y%m%d)
CUR_INODE=$(stat -c %i "$LOG")
CUR_SIZE=$(stat -c %s "$LOG")
LAST_INODE=0
LAST_OFFSET=0
[ -f "$STATE_FILE" ] && read LAST_INODE LAST_OFFSET < "$STATE_FILE"
process_log() {
awk -v today="$TODAY" -v yesterday="$YESTERDAY" '
{
domain = $1
match($0, /\[([^:]+)/, arr)
date = arr[1]
if (date == today) t[domain]++
if (date == yesterday) y[domain]++
}
END {
for (d in t) seen[d]=1
for (d in y) seen[d]=1
for (d in seen)
print d, (t[d]?t[d]:0), (y[d]?y[d]:0)
}'
}
declare -A T_CNT Y_CNT
if [ "$CUR_INODE" != "$LAST_INODE" ]; then
if [ -f "${LOG}.1" ]; then
while read -r domain t_count y_count; do
((T_CNT[$domain]+=t_count))
((Y_CNT[$domain]+=y_count))
done < <(tail -c +$((LAST_OFFSET + 1)) "${LOG}.1" | process_log)
fi
LAST_OFFSET=0
fi
while read -r domain t_count y_count; do
((T_CNT[$domain]+=t_count))
((Y_CNT[$domain]+=y_count))
done < <(tail -c +$((LAST_OFFSET + 1)) "$LOG" | process_log)
echo "$CUR_INODE $CUR_SIZE" > "$STATE_FILE"
[ ${#T_CNT[@]} -eq 0 ] && [ ${#Y_CNT[@]} -eq 0 ] && exit 0
all_domains=("${!T_CNT[@]}" "${!Y_CNT[@]}")
unique_domains=($(printf '%s\n' "${all_domains[@]}" | sort -u))
for domain in "${unique_domains[@]}"; do
outfile="$OUTDIR/count-$domain.json"
t=${T_CNT[$domain]:-0}
y=${Y_CNT[$domain]:-0}
old_total=0
old_today=0
old_yesterday=0
last_date=""
if [ -f "$outfile" ]; then
old_total=$(jq '.total // 0' "$outfile")
old_today=$(jq '.today // 0' "$outfile")
old_yesterday=$(jq '.yesterday // 0' "$outfile")
last_date=$(jq -r '.last_date // ""' "$outfile")
fi
if [ "$last_date" == "$TODAY_KEY" ]; then
new_today=$((old_today + t))
new_yesterday=$((old_yesterday + y))
else
new_today=$t
new_yesterday=$old_today
fi
total=$((old_total + t))
tmp=$(mktemp)
jq -n \
--argjson today "$new_today" \
--argjson yesterday "$new_yesterday" \
--argjson total "$total" \
--arg last_date "$TODAY_KEY" \
'{ today: $today, yesterday: $yesterday, total: $total, last_date: $last_date }' \
> "$tmp" && mv "$tmp" "$outfile"
done
echo "updated: ${#unique_domains[@]} domains"
EOF
chmod +x /usr/local/bin/gen-counter.sh
증분 처리 흐름 (입력 → 조건 → 실행 → 출력)
이 스크립트는 전체 로그를 읽지 않는다. 흐름은 다음과 같다.
입력 단계에서는 access_log와 상태 파일을 읽는다. 상태 파일에는 이전 실행 시점의 inode와 offset이 저장되어 있다.
조건 판단 단계에서는 현재 inode와 이전 inode를 비교한다. 값이 다르면 logrotate가 발생한 것으로 판단한다.
처리 단계에서는 tail을 이용해 마지막 offset 이후 데이터만 읽는다. awk로 날짜를 파싱하여 today와 yesterday를 도메인 기준으로 집계한다.
출력 단계에서는 JSON 파일을 생성하거나 기존 데이터를 갱신한다. 결과는 도메인별 파일로 저장된다.
성능 변화와 처리량 기준
이 방식의 핵심은 처리 범위를 줄이는 것이다.
예를 들어 로그 파일이 1GB이고, 1분 동안 추가된 로그가 3MB라고 가정하면 실제 처리량은 3MB다. 기존 방식은 매번 1GB를 읽는다. 처리량 기준으로 약 300배 차이가 발생한다.
cron으로 1분 주기로 실행해도 CPU 사용량 변화가 거의 없다. 로그 크기가 증가해도 처리 시간은 일정하게 유지된다.
logrotate 대응과 데이터 보존
로그가 rotate 되면 inode가 변경된다. 스크립트는 이 값을 기준으로 이전 로그 파일(access_log.1)을 추가로 처리한다. 이후 offset을 초기화하고 현재 로그를 이어서 읽는다.
이 과정을 통해 로그가 교체되는 시점에도 데이터 누락 없이 집계가 이어진다.
다른 방식과 비교 기준
전체 로그 재처리 방식은 구현이 단순하지만 로그 크기에 비례해 시간이 증가한다. DB 저장 방식은 조회는 빠르지만 쓰기 비용과 구조 복잡도가 올라간다.
현재 방식은 상태 파일 기반 증분 처리다. 로그 크기가 커질수록 효과가 커진다. 로그가 100MB 이하라면 단순 방식도 가능하지만 1GB 이상 환경에서는 이 방식이 적합하다.
적용 환경과 확장 가능성
이 구조는 다중 도메인을 운영하면서 단순 방문자 수 집계가 필요한 경우에 적합하다. 외부 분석 도구를 사용할 수 없는 환경에서도 동작한다.
주의할 점은 jq 설치와 파일 권한 설정이다. 또한 cron 중복 실행을 방지하기 위해 flock을 유지해야 한다.
확장 방향으로는 IP 기준 중복 제거, 특정 URL 제외 처리, Redis 캐시 연동 등이 있다.











로그인 후 댓글내용을 입력해주세요