대용량 트래픽 상황에서도 안정적으로 로그를 처리하기 위해, Spring 애플리케이션에 Redis Sentinel을 이용한 고가용성(High-Availability) 로그 버퍼 시스템을 구축하는 과정을 A부터 Z까지 자세히 알아보겠습니다.
DB에 직접 로그를 쌓다가 장애가 발생했던 경험이 있다면, 이 글이 좋은 해결책이 될 것입니다.
1. 우리가 만들 시스템 아키텍처
목표는 간단합니다. API 서버에서 발생하는 수많은 로그를 DB에 직접 저장하지 않고, 중간에 Redis를 '완충 지대(Buffer)'로 두어 안정성을 확보하는 것입니다. 이때 Redis 서버 하나가 죽더라도 서비스 중단이 없도록 Sentinel을 이용해 이중화하는 것이 핵심입니다.
- API 서버: 로그 발생 시 Redis에 빠르게 기록하고 응답
- Redis (Master/Replica): 로그 데이터를 임시 저장 (Master 장애 시 Replica가 Master로 자동 승격)
- Redis Sentinel: Master 서버를 감시하고, 장애 발생 시 자동으로 장애 조치(Failover) 수행
- Batch 서버: 주기적으로 Redis에 쌓인 로그를 가져와 DB에 안전하게 저장
2. Redis 서버 구축: 설치부터 서비스 등록까지
먼저 로그를 저장할 Redis 서버 2대(Master, Replica)를 준비합니다. 여기서는 CentOS 7+, Redis 7.2.5 버전을 기준으로 설명합니다.
(두 서버 모두에서 아래 과정을 진행해주세요.)
2-1. 개발 도구 설치 및 사용자 생성
Redis 소스 코드를 컴파일하기 위해 필요한 기본 도구를 설치하고, 보안을 위해 Redis 전용 사용자를 생성합니다.
# C 컴파일러 등 개발 도구 설치
sudo yum groupinstall "Development Tools"
# Redis 전용 그룹 및 사용자 생성
sudo groupadd redis
sudo useradd -r -g redis -s /bin/false redis
2-2. Redis 설치
소스 코드를 직접 다운로드하여 설치합니다.
# 소스 코드 다운로드 및 압축 해제
cd /tmp
sudo wget https://download.redis.io/releases/redis-7.2.5.tar.gz
sudo tar xzf redis-7.2.5.tar.gz
# 컴파일 및 설치
cd redis-7.2.5
sudo make
sudo make install
2-3. 커스텀 디렉터리 설정
로그와 설정 파일을 표준 경로가 아닌 /logs/WAS/redis 경로에서 관리하겠습니다.
# 데이터, 로그 저장을 위한 디렉터리 생성
sudo mkdir -p /logs/WAS/redis/{data,logs}
# 설정 파일 복사
sudo cp /tmp/redis-7.2.5/redis.conf /logs/WAS/redis/
# 디렉터리 소유권을 redis 사용자로 변경
sudo chown -R redis:redis /logs/WAS/redis
2-4. 성능 최적화를 위한 커널 파라미터 수정
Redis는 메모리 할당 정책과 관련된 커널 파라미터 수정이 필요합니다.
# sysctl.conf 파일을 열어 맨 아래에 내용 추가
sudo nano /etc/sysctl.conf
# 추가할 내용:
vm.overcommit_memory = 1
# 서버 재부팅 없이 즉시 적용
sudo sysctl -p
💡 overcommit_memory란? Redis가 백그라운드에서 데이터를 저장(save)할 때 메모리 부족 오류를 방지하기 위한 설정입니다. 1로 설정하면 항상 메모리 할당을 허용하여 안정성을 높입니다.
3. Redis Sentinel 이중화 구성하기
이제 두 서버를 Master-Replica 관계로 묶고, Sentinel이 서로를 감시하도록 설정합니다.
- 서버 A (Master): 10.13.12.7
- 서버 B (Replica): 10.13.12.8
3-1. 서버 A (Master) 설정
redis.conf
sudo nano /logs/WAS/redis/redis.conf
# 아래 내용으로 수정 또는 추가
bind 10.13.12.7 127.0.0.1
requirepass your_strong_password
logfile /logs/WAS/redis/logs/redis-server.log
dir /logs/WAS/redis/data
sentinel.conf
sudo nano /logs/WAS/redis/sentinel.conf
# 아래 내용 추가
port 26379
logfile /logs/WAS/redis/logs/sentinel.log
sentinel monitor mymaster 10.13.12.7 6379 1
sentinel auth-pass mymaster your_strong_password
sentinel down-after-milliseconds mymaster 5000
💡 sentinel monitor mymaster 10.13.12.7 6379 1의 의미: "mymaster"라는 이름의 마스터 서버(10.13.12.7:6379)를 감시해. 센티널 1개만 동의하면 장애라고 판단해!
3-2. 서버 B (Replica) 설정
redis.conf
sudo nano /logs/WAS/redis/redis.conf
# 아래 내용으로 수정 또는 추가
bind 10.13.12.8 127.0.0.1
requirepass your_strong_password
logfile /logs/WAS/redis/logs/redis-server.log
dir /logs/WAS/redis/data
# --- [핵심] 마스터(서버 A)를 복제하도록 설정 ---
replicaof 10.13.12.7 6379
masterauth your_strong_password
sentinel.conf 서버 B의 sentinel.conf 내용은 서버 A의 것과 완전히 동일하게 작성합니다. 센티널들은 서로 통신하며 동일한 마스터를 감시해야 하기 때문입니다.
3-3. 서비스 등록 및 실행
두 서버 모두에서 Redis와 Sentinel을 systemd 서비스로 등록하여 편리하게 관리합니다.
redis.service
sudo nano /etc/systemd/system/redis.service
# 파일 내용
[Unit]
Description=Redis In-Memory Data Store
After=network.target
[Service]
User=redis
Group=redis
ExecStart=/usr/local/bin/redis-server /logs/WAS/redis/redis.conf --supervised systemd
ExecStop=/usr/local/bin/redis-cli shutdown
Restart=always
[Install]
WantedBy=multi-user.target
redis-sentinel.service
sudo nano /etc/systemd/system/redis-sentinel.service
# 파일 내용
[Unit]
Description=Redis Sentinel
After=network.target
[Service]
User=redis
Group=redis
ExecStart=/usr/local/bin/redis-sentinel /logs/WAS/redis/sentinel.conf
ExecStop=/usr/local/bin/redis-cli -p 26379 shutdown
Restart=always
[Install]
WantedBy=multi-user.target
이제 서비스를 시작하고, 부팅 시 자동 실행되도록 활성화합니다. (두 서버 모두에서 실행)
sudo systemctl daemon-reload
sudo systemctl restart redis redis-sentinel
sudo systemctl enable redis redis-sentinel
# 방화벽에서 포트 허용
sudo firewall-cmd --permanent --add-port=6379/tcp
sudo firewall-cmd --permanent --add-port=26379/tcp
sudo firewall-cmd --reload
4. Spring Application 연동 코드
이제 Spring 애플리케이션이 이 Sentinel 환경에 접속하도록 설정합니다. spring-data-redis와 jedis 라이브러리를 사용합니다.
4-1. pom.xml 의존성 추가
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.9.0</version>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.7.18</version>
</dependency>
4-2. RedisConfig.java 작성
<entry key="redis.host">127.0.0.1</entry>
<entry key="redis.port">6379</entry>
<entry key="redis.sentinel.master">mymaster</entry>
<entry key="redis.sentinel.nodes">10.13.12.7:26379,10.13.12.8:26379</entry>
<entry key="redis.password">your_strong_password</entry>
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisSentinelConfiguration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisClientConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import redis.clients.jedis.JedisPoolConfig;
@Configuration
public class RedisConfig {
@Value("#{application['server.type']}")
private String serverType;
// 개발용 프로퍼티
@Value("#{application['redis.host']}")
private String redisHost;
@Value("#{application['redis.port']}")
private int redisPort;
// 운영(Sentinel)용 프로퍼티
@Value("#{application['redis.sentinel.master']}")
private String redisMasterName;
@Value("#{application['redis.sentinel.nodes']}")
private String sentinelNodes;
@Value("#{application['redis.password']}")
private String redisPassword;
@Bean
public JedisPoolConfig jedisPoolConfig() {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(10);
poolConfig.setMaxIdle(5);
return poolConfig;
}
@Bean
public RedisConnectionFactory redisConnectionFactory(JedisPoolConfig jedisPoolConfig) {
if ("PROD".equalsIgnoreCase(serverType)) {
// --- 운영(PROD) 환경: Sentinel 설정 사용 ---
RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration()
.master(redisMasterName);
for (String node : sentinelNodes.split(",")) {
String[] parts = node.split(":");
sentinelConfig.sentinel(parts[0], Integer.parseInt(parts[1]));
}
sentinelConfig.setPassword(redisPassword);
return new JedisConnectionFactory(sentinelConfig, jedisPoolConfig);
} else {
// --- 개발/로컬 환경: Standalone 설정 사용 ---
RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration(redisHost, redisPort);
// standaloneConfig.setPassword(redisPassword); // 필요시 주석 해제
JedisClientConfiguration clientConfig = JedisClientConfiguration.builder()
.usePooling().poolConfig(jedisPoolConfig).build();
return new JedisConnectionFactory(standaloneConfig, clientConfig);
}
}
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
5. 로그 수집 로직 구현
이제 실제 비즈니스 로직에서 로그를 Redis에 기록하고, 배치로 DB에 저장하는 코드입니다.
5-1. 로그 생성 및 큐에 추가
API 요청/응답 시점에 아래 메소드들이 호출됩니다. 로그 데이터는 Hash에, 처리할 대상 목록은 List (Queue)에 저장합니다.
// ... (필요한 @Autowired 및 변수 선언) ...
private final ListOperations<String, String> listOps;
private final RedisTemplate<String, String> redisTemplate;
private final HashOperations<String, String, String> hashOps;
private static final String READY_QUEUE_KEY = "log:ready_queue";
private static final String KEY = "asd;lkzxlckjalskjndmnwqeljhqwel";
@Autowired
public SYSYInterfaceServiceImpl(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.listOps = redisTemplate.opsForList();
this.hashOps = redisTemplate.opsForHash();
}
/**
* API 요청 로그를 Redis Hash에 임시 저장
*/
public int insertSySyInterfaceLogInner(Map<String, Object> param, HttpServletRequest request) {
// ... (IP 정보 등 파라미터 가공) ...
param.put("REDIS_UUID", GenerateID.generateUUID());
if (!"LOCAL".equals(serverType)) {
try {
ObjectMapper mapper = new ObjectMapper();
String jsonStr = mapper.writeValueAsString(param);
String uuid = ShaCryptUtil.getEncrypt(String.valueOf(param.get("REDIS_UUID")), KEY);
String key = "log:" + uuid;
// Hash에 요청 데이터와 시간 저장
hashOps.put(key, "SEND_DATA", jsonStr);
hashOps.put(key, "SEND_TIME", String.valueOf(new Timestamp(System.currentTimeMillis())));
redisTemplate.expire(key, 5, TimeUnit.MINUTES); // 5분 후 만료 (응답이 없을 경우 대비)
} catch (Exception e) {
logger.error("Redis 요청 로그 저장 오류: " + e.getMessage(), e);
}
}
return 1;
}
/**
* API 응답 로그를 Redis Hash에 업데이트하고, 처리할 큐(List)에 추가
*/
public int updateSySyInterfaceLogInner(Map<String, Object> param) {
if (!"LOCAL".equals(serverType)) {
try {
ObjectMapper mapper = new ObjectMapper();
String jsonStr = mapper.writeValueAsString(param);
String uuid = ShaCryptUtil.getEncrypt(String.valueOf(param.get("REDIS_UUID")), KEY);
String key = "log:" + uuid;
// Hash에 응답 데이터와 시간 저장
hashOps.put(key, "RECEIVE_DATA", jsonStr);
hashOps.put(key, "RECV_TIME", String.valueOf(new Timestamp(System.currentTimeMillis())));
// 처리할 대상 목록(List)의 왼쪽에 UUID 추가
listOps.leftPush(READY_QUEUE_KEY, uuid);
} catch (Exception e) {
logger.error("Redis 응답 로그 저장 오류: " + e.getMessage(), e);
}
}
// ... (DB에 직접 저장하는 로직은 배치로 이동했으므로 주석 처리 또는 제거) ...
return 1;
}
5-2. Redis 로그를 DB에 저장하는 배치
@Scheduled 등을 이용해 1분마다 주기적으로 실행되는 배치 메소드입니다.
/**
* Redis로 수집한 인터페이스 로그를 DB에 대량 등록하는 배치 (1분 주기)
*/
public void insertRedisLogBatch() {
if ("LOCAL".equals(serverType)) return;
try {
Long queueSize = listOps.size(READY_QUEUE_KEY);
if (queueSize == null || queueSize == 0) {
return;
}
// 최대 1000개로 처리량 제한
long processCount = Math.min(queueSize, 1000);
// 큐(List)의 오른쪽에서 하나씩 UUID를 꺼냄
List<String> uuidsToProcess = new ArrayList<>();
for (int i = 0; i < processCount; i++) {
String uuid = listOps.rightPop(READY_QUEUE_KEY);
if (uuid == null) break;
uuidsToProcess.add(uuid);
}
if (uuidsToProcess.isEmpty()) return;
// UUID 목록을 이용해 Redis Hash에서 로그 데이터들을 가져옴
List<Map<String, Object>> logDataBatch = uuidsToProcess.stream()
.map(uuid -> {
Map<String, String> rawHash = hashOps.entries("log:" + uuid);
// ... (rawHash를 DB에 저장할 최종 Map 형태로 가공하는 로직) ...
return processedMap;
})
.collect(Collectors.toList());
// DB에 대량 삽입 (MyBatis Bulk Insert)
if (!logDataBatch.isEmpty()) {
sysyInterfaceMapper.insertSySyInterfaceLogList(logDataBatch);
}
// 처리가 끝난 Hash 데이터를 Redis에서 삭제
List<String> keysToDelete = uuidsToProcess.stream()
.map(uuid -> "log:" + uuid)
.collect(Collectors.toList());
redisTemplate.delete(keysToDelete);
} catch (Exception e) {
logger.error("Redis 로그 DB 저장 배치 오류: " + e.getMessage(), e);
}
}
마무리
이제 여러분의 애플리케이션은 갑작스러운 로그 폭증에도 DB에 직접적인 부담을 주지 않고, Redis를 통해 안전하게 로그를 수집할 수 있게 되었습니다. 또한 Redis Master 서버에 장애가 발생하더라도 Sentinel이 자동으로 Replica를 Master로 승격시켜주므로, 서비스 중단 없이 로그 시스템을 운영할 수 있습니다.
'프로젝트' 카테고리의 다른 글
| Spring Boot와 React를 사용하여 MSA 프로젝트 만들기 (2) | 2024.08.30 |
|---|---|
| 스프링 부트 게시판 - 개발 (0) | 2024.03.23 |