기술 이야기
home
Programming
home
🔐

GeoIP API 성능 개선

Upload date
2021/12/23
Tag
Golang
GeoIP
플랫폼쉴드
API
성능개선
프로파일링
보안
클린
코드
아키텍쳐
Editor
플랫폼쉴드팀_김민철
Editor is
소프트웨어 엔지니어입니다. 인생의 좌우명은 재미있게 살자이며, 계절을 잃지 않으려고 노력하고 있습니다.
2 more properties

Table of contents

들어가며

안녕하세요 넥슨 인텔리전스랩스 플랫폼쉴드팀 김민철입니다.
플랫폼쉴드팀은 가입부터 탈퇴까지의 유저 라이프 사이클에 적용 가능한 플랫폼쉴드라는 보안 플랫폼을 개발하여 제공하는 팀입니다.
이 글에서는 go 언어로 작성된 API를 분석하며 성능과 품질을 개선하는 작업 과정을 공유합니다. 이 작업을 통해 다음과 같은 4가지의 개선된 결과를 얻었습니다.
실질 메모리 사용률 개선
초당 요청량 129% 개선
코드 품질 개선
테스트 커버리지 46% 증가
본문의 예제로 사용된 코드는 GitHub를 확인해주시면 감사하겠습니다

GeoIP API 서비스 배경

GeoIP database는 사용 목적에 따라서 필요한 데이터만 사용할 수 있도록 여러 개의 파일로 분리되어 있는데 각 파일마다 업데이트 주기가 다르며, 업데이트가 지연되는 경우도 있어서 여러 서버로 관리할 경우 동기화에 문제가 발생할 수 있습니다.
넥슨은 여러 부서가 유기적으로 연결되어 서비스를 제공하고 있습니다. IP주소의 정보가 사용 부서 별로 상이할 경우 서비스에 문제가 생길 수 있는데요. 이런 문제를 해결하기 위해 중앙에서 관리하여 API만 연동하면 업데이트 등의 관리 이슈 없이 기능을 사용할 수 있도록 제공하고 있고, 또한 단순 IP주소의 GeoIP정보 이외에 내부 플랫폼쉴드에서 수집 및 가공되는 IP주소의 평판정보도 함께 제공되고 있습니다.

분석 및 개선

최근 GeoIP API의 사용처가 늘어나면서 서비스의 중요도가 높아졌고 안정성과 응답 속도 개선에 대한 필요성이 증가하여 서비스 분석을 진행했습니다.
서비스를 분석하면서 아래의 설계 방법론을 추가하여 테스트가 쉬운 구조로 변경하였습니다.
1. The Twelve-Factor App
2. Layered Architecture (controller, service, domain, repository)
3. Dependency Injection

Software Architecture : 1) The Twelve-Factor app

시간이 지나면서 software가 유기적으로 성장하는 부분, 개발자들 간의 협업, 시간이 지나면서 망가지는 소프트웨어 유지 비용을 줄이는 법에 집중하여 이상적인 앱 개발 방법을 찾고자 나온 방법론입니다.
I. 코드베이스 버전 관리되는 하나의 코드베이스와 다양한 배포 II. 종속성 명시적으로 선언되고 분리된 종속성 III. 설정 환경(environment)에 저장된 설정 IV. 백엔드 서비스 백엔드 서비스를 연결된 리소스로 취급 V. 빌드, 릴리즈, 실행 철저하게 분리된 빌드와 실행 단계 VI. 프로세스 애플리케이션을 하나 혹은 여러 개의 무상태(stateless) 프로세스로 실행 VII. 포트 바인딩 포트 바인딩을 사용해서 서비스를 공개함 VIII. 동시성(Concurrency) 프로세스 모델을 사용한 확장 IX. 폐기 가능(Disposability) 빠른 시작과 그레이스풀 셧다운(graceful shutdown)을 통한 안정성 극대화 X. 개발/프로덕션 환경 일치 개발, 스테이징, 프로덕션 환경을 최대한 비슷하게 유지 XI. 로그 로그를 이벤트 스트림으로 취급 XII. Admin 프로세스 admin/maintenance 작업을 일회성 프로세스로 실행
Plain Text
복사
The Twelve-Factor App
Twelve-Factor 방법론을 통해 얻을 수 있는 것
설정 자동화를 위한 절차(declarative)를 체계화하여 새로운 개발자가 프로젝트에 참여하는데 드는 시간과 비용을 최소화한다.
OS에 따라 달라지는 부분을 명확히하고 실행 환경 사이의 이식성을 극대화 한다.
최근 등장한 클라우드 플랫폼 배포에 적합해지고 서버와 시스템의 관리가 필요 없어진다.
개발 환경과 운영 환경의 차이를 최소화하고 민첩성을 극대화하여 지속적인 배포가 가능하다.
툴, 아키텍처, 개발 방식을 크게 바꾸지 않고 확장(scale up) 할 수 있다.
서비스에 변경 또는 추가된 항목
3. viper -> go-env 변경 (yaml파일에서 환경변수로 변경) 4. repository layer 추가 6. cobra cli 추가 9. fx 추가 (테스트 코드, graceful shutdown) 10. docker image 추가 12. admin/maintenance endpoint 추가
Plain Text
복사
12가지 항목 중 6가지 항목을 추가하여 아래와 같이 방법론을 적용했습니다.
1. git을 통한 코드베이스 관리 2. go module을 이용한 종속성 관리 3. 환경변수를 이용한 설정을 관리하도록 uber의 go-env를 사용 4. 백엔드 서비스는 연결된 리소스로 처리하기 위해 repository layer로 분리 5. ci를 이용한 빌드와 분리된 실행 단계 6. 여러 개의 무상태 프로세스로 실행할 수 있도록 cobra cli를 이용한 run command 분리 7. 포트 바인딩을 통한 서비스 공개 8. goroutine을 활용한 동시성 처리 9. uber의 의존성 주입 프레임워크 fx의 hook을 이용한 graceful shutdown 처리 10. docker image 빌드를 통해 개발/프로덕션 환경이 일치하도록 처리 11. 로그를 이벤트 스트림으로 처리 12. admin/maintenance 작업을 일회성 프로세스로 실행 할 수 있도록 관리용 endpoint 추가
Plain Text
복사

