본문 바로가기

내일배움캠프

[최종프로젝트]Embedded Redis 이슈

프론트엔드와 백엔드의 초기 mvp목표를 달성했기 때문에 이제 추가 기능구현보다는 테스트 코드를 만드는 데에 집중하기로 했다. CI/CD 자동화를 위해서는 gradle의 테스트 코드 실행 과정을 거쳐야 했기 때문에 테스트 코드가 반드시 필요했다. 보통 프로젝트를 할 때는 백엔드에서 비즈니스 로직이 구현되면 그에 관련된 테스트 코드부터 작성을 우선시 하는게 맞다.

그러나 이번 프로젝트에서 테스트 코드부터 작성을 하면 프론트엔드 뷰를 구현하지 않아서 중간 발표회에서 비즈니스 로직 시연이 어려울 거라 판단하고 CI/CD자동화는 나중에 구현하기로 하고 따라서 테스트 코드를 제일 나중에 작성하기로 했다.

 

CI/CD자동화를 빨리 구현하기 위해 우선 비즈니스 로직의 모든 기능이 작동하는지 확인할 수 있는 통합 테스트코드부터 작성하기로 했다. 그래서 필자는 users controller 테스트 코드를 만들었다. 테스트 코드는 request dto클래스에 값을 입력하고 dto클래스를 json객체로 직렬화를 거친 뒤 request body에 넣어서 api를 호출하면 반환되는 status code와 반환된 response dto의 값을 검증하는 식으로 테스트 코드를 만들었다. 이 때 @SpringBootTest 어노테이션을 사용해서 실제 애플리케이션 컨텍스트를 가져와서 bean을 가져오게끔 구현을 했기 때문에 service, repository같은 bean 객체들은 mocking으로 만들지 않고 실제 service, repository객체가 구현되서 테스트가 진행된다.

예시:

@Test
@Order(1)
void createUser() throws Exception{
    SignupRequestDto requestDto = createTestSignupRequestDto();

    ResultActions resultActions = mockMvc.perform(post("/api/users/signup")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(requestDto))
            .accept(MediaType.APPLICATION_JSON))
        .andDo(print());

    resultActions
        .andExpectAll(status().isCreated(),
            jsonPath("data.name").value(requestDto.getName()),
            jsonPath("data.email").value(requestDto.getEmail()),
            jsonPath("data.role").value(testUserRole.toString()));
}

(컨트롤러 테스트코드 전문을 넣기에는 너무 길어서 테스트 메소드중 하나만 골라서 예시로 넣었다.)

 

지금 작성하는 포스트는 테스트 코드를 작성하던 도중 가장 기억에 남는 이슈를 글로 남기는 것이다.

본론으로 들어가자면, jwt 액세스 토큰을 갱신하는데 필요한 리프레쉬 토큰을 테스트 하기 위해 실제 서비스에 사용되는 redis와 분리된 환경이 필요했다. 테스트 코드에서 실제 사용되는 redis와 데이터를 공유하게 되면 이미 저장된 key값의 value가 덮어씌워지는 문제가 발생할 수 있다. 그래서 별도의 테스트용 redis환경이 필요했고 EmbeddedRedis라이브러리를 사용하게 되었다.

embedded redis 라이브러리는 두 종류가 있는데 kstryc라는 github유저가 개발한 EmbeddedRedis라이브러리, 앞서 말한 라이브러리를 포크해서 개발한 좀 더 개선된 버전인 ozimov유저가 개발한 EmbeddedRedis라이브러리가 있다.

