만약 애플리케이션에서 Redis 환경을 사용한다면 테스트 환경에서도 Redis를 필요로 하며, 만약 별도의 Redis 설정이 없다면 에러가 발생한다.
그렇다고 애플리케이션에서 사용하는 Redis와 연결하는 것은 좋은 생각이 아니다. 물론 서버와 연결하여 테스트 하는 것도 중요하지만, 실제 서버를 테스트 코드와 연결하는 것은 실제 서비스에 영향을 줄 수 있을 것이다. 그러므로 테스트 용도로 사용하기 위한 Redis가 별도로 필요할 것이다.
보통 Redis는 로컬 환경에서 Redis 서버를 구축하거나, Redis를 도커로 올려서 사용하는 방법이 있지만, 테스트에서 사용할 Redis가 서버를 구축해야 할 정도나 도커를 사용해야 할 정도로 무겁게 사용할 필요가 없다고 생각한다. 그렇기 때문에 임베디드 환경에서 Redis를 구축하려고 한다.
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation ('it.ozimov:embedded-redis:0.7.3') { exclude group: "org.slf4j", module: "slf4j-simple" }
이때 테스트 환경에서 작업할 것이기 때문에 test/src/resources/application.yml에 다음을 추가한다.
spring:
redis:
host: localhost
port: 6379
@Slf4j
@Profile("test")
@Configuration
public class EmbeddedRedisConfig {
private RedisServer redisServer;
@Value("${spring.redis.port}")
private int redisPort;
@Value("${spring.redis.host}")
private String redisHost;
private static final String REDIS_SERVER_MAX_MEMORY = "maxmemory 512M";
@PostConstruct
public void startRedis() {
redisServer = RedisServer.builder()
.port(redisPort)
.setting(REDIS_SERVER_MAX_MEMORY)
.build();
try {
redisServer.start();
} catch (Exception e) {
log.error("", e);
}
}
@PreDestroy
public void stopRedis() {
if (redisServer != null) {
redisServer.stop();
}
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration();
redisConfiguration.setHostName(redisHost);
redisConfiguration.setPort(redisPort);
return new LettuceConnectionFactory(redisConfiguration);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
항상 트러블 슈팅은 술 안마시는 내게 술을 땡기게 하는 기분을 느끼게 한다.
어제와 오늘에 거쳐 해결한 과정을 알아보자.
우선 내가 발견한 에러는 크게 두 종류로 나눌 수 있었다.
에러를 크게 나눠 한 번 살펴보자.
보통 이 에러가 발생하면 현재 사용하는 포트가 이미 사용 중인지 확인하자.
본인의 경우 local redis가 돌아가는 중이었기 때문에 계속 둘이 포트 사용으로 충돌했다.
local redis와 embedded redis의 포트 번호를 다르게 해야 한다.
-> 만약 local redis의 포트를 변경하기 위해 redis.windows-service.conf를 수정했다면 redis를 재시작하기 위해 전체 재부팅이 필요할 수 있다. 해당 conf는 os가 관리하는 conf이기 때문에 전체 재부팅 후에야 반영이 된다. (사실 redis 재부팅만 할 수 있다면 그 방법을 사용하는게 맞을 것 같다. 나는 결국 방법을 찾지 못했다.)
또는 test/src/resources/application.yml을 제대로 읽기 못해서 발생하는 문제일 수 있다. test 디렉터리에 들어있어서 별도의 처리를 하지 않았는데, 만약 Config에서 @Profile("test")를 작성했다면 application.yml에도 다음과 같이 작성해야 한다.
spring:
profiles:
active: test
redis:
host: localhost
port: 6379
application.yml에서 spring.profiles.active: test를 사용하면 실행할 스프링 부트 애플리케이션의 환경을 선택할 수 있으며, 해당 프로파일에 맞는 프로퍼티 파일을 사용할 수 있다. 별도의 프로파일이 지정되지 않으면 default로 적용되기 때문에 test로 지정을 해줘야 하는 듯하다.
[RedisInstanceCleaner] INFO 24-03-08 20:23:05 [AbstractRedisInstance-stop:92] - Stopping redis server...
[RedisInstanceCleaner] INFO 24-03-08 20:23:05 [AbstractRedisInstance-stop:99] - Redis exited
[redisson-netty-4-20] ERROR 24-03-08 20:23:05 [ErrorsLoggingHandler-exceptionCaught:47] - Exception occured. Channel: [id: 0x97fd4b36, L:/127.0.0.1:6461 - R:localhost/127.0.0.1:6379]
java.io.IOException: 현재 연결은 원격 호스트에 의해 강제로 끊겼습니다
at java.base/sun.nio.ch.SocketDispatcher.read0(Native Method)
at java.base/sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:43)
at java.base/sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:276)
at java.base/sun.nio.ch.IOUtil.read(IOUtil.java:233)
at java.base/sun.nio.ch.IOUtil.read(IOUtil.java:223)
at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:358)
at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:254)
at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:357)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:834)
이번 트러블 슈팅의 핵심이다.
연결이 되면 뭐하겠는가, 이번엔 연결을 지가 뭐 잘났다고 끊는다.
"만약 여러 테스트를 실행하다가 포트 충돌이 났을 때 벌어진 일이라면?"을 가정하여 코드를 추가하였다. 해당 코드는 디폴트 6379 포트가 사용중이라면 다른 포트를 사용하도록 포트를 옮기는 코드이다.
@Slf4j
@Profile({"embedded", "test"})
@Configuration
public class EmbeddedRedisConfig {
private RedisServer redisServer;
@Value("${spring.redis.port}")
private int redisPort;
@Value("${spring.redis.host}")
private String redisHost;
private static final String REDIS_SERVER_MAX_MEMORY = "maxmemory 512M";
private static final String CHECK_PORT_IS_AVAILABLE_WIN = "netstat -nao | find \"LISTEN\" | find \"%d\"";
private static final String CHECK_PORT_IS_AVAILABLE_ANOTHER = "netstat -nat | grep LISTEN|grep %d";
@PostConstruct
public void startRedis() throws IOException {
int port = isRedisRunning() ? findAvailablePort() : redisPort;
redisServer = RedisServer.builder()
.port(port)
.setting(REDIS_SERVER_MAX_MEMORY)
.build();
try {
redisServer.start();
} catch (Exception e) {
log.error("", e);
}
}
@PreDestroy
public void stopRedis() {
if (redisServer != null) {
redisServer.stop();
}
}
private boolean isRedisRunning() throws IOException {
return isRunning(executeGrepProcessCommand(redisPort));
}
private boolean isRunning(Process process) {
String line;
StringBuilder pidInfo = new StringBuilder();
try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
while ((line = input.readLine()) != null) {
pidInfo.append(line);
}
} catch (Exception e) {
}
return StringUtils.hasText(pidInfo.toString());
}
private Process executeGrepProcessCommand(int port) throws IOException {
String os = System.getProperty("os.name").toLowerCase();
String[] shell;
if (os.contains("win")) {
String command = String.format(CHECK_PORT_IS_AVAILABLE_WIN, port);
shell = new String[] {"cmd.exe", "/y", "/c", command};
} else {
String command = String.format(CHECK_PORT_IS_AVAILABLE_ANOTHER, port);
shell = new String[] {"/bin/sh", "-c", command};
}
return Runtime.getRuntime().exec(shell);
}
private int findAvailablePort() throws IOException {
for (int port = 10000; port <= 65535; port++) {
Process process = executeGrepProcessCommand(port);
if (!isRunning(process)) {
return port;
}
}
throw new IllegalArgumentException("Not Found Available port: 10000 ~ 65535");
}
...
}
우선 기능만 넣으려고 커스텀 에러 추가나 리팩토링은 따로 안했다.
이때 executeGrepProcessCommand(port)에서는 운영체제마다 다른 명령어를 사용하기 때문에 배포하는 서버에 맞는 명령어를 사용해야 한다.
맛침반의 경우 전원 윈도우를 사용하지만, 서버는 우분투이기 때문에 중간에 os를 확인하는 코드도 추가했다.

테스트 코드를 돌렸을 때 에러 없이 잘 돌아가는 모습이며, 중간에 잠깐 정지한다던지 등의 잔 에러도 없다.
에러가 발생할 때마다 항상 공통 키워드로 나왔던 게 Redisson이었다. 사실 의존성 추가나 Config 코드에는 추가하지 않았지만 Redisson 부분을 추가했었다.
Redission이 분산락과 관련이 있는 것으로 알아서 포트 중복이 원인이지 않을지 생각의 핵심을 옮겨 해결하게 된 케이스이다. 로그의 맨 마지막을 확인하는 습관이 있지만, 전체 로그를 확인하여 왜 이런 상황이 발생하게 됐는지 파악하는 것이 중요하다고 생각됐다. 로그는 부분이 아닌 전체를 확인하자!
특히 검색을 해도 잘 나오지 않는 에러라면 더욱 그래야 할 것이다.
이제 로직 구현 하러가야겠다. 오늘까지 구현하겠다고 약속했는데.. ㅠ