우아한테크캠프 7기: 데모데이 1위 팀의 로그 매니저 프로젝트를 소개합니다!
유난히 길었던 지난 여름, 우아한형제들에서는 서버 개발자 양성을 위한 우아한테크캠프(이하 우테캠) 7기가 진행되었습니다.
10주간 실력을 갈고 닦은 교육생들은 한곳에 모여 데모데이에서 열정을 불태웠는데요. 이번 우테캠 데모데이에서는 앞서 3주간 개발한 Spring 기반의 프로젝트를 선보였고, 부스를 방문하는 분들이 직접 마음에 드는 서비스에 투표할 수 있었습니다.
현장에서 가장 많은 표를 얻은 프로젝트를 소개합니다!
프로젝트 소개
우아한테크캠프 7기 데모데이에서 가장 많은 표를 얻은 김민주, 박정제, 이경민입니다.
저희가 준비한 최종 프로젝트 ‘Log Bat’는 토이 프로젝트나 사이드 프로젝트를 진행할 때 추가적인 인프라 없이 간편하게 로그를 모아볼 수 있는 로그 매니저(Log Manager)입니다.
프로젝트 주제로 선정한 이유를 소개합니다.
시작하며: 주제 선정
교육 기간 중 프로젝트를 진행하면서 백엔드는 프론트엔드로부터 로그를 전달받아 확인하며 디버깅을 하는 과정이 반복되었습니다. 하나의 프로젝트 내의 모든 애플리케이션의 로그를 하나로 모아보면 좋겠다는 의견을 모아 최종 프로젝트 아이템으로 선정했습니다.
첫 번째 목표는 ‘빠르게 개발 사이클을 거쳐 다른 교육생들의 프로젝트에 배포하자!’ 였습니다. 이를 위해 프로토타입 방식을 도입했고, 2~4일의 간격으로 총 3개의 MVP를 개발했습니다.
MVP 1: 최소 기능 개발하기
첫 번째 MVP는 ‘이틀 안에 최소 기능 구현을 완료하자!’는 목표를 잡고, 구현할 최소 기능을 크게 3개로 정의했습니다.
- 로그를 전송할 SDK
- 로그를 받을 서버
- 로그를 저장할 DB
먼저 단건 로그를 서버에 하나씩 전송하고 처리해서 데이터베이스까지 저장하도록 기능을 구현했습니다. SDK는 빠르게 구현하기 위해 JavaScript 기반으로 기획하고 개발했습니다.
JavaScript용 SDK 개발하기
SDK 구현의 가장 큰 목표는 ‘프로덕션 코드를 수정하지 않고 로그를 수집하자!’ 였습니다. JavaScript는 정적 타입 언어가 아니므로 console.log()를 Override할 수 있다는 특성을 활용했습니다.
LogBat.ts
class LogBat {
private static appKey: string = '';
private static originalConsole: {
info: typeof console.info
};
public static init(appKey: string): void {
this.appKey = appKey;
this.originalConsole = {
log: console.log
};
console.log = (...args: any[]): void => {
this.originalConsole.log(...args);
this.sendLog('info', args);
};
}
private async sendLog(args: any[]) {
// ...
}
}
구현을 간단하게 살펴보면 다음과 같습니다.
- 기존 console.log()를 originalConsole에 저장합니다.
- console.log()를 기존 console.log()를 사용하면서도 서버로 로그를 전송하도록 override 합니다.
로그 API 서버 개발하기
서버에 로그를 저장할 때 출처를 구분하기 위해 Project와 App이라는 도메인을 구현했습니다. 하위 키인 appKey에 기반해서 source 구분을 진행했습니다.
개발을 진행하는 과정에서 ‘과연 로그를 관리할 때 영속성이 필요할까?’에 대한 의문이 발생했습니다. 이때 로그는 저장, 조회, 삭제만 필요하고, 영속성을 활용하는 점에서 이점이 없다고 판단해 ‘No’라는 결론을 내렸습니다. 그래서 JPA를 사용하지 않고 JDBC를 활용해서 로그를 DB에 저장했습니다.
LogRepository.java
public class LogRepository {
private final JdbcTemplate jdbcTemplate;
public void save(Log log) {
String sql = "INSERT INTO logs (application_id, level, log_data, timestamp) VALUES (?, ?, ?, ?)";
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(sql);
ps.setLong(1, log.getApplicationId());
ps.setString(2, log.getLevel().name());
ps.setString(3, log.getLogData().getValue());
ps.setTimestamp(4, Timestamp.valueOf(log.getTimestamp()));
return ps;
});
}
}
로그 저장 DB 선택하기
NoSQL, MySQL, 시계열 데이터 특성을 반영할 수 있는 DB 등 여러 선택지에서 딜레마에 빠졌습니다. 결론적으로 3주라는 짧은 시간 내에 새로운 DB를 학습하고 적용하는 것과 AWS에서 NoSQL을 바로 활용하는 것은 현실적으로 어려우므로 MySQL 방식을 채택했습니다.
또한 RDB가 로그를 저장하는 상황에서 적절하지 않은지도 직접 느껴보고 싶었습니다. RDB 내에서 최적화를 진행해 보고, 한계에 직면하게 되었을 때 DB를 개선하면 좋겠다고 판단했습니다.
로그를 저장할 때 App별로 로그를 모아서 조회하고, 조회 시에는 시간 순으로 정렬하는 경우가 잦을 것으로 가정하고 테이블을 설계했습니다.
ddl.sql
create table logs
(
id bigint auto_increment
primary key,
app_id bigint not null,
level tinyint not null,
data text not null,
timestamp datetime not null
);
create index idx_app_id_timestamp
on logs (app_id, timestamp);
create index idx_app_id_timestamp_level
on logs (app_id, timestamp, level);
테스트 환경 구축하기
MVP 1의 개발이 완료되고 시스템이 실제로 얼마나 많은 데이터를 처리할 수 있는지 테스트하기 위한 환경을 구축했습니다. 단순히 처리량을 측정하는 것뿐만 아니라, 부하 상태에서 시스템이 어떤 한계에 도달하는지가 주요 포인트였습니다. 이 과정에서 Nginx, Spring, MySQL 각 컴포넌트가 어떻게 성능을 발휘하는지 확인하기 위해 다양한 부하 조건을 적용했습니다.
- Nginx: 부하가 몰릴 때 얼마나 많은 요청을 안정적으로 전달할 수 있는가?
- Spring 서버: 트래픽이 증가할 때 서버가 정상적으로 응답하고, 처리량이 얼마나 되는가?
- MySQL: 대량의 로그 데이터를 처리하면서, DB가 어느 시점에서 병목 현상이 발생하는지?
이 프로젝트가 실제로 배포된 후를 고려했을 때, 클라이언트에서 수집된 로그를 처리해야 하는 상황을 염두에 두었습니다. 예시로 사용자 100만 명이 있는 서비스 앱이 이 로그 시스템을 사용하게 될 경우, 100만 개의 서로 다른 인스턴스에서 로그 요청이 들어오게 됩니다. 이 때 평균 처리량뿐만 아니라 불특정 다수의 인스턴스에서 동시에 요청이 들어왔을 때도 시스템이 얼마나 잘 처리할 수 있을지가 중요한 포인트였습니다.
Lambda vs EC2: 테스트 환경 선택
부하 테스트를 진행하기에 앞서 순간적으로 높은 부하를 견딜 수 있을지에 대해 검증하고자 했습니다. 비교적 EC2보다 확장이 용이하고 비용적 이점이 큰 AWS Lambda를 사용했습니다.
Node.js 기반의 Lambda 테스트 환경을 만들었고, 인스턴스 400개를 동시에 실행해 로그 서버에 요청을 보냈습니다. 이 테스트를 통해 동시 요청 시 어떻게 서버가 반응하는지, 어디서 병목이 발생하는지 확인할 수 있었습니다.
테스트 결과: 데이터베이스 커넥션 부족 문제
Lambda 인스턴스 400개를 동시에 실행하여 부하를 발생시키는 과정에서 예상치 못한 문제가 발생했습니다. Spring 서버의 DB Connection 풀이 부족해서 로그가 DB까지 저장되지 못하고 있었습니다.
이 문제는 서버가 특정 한계에 도달했을 때 발생하는 병목을 직접 확인할 수 있는 중요한 결과였습니다. Spring 서버의 커넥션 풀이 제한된 상태에서 과도한 요청이 들어오면, 모든 요청이 DB에 접근하려는 순간 커넥션 풀을 초과하게 되어 로그 저장이 불가능해지는 것입니다.
MVP 2: 로직 개선하기
효율적인 자원 활용
커넥션 풀을 무한정으로 늘릴 수 없는 상황 속에서 효율적으로 이용할 수 있는 수단이 필요했습니다. 커넥션 1개당 로그 1건이 아닌 여러 로그를 한 번에 저장하는 것이 효과적일 것으로 판단했습니다. 따라서 한 번의 요청으로 여러 데이터를 한 번에 저장할 수 있는 Bulk Insert를 이용했습니다.
또한 저장 요청 외에도 해당 로그 요청이 유효한지 확인하기 위해 AppKey를 검증하는 과정에서 지속적으로 DB Connection을 사용하여 리소스를 낭비하고 있었습니다. 이때 캐시를 도입하여 DB Connection 사용을 최소화하는 방법으로 개선했습니다.
로그 저장 요청을 Bulk Size만큼 모아서 한 번에 처리하도록 개선하기
- 로그 요청 유효성 검사 캐싱하기
- 로그 저장 요청을 Bulk Size만큼 모아서 한 번에 처리하도록 개선하기
기존 구현을 보면 SDK에서 보낸 단건 로그를 모아 Bulk Insert를 해야 하기 때문에 버퍼링을 할 공간이 필요했습니다. 요청을 1차적으로 Queue에 저장한 뒤, Queue에서 Bulk Size만큼 꺼내서 DB에 저장하도록 설계했습니다. 이를 구현하는데 필요한 로직은 다음 2가지로 정리했습니다.
- Queue에 Bulk Size이상 쌓이면 즉시 저장한다.
- Queue에 Bulk Size이상 쌓이지 않았더라도 타임아웃을 도입하여 n초가 지나면 저장한다.
Bulk Size만큼 쌓이지 않더라도 적절히 처리할 필요성이 대두되었고, 타임아웃을 도입하여 부하가 적을 때에도 주기적으로 저장해 주는 로직을 추가했습니다.
과제를 활용한 구현 방식
구현 방식에 대해 고민하던 중 테크캠프 2주 차에 진행했던 WAS 과제가 떠올랐습니다. 요청을 listen하는 스레드는 메인 스레드 하나로 두고, 요청이 accept되었을 때 Socket을 스레드풀로 제출해서 처리하도록 하는 구조를 반영하면 좋을 것 같다는 아이디어였습니다.
ThreadPoolExecutor
를 사용해 워커 스레드를 만들고, 리더 스레드로 싱글 스레드를 추가하여 진행했습니다. Queue를 확인하는 스레드를 1개로 제한하여 불필요하게 대기하는 스레드를 최소화했습니다.
워커 스레드 풀은 그대로 두고, 리더 스레드(싱글 스레드)가 Queue에서 Bulk Size만큼 가득 찰 때까지 대기합니다. 이후 Queue에 Bulk size만큼 모이거나, timeout이 지나면 Queue에서 로그를 꺼내 워커 스레드 풀에 저장 요청을 제출하는 방식으로 구현했습니다.
이렇게 하면 불필요하게 대기하게 되는 스레드를 최소화할 수 있으므로 훨씬 자원을 효과적으로 이용할 수 있습니다. 이와 같은 로직으로 테스트를 수행하여 아래와 같은 결과를 얻었습니다.
기존이라면 Lambda 인스턴스 100개에 100번의 요청을 넣기만 해도 커넥션 풀이 부족한 상황이었습니다. 테스트 이후 인스턴스 300개에서 100번의 요청을 넣더라도 TPS 400으로 안정적으로 받는 상황으로 개선되었습니다.
하지만 시간이 지날수록 응답속도가 늦어지는 부분이 있어 캐시 구현을 통해 추가적으로 개선해 보기로 했습니다.
애플리케이션 캐시 vs 분산 캐시
단일 인스턴스에서만 로그를 처리하는 상황이므로 분산 캐시를 두는 것은 불필요하다고 판단했습니다. 1차적으로는 애플리케이션 캐시를 이용하도록 구현하고, scale-out이 필요하다면 다른 방법을 고려하고자 했습니다.
애플리케이션 캐시는 Spring Cache를 사용하여 구현했습니다.
@Cacheable(key = "#token")
public AppCommonResponse getAppByToken(String token) {
UUID tokenUUID = UUID.fromString(token);
App app = appRepository.findByAppKey(tokenUUID)
.orElseThrow(() -> new IllegalArgumentException(APP_NOT_FOUND_MESSAGE));
return new AppCommonResponse(app.getId(), app.getName());
}
요청에 대한 앱 정합성 검증이 필요로 하여 AppCommonResponse
를 캐싱했습니다. 반복된 AppKey 요청에 대해 데이터베이스 접근을 최소화했고, 애플리케이션 캐시를 추가한 후 테스트를 재진행했습니다.
수행 결과 Lambda 인스턴스 400개에서 각각 100번의 요청을 넣었을 때 40,000개의 데이터가 모두 처리되는데 46초가 소요되었습니다. TPS는 이전 400대에서 수렴했던 것과 달리 2배가량 향상되어 864까지 달성했습니다. 결과적으로 응답시간 역시 절반 수준인 419ms로 감소하여 백엔드 성능을 안정화시켰습니다.
SDK 개선하기
기존 SDK는 로그가 발생하면 발생 즉시 서버로 전송하는 구조입니다. 이는 로그 발생량이 조금만 늘어나도 SDK를 사용하는 프로덕션 코드에 부하를 주어 악영향을 줄 수 있습니다. SDK에서 로그를 전송하는 로직은 기존 프로덕션 애플리케이션에 주는 영향을 최소화하기 위해 Buffer를 추가했습니다.
Java SDK 구현하기
Java SDK는 Spring Boot에서 default로 사용되는 SLF4J의 구현체인 Logback을 이용하여 Logback Appender를 구현합니다.
먼저 AppenderBase
를 구현합니다. Logback에서 로그를 남길 때는 Logger log = LoggerFactory.getLogger(Class.class)
를 이용하여 log.info()
메서드를 호출합니다. log.info()
메서드의 내부에서는 AppenderAttachableImpl
에 등록된 Appender를 순회하며 로그를 추가하게 됩니다.
발생하는 로그는 Logback Appender를 구현한 LogBat Appender에서 버퍼링 하여 주기적으로 서버로 전송하는 구조로 설계했습니다. 따라서 로그를 백엔드로 전송하기 위해서는 AppenderAttachableImpl
에 적절하게 구현된 Appender를 등록하면 됩니다. 이를 위해, 우리는 AppenderBase
를 상속받아 간편하게 커스텀 Appender를 구현했습니다.
이때의 요구사항을 정리하면 아래와 같이 정리할 수 있습니다.
- 로그가 발생하면 AppenderBase를 통해 중앙 로그 큐(queue) 일시적으로 버퍼링
- 버퍼링된 로그는 주기적으로 저장하도록 설계
로그는 동기화된 큐인 LinkedBlocking
에 저장할 것이기 때문에 동기화되지 않은 Appender인 UnsyncrhonizedAppenderBase
를 구현했습니다.
이후 로그 큐에 담긴 로그들은 ScheduledExecutorService
를 통해 주기적으로 백엔드로 전송하도록 설계했습니다.
SDK를 개발하면서 확장 가능한 구조로 설계하기 위해 고군분투했지만 이 부분은 당장 크게 중요한 내용은 아니므로 아래 클래스 구조도로 간략히 표현했습니다.
JavaScript SDK 개선하기
JavaScript SDK에서도 Java SDK에 반영된 내용처럼 버퍼 큐를 만들어 로그를 쌓아두고, 주기적으로 전송하도록 구현했습니다.
JavaScript는 싱글 스레드로 동작하기 때문에 이벤트를 등록하는 방식으로 해결했습니다.
로그 큐잉 및 배치 처리
private static queueLog(level: string, args: any[]): void {
const logData = {
//...
};
if (this.currentQueueSize > this.maxQueueSize) {
this.sendBatch();
}
else {
this.scheduleBatch();
}
}
private static scheduleBatch(): void {
if (this.batchTimeoutId === null) {
this.batchTimeoutId = window.setTimeout(() => {
// ...
}, this.batchInterval);
}
}
로그를 즉시 전송하지 않고 큐에 추가합니다.
큐 크기가 임곗값(100KB)을 초과하면 즉시 전송을 시작합니다.
그렇지 않으면 배치 전송을 스케줄링합니다.
스케줄링된 배치가 없으면 스케줄링하고, 이미 있으면 아무것도 하지 않습니다.
비동기 배치 전송
private static async sendBatch(): Promise<void> {
fetch(this.apiEndpoint, {
// ...
});
}
누적된 로그를 비동기적으로 서버에 전송합니다.
fetch API를 사용하여 HTTP 요청을 보냅니다.
최종 구조
SDK에서 buffer를 이용해 한 번에 로그를 보내는 설계로 서버의 로직을 변경하여 MVP 2 구조를 마무리했습니다.
현재 기준으로 초당 로그 15,000개를 처리할 수 있는 수준의 성능을 제공합니다.
MVP 3: 성능 최적화하기
성능 최적화를 위한 초석으로 아키텍처에서 병목 발생 여부 판단을 위한 가설 도입 및 검증을 진행했습니다.
가설 1. DB가 병목지점일 것
DB가 병목이라면 DB의 성능이 좋아졌을 때 전반적인 성능이 향상되어야 합니다. 따라서 DB를 Scale-Up을 하고 테스트해 보았습니다. 하지만 성능 변화가 없음을 확인하고 다음 가설로 넘어갑니다.
가설 2. 특정 자원에 대한 과도한 경쟁 발생
과도한 경쟁이 발생했다면 단순히 CPU 코어를 늘렸을 때도 전반적인 성능 향상이 생기지 않을 것입니다. 이번엔 서버를 Scale-Up 하고 테스트를 했습니다. 역시나 성능 변화는 없었으며, 서버 내부에 특정 자원에 대한 과도한 경쟁이 생겼다는 것을 발견했습니다.
병목 지점을 찾던 중 LinkedBlockingQueue
에서 힌트를 찾을 수 있었습니다. LinkedBlockingQueue
에 .addAll()
를 활용하고 있는데 .addAll()
메서드가 데이터를 하나씩 큐에 삽입할 때마다 lock을 사용한다는 점을 확인했습니다.
public abstract class AbstractQueue<E> extends AbstractCollection<E> implements Queue<E> {
public boolean add (E e) {
if (offer(e))
return true;
else
throw new IllegalStateException ("Queue full");
}
public boolean addAll(Collection<? extends E> c) {
if(c= null)
throw new NullPointerException();
if (c = this)
throw new IllegalArgumentException ();
boolean modified = false;
for (Ee : c)
if (add(e))
modified = true;
return modified;
}
}
public class LinkedBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E> {
public boolean offer(E e, long timeout, TimeUnit unit) {
putLock.lockInterruptibly();
try {
enqueue (new Node<E>(e));
}
finally {
putLock.unlock();
}
return true;
}
}
지속적인 Lock 경합에 의해 Queue에 데이터를 삽입하는 과정에서 병목이 발생할 것이다는 가설을 세우고 이를 개선하기 위한 로직을 고민했습니다.
새로운 구조 탐색
Lock 경합 해소를 위한 열띤 토론 결과, Single Thread 기반의 Producer – Consumer 파이프라인 로직을 구성했습니다. 이후 Queue에 삽입하는 스레드를 Single Thread로 제한하여 Queue에 삽입하기 위해 잠금을 얻기 위해 스레드 간의 경쟁을 제거했습니다.
추가로 파이프라인을 다중으로 확장해 하나의 LogQueue에 가해지는 부하를 분산하여 개선하는 효과를 보았습니다.
테스트해보고 발전시키기
순간 부하를 검증하는 방식보다 지속 부하에 대한 검증이 필요하다는 의견에 수렴했고, 기존의 Lambda 테스트가 아닌, Locust를 사용하여 여러 EC2 인스턴스를 띄워 수행하는 테스트를 진행했습니다.
이러한 방식으로 약 1시간 동안 부하 테스트를 진행했을 때에도 높은 부하의 로그 요청을 안정적으로 처리했습니다.
데모데이 D-Day
데모데이 직전 문제가 하나 발생했습니다. 바로 샌드박스 테스트의 멱등성이 깨진 것이었습니다. 동일한 상황에 대한 동일한 세팅에서 테스트 값이 상이한 이슈가 발생했습니다.
변인 통제를 철저히 진행해서 샌드박스 테스트를 진행했음에도 멱등성이 깨진 구멍들을 살펴보았습니다.
-
데브 서버 분리를 하지 않아서 이전에 미리 배포한 SDK 요청이 섞어 들어왔습니다.
- 부하 테스트를 진행하는 과정에서 빠르게 올라가는 오류가 존재했습니다. 확인해 보니 이전에 배포한 SDK AppKey 검증 요청이었습니다. 이 값은 DB에 저장되어 있지 않아 요청이 들어올 때마다 DB 커넥션을 점유했고, 기존 로그를 저장하는 데 쓰여야 할 커넥션이 방해받아 테스트 환경의 일관성이 훼손되었습니다.
-
VPC 분리의 미비 (네트워크 대역폭의 공격)
- 우형에서 지원받은 AWS는 2팀당 하나의 VPC가 제공되었습니다. VPC를 함께 사용한 다른 팀의 네트워크 대역폭을 37.5 Gbps를 요구하는 부하 테스트와 테스트 시간이 겹쳤고, 이로 인해 변인이 완벽하게 통제되지 못했던 점도 존재했습니다.
-
RDS 크레딧 이슈
- 원인 조사를 하던 중 RDS에 CPUBalancedCredit이라는 요소가 존재하는 것을 확인했습니다. 그래프를 살펴보니 그래프가 심하게 요동 친 포인트를 찾을 수 있었습니다. 간단한 서버의 특성상 테스트의 부하가 고스란히 DB로 전달되었고, 이로 인한 DB Credit 감소로 직결되었습니다. 결국 DB가 제 성능을 발휘하지 못했습니다.
완벽한 샌드박스 환경을 다시 구성하고 테스트를 진행한 결과 약 50 RPS 성능 차이만 발생하는 것을 확인했습니다. 물론 Response Time 수치가 안정화되었다는 개선점은 매우 유효했습니다.
이를 통해 최종 개선된 서버를 격리된 환경에서 테스트해 본 결과 RPS 500~600에서 수렴하는 것을 확인할 수 있었습니다.
마치며: To be continued…
이렇게 폭풍 같은 3주라는 시간이 흘러갔습니다. 프로젝트 데모를 진행했지만 아직 시도해보고 싶은 것이 많아 마무리하기에는 아쉬운 마음이 너무 컸습니다. 그래서 데모데이에서 받은 피드백과 프로젝트를 진행하며 고려한 방향성을 기반으로 개선하고자 합니다.
- 도메인에 특화된 DB로 전환이 필요하다는 의견
- SDK 로그 버퍼링 방식 변경(메모리 버퍼에서 파일 버퍼로)
- 결함에 대한 내성이 필요
이런 개선점 외에도 프로젝트를 진행하면서 더 하고 싶었는데 하지 못한 것들
- Spring 외의 다른 언어의 프레임워크를 활용한 API 서버 성능 비교를 통해 API 서버를 개선할 방안을 탐색하기
- 중앙 서비스 외에 사이드 프로젝트나 토이 프로젝트에서 가볍게 사용할 수 있는 임베디드 서버를 만들어 배포하기
- JavaScript와 Java 외의 다양한 언어에 대한 SDK를 제공하여 서비스의 확장성을 높이기
궁극적으로는 Log Bat이 개발자들에게 유용한 오픈 소스 프로젝트로 자리매김할 수 있도록 발전시키고자 합니다. 자세한 프로젝트 내용은 아래 리포지토리에서 보실 수 있습니다.
[Project LogBat]