웹 서비스를 운영하는 서버 관리자라면 외부에서 어떤 요청이 들어오는지 그리고 어떤 사용자가 있는지에 대한 정보를 담고 있는 액세스 로그에 관심을 가질 필요가 있습니다. 과거 인터넷이 발달하기 전 시절과는 달리 요즘은 PC 및 스마트폰의 대중화로 다양한 OS와 브라우저를 사용하고 있고, 공개 도메인에 대해 크롤러나 외부 해킹과 같은 무차별적 호출이 많아지고 있는 추세이기 때문입니다.

다행히도 일반적인 웹 서버는 이와 같은 외부 호출에 대한 정보를 로그로 남기는 기능을 제공합니다. 특히 Apache는 다양한 형식으로 액세스 로그를 남길 수 있는 모듈을 제공하기 때문에 이를 활용하면 유의미한 데이터를 만들어 낼 수 있습니다.

이 글에서는 Apache 2.2를 웹 서버로 운영하며 액세스 로그를 분석해 유의미한 결과를 얻은 사례를 공유하겠습니다.

Apache 액세스 로그는 어떻게 생겼을까

Apache 액세스 로그를 분석하기 위해서 어떤 형식으로 구성되어 있는지, 그리고 각 데이터에서 어떤 정보를 추출할 수 있는지 알아보자.

Apache는 log_config_module 모듈을 제공하여, 형식 문자열을 사용하여 액세스 로그의 형식을 설정할 수 있게 한다. 형식 문자열 중 이 글에서 필요한 부분만 살펴보면 다음과 같다.

표 1 Apache 액세스 로그 형식 문자열(출처: 아파치 모듈 mod_log_config)

형식 문자열설명
%%퍼센트 기호
%...bHTTP 헤더를 제외한 전송 바이트 수. CLF 형식과 같이, 전송한 내용이 없는 경우 0 대신 -가 나온다.
%...D요청을 처리하는 데 걸린 시간(마이크로초 단위).
%...h원격 호스트
%...{Foobar}i서버가 수신한 요청에서 Foobar: 헤더의 내용.
%...l(있다면 identd가 제공한) 원격 로그인명. mod_ident가 있고 IdentityCheck가 On이 아니면 빼기 기호를 기록한다.
%...r요청의 첫 번째 줄
%...s상태(status). 내부 리다이렉션된 요청의 경우 원래 요청의 상태이다. 최종 요청의 상태는 %...>s.
%...tcommon log format 시간 형식(표준 영어 형식)의 시간
%...u원격 사용자(auth가 제공하며, 상태(%s)가 401인 경우 이상한 값이 나올 수 있음)

형식 문자열을 사용하여 Apache 설정 파일(httpd.conf)에 다음과 같이 액세스 로그의 형식을 설정할 수 있다.

LogFormat "%h %l %u %t \"%r\" %>s %b %D \"%{Referer}i\" \"%{User-Agent}i\"" combined  
CustomLog "| /~<del>/apache/bin/rotatelogs -l /</del>~/logs/apache/access.log.%Y%m%d 86400" combined env=!nolog-request  

위와 같이 설정한 결과 생성된 액세스 로그는 다음과 같다.

123.123.123.123 - - [12/Apr/2018:17:03:50 +0900] "GET /api/aaaa HTTP/1.1" 200 34 1468 "https://m.naver.com" "Mozilla/5.0 (iPhone; CPU iPhone OS 11_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E216 NAVER(inapp; search; 580; 8.6.3; 7)"  

외부에서 요청이 발생할 때마다 위와 같은 문자열이 로깅된다. 그렇다면 이 문자열은 무슨 의미일까? 간단하게 살펴보면 아래와 같은 정보로 구성되어 있는 것을 확인할 수 있다.

그림 1 액세스 로그 구성 예