Software Architecture : 2) Layered Architecture

소프트웨어의 아키텍처는 소스 코드를 보지 않고 프로젝트를 빨리 파악하여 작업이 가능하도록 도와주는 일종의 약속입니다. 아키텍처를 적용하면 기능을 추가하거나 버그를 수정할 때 어떤 계층에서 작업해야 할지 예측할 수 있게 되어 작업 시간(비용)이 절약됩니다.
소프트웨어는 시간이 흐를수록 작성하는 시간보다 읽는 시간이 더 많아지기 때문에 읽기 쉽도록 작성하여 추가적인 비용이 발생하지 않도록 해야 합니다. 특히 소프트웨어는 방향을 반드시 정해야 합니다. 방향(계층)을 정해두지 않으면 소프트웨어는 점차 이해하기가 어려운 방향으로 발전하며, 이는 장애를 파악하거나 기능을 추가할 때 장애물이 됩니다. 그래서 보통 팀마다 정해진 규칙과 소프트웨어 아키텍처를 사용합니다. (콘웨이의 법칙)
문제의 답은 하나지만 해결하는 방식은 여러 개일 수 있습니다. 방향을 정하지 않으면 입구와 출구는 같지만 길이 여러 갈래가 되거나 같은 곳을 빙빙 도는 미궁이 될 수 있습니다. 이런 코드가 쌓이면 어느 순간 미노타우로스를 처치해 줄 테세우스가 필요해질 수도 있습니다.
국제 표준화 기구에서 만든 OSI 7계층처럼 소프트웨어도 다음과 같이 계층 구조를 가지는 것이 좋습니다. 이런 계층 구조의 아키텍처를 Layered Architecture라고 합니다. 클린 아키텍처가 대중적으로 많이 사용하는 구조로 대부분의 프레임워크에서 볼 수 있습니다.
각 계층은 서로의 역할에 대해서만 책임지므로 분리하여 테스트할 수 있다는 장점이 있지만 하위 계층으로 접근하는 비용이 들기 때문에 성능에 일부 손해가 발생합니다. 테스트와 성능은 반비례 관계이므로 적당한 타협점을 찾아야 합니다. 또한 프로젝트의 역할이 많아질수록 계층의 구성 요소들 간의 연관 관계가 줄어들어 전체적인 프로젝트의 이해도를 떨어뜨리므로 장고의 다중 앱 구조로 변경하거나 MSA(Micro Service Architecture)로의 변경을 항상 염두에 두어야 합니다.
그림 1 : layered architecture
그림 2 : Clean Architecture of uncle Bob
펼치면 아래의 그림과 같습니다.
그림 3 : go clean arch (https://github.com/bxcodec/go-clean-arch)

layered architecture 예제 및 설명 (feat. todolist-api)

계층 구조를 이해하기 위해 todolist 예제를 만들어서 설명하겠습니다. todolist는 설명하지 않아도 이해하기 쉬우며 서비스에 필요한 기본적인 기술을 사용하게 되므로 많이 사용하는 프로젝트 주제입니다.
새로운 언어를 익힐 때 GitHub에 올라온 예제들을 사용하면 좋습니다. javascript 커뮤니티에는 TodoMVC라는 프레임워크별 구현체 모음도 있습니다.
계층 구조는 테스트하기 쉽지만 상위 계층을 테스트할 때 하위 계층을 생성하고 상위 계층에 전달하는 코드를 많이 작성해야 합니다. 테스트할 코드보다 의존 코드를 더 많이 작성하여 테스트 코드 작성에 피로감을 느끼기 쉽습니다. 이를 해결하기 위해 의존성 주입 도구 코드가 포함되어 있습니다. 관련 내용은 아래에서 추가적으로 설명하겠습니다.
$ tree todolist-api/modules -L 1 # 1 depth만 출력 todolist-api/modules ├── README.md ├── _test ├── config ├── delivery ├── domains └── repository
Bash
복사
delivery, repository, domains로 구성했으며 application의 환경과 테스트를 위한 코드 관리를 위해 config_test를 추가했습니다. go 언어는 directory를 하나의 파일처럼 인식하므로 설정 당 하나의 파일로 관리하기 위해 settings_app, settings_database와 같이 파일을 분리했습니다.
1) Delivery
$ tree delivery -L 3 delivery ├── README.md ├── cmd │ ├── README.md │ ├── config.go │ ├── root.go │ └── run.go └── web ├── README.md ├── modules.go ├── server.go ├── v1 │ └── todo │ ├── handler.go │ ├── params.go │ └── response.go └── validator.go
Bash
복사
전달(Delivery/Controller)은 기능을 사용하는 인터페이스를 의미합니다. 예제에서는 command line interface인 cmd와 http로 제공되는 web 모듈이 있습니다. cmd의 파일 하나당 명령어를 의미하고, web의 디렉토리 구조는 REST API의 endpoint와 동일합니다.
cmd
Usage: todolist-api [command] Available Commands: completion generate the autocompletion script for the specified shell config todolist-api Config help Help about any command run todolist-api Run Flags: -h, --help help for todolist-api -v, --version version for todolist-api Use "todolist-api [command] --help" for more information about a command.
Bash
복사
web : swagger 참고
2) Repository
$ tree repository -L 2 repository ├── README.md └── _dbms ├── logger.go ├── modules.go ├── mysql.go └── sqlite.go
Bash
복사
저장소(Repository)는 데이터가 저장된 저장소를 의미합니다. 예제에서는 DBMS(database management system)로 sqlite와 mysql을 사용했습니다. 그 외에 인터넷 서비스(REST API 등)가 여기에 해당됩니다.
3) Domains
$ tree domains -L 2 domains ├── modules.go ├── response.go └── todo ├── README.md ├── models.go ├── service.go └── service_test.go
Bash
복사
도메인(Domain)은 application에서 가장 중요한 데이터 구조체의 집합입니다. 데이터의 저장과 처리를 위한 순수한 로직만을 담고 있으며 재사용 확률이 가장 높은 코드가 여기에 작성됩니다. 도메인이라는 말을 직역하면 환경에 대한 지식으로 가장 순수한 정보와 기술이 포괄된 단어입니다.
도메인은 데이터/상태를 저장하는 models와 이를 이용하여 실제 기능을 표현하는 service로 구성했습니다.
계층 구조를 사용하면 각 계층의 테스트가 쉬워집니다. 쉬운 테스트를 위해 일반적으로 인스턴스를 생성할 때 생성자 파라미터에 의존성을 전달하는 방식을 권장합니다. 각 계층의 테스트에 필요하지 않은 의존성은 테스트 더블을 이용하여 처리합니다.
settings := getEnv() repository, err := NewRepository(settings.DSN) if err != nil { return err } service, err := NewService(repository) if err != nil { return err } controller, err := NewController(service) if err != nil { return err }
Go
복사
계층 구조의 일반적인 코드 형태
controller를 테스트하려면 service, repository, settings를 생성하고 생성자의 파라미터에 작성하는 등의 코드 작업이 필요합니다. 예제는 각 생성자마다 하나의 파라미터만을 필요로 하지만 의존성이 추가될 때마다 생성자를 수정해야 하는 불편함이 있습니다. 새로운 기능으로 인해 기존에 잘 동작 중인 코드가 변경되는 것은 상당한 리스크가 있습니다.
그리고 테스트 코드를 작성할 때 테스트할 코드보다 의존 코드들을 더 많이 작성하게 되어 코드 작성에 피로감을 느끼게 됩니다. 피로감 누적은 전체적인 코드의 품질을 떨어뜨리는 문제를 야기합니다. 이런 문제들을 방지하기 위해 의존성 주입 도구를 함께 사용합니다.
4) 의존성 주입 도구
의존성 주입 도구는 인스턴스 초기화 및 주입하는 코드를 개발자가 아닌 도구가 대신해주는 IoC(제어의 역전;Inversion of Control)를 지원하는 도구입니다. java의 spring bean, dagger(android), kotlin의 koin이 대표적입니다.
go언어는 아래와 같은 도구들이 있습니다.
1. wire 2. fx(built on top of dig)
wire는 google에서 만들었으며 code generator 방식으로 build-time에 코드를 만들어내는 도구입니다. fx는 uber에서 만들었으며 reflect를 이용해서 runtime에 의존성을 주입하는 도구입니다.
일반적으로 go 언어를 개발한 google에서 만든 wire를 주로 많이 사용하지만, 저는 lifecycle에 OnStart, OnStop hook을 활용해서 setup과 tear down을 처리하는 방식과 테스트 코드 작성이 편한 점이 좋아서 fx를 선택했습니다.
그림 4 : 의존성 주입 도구를 이용한 코드 관리 개선

