Keycloak과 Testcontainers로 Spring Boot 마이크로서비스 보안 설정하기

Keycloak과 Testcontainers로 Spring Boot 마이크로서비스 보안 설정하기

이 가이드에서는 Spring Boot로 OAuth 2.0 Resource Server를 만들고, Keycloak으로 API 엔드포인트를 보호하며, Testcontainers Keycloak 모듈로 애플리케이션을 테스트하는 방법을 배워요.

출처: 문서

본문

이 가이드를 통해 다음 내용을 배울 수 있어요.

  • Spring Boot로 OAuth 2.0 Resource Server 만들기
  • Keycloak으로 API 엔드포인트 보호하기
  • Testcontainers Keycloak 모듈로 API 테스트하기
  • Testcontainers Keycloak 모듈로 애플리케이션을 로컬에서 실행하기

사전 준비 (Prerequisites)

  • Java 17 이상
  • Maven 또는 Gradle
  • Testcontainers가 지원하는 Docker 환경

참고: Testcontainers가 처음이라면 Testcontainers 개요를 방문해 알아보는 걸 권장해요.

Spring Boot 프로젝트 만들기

Spring Initializr에서 Spring Web, Validation, JDBC API, PostgreSQL Driver, Spring Security, OAuth2 Resource Server, Testcontainers 스타터를 선택해 Spring Boot 프로젝트를 만들어요. 또는 가이드 저장소를 클론해도 돼요.

애플리케이션을 생성한 뒤, testcontainers-keycloak 커뮤니티 모듈과 REST Assured를 테스트 의존성으로 추가해요. pom.xml의 핵심 의존성은 다음과 같아요.

<properties>
    <java.version>17</java.version>
    <testcontainers.version>2.0.4</testcontainers.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>com.github.dasniko</groupId>
        <artifactId>testcontainers-keycloak</artifactId>
        <version>3.4.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

도메인 모델 만들기

도메인 객체를 나타내는 Product 레코드를 만들어요.

package com.testcontainers.products.domain;

import jakarta.validation.constraints.NotEmpty;

public record Product(Long id, @NotEmpty String title, String description) {}

리포지토리 만들기

Spring JdbcClient를 사용해 PostgreSQL 데이터베이스와 상호작용하는 ProductRepository를 구현해요.

package com.testcontainers.products.domain;

import java.util.List;

import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;

@Repository
public class ProductRepository {
    private final JdbcClient jdbcClient;

    public ProductRepository(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }

    public List<Product> getAll() {
        return jdbcClient.sql("SELECT * FROM products").query(Product.class).list();
    }

    public Product create(Product product) {
        String sql = "INSERT INTO products(title, description) VALUES (:title,:description) RETURNING id";
        KeyHolder keyHolder = new GeneratedKeyHolder();

        jdbcClient.sql(sql)
            .param("title", product.title())
            .param("description", product.description())
            .update(keyHolder);

        Long id = keyHolder.getKeyAs(Long.class);
        return new Product(id, product.title(), product.description());
    }
}

스키마 생성 스크립트 추가하기

products 테이블을 초기화할 src/main/resources/schema.sql을 만들어요.

CREATE TABLE products (
    id bigserial primary key,
    title varchar not null,
    description text
);

src/main/resources/application.properties에서 스키마 초기화를 활성화해요.

spring.sql.init.mode=always

프로덕션 애플리케이션에서는 대신 Flyway나 Liquibase 같은 데이터베이스 마이그레이션 도구를 쓰는 게 좋아요.

API 엔드포인트 구현하기

모든 제품을 가져오고 제품을 생성하는 엔드포인트가 있는 ProductController를 만들어요.

package com.testcontainers.products.api;

import com.testcontainers.products.domain.Product;
import com.testcontainers.products.domain.ProductRepository;
import jakarta.validation.Valid;
import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/products")
class ProductController {
    private final ProductRepository productRepository;

    ProductController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @GetMapping
    List<Product> getAll() {
        return productRepository.getAll();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    Product createProduct(@RequestBody @Valid Product product) {
        return productRepository.create(product);
    }
}

OAuth 2.0 보안 구성하기

JWT 토큰 기반 인증으로 API 엔드포인트를 보호하는 SecurityConfig 클래스를 만들어요.

package com.testcontainers.products.config;

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(c -> c
                .requestMatchers(HttpMethod.GET, "/api/products").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/products").authenticated()
                .anyRequest().authenticated())
            .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .cors(CorsConfigurer::disable)
            .csrf(CsrfConfigurer::disable)
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
        return http.build();
    }
}