그림 1 액세스 로그 구성 예

  1. 원격 호스트 IP 주소(요청자)
  2. 요청 시간
  3. 'GET' 메서드를 사용하고 '/api/aaaa'라는 경로에 'HTTP/1.1'의 프로토콜로 요청
  4. HTTP 상태 코드
  5. HTTP 헤더를 제외한 전송 바이트 수
  6. 요청을 처리하는 데 걸린 시간(ms)
  7. 리퍼러(referrer)

위에서 언급한 LogFormat 내용 중 마지막에 있는 User-Agent(이하 UA)는 Wikipedia에 따르면 ‘사용자를 대신하여 일을 수행하는 소프트웨어 에이전트’라고 한다. 즉, UA만 알아도 어떤 기기/브라우저를 사용하는지 알 수 있다는 것이다. UserAgentString.com에 접속해 보면 자신의 UA 문자열을 파싱하여 얻은 OS, 브라우저 등의 정보를 볼 수 있다. Mozilla MDN의 User-Agent - HTTP에는 다양한 브라우저의 UA 문자열과 지원 정보가 정리되어 있다. 주로 사용되는 브라우저는 모두 UA 문자열을 지원하는 것을 확인할 수 있다.

그림 2 User-Agent 브라우저 호환성

그림 2 User-Agent 브라우저 호환성(원본 출처: http://developer.mozilla.org)

이렇게 Apache가 제공하는 모듈을 사용하면 요청지의 다양한 정보를 확인할 수 있다. 하지만 하루에 생성되는 액세스 로그가 수백 건, 수천 건이라면 한줄 한줄을 살펴보고 있을 수는 없다. 우리는 개발자이니 개발자답게 이를 분석해서 한눈에 볼 수 있는 방법을 생각해 보자.

분석 방법 1: 가장 단순하게

그럼 내가 운영하는 서버에 들어온 사용자 혹은 요청을 어떻게 분석할 수 있을까? 가장 단순한 방법으로는 이러한 데이터를 Linux 명령어나 Excel 등을 활용하여 데이터를 추출한 후 정규식을 사용하여 일정한 형식으로 만들고 그 결과를 다시 그룹화하면 얼추 원하는 정보를 얻을 수 있다. UA 문자열과 같은 경우는 이미 다른 사람들이 만들어 둔 정규식을 가져다 사용할 수도 있겠다(예: RegExr).

하지만 자동화되어 있지 않아 정보를 얻으려 할 때마다 매우 번거롭다. 데이터 추출 자동화를 직접 개발한다고 해도 실시간으로 정보를 살펴보고 싶은 경우에는 제한 사항이 많다.

분석 방법 2: Elastic Stack을 활용한다면

Elastic 공식 홈페이지에서도 소개하듯이 Elastic Stack은 실시간 데이터 분석에서부터 다양한 형식의 시각화까지 제공하고 있다. 오픈소스인 Elastic Stack은 크게 데이터를 담는 Elasticsearch, 데이터를 수집 및 전송하며 파이프라인 역할을 담당하는 Logstash, 그리고 데이터를 시각화하는 Kibana로 구성되어 있다. 전에는 ELK라고도 불렸으나 지금의 정식 명칭은 Elastic Stack이다. 그럼 이 Elastic Stack을 활용하여 어떻게 단순한 방법을 벗어나서 효율적으로 서버로부터의 인입을 분석할 수 있을까?

간단하게 설명하면, 위에서 언급한 액세스 로그를 사용하지 않고 프런트엔드 단계에서 JavaScript로 UA 문자열을 구한다. 이러한 정보를 받을 수 있는 API를 서버에 만들어 두고 서버에 UA 문자열을 전송한다. 서버에서는 해당 UA 문자열을 분석 후 Elasticsearch로 전송한다. 알기 쉽게 그림으로 살펴보자.

그림 3 좀 더 '나은' 방법 Elastic Stack 활용

그림 3 좀 더 '나은' 방법 - Elastic Stack 활용

프런트엔드 단계에서는 navigator.userAgent를 활용하여 UA 문자열을 구할 수 있다. API에서는 UA 문자열을 받아 파싱하는데 관련 코드는 다음과 같이 작성하였다.

private static final String VERSION_SEPARATOR = ".";  
private void userAgentParsingToMap(String userAgent, Map<String, Object> dataMap) {  
    HashMap browser = Browser.lookup(userAgent);
    HashMap os = OS.lookup(userAgent);
    HashMap device = Device.lookup(userAgent);
    dataMap.put("browserName", browser.get("family"));
    dataMap.put("browserVersion", getVersion(browser));
    dataMap.put("osName", os.get("family"));
    dataMap.put("osVersion", getVersion(os));
    dataMap.put("deviceModel", device.get("model"));
    dataMap.put("deviceBrand", device.get("brand"));
}
private String getVersion(HashMap dataMap) {  
    String majorVersion = (String)dataMap.get("major");
    if (StringUtils.isEmpty(majorVersion)) {
        return StringUtils.EMPTY;
    }
    String minorVersion = (String)dataMap.get("minor");
    String pathVersion = (String)dataMap.get("path");
    StringBuffer sb = new StringBuffer();
    sb.append(majorVersion);
    if (!StringUtils.isEmpty(minorVersion)) {
        sb.append(VERSION_SEPARATOR);
        sb.append(minorVersion);
    }
    if (!StringUtils.isEmpty(pathVersion)) {
        sb.append(VERSION_SEPARATOR);
        sb.append(pathVersion);
    }
    return sb.toString();
}

참고로 Java 단계에서 UA 문자열을 파싱하는 파서는 여러 가지가 있는데 그중 uap_clj 모듈이 상세한 파싱 결과를 제공하여 이를 사용했다.

표 2 UA 문자열 파싱 모듈 비교

모듈브라우저OS기기
eu.bitwalker.useragentutils.UserAgentO불명확함(예: Android 5.x)X
net.sf.uadetector.UserAgentStringParserOO불명확함(예: Smartphone)
uap_clj.java.api.*OOO

파싱 결과는 다음과 같다.

Browser : {patch=3239, family=Chrome Mobile, major=63, minor=0}  
OS : {patch=1, patch_minor=, family=Android, major=5, minor=1}  
Device : {model=Nexus 6, family=Nexus 6, brand=Generic_Android}  

위와 같이 구성하면 Elasticsearch에 인덱싱된 데이터를 Kibana에서 입맛에 맞게 실시간으로 볼 수 있다. 하지만 별도의 API를 만들어야 한다는 점, 프런트엔드 단계에서 로깅을 위한 JavaScript 로직이 들어가야 한다는 점, 운영하는 모든 페이지에 해당 JavaScript를 넣어야 한다는 점, 액세스 로그만큼 풍부한 정보를 볼 수 없다는 점 등 여러모로 아쉬운 점이 많다. 뭔가 더 좋은 방법이 없을까? 깔끔하면서도 '우아한' 방법. 더 열심히 생각해 보자.

분석 방법 3: 조금 더 우아하게

Logstash보다 가벼운 log shipper인 Filebeat를 이용하여 Apache 액세스 로그를 수집한다. Filebeat의 설정은 다음과 같다.

filebeat.prospectors:  
  - type: log
    enabled: true
    paths:
      - /log/access.log.*
output.logstash:  
  hosts: ["x.x.x.x:5044"]

이렇게 Filebeat가 액세스 로그를 수집하여 Logstash에 전달하면 UA 문자열을 파싱하는 Logstash의 plugins-filters-useragent와 IP 주소를 정해진 규칙에 의해 위도/경도 값으로 바꿔주는 Logstash의 plugins-filters-geoip를 활용해서 로그를 입맛에 맞게 필터링한다. 설정은 다음과 같다.

input {  
        beat {
                port => "5044"
        }
}
filter {  
        grok {
                match => { "message" => ["%{IPORHOST:clientip} (?:-|%{USER:ident}) (?:-|%{USER:auth}) \[%{HTTPDATE:timestamp}\] \"(?:%{WORD:httpMethod} %{NOTSPACE:uri}(?: HTTP/%{NUMBER:httpversion})?|-)\" %{NUMBER:responseCode} (?:-|%{NUMBER:bytes}) (?:-|%{NUMBER:bytes2})( \"%{DATA:referrer}\")?( \"%{DATA:user-agent}\")?"] }
                remove_field => ["timestamp","@version","path","tags","httpversion","bytes2"]
        }
        useragent {
                source => "user-agent"
        }
        if [os_major]  {
                mutate {
                        add_field => {
                                os_combine => "%{os} %{os_major}.%{os_minor}"
                        }
                }
        } else {
                 mutate {
                        add_field => {
                                os_combine => "%{os}"
                        }
                }
        }
        if [os] =~ "Windows" {
                mutate {
                        update => {
                                "os_name" => "Windows"
                        }
                }
        }
        if [os] =~ "Mac" {
                mutate {
                        update => {
                                "os_name" => "Mac"
                        }
                }
        }
        geoip {
                source => "clientip"
                target => "geoip"
        }
}
output {  
        kafka {
                bootstrap_servers => "~<del>"
                topic_id => "</del>~"
                codec => json{}
        }
}

설정하는 과정에서 grok filter 패턴을 작성하는 데 많은 시간을 할애해야만 했다. 개인적으로는 http://grokconstructor.appspot.com/do/match?example=2에서 테스트해 보면서 패턴을 작성한 것이 도움이 되었다. 그리고 Logstash가 파싱한 정보를 조금 다듬기 위해 mutate filter를 사용해서 필드를 조합, 수정하였다.

마지막으로, 인입량이 많을 경우 버퍼 역할을 하고 데이터 유실을 방지하기 위해 중간에 Kafka를 두었다. 관련 내용은 "아파치 엑세스 로그를 엘라스틱서치에 인덱싱 해보자"에 정리했다. 전체적인 흐름을 그림으로 보자.

그림 4 좀 더 우아한 방법

그림 4 좀 더 우아한(?) 방법

분석 결과를 한눈에

위에서 설정을 이용해서 수집한 액세스 로그가 Elasticsearch에 담겼으니 Kibana를 이용하여 시각적으로 나타내면 분석에 도움이 될 수 있다.

Logstash에 내장된 데이터베이스를 활용하여 IP 주소를 위치(geoip)로 변환해서 지도 및 그래프로 나타냈다. 그리고 URI를 그룹화하여 타입별로 시각화했다. 해외에는 서비스하고 있지 않기 때문에 국내 접속이 월등히 많고 그중 서울에서 접속을 가장 많이 한다는 것을 한눈에 볼 수 있다(해외 접속은 무차별적인 호출 또는 해킹 시도로 보인다).

그림 5 지도 및 그래프로 나타낸 접속 위치

그림 5 지도 및 그래프로 나타낸 접속 위치

또한 UA를 각 타입별로 그룹화할 수도 있다. PC보다 모바일 사용자가 더 많고, 그중 안드로이드 7.0에서 가장 많이 접속하는 것을 확인할 수 있다(이런 정보를 기반으로 비즈니스 모델을 타겟화할 수도 있겠다).

그림 6 그래프로 나타낸 UA 타입별 접속 수

그림 6 그래프로 나타낸 UA 타입별 접속 수

마치며

여러 가지 방법을 사용하여 Apache 액세스 로그를 분석해 보았다. raw 상태의 데이터를 보는 것보다 유의미한 데이터로 가공하여 시각화하는 것이 데이터의 의미를 이해하는 데 도움이 되었다.

또한 결과를 도출하기 위해 백지 상태부터 개발하여 도구를 만드는 것도 좋은 방법일 수 있으나, 이 사례에서 Elastic Stack이라는 오픈소스를 활용했듯이 외부 오픈소스를 활용하는 것도 고려해볼 만하다고 생각한다.

블로그 이미지

낭만가을

,