(출처:https://github.com/kstyrc/embedded-redis),

(https://github.com/ozimov/embedded-redis)

 

GitHub - kstyrc/embedded-redis: Redis embedded server for Java integration testing

Redis embedded server for Java integration testing - kstyrc/embedded-redis

github.com

 

GitHub - ozimov/embedded-redis: Redis embedded server

Redis embedded server. Contribute to ozimov/embedded-redis development by creating an account on GitHub.

github.com

 

각각 6년전, 4년전에 업데이트가 멈춘 라이브러리 이지만 다른 선택지가 없기에 그나마 최신 버전인 ozimov가 개발한 embeddedredis 라이브러리를 사용하기로 했다.

 

@Profile("test")
@Configuration
public class EmbeddedRedisConfig {
    private RedisServer redisServer;

    public EmbeddedRedisConfig(@Value("${spring.data.redis.port}") int port) throws IOException {
        this.redisServer = new RedisServer(port);
    }

    @PostConstruct
    public void startRedis() {
        this.redisServer.start();
    }

    @PreDestroy
    public void stopRedis() {
        this.redisServer.stop();
    }
}

EmbeddedRedis환경을 구축하기 위한 Config파일이다. 이 상태로 테스트를 진행했는데 embeddedredis가 구축되는게 아닌 자꾸 실제 redis에 테스트용 데이터가 저장되었었다.

 

관련 이슈를 검색해 보던 도중 한 기술블로그에서는 test폴더에 저 config파일을 넣어야만 embeddedredis가 실행된다고 했다.(출처:https://velog.io/@yshjft/%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9C%84%ED%95%9C-Embedded-Redis)

 

테스트를 위한 Embedded Redis

CI/CD를 공부하고 해당 방법을 사용해보며 한가지 느끼게된 점이 있습니다. 지금까지 테스트 코드를 작성하면 로컬에서만 사용하였기 때문에 필요한 외부 의존성(ex. DB)을 직접 실행시켜 사용하

velog.io

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
@TestMethodOrder(value = MethodOrderer.OrderAnnotation.class)
class UsersControllerTest {
    @Value("${ADMIN_TOKEN}")
    private String ADMIN_TOKEN;
    private String testName = "testuser";
    private String testName1 = "testuser1";
    private String modifiedTestName = "modifiedtestuser";
    private String testEmail = "test@email.com";
    private String testEmail1 = "test1@email.com";
    private String testPassword = "Asdf1234!";
    private String intro = "myintro";
    private RoleEnum testUserRole = RoleEnum.USER;
    private RoleEnum testAdminRole = RoleEnum.ADMIN;
    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private UsersService usersService;

    private SignupRequestDto createTestSignupRequestDto() {
        SignupRequestDto requestDto = new SignupRequestDto();

        ReflectionTestUtils.setField(requestDto, "name", testName);
        ReflectionTestUtils.setField(requestDto, "email", testEmail);
        ReflectionTestUtils.setField(requestDto, "password", testPassword);

        return requestDto;
    }

실제로 필자는 main폴더의 config 패키지에 넣어놨었다. 필자는 위의 소스코드대로 profile도 설정해 놨는데 왜 test폴더에 넣어야 실행된다는 거지? 라고 의심하면서 속는 셈 치고 한번 시도해봤는데 정말로 test폴더에 넣고 실행해보니 embeddedredis가 실행되었다.

이렇게 문제를 해결하더니 또 다른 문제가 생겼다. 위의 컨트롤러 통합테스트 코드를 처음 한두번 실행하면은 문제가 없었는데 계속 실행하다 보면은 에러가 발생했다.

사진처럼 embeddedredisconfig bean이 생성되지 않아서 redis가 시작할 수 없다는 에러가 발생했다.

이것도 해결책을 찾기 위해 기술 블로그들을 돌아다녀 보니 embeddedredis의 maxmemory를 설정해 줘야 실행이 된다고 적혀있었다.

(https://tbread-development.tistory.com/21)

 

Spring Embedded Redis 사용하며 겪은문제

redis가 현재 프로젝트를 진행하면서 정말 많은 고통을 준다. 초기에도 그랬고 지금도 그렇다 "Can't start redis server. Check logs for details" 이 문구가 날 돌아버리게한게 대체 몇번인지 셀수조차없다 초

tbread-development.tistory.com

아마도 메모리에서 오버플로우가 나서 redis가 실행할 수 없었던 것 같았다. 블로그에 적힌 대로 메모리 사이즈를 지정해 주니 다시 잘 실행이 되었다.

@Profile("test")
@Configuration
public class EmbeddedRedisConfig {
    private RedisServer redisServer;

    public EmbeddedRedisConfig(@Value("${spring.data.redis.port}") int port) throws IOException {
        this.redisServer = RedisServer.builder()
            .port(port)
            .setting("maxmemory 128M")
            .build();
    }

    @PostConstruct
    public void startRedis() {
        this.redisServer.start();
    }

    @PreDestroy
    public void stopRedis() {
        this.redisServer.stop();
    }
}

이제 user 컨트롤러 통합테스트는 더 이상 문제없이 실행되었다.

문제는 모든 통합 테스트 코드를 일괄적으로 실행할 때 문제가 생겼다.

사진처럼 redis를 사용하는 community 서비스 단위 테스트는 문제없이 작동하는데 community 컨트롤러 통합테스트를 하려고 하면 redis가 시작되지 않는 에러가 발생했다.

 

그래서 이번에도 문제를 해결하기 위해 구글링을 하던 도중 한 블로거가 embeddedredis의 config 코드를 작성한 글을 보게 되었다.(https://green-bin.tistory.com/77)

 

Spring - 로컬 환경을 위한 Embedded Redis 적용하기 (+ Can't start redis server. Check logs for details)

배경JWT를 활용한 Spring Security를 작업하면서 Refresh Token을 저장하기 위해서 처음으로 Redis를 Spring에 적용해 봤다. Redis를 공부하면서 알게 된 Embedded Redis가 무엇인지 정리해 보고 적용했던 과정을

green-bin.tistory.com

 /**
     * Embedded Redis가 현재 실행중인지 확인
     */
    private boolean isRedisRunning() throws IOException {
        return isRunning(executeGrepProcessCommand(redisPort));
    }

    /**
     * 해당 port를 사용중인 프로세스를 확인하는 sh 실행
     */
    private Process executeGrepProcessCommand(int redisPort) throws IOException {
        String command = String.format("netstat -nat | grep LISTEN|grep %d", redisPort);
        String[] shell = {"/bin/sh", "-c", command};

        return Runtime.getRuntime().exec(shell);
    }

    /**
     * 해당 Process가 현재 실행중인지 확인
     */
    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) {
            throw new BusinessLogicException(ExceptionCode.ERROR_EXECUTING_EMBEDDED_REDIS);
        }
        return StringUtils.hasText(pidInfo.toString());
    }

저 글의 작성자는 위에 올려놓은 코드처럼 embeddedredis가 실행중인지 확인하는 코드와 특정 포트번호를 점유하고 있는지 확인하는 코드를 구현해 놓았다. 저 글을 확인해보고 이미 embeddedredis가 실행중인데 또 실행하려고 해서 포트 번호가 충돌해 에러가 난건 아닌지 의심해봤다.

예상대로 이미 redis를 실행하고 테스트 코드를 실행중인데 redis를 또 실행하려고 시도하는 모습을 보았다.

그래서 필자는 위의 블로그 포스트를 보고 redis가 실행중인지, 실행중이면 특정 포트번호를 이미 점유한 프로세스가 있는지 검사하는 코드를 추가했다.

 

@Profile("test")
@Configuration
public class EmbeddedRedisConfig {
    @Value("${spring.data.redis.port}")
    int port;
    private RedisServer redisServer;

//    public EmbeddedRedisConfig() throws IOException {
//        this.redisServer = RedisServer.builder()
//            .port(this.port)
//            .setting("maxmemory 128M")
//            .build();
//    }

    @PostConstruct
    public void startRedis() throws IOException {
        if(!isRedisRunning()) {
            this.redisServer = RedisServer.builder()
                .port(this.port)
                .setting("maxmemory 128M")
                .build();
            this.redisServer.start();
        }
    }

    @PreDestroy
    public void stopRedis() {
        this.redisServer.stop();
    }
    private boolean isRedisRunning() throws IOException {
        return isRunning(executeGrepProcessCommand(port));
    }
    private Process executeGrepProcessCommand(int redisPort) throws IOException {
        String command = String.format("netstat -an | findstr LISTENING | findstr :%d", redisPort);
        String[] shell = {"cmd.exe", "/c", command};

        return Runtime.getRuntime().exec(shell);

    }
    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) {
            //throw new RuntimeException();
        }
        return StringUtils.hasText(pidInfo.toString());
    }
}

위의 블로그에서는 맥을 기준으로 코드를 작성했기에 포트를 점유중인 프로세스를 확인하는 코드를 윈도우에 맞춰서 바꿔줬다.(사실 모르고 그냥 그대로 복붙해서 실행했다가 에러나서 뒤늦게 고쳐서 실행했다.)

 

코드를 수정해주니 이제는 정상적으로 모든 테스트 코드가 실행되었다.

문제는 Github action으로 CI/CD를 진행하는 도중에 문제가 발생했다.

필자의 컴퓨터와 팀원들의 컴퓨터에서 테스트 했을 때는 문제가 없었는데 CI/CD를 진행할 때만 에러가 발생했다. 이 때 보았던 에러 메세지가 내가 블로그의 코드를 그대로 복붙했을 때 보았던 에러와 비슷해서 혹시 리눅스를 기반으로 CI/CD를 진행해서 그런게 아닌가? 하고 추측을 하고 실행 환경별로 코드가 다르게 실행되도록 코드를 수정했다.

private Process executeGrepProcessCommand(int redisPort) throws IOException {

    String githubActions = System.getenv("GITHUB_ACTIONS");

    if ("true".equals(githubActions)) {
      String command = String.format("netstat -nat | grep LISTEN|grep %d", redisPort);
      String[] shell = {"/bin/sh", "-c", command};
      return Runtime.getRuntime().exec(shell);

    } else {
      String command = String.format("netstat -an | findstr LISTENING | findstr :%d", redisPort);
      String[] shell = {"cmd.exe", "/c", command};
      return Runtime.getRuntime().exec(shell);
    }
  }

만약 Github actions에서 테스트 코드를 실행하면 리눅스 스타일로 코드를 실행하고 그게 아니면 윈도우 스타일로 실행하도록 바꿨다.(우리 팀원들은 모두 윈도우를 써서 저 당시에는 윈도우 외의 로컬 환경에서 실행할 경우를 상정하지 않았다.)

어쨌든 저렇게 코드를 수정하고 나니 CI가 정상적으로 진행되었고 CD도 무사히 잘 진행되었다.

 

필자가 사실 테스트 코드에서 어떻게 redis를 사용하는지 조사를 했었는데 본문의 내용처럼 embeddedredis라이브러리를 사용하지 않고 redis가 설치된 테스트용 docker 컨테이너를 만들어서 거기서 테스트를 진행한다고 하였다. 확실히 embeddedredis 라이브러리는 사용을 해보니 개발 도중 여러 이슈들이 발생했었다.

만약 docker 이미지를 만들고 거기에 redis를 설치해서 테스트를 했다면 호스트와 분리되어 있기 때문에 redis가 여러번 실행될 일도, 포트번호가 중복되서 에러가 발생할 일도 없었을 것이고 실행 환경별로 나누어서 중복된 코드가 생길 일도 없었을 것이다. 하지만 이미 구성한 파이프라인을 다시 재구성해서 생긴 문제는 감당하기 어려울 것 같다고 판단해서 기존해 구상한 파이프라인을 고치지 않는 선에서 사용할 수 있는 것이 embeddedredis 라이브러리 였고 어쩔 수 없이 embeddedredis 라이브러리를 사용할 수 밖에 없었다.

 

위에서 언급한 두 embeddedredis 라이브러리의 개발자들이 왜 업데이트를 멈췄는지 알 것 같기도 하다.(docker 컨테이너에서 redis를 테스트 하는게 더 안정적이니까?)