이 구성은 다음을 수행해요.

  • GET /api/products에 대한 미인증 접근을 허용해요.
  • POST /api/products와 그 외 모든 엔드포인트에 인증을 요구해요.
  • JWT 토큰 기반 인증으로 OAuth 2.0 Resource Server를 구성해요.
  • 무상태(stateless) API이므로 CORS와 CSRF를 비활성화해요.

application.properties에 JWT 발급자 URI를 추가해요.

spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9090/realms/keycloaktcdemo

Keycloak realm 구성 내보내기

테스트를 작성하기 전에, 테스트 환경이 자동으로 가져올 수 있도록 Keycloak realm 구성을 내보내요. 임시 Keycloak 인스턴스를 시작해요.

$ docker run -p 9090:8080 \
  -e KEYCLOAK_ADMIN=admin \
  -e KEYCLOAK_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:25 start-dev

http://localhost:9090을 열고 Admin Console에 admin/admin으로 로그인해요. 그다음 realm을 설정해요.

  • 왼쪽 위에서 realm 드롭다운을 선택하고 keycloaktcdemo라는 이름의 realm을 만들어요.
  • keycloaktcdemo realm 아래에서 다음 설정으로 클라이언트를 만들어요.
    • Client ID: product-service
    • Client Authentication: On
    • Authentication flow: Service accounts roles만 선택

Client details 화면의 Credentials 탭으로 가서 Client secret 값을 복사해요.

realm 구성을 내보내요.

$ docker ps  # keycloak container id 복사
$ docker exec -it <container-id> /bin/bash
$ /opt/keycloak/bin/kc.sh export --dir /opt/keycloak/data/import --realm keycloaktcdemo
$ exit
$ docker cp <container-id>:/opt/keycloak/data/import/keycloaktcdemo-realm.json keycloaktcdemo-realm.json

내보낸 keycloaktcdemo-realm.json 파일을 src/test/resources에 복사해요.

Testcontainers로 테스트 작성하기

보호된 API 엔드포인트를 테스트하려면 실행 중인 Keycloak 인스턴스와 PostgreSQL 데이터베이스, 그리고 시작된 Spring 컨텍스트가 필요해요. Testcontainers가 두 서비스를 Docker 컨테이너에서 띄우고, 동적 속성 등록을 통해 Spring에 연결해요.

테스트 컨테이너 구성하기

Spring Boot의 Testcontainers 지원을 사용하면 컨테이너를 빈(bean)으로 선언할 수 있어요. Keycloak의 경우 @ServiceConnection을 사용할 수 없으므로 DynamicPropertyRegistry로 JWT 발급자 URI를 동적으로 설정해요.

src/test/java 아래에 ContainersConfig.java를 만들어요.

package com.testcontainers.products;

import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.testcontainers.postgresql.PostgreSQLContainer;

@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfig {
    static String POSTGRES_IMAGE = "postgres:16-alpine";
    static String KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:25.0";
    static String realmImportFile = "/keycloaktcdemo-realm.json";
    static String realmName = "keycloaktcdemo";

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgres() {
        return new PostgreSQLContainer(POSTGRES_IMAGE);
    }

    @Bean
    KeycloakContainer keycloak(DynamicPropertyRegistry registry) {
        var keycloak = new KeycloakContainer(KEYCLOAK_IMAGE)
            .withRealmImportFile(realmImportFile);

        registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
                () -> keycloak.getAuthServerUrl() + "/realms/" + realmName);

        return keycloak;
    }
}

이 구성은 다음을 수행해요.

  • @ServiceConnection이 달린 PostgreSQLContainer 빈을 선언해서 PostgreSQL 컨테이너를 시작하고 데이터소스 속성을 자동 등록해요.
  • quay.io/keycloak/keycloak:25.0 이미지를 사용하는 KeycloakContainer 빈을 선언하고, realm 구성 파일을 가져오며, Keycloak 컨테이너의 auth 서버 URL에서 JWT 발급자 URI를 동적으로 등록해요.

테스트 작성하기

ProductControllerTests.java를 만들어요.

package com.testcontainers.products.api;

import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when;
import static java.util.Collections.singletonList;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.testcontainers.products.ContainersConfig;
import io.restassured.RestAssured;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(ContainersConfig.class)
class ProductControllerTests {
    static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
    static final String CLIENT_ID = "product-service";
    static final String CLIENT_SECRET = "jTJJqdzeCSt3DmypfHZa42vX8U9rQKZ9";

    @LocalServerPort
    private int port;

    @Autowired
    OAuth2ResourceServerProperties oAuth2ResourceServerProperties;

    @BeforeEach
    void setup() {
        RestAssured.port = port;
    }

