Dynamic Mapping이란 무엇인가
Elasticsearch는 인덱스가 없는 상태에서 문서가 들어오면 알아서 인덱스를 만들고 타입을 추론한다.
이것이 Dynamic Mapping이다.
https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dynamic
dynamic | Elasticsearch Reference
When you index a document containing a new field, Elasticsearch adds the field dynamically to a document or to inner objects within a document. The following...
www.elastic.co
처음 들어온 데이터의 타입으로 매핑이 고착되고, 한번 생성된 매핑은 변경이 불가능하다.
예를 들어 이런 문서가 들어왔다고 하면:
{
"name": "봄날의 꽃꽂이 클래스",
"price": 35000
}
ES가 자동 추론한 매핑은 이렇게 된다:
"name": { "type": "text" },
"price": { "type": "long" }
문제는 ES 가 추론해서 만든 매핑에는 name 필드에 nori analyzer가 없다는 것이었다.
JabaClass 에서 겪은 문제
인프라 서버 비용 관리를 위해 매일 서버를 껐다가 켜야 했는데, Product 서버와 ES 서버를 껐다가 켰을 때 색인이 안 되는 현상이 반복됐다.
핵심은 다음과 같았다. -> ES는 인덱스와 매핑 정보를 디스크에 영속화(저장)한다.
구체적으로는 우리 프로젝트가 AWS EC2 위에서 k3s로 운영되고 있는데, ES 파드가 재시작되더라도 EC2 EBS 볼륨에 매핑 정보가 그대로 남아있다. 즉, 파드를 껐다 켜는 것은 프로세스만 재시작하는 것이지 디스크를 초기화하는 것이 아니다.
즉, ES를 껐다 켜도 잘못 만들어진 매핑이 그대로 살아 돌아온다. "요섭님 이거 또 안돼요..." 가 반복된 이유가 이것 때문이었다..
ElasticsearchIndexInitializer 도입
그래서 ApplicationRunner를 구현해서 서버 시작 시점에 인덱스를 초기화하는 클래스를 만들었다.
@Slf4j
@Component
@RequiredArgsConstructor
public class ElasticsearchIndexInitializer implements ApplicationRunner {
private final ElasticsearchOperations elasticsearchOperations;
@Value("${spring.jpa.hibernate.ddl-auto:none}")
private String ddlAuto;
@Override
public void run(ApplicationArguments args) {
IndexOperations indexOps = elasticsearchOperations.indexOps(ProductDocument.class);
if ("create".equals(ddlAuto) || "create-drop".equals(ddlAuto)) {
if (indexOps.exists()) {
indexOps.delete();
log.info("products 인덱스 삭제 (DB 초기화 감지)");
}
indexOps.createWithMapping();
log.info("products 인덱스 재생성 완료");
} else if (!indexOps.exists()) {
indexOps.createWithMapping();
log.info("products 인덱스 생성 완료");
}
}
}
도입 의도는 맞았다. JPA ddl-auto가 create일 때 ES 인덱스도 함께 초기화하고, 없으면 명시적 매핑으로 생성하는 구조다.
그런데 이 코드에도 문제가 있었다.
이 코드의 문제점
문제 1: JPA 설정으로 ES를 제어하고 있다
@Value("${spring.jpa.hibernate.ddl-auto:none}")
private String ddlAuto;
spring.jpa.hibernate.ddl-auto는 PostgreSQL 스키마 초기화 설정이다. 사실 ES와 아무 관계가 없다.
개발 환경에서 ddl-auto: none이나 validate로 설정하면 create/create-drop 분기를 타지 않는다.
결국 잘못된 매핑이 남아있어도 아무것도 하지 않는다.
반대로 ddl-auto: create인 상황에서 ES 인덱스는 멀쩡한데 DB만 초기화하고 싶은 경우에도 ES 인덱스까지 강제로 날아간다.
두 저장소의 초기화 생명주기가 완전히 다름에도 불구하고 하나의 설정값으로 묶여버린 것이다.
문제 2: Kafka consumer 시작 순서가 보장되지 않는다
Spring Context 로딩이 완료되면 ApplicationRunner와 Kafka consumer가 거의 동시에 시작된다.
어느 쪽이 먼저 실행될지는 보장되지 않는다. 그래서 두 가지 케이스가 존재한다.
Case 1 (정상): ApplicationRunner.run()이 먼저 실행되어 인덱스가 생성된 뒤 Kafka consumer가 메시지를 consume하면 정상적으로 색인된다.
Case 2 (문제 발생): Kafka consumer가 먼저 메시지를 consume하면, 그 시점에 아직 인덱스가 없다. 그래서 ES는 Dynamic Mapping으로 인덱스를 자동 생성해버리고, nori analyzer가 없는 잘못된 매핑이 발생한다.
이것이 바로 Race Condition이다.
서버를 껐다가 켤 때마다 이 타이밍 경쟁이 매번 새로 벌어지기 때문에, 어떤 날은 되고 어떤 날은 안 되는 현상이 반복됐다.
ApplicationRunner는 Kafka consumer보다 먼저 실행된다는 보장이 없다.
인덱스를 만들기 전에 메시지가 consume되면 같은 문제가 반복될 수 있다.
개선 방향
두 문제의 해결 방향은 ES 초기화를 ES 전용으로 분리하고, Kafka consumer보다 반드시 먼저 실행되도록 순서를 보장하는 것이다.
1. ES 전용 설정으로 분리
// 기존: JPA 설정에 의존
@Value("${spring.jpa.hibernate.ddl-auto:none}")
private String ddlAuto;
// 개선: ES 전용 설정으로 분리
@Value("${elasticsearch.index.recreate-on-startup:false}")
private boolean recreateOnStartup;
JPA 설정에 의존하지 않고 ES 전용 프로퍼티로 제어한다.
2. 매핑 변경 감지 추가
Map<String, Object> currentMapping = indexOps.getMapping();
Map<String, Object> expectedMapping = indexOps.createMapping();
if (!currentMapping.equals(expectedMapping)) {
indexOps.delete();
indexOps.createWithMapping();
log.warn("매핑 변경 감지 → 인덱스 재생성");
}
단, 재생성 시 기존 색인 데이터가 전부 유실되므로 Kafka로 재동기화 트리거를 함께 발행하는 구조가 필요하다.
3. 초기화 시점 변경
ApplicationRunner는 Spring Context 로딩이 완전히 완료된 후 실행된다.
문제는 Kafka consumer도 같은 시점에 시작된다는 것이다.
@PostConstruct는 Spring Context 로딩 중, 해당 Bean의 의존성 주입이 완료되는 시점에 실행된다.
Kafka consumer는 Context 로딩이 끝난 후에 시작되므로, @PostConstruct로 변경하면 인덱스 초기화가 Kafka consumer 시작보다 반드시 먼저 완료된다.
// 기존
implements ApplicationRunner
// 개선
@PostConstruct
public void init() {
// 인덱스 초기화 로직
}
결론
Dynamic Mapping은 편리하지만, 한 번 잘못 고착된 매핑은 변경이 불가능하고 ES 디스크에 영속화된다.
nori analyzer처럼 커스텀 세팅이 필요한 경우엔 반드시 명시적 매핑을 먼저 생성하고, 그 초기화 로직은 ES 전용으로 분리해서 Kafka consumer보다 먼저 실행되도록 순서를 보장해야 한다.
결론은 걍 인덱스 밀고 새로 만드니까 정상적으로 작동 했다 ㅋㅋ