알짜게시판

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 캐시 연동 등이 있다.

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

제목 글쓴이 조회 날짜
개발팁 VSCode 한국어 설정법, Configure Display Language로 UI 언… 148 26-04-24
개발팁 Apache Access Log 기반 도메인별 방문자 카운터 구현 340 26-03-31
개발팁 네이버 검색 Open API 845 26-02-10
개발팁 네이버 검색광고 키워드 도구 API 검색 1,020 26-02-10
개발팁 MariaDB column_stats 테이블 오류 해결 방법 1,024 25-12-06
개발팁 PHP-FPM 에러 로그 실시간 모니터링 921 25-12-03
개발팁 다국어 번역 함수 구현 방법 3,344 25-11-08
개발팁 애드센스 충돌 문제 해결하기 74,780 25-09-20
개발팁 애드센스 자동 광고 사용 시 빈 화면이 출력된다면? 159,427 25-06-20
개발팁 검색 로봇 접속시 종료 57,283 24-10-07
개발팁 REQUEST 값 받기(application/json 포함) 100,124 24-04-20
개발팁 Porto 테마에서 포스팅만 Loading Overlay 59,096 24-03-08
개발팁 업데이트 이후 에러 발생([ERROR] Incorrect definition of ta… 92,604 23-12-08
개발팁 MySQL, MariaDB 업데이트 74,355 23-11-24
개발팁 [RN] android directory was detected in the proje… 66,344 23-07-20
개발팁 [부트스트랩] nav 에서 하위 항목 활성화 시 부모 항목 활성화 35,821 23-04-30
개발팁 Curl 접속 속도가 늦은 경우 37,492 23-04-10
개발팁 unblock with 'mysqladmin flush-hosts' 에러 54,704 23-01-01
개발팁 [ReactNative] 캐시 초기화 하는 방법 58,259 22-12-27
개발팁 Imagek 를 이용한 썸네일 생성 46,555 22-08-26
개발팁 파일 포맷 알아내기 46,309 22-08-23
개발팁 PHP 수정시 적용이 안되는 경우 해결 (opcache 중지) 36,258 21-03-31
개발팁 [Ruby] 윈도우에서 pod install 실행시 UTF-8 오류 27,900 21-02-28
개발팁 [Ruby] Could not open library 'libcurl' 26,157 21-02-28
개발팁 [Android] Error type 3: Activity class {com.?.Ma… 17,300 21-02-02
개발팁 WinDivert 오류코드 5,073 21-01-24
개발팁 Curl 을 이용하여 파일 다운로드 6,795 21-01-19
개발팁 Curl 을 이용하여 json 을 post 보내기 7,415 21-01-19
개발팁 워드프레스와 그누보드 연동 모듈 4,595 20-12-27
개발팁 날짜필드 연장하기 2,707 20-03-20
개발팁 실시간 화면 출력 5,038 19-08-04
목록
바투어결혼의정석자기계발결혼의신책과지식