    @Test
    void shouldGetProductsWithoutAuthToken() {
        when()
            .get("/api/products")
            .then()
            .statusCode(200);
    }

    @Test
    void shouldGetUnauthorizedWhenCreateProductWithoutAuthToken() {
        given()
            .contentType("application/json")
            .body("""
                {
                  "title": "New Product",
                  "description": "Brand New Product"
                }
                """)
            .when()
            .post("/api/products")
            .then()
            .statusCode(401);
    }

    @Test
    void shouldCreateProductWithAuthToken() {
        String token = getToken();
        given()
            .header("Authorization", "Bearer " + token)
            .contentType("application/json")
            .body("""
                {
                  "title": "New Product",
                  "description": "Brand New Product"
                }
                """)
            .when()
            .post("/api/products")
            .then()
            .statusCode(201);
    }

    private String getToken() {
        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
        map.put("grant_type", singletonList(GRANT_TYPE_CLIENT_CREDENTIALS));
        map.put("client_id", singletonList(CLIENT_ID));
        map.put("client_secret", singletonList(CLIENT_SECRET));

        String authServerUrl =
            oAuth2ResourceServerProperties.getJwt().getIssuerUri() + "/protocol/openid-connect/token";

        var request = new HttpEntity<>(map, httpHeaders);
        KeyCloakToken token = restTemplate.postForObject(authServerUrl, request, KeyCloakToken.class);

        assert token != null;
        return token.accessToken();
    }

    record KeyCloakToken(@JsonProperty("access_token") String accessToken) {}
}

테스트가 다루는 내용을 살펴보면,

  • shouldGetProductsWithoutAuthToken()은 Authorization 헤더 없이 GET /api/products를 호출해요. 이 엔드포인트는 미인증 접근을 허용하도록 구성됐으므로 응답 상태 코드가 200이에요.
  • shouldGetUnauthorizedWhenCreateProductWithoutAuthToken()은 Authorization 헤더 없이 보호된 POST /api/products 엔드포인트를 호출하고 응답 상태 코드가 401(Unauthorized)인지 단언해요.
  • shouldCreateProductWithAuthToken()은 먼저 Client Credentials 흐름으로 access_token을 얻어요. 그런 다음 POST /api/products를 호출할 때 그 토큰을 Authorization 헤더의 Bearer 토큰으로 포함시키고 응답 상태 코드가 201(Created)인지 단언해요.
  • getToken() 헬퍼 메서드는 내보낸 realm에서 설정된 클라이언트 ID와 클라이언트 시크릿으로 Keycloak 토큰 엔드포인트에서 액세스 토큰을 요청해요.

로컬 개발에 Testcontainers 사용하기

Spring Boot의 Testcontainers 지원은 로컬 개발에도 동작해요. src/test/java 아래에 TestApplication.java를 만들어요.

package com.testcontainers.products;

import org.springframework.boot.SpringApplication;

public class TestApplication {
    public static void main(String[] args) {
        SpringApplication.from(Application::main)
            .with(ContainersConfig.class)
            .run(args);
    }
}

메인 Application.java 대신 IDE에서 TestApplication.java를 실행해요. ContainersConfig에 정의된 컨테이너를 시작하고, 동적으로 등록된 속성을 사용하도록 애플리케이션을 구성해요. 그래서 PostgreSQL과 Keycloak을 수동으로 설치하거나 구성할 필요가 없어요.

테스트 실행과 다음 단계

테스트를 실행해요.

$ ./mvnw test

또는 Gradle로,

$ ./gradlew test

Keycloak과 PostgreSQL Docker 컨테이너가 realm 설정이 가져와진 채 시작되고 테스트가 통과하는 걸 볼 수 있어요. 테스트가 끝나면 컨테이너는 자동으로 중지되고 제거돼요.

요약 (Summary)

Testcontainers Keycloak 모듈을 사용하면 목(mock) 대신 실제 Keycloak 서버로 애플리케이션을 개발하고 테스트할 수 있어요. 프로덕션 설정을 그대로 반영한 실제 OAuth 2.0 제공자에 대해 테스트하면 보안 구성과 토큰 기반 인증 흐름에 더 큰 확신을 얻을 수 있어요.

Testcontainers에 대해 더 알아보고 싶다면 Testcontainers 개요를 방문해요.

더 읽어보기 (Further reading)

  • Java Spring Boot 프로젝트에서 Testcontainers 시작하기
  • Testcontainers Keycloak 모듈
  • testcontainers-keycloak GitHub 저장소
  • Spring Boot OAuth 2.0 Resource Server

더 알아보기 (Learn more)