Coding/사이드 플젝

[CtrlU] 아키텍처 (feat. Docker, Redis, 테스트 인스턴스)

kangplay 2025. 5. 3. 22:54

서비스의 구조를 설계할 때, 가장 중요한 것은 기술 중심이 아닌 문제 중심의 사고라고 생각한다.
단순히 "빠르다", "편하다"는 이유로 기술을 선택하기보다는 "내가 어떤 문제를 겪고 있고, 이를 어떻게 해결할 것인가?" 를 고민하는 것이 먼저이다.

Redis를 선택한 이유

1. refresh key 저장

  • 어떤 문제가 있었나?
    • 사용자의 refresh 토큰을 저장하고 관리해야 한다.
    • 사용자가 로그아웃하거나 토큰이 만료되었을 때, 해당 토큰을 즉시 무효화해야 합니다.
    • 토큰은 일정 시간 후 자동으로 만료되어야 하며, 빠르게 조회 및 삭제가 가능해야 합니다.
  • 왜 Redis인가?
    • 토큰마다 TTL 설정 가능 → 일정 시간 뒤 자동 삭제
    • 로그아웃 시 빠르게 삭제 가능
    • 인증 서버가 여러 대로 확장되어도 중앙 저장소로 동작
    • 키: 유저 ID / 값: 토큰 → 빠른 조회, 간단한 구조

2. 스토리 본 이력 저장

  • 어떤 문제가 있었나?
    • 유저가 스토리를 볼 때, 이미 본 스토리 이후부터 보여줘야 한다.
    • 해당 데이터는 유저별로 다르고, 24시간이 지나면 자동 삭제되어야 한다.
    • 사용자 수가 많고, 조회가 빈번한 기능이므로 속도와 확장성이 중요하다.
  • 왜 Redis인가?
    • Set 혹은 List 자료구조로 본 스토리 ID 저장 가능
    • TTL 설정으로 24시간 후 자동 삭제
    • 빠른 연산으로 "다음 스토리 조회" 처리가 쉬움
    • 메모리 기반 저장으로 대규모 사용자 트래픽에도 대응 가능

즉, Redis는 "짧은 생명 주기 + 사용자별 임시 상태 관리" 에 유리한 자료구조이다.

테스트 환경: Docker 기반 테스트 인스턴스

이전 프로젝트에서는 테스트 환경으로 H2 인메모리 DB를 사용하고, 운영 환경에서는 MySQL을 사용했었다. 하지만 이렇게 서로 다른 DB를 사용하다 보니, 테스트에서는 통과되지만 운영 서버에서는 실패하는 문제가 발생했다.

예를 들어, 운영 DB에서는 예약어로 지정된 키워드를 테이블 컬럼명으로 사용한 것이 문제가 되었고, 이는 H2에서는 허용되었지만 MySQL에서는 허용되지 않아 런타임 에러로 이어졌다.

 

따라서 이번 프로젝트에서는 Testcontainers + Docker 기반으로 MySQL 인스턴스 사용하여 테스트 환경조차 운영 환경과 완전히 동일하게 맞추도록 했다. 아래는 운영 환경과 동일한 MySQL 8 버전을 Docker 컨테이너로 띄우고, 테스트 시 해당 컨테이너에 동적으로 연결되도록 구성한 HealthyRepository 테스트 코드이다.

@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class HealthyRepositoryTest {

    @Autowired
    private HealthyRepository healthyRepository;

    @Container
    static MySQLContainer mySQLContainer = new MySQLContainer("mysql:8")
            .withDatabaseName("foureach_test")
            .withUsername("root")
            .withPassword("");

    @DynamicPropertySource
    public static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mySQLContainer::getUsername);
        registry.add("spring.datasource.password", mySQLContainer::getPassword);
        registry.add("spring.datasource.driver-class-name", mySQLContainer::getDriverClassName);
    }

    @Test
    void saveAndFindHealthy() {
        // given
        Healthy healthy = Healthy.builder().name("test").build();

        // when
        healthyRepository.save(healthy);
        Healthy found = healthyRepository.findById(healthy.getId()).orElse(null);

        // then
        assertThat(found).isNotNull();
    }
}
🔄 application-test.yml에 설정이 필요 없는 이유?
해당 테스트에서는 @DynamicPropertySource를 통해 JDBC 연결 설정을 동적으로 주입하기 때문에 별도로 application-test.yml에 datasource 설정을 할 필요가 없다. 오히려 yml 파일에 MySQL 설정을 같이 작성하면, 컨테이너가 두 번 생성되거나 충돌이 발생할 수 있으므로 주의가 필요하다.

참고)  https://jjuunn.tistory.com/32

개발 환경: Docker 기반 로컬 개발

이전 프로젝트에서, 팀원마다 개발 환경이 달라 내 환경에서만 실행이 안 되는 문제가 자주 발생했다.
이를 방지하기 위해 로컬 개발 환경은 다음과 같이 Docker를 활용해 구성하였다 :

운영 환경: 추후 확장 고려한 구조로

운영 환경에서는 단순히 한 대의 서버에서 잘 돌아가는 것을 넘어서, 트래픽 증가나 서비스 확장에 유연하게 대응할 수 있는 구조를 갖추는 것이 중요하다. 이러한 상황을 대비해 scale-out(수평 확장)을 염두에 두고 아키텍처를 설계했다.

 

이때 핵심은, 어떤 인스턴스를 몇 대 띄우더라도 서비스가 항상 동일한 실행 환경에서 일관되게 동작해야 한다는 점이다.

  • Docker는 컨테이너 안에 애플리케이션뿐만 아니라 실행 환경 자체를 함께 패키징하므로, 어떤 서버든 동일한 이미지로 완벽하게 일관된 실행 환경을 보장할 수 있다.
  • 컨테이너는 가볍고 빠르게 실행되기 때문에, 단일 서버에서도 여러 개의 인스턴스를 동시에 띄워 서비스 확장에 유연하게 대응할 수 있다.

Redis 컨테이너는 docker-compost.prod.yml과 별도로 분리하여 운영
Redis는 docker-compose.yml에 포함시키지 않고 별도로 컨테이너를 실행했다. 그 이유는 다음과 같다:
1. 생명주기
Redis는 Spring 서버와 달리 항상 상시 실행되어야 하는 인프라 컴포넌트이다.
docker-compose에 함께 묶을 경우, Spring 서버를 재배포하거나 중단할 때 Redis도 같이 중단될 수 있다.
따라서 애플리케이션의 재시작과 무관하게 Redis가 계속 유지되도록 분리했다.
2. 다른 도커 컨테이너와 연결이 용이
Redis를 Docker로 띄우면, 같은 네트워크(--network)에 연결을 통해 Spring 서버 등 다른 컨테이너와 문제없이 통신할 수 있다.

1차 배포 이후 계획: Kubernetes 도입 준비

현재는 단일 EC2 인스턴스에서 개발 서버 하나로만 운영하고 있다. 하지만 장기적으로는 멀티 서버 환경에서의 확장성을 고려해야 하며,
이를 위해 Docker 오케스트레이션 기술인 Kubernetes(K8s) 학습 및 적용을 계획하고 있다.