알짜게시판

Apache Access Log 기반 도메인별 방문자 카운터 구현

로그 기반 방문자 집계가 필요했던 배경

여러 도메인을 하나의 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 캐시 연동 등이 있다.

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

제목 글쓴이 조회 날짜
개발팁 Apache Access Log 기반 도메인별 방문자 카운터 구현 10 1시간 전
윈도우 ZImage AI 이미지 생성 환경 구축 방법 207 26-03-26
윈도우 파일 복사 후 실행 배치파일 - 에러 감지 포함 246 26-03-10
개발팁 네이버 검색 Open API 506 26-02-10
개발팁 네이버 검색광고 키워드 도구 API 검색 562 26-02-10
개발팁 MariaDB column_stats 테이블 오류 해결 방법 717 25-12-06
개발팁 PHP-FPM 에러 로그 실시간 모니터링 676 25-12-03
윈도우 윈도우11 업데이트 후 네트워크 드라이브 접근 불가 문제 해결 방법 1,311 25-11-24
개발팁 다국어 번역 함수 구현 방법 3,062 25-11-08
워드프레스 "치명적인 오류가 발생했습니다" 디버깅 가이드 13,874 25-10-31
리눅스 Apache 웹 로그 분석하기 – awk와 GoAccess 활용 18,441 25-10-28
길호넷 칼무리 - 외부 명령으로 캡처 자동화하기 1 40,074 25-10-14
윈도우 윈도11 강제업데이트 피하고 윈도10 계속 쓰기 41,615 25-10-12
리눅스 PHP 파일 업로드 용량 늘리기 56,517 25-10-03
개발팁 애드센스 충돌 문제 해결하기 74,507 25-09-20
파이썬 Python Playwright로 Edge 브라우저 제어하기 76,569 25-09-12
델파이 TDirectory.Delete 대신 CMD 으로 폴더 삭제 74,894 25-09-10
윈도우 Chrome 및 Edge를 TLS 1.2 모드로 실행 74,260 25-08-30
개발팁 애드센스 자동 광고 사용 시 빈 화면이 출력된다면? 159,169 25-06-20
윈도우 브라우저 환경설정 추출 145,733 25-06-18
워드프레스 xmlrpc.php 차단으로 보안 강화하기 136,899 25-06-05
델파이 폼이 모니터 한 가운데 있는 경우 자연스럽게 왼쪽으로 이동시키기 134,456 25-05-26
윈도우 WSH script registration is not valid. 134,655 25-05-23
워드프레스 제목 블록 기본값을 H5로 변경하는 방법 115,974 25-05-22
파이썬 동영상 특정 영역 모자이크(흐리게) 하기 47,677 25-04-30
리눅스 구글 크롬 콘솔 'Attestation check for Topics on' 에러 원인 &… 60,844 25-04-11
기타 크롬 - 개발자 도구(DevTools)에서 붙여넣기 안될때 73,408 25-03-10
그누보드 SmartEditor2Skin.html 다운로드 되는 현상 110,890 25-01-08
윈도우 네트워크 탐지 115,471 24-12-31
델파이 TrayIcon 이 흐릿하게 보이는 버그? 수정 128,860 24-12-12
델파이 실행 중인 프로세스가 활성 창인지 확인하는 함수 146,194 24-11-02
클라우드AI철학인문바투어주점닷컴역사과학