성능 측정 및 분석

위의 방법론과 도구를 사용하여 테스트가 쉬운 구조로 변경하고 테스트 코드를 작성하여 성능을 측정하고 분석했습니다.

Benchmark

go 언어는 test 명령어를 지원합니다. 이 명령어는 여러가지 flag를 지원합니다. -bench라는 flag를 사용하면 Benchmark 테스트 코드를 실행하여 성능을 비교할 수 있습니다.
func BenchmarkRandInt(b *testing.B) { for i := 0; i < b.N; i++ { rand.Int() } }
Go
복사
위와 같이 Benchmark 접두사를 사용하고 *testing.B 를 인수로하는 함수를 작성한 뒤 반복문으로 테스트할 함수를 감싸면 OPS(1초에 몇 회 호출), 1회 호출 시간, 메모리 할당량, 메모리 할당 횟수를 확인할 수 있습니다. 이를 통해 불필요하게 메모리가 할당되는 코드를 제거하고 벤치마크 테스트를 통해서 기존 코드와 8%의 성능 개선을 확인했습니다.
그림 5 : OPS, 1회 호출 시간, 메모리 할당량, 메모리 할당횟수

Profiling

pprof를 이용하면 어느 지점에서 병목이 발생하고 성능이 차이가 발생하는지 확인할 수 있습니다. 추가로 pprof로 추출한 성능 데이터와 graphviz를 함께 사용하면 프로파일링 정보를 시각화할 수 있습니다.

