귀찮은 로깅 시스템, 직접 구현하지 마세요! - github.com/MurHyun2/cholog-logger
Spring Boot 애플리케이션을 위한 중앙화된 로깅 SDK를 개발하고 운영하면서 마주쳤던 주요 성능 문제들과 이를 해결하기 위한 최적화 과정을 공유합니다. 이 트러블슈팅 경험은 현재 SDK 버전에 반영되었으며, 각 개선 사항은 서비스 안정성과 성능에 긍정적인 영향을 미쳤습니다.
로그 서버로 데이터를 전송할 때 "Required request body is missing" 오류가 발생했습니다. 이로 인해 중앙 서버로 로그가 전송되지 않아 데이터 분석 및 모니터링에 공백이 생겼습니다. 특히, 개발 환경에서 로그를 배치(예: 10개)로 묶어 15초 간격으로 전송하는 테스트 중에도 간헐적으로 요청이 실패하여 데이터 유실이 발생했습니다.
문제의 근원은 LogSenderService
의 로그 전송 로직 executeSend
이었습니다. Apache HttpClient를 사용하여 HTTP POST 요청을 생성할 때, 요청 헤더와 URL은 정확히 설정했지만 정작 요청 본문(Request Body)을 지정하는 코드가 누락되어 있었습니다.
LogSenderService
내에서 HTTP POST 요청 시 요청 본문을 명시적으로 설정하도록 수정했습니다. createRequestEntity
메서드에서 로그 데이터(jsonBatch
)를 사용하여 StringEntity
(압축 미사용 시) 또는 ByteArrayEntity
(압축 사용 시)를 생성하고, 이를 HttpPost
객체에 설정합니다.
// LogSenderService.java의 createRequestEntity 메서드 (압축 미적용 시 예시)
StringEntity entity = new StringEntity(jsonData, StandardCharsets.UTF_8);
entity.setContentType("application/json");
return entity;
// LogSenderService.java의 executeSend 메서드
httpPost.setEntity(createRequestEntity(jsonBatch));
이를 통해 전송할 데이터를 JSON 형식으로 지정하고, UTF-8 인코딩을 사용하여 다양한 언어의 로그 메시지도 문제없이 전송할 수 있게 되었습니다.
StringBuilder
활용 (및 String.join
) 로그 데이터를 JSON 배열 형태로 가공할 때, String
객체의 반복적인 생성으로 인해 메모리 사용량이 불필요하게 증가하고 가비지 컬렉션(GC) 발생 빈도가 잦아졌습니다. 특히, 테스트 시나리오에서 분당 약 15건의 로그 메시지를 생성하는 상황에서 약 40-50초마다 Minor GC가 발생하여 시스템 응답성에 영향을 미쳤습니다.
Java에서 String
객체는 불변(immutable)이기 때문에, +
연산자를 사용한 문자열 결합은 매번 새로운 String
객체를 생성합니다. 루프 내에서 이러한 작업이 반복되면 메모리 단편화와 GC 오버헤드가 커질 수밖에 없습니다.
개별 로그 메시지는 ObjectMapper
를 통해 JSON 문자열로 변환되며, 여러 로그 문자열을 모아 하나의 JSON 배열로 만들 때는 String.join()
메서드와 문자열 접합을 사용합니다. String.join()
은 내부적으로 StringBuilder
과 StringJoiner
를 사용하여 효율적입니다.
// LogSenderService.java 에서 여러 로그(batch: List<String>)를 JSON 배열 문자열로 만드는 부분
String jsonArray = "[" + String.join(",", batch) + "]";
이 방식은 반복적인 String 객체 생성을 최소화하여 메모리 사용 효율을 높입니다.
Atomic
클래스를 이용한 경쟁 상태 방지 여러 스레드가 동시에 서버 가용성 상태(isServerAvailable
)와 마지막 연결 확인 시간(lastConnectionCheckTime
) 변수에 접근하면서 경쟁 상태가 발생했습니다. 이로 인해 간헐적으로 로그 전송이 실패하거나 불필요한 중복 연결 확인 작업이 수행되었습니다. 테스트 중 1-2개의 동시 요청이 발생하는 상황에서 약 5분마다 한 번씩 관련 오작동이 관찰되었습니다.
일반 boolean
이나 long
타입 변수를 여러 스레드에서 별도의 동기화 메커니즘 없이 공유하면, 값의 가시성 및 원자성 문제가 발생할 수 있습니다.
java.util.concurrent.atomic
패키지의 AtomicBoolean
과 AtomicLong
을 사용하여 해당 변수들의 스레드 안전성을 확보했습니다.
// LogSenderService.java
// 변경 전 예시:
private boolean isServerAvailable = true;
private long lastConnectionCheckTime = System.currentTimeMillis();
// 변경 후 (v1.0.2 실제 코드):
private final AtomicBoolean isServerAvailable = new AtomicBoolean(true);
private final AtomicLong lastConnectionCheckTime = new AtomicLong(System.currentTimeMillis());
// 추가로 lastErrorLogTime, errorLogsInPeriod 등도 Atomic 변수 사용
이를 통해 여러 스레드가 동시에 접근하더라도 값의 일관성을 유지하고, compareAndSet()
같은 원자적 연산을 활용할 수 있게 되었습니다.
네트워크 불안정이나 서버 다운타임 동안 로그 전송이 실패하면 디스크 큐에 저장하여 나중에 재전송하는 메커니즘이 있었으나, 큐 관리에 문제가 있었습니다. 테스트 환경에서 네트워크 차단 상황을 시뮬레이션했을 때, 약 15%의 로그 파일이 계속 재시도 큐에 남아 결국 디스크 공간 부족 문제가 발생할 수 있었습니다.
LogSenderService
의 resendFromDisk
메서드 내 디스크 큐 처리 로직을 개선했습니다.
// LogSenderService.java의 resendFromDisk 메서드 관련 로직
// 최대 재시도 횟수(MAX_BATCH_RETRY_ATTEMPTS) 설정 및 초과 시 'retried' 폴더로 이동
// 개별 파일 처리 실패(예: 파싱 오류) 시 'errors' 폴더로 이동 또는 로깅 후 다음 파일 처리 계속
filesInDiskQueue.forEach(file -> {
int retryCount = getRetryCountForFile(file);
if (retryCount >= MAX_BATCH_RETRY_ATTEMPTS) {
moveToRetriedDirectory(file);
return; // 다음 파일로
}
try {
boolean success = processAndSendFile(file);
if (success) {
deleteFile(file);
} else {
incrementRetryCountForFile(file);
}
} catch (Exception e) {
logger.error("Error processing queued file: {}", file.getName(), e);
moveToErrorDirectoryOrLog(file); // 오류 파일 처리
}
});
최대 재시도 횟수를 초과한 파일은 별도의 'retried' 디렉토리로 이동시켜 영구 실패 파일이 시스템에 미치는 영향을 최소화하고, 개별 파일 처리 중 예외 발생 시에도 전체 재전송 로직이 중단되지 않도록 했습니다.
1. GZIP 압축 적용
cholog.logger.compress-logs
속성을 true
로 설정하면, LogSenderService
는 로그 데이터를 GZIP으로 압축하여 전송합니다. 이때 Content-Encoding: gzip
헤더가 HTTP 요청에 추가됩니다.
// LogSenderService.java의 createRequestEntity 메서드
if (properties.isCompressLogs()) {
// GZIP으로 압축된 엔티티 생성
byte[] originalData = jsonData.getBytes(StandardCharsets.UTF_8);
byte[] compressedJson = compressData(originalData); // compressData는 내부 압축 로직
ByteArrayEntity entity = new ByteArrayEntity(compressedJson);
entity.setContentType("application/json");
return entity;
}
// LogSenderService.java의 executeSend 메서드
if (properties.isCompressLogs()) {
httpPost.setHeader("Content-Encoding", "gzip");
}
2. API 키 인증 헤더 설정
cholog.logger.api-key
속성에 설정된 API 키는 X-API-Key
라는 HTTP 헤더를 통해 전송됩니다.
// LogSenderService.java의 addApiKeyHeaders 메서드
String apiKey = properties.getApiKey();
if (apiKey != null && !apiKey.isEmpty()) {
httpPost.setHeader("X-API-Key", apiKey);
}
중간 규모의 로그 데이터를 전송할 때 네트워크 대역폭 사용량이 증가했습니다. 특히 테스트 환경에서 분당 수십 건의 로그가 발생할 때, 네트워크 트래픽이 최대 1MB/s까지 증가하여 개발 네트워크에 부담을 주었습니다.
로그 데이터는 텍스트 기반이며 반복적인 패턴을 많이 포함하고 있어 압축률이 높은 특성이 있습니다. 하지만 기존 구현에서는 압축 없이 원본 JSON 데이터를 그대로 전송하여 네트워크 대역폭을 비효율적으로 사용하고 있었습니다.
GZIP 압축을 사용하여 로그 데이터 전송 크기를 줄입니다. cholog.logger.compress-logs
속성이 true
로 설정되어 있으면, HTTP 요청 시 Content-Encoding: gzip
헤더를 추가하고 요청 본문을 GZIP으로 압축하여 전송합니다.
// LogSenderService.java의 createRequestEntity 및 executeSend 메서드 (위 5번 항목 코드 참조)
// properties.isCompressLogs() 값에 따라 분기하여 압축 처리 및 헤더 설정
또한 ELK 스택의 Logstash 설정에 압축 해제 옵션을 추가하는 가이드를 제공할 수 있습니다.
# Logstash HTTP input 플러그인 설정 예시
input {
http {
port => 5000 # Logstash가 수신할 포트
codec => json
decompress_request => true # 추가된 설정: 압축된 요청을 자동으로 해제
}
}
특정 로그 배치 파일이 지속적인 오류로 인해 계속 재시도되면서 로그 처리가 지연되는 현상이 발생했습니다. 테스트 환경에서 손상된 로그 파일이 재시도 큐에 남아 매 재전송 주기(1분)마다 처리 시도와 실패를 반복하면서 다른 정상 로그의 처리까지 지연시키는 사례가 관찰되었습니다.
기존 구현에서는 재시도 횟수에 제한이 없어, 영구적으로 실패하는 로그 파일이 무한정 재시도 대상에 남아 시스템 리소스를 낭비하고 다른 로그 처리를 방해할 수 있었습니다.
LogSenderService
의 resendFromDisk
메서드 내에서 최대 재시도 횟수(MAX_BATCH_RETRY_ATTEMPTS
, 기본값 5)를 설정하고, 이를 초과한 파일은 별도의 'retried' 폴더로 이동시켜 정상적인 로그 처리 흐름을 방해하지 않도록 구현했습니다.
// LogSenderService.java의 resendFromDisk 메서드 관련 로직
private static final int MAX_BATCH_RETRY_ATTEMPTS = 5;
private static final String RETRIED_FOLDER_NAME = "retried";
if (retryCount >= MAX_BATCH_RETRY_ATTEMPTS) {
Path retriedDir = diskQueueDir.resolve(RETRIED_FOLDER_NAME);
// retriedDir 디렉토리 생성 (없는 경우)
// file을 targetPath(retriedDir 내부)로 이동
// 관련 로그 기록 및 retryCountMap에서 해당 파일 정보 제거
continue; // 다음 파일 처리로 진행
}
현재 cholog-logger
SDK v1.0.2
버전에는 위에서 언급된 여러 최적화 사항들이 반영되어, 로깅 시스템의 전반적인 성능과 안정성이 향상되었습니다. 특히 스레드 안전성, 메모리 효율성, 디스크 큐 관리 측면에서의 개선은 안정적인 로깅 서비스를 제공하는 데 기여하고 있습니다.
지속적인 모니터링과 피드백을 통해 앞으로도 더 많은 개선을 이어나갈 예정입니다.