CPU profiling

1.1 vscode를 이용한 방법(추천)
Go Test Explorer과 graphviz를 설치하면 vscode에서 profile 결과를 이미지로 볼 수 있습니다. 빨간상자는 프로파일링한 지표에서 가장 많은 리소스를 사용한 지점을 시각화한 것입니다.
그림 7 : 테스트 결과 실행
그림 8 : vscode에서 본 cpu profile 이미지
1.2 명령어를 이용한 방법(비교 시 사용)
# new profile $ go test -benchmem \ -run=^$ \ -bench ^new_함수명$ new_모듈_경로 \ -cpuprofile new_cpu.out # legacy profile $ go test -benchmem \ -run=^$ \ -bench ^legacy_함수명$ legacy_모듈_경로 \ -cpuprofile old_cpu.out # cpu 비교 $ go tool pprof -diff_base=new_cpu.out legacy_cpu.out File: geoip.test Type: cpu Time: Dec 14, 2021 at 2:51pm (KST) Duration: 2.81s, Total samples = 1.26s (44.85%) Entering interactive mode (type "help" for commands, "o" for options) (pprof) svg Generating report in profile001.svg
Bash
복사
비교할 프로파일링 데이터를 추출한 뒤 diff_base 옵션을 사용하여 성능 차이를 시각화 할 수 있습니다.
그림 9 : CPU 프로파일링 그래프
그림 10 : 메모리 프로파일링 비교 그래프

web UI

pprof는 web UI도 지원합니다. pprof를 import 해서 net/http에 binding 되면 메모리가 얼마나 할당되고 gorouine이 현재 누수되고 있는지 web에서 확인할 수 있습니다.
그림 11 : 신규(좌) vs 기존(우)
10만 번 요청한 뒤 프로파일링한 결과를 보면 heap 메모리 할당이 기존에 비해 50% 적은 것을 확인 할 수 있습니다. go 언어에서는 메모리를 stack과 heap 두 가지 영역에 할당하는데 heap은 stack에 비해 비용이 많이 들고 성능 관리도 stack보다 불리합니다.

Static Analysis

정적 분석 도구를 이용하여 코드 품질을 개선했습니다. 아래의 도구들을 사용하면 기본적인 언어의 문법들을 체크할 수 있어 언어가 지향하는 공통 컨벤션을 익힐 수 있습니다. 특히 go 언어는 기본적으로 github를 이용해 의존성을 관리하는 언어이므로 오픈소스 코드를 읽을 기회가 많기 때문에 공통 컨벤션을 익히는 것은 도움이 많이 됩니다.
golint: 코딩 스타일을 점검하는 도구로 effective go, go wiki - code review comments 등의 커뮤니티에서 유명한 스타일들을 제안합니다.
go vet: 기본 패키지에 포함되어 있는 도구로 의심스러운 코드들을 알려줍니다. 예를 들어 로직 상 절대로 도달하지 않는 코드가 있거나 명시적으로 에러를 알려줍니다.
gosec: 30개의 취약점 코드 점검
goreportcard: 코드 정적 분석 후 점수 채점 후 report 생성(fmt, vet, cyclo, lint, ineffasign, license, misspell 체크)

Performance Test

벤치마크 결과는 테스트 코드를 초당 몇 회 실행할 수 있는지 측정하고 비교하기 위한 용도이므로 실제 네트워크 환경의 테스트로 보기는 어렵습니다. 그래서 go로 만들어진 bombardier라는 http benchmarking tool을 사용하여 성능을 측정했습니다.
*테스트 환경은 CPU 1 core Memory 2G의 VM입니다.
그림 15 : performance test
조회 Service 벤치마크 테스트에 비해 값이 작은 것 같아 go 언어 기본 네트워크 패키지인 net/http에 조회 service를 등록하여 성능을 비교해 봤습니다.
그림 16 : 기존 + 조회 service
그림 17 : net/http + 조회 service
성능 차이가 250%인 것을 확인하고 원인을 분석했습니다. 원인은 request를 logging 하는 middleware 에서 로깅 시간을 기록하는 부분과 json으로 저장하기 전 heap 메모리를 할당하는 것이었습니다. 메모리를 할당하지 않는 zerolog로 변경하여 처리량을 개선했습니다.
zerolog는 추가적인 instance를 생성하지 않고 sync.Pool로 미리 할당된 byte 배열에 append하는 방식으로, 메모리를 할당하지 않고 로그를 작성하므로 속도가 빠릅니다. 코드를 확인해보면 패키지 import 시 eventPool이라는 이름으로 sync.Pool을 생성하여 관리합니다. sync.Pool은 여러 고루틴에서 접근이 필요한 메모리를 안전하게 관리해 주므로 메모리 할당을 줄여서 gc의 부담을 줄여 전체적인 성능을 향상시킵니다.
// event.go#L12 var eventPool = &sync.Pool{ New: func() interface{} { return &Event{ buf: make([]byte, 0, 500), } }, } ... // event.go#L23 type Event struct { buf []byte w LevelWriter level Level done func(msg string) stack bool // enable error stack trace ch []Hook // hooks from context skipFrame int // The number of additional frames to skip when printing the caller. } ... // event.go#L243 // Str 문자열 타입의 값을 로깅하기 위한 메서드 func (e *Event) Str(key, val string) *Event { if e == nil { return e } e.buf = enc.AppendString(enc.AppendKey(e.buf, key), val) return e }
Go
복사
. . .

나가며

분석 및 개선 작업으로 다음과 같은 결과를 얻을 수 있었습니다.
결과 1 : 코드 품질 개선
그림 18 : 코드 품질 개선 결과
결과 2 : RPS 개선
그림 19 : 10초 간 요청 테스트
결과 3 : Latency 감소, 실질 메모리 사용율 감소 (라이브 모니터링)
그림 20 : 라이브 모니터링 결과
실제 라이브 배포 후 평균 latency는 약 40% 감소되었으며 실질 메모리 사용율도 줄어든 것을 확인할 수 있었습니다.

Reference

Related Sites
 넥슨 게임 포탈
회사 소개
인텔리전스랩스 소개
인재 영입
인텔리전스랩스 블로그 운영 정책
 테크블로그 문의 devrel@nexon.co.kr