Java 백엔드 개발자라면 한 번쯤 Elasticsearch를 붙여본 경험이 있을 거다. 다만 Spring Boot 프로젝트에서 Elasticsearch RestClient를 설정하는 순간 'Connection refused' 에러가 뜨면서 벽에 부딪히는 경우가 많다. 포트 번호가 틀렸나, 서버가 안 떠있나, 아니면 설정이 잘못됐나 하면서 무작정 인터넷에서 가져온 코드를 여기저기 붙여본다. 이번에는 이 문제를 어디서 비롯되는지, 어떻게 해결하는지 실무 기반으로 정확히 정리해서 소개하겠다.

 

1단계. Elasticsearch 연결 구조 이해하기

Elasticsearch는 기본적으로 REST API 기반이다. 클라이언트는 HTTP로 Elasticsearch 서버의 특정 포트에 연결해야 한다. Spring Boot에서는 여러 가지 클라이언트 라이브러리를 쓸 수 있는데, 가장 많이 쓰는 것은 'Elasticsearch RestClient'(저수준)와 'RestHighLevelClient'(고수준)다. 다만 RestHighLevelClient는 7.17 버전 이후로 deprecated 되었으니, 최신 버전에서는 RestClient를 써야 한다.

Connection refused 에러는 보통 다음 두 가지 원인에서 비롯된다. 첫 번째는 Elasticsearch 서버 자체가 안 떠있거나 포트가 잘못됐을 때다. 두 번째는 Spring Boot 설정에서 RestClient가 올바르게 초기화되지 않았을 때다. 이 둘을 순서대로 점검해야 한다.

 

포트 번호 확인하기

Elasticsearch 기본 포트는 9200(REST API)과 9300(클러스터 통신)이다. Spring Boot 클라이언트는 9200으로 연결해야 한다. 로컬 머신이거나 Docker로 떠있다면, 먼저 이 명령어로 Elasticsearch가 제대로 응답하는지 확인하자.

curl -X GET http://localhost:9200

성공하면 클러스터 정보가 JSON으로 돌아온다. 만약 'Connection refused'가 뜬다면 서버가 안 떠있거나 포트가 다르다는 뜻이다.

 

2단계. Spring Boot Elasticsearch 의존성 추가

먼저 pom.xml(Maven)에 정확한 버전의 라이브러리를 넣어야 한다. Spring Boot 버전과 Elasticsearch 버전의 호환성이 중요하다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <groupId>com.example</groupId>
    <artifactId>spring-boot-es</artifactId>
    <version>1.0.0</version>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.5</version>
    </parent>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- Elasticsearch 8.x 이상용 -->
        <dependency>
            <groupId>co.elastic.clients</groupId>
            <artifactId>elasticsearch-java</artifactId>
            <version>8.10.0</version>
        </dependency>
        
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>
</project>

Spring Boot 3.1.5는 Elasticsearch 8.10.0과 호환된다. 다른 버전 조합을 쓴다면 반드시 공식 호환성 문서를 확인하자.

 

3단계. RestClient 설정 클래스 작성

이제 Spring의 @Configuration으로 RestClient 빈을 만든다. 이 부분에서 설정 실수가 가장 많이 일어난다.

✗ 잘못된 코드
@Configuration
public class ElasticsearchConfig {
    
    @Bean
    public ElasticsearchClient elasticsearchClient() {
        // 기본값으로만 설정해서 타임아웃 없고 인증도 없음
        RestClient restClient = RestClient.builder(
                new HttpHost("localhost", 9200, "http")
        ).build();
        
        ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
        return new ElasticsearchClient(transport);
    }
}

이 코드의 문제점은 무엇인가. 첫째, 타임아웃 설정이 없어서 네트워크 지연이 발생하면 무한 대기한다. 둘째, 연결 풀이나 재시도 로직이 없다. 셋째, HttpHost만 하드코딩되어 있어서 환경별로 설정을 바꾸기 어렵다.

✓ 올바른 코드
@Configuration
public class ElasticsearchConfig {
    
    @Value("${elasticsearch.host:localhost}")
    private String esHost;
    
    @Value("${elasticsearch.port:9200}")
    private int esPort;
    
    @Value("${elasticsearch.username:}")
    private String esUsername;
    
    @Value("${elasticsearch.password:}")
    private String esPassword;
    
    @Bean
    public ElasticsearchClient elasticsearchClient() {
        // HttpHost 생성
        HttpHost host = new HttpHost(esHost, esPort, "http");
        
        // RestClient 빌더 설정
        RestClientBuilder builder = RestClient.builder(host);
        
        // 기본 인증 설정
        if (esUsername != null && !esUsername.isEmpty()) {
            builder.setHttpClientConfigCallback(httpClientBuilder -> {
                CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
                credentialsProvider.setCredentials(
                    AuthScope.ANY,
                    new UsernamePasswordCredentials(esUsername, esPassword.toCharArray())
                );
                return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
            });
        }
        
        // 타임아웃 설정
        builder.setRequestConfigCallback(requestConfigBuilder -> {
            requestConfigBuilder.setConnectTimeout(5000);
            requestConfigBuilder.setSocketTimeout(30000);
            return requestConfigBuilder;
        });
        
        RestClient restClient = builder.build();
        ElasticsearchTransport transport = new RestClientTransport(
            restClient,
            new JacksonJsonpMapper()
        );
        return new ElasticsearchClient(transport);
    }
}

이 코드가 올바른 이유는 다음과 같다. 첫째, application.yml에서 호스트와 포트를 읽어오므로 환경별로 쉽게 바꿀 수 있다. 둘째, 타임아웃을 명시적으로 설정했다(연결 5초, 소켓 30초). 셋째, Elasticsearch에 인증이 필요하면 기본 인증을 지원한다. 넷째, 에러 처리와 재연결 로직을 RestClient 내부에서 자동으로 처리한다.

 

4단계. 환경 설정 파일 작성

application.yml 파일에 Elasticsearch 연결 정보를 넣는다.

spring:
  application:
    name: es-app

elasticsearch:
  host: localhost
  port: 9200
  username: elastic
  password: changeme

로컬 개발 환경에서는 인증이 필요 없을 수 있다. 그 경우 username과 password를 빈 문자열로 두면 된다.

 

5단계. 실전 예제 - 문서 색인과 검색

이제 실제로 Elasticsearch에 데이터를 저장하고 검색해보자.

@Service
public class ElasticsearchService {
    
    private final ElasticsearchClient esClient;
    
    public ElasticsearchService(ElasticsearchClient esClient) {
        this.esClient = esClient;
    }
    
    // 문서 색인
    public void indexDocument(String indexName, String docId, Map<String, Object> document) throws IOException {
        esClient.index(i -> i
            .index(indexName)
            .id(docId)
            .document(document)
        );
        System.out.println("Document indexed: " + docId);
    }
    
    // 검색
    public List<Map<String, Object>> search(String indexName, String keyword) throws IOException {
        SearchResponse<Map> response = esClient.search(s -> s
            .index(indexName)
            .query(q -> q
                .match(m -> m
                    .field("title")
                    .query(keyword)
                )
            ),
            Map.class
        );
        
        List<Map<String, Object>> results = new ArrayList<>();
        for (Hit<Map> hit : response.hits().hits()) {
            results.add(hit.source());
        }
        return results;
    }
}

이 코드는 'title' 필드에서 키워드를 찾는다. Search API 응답에서 hits() 배열을 반복하면서 각 문서를 추출한다.

 

6단계. 주의사항과 흔한 실수
상황 원인 해결책
Connection refused Elasticsearch 서버가 안 떠있음 curl로 9200 포트 응답 확인
SocketTimeoutException 타임아웃 설정이 너무 짧음 setSocketTimeout() 값 증가
401 Unauthorized 인증 정보가 잘못됐거나 없음 username/password 확인 또는 빈 문자열로 설정
TooManyRequests (429) 클라이언트가 요청을 너무 빨리 보냄 요청 사이에 delay 추가 또는 배치 처리 도입
IndexNotFoundException 색인이 존재하지 않음 먼저 색인 생성 API 호출하기

 

색인 미리 생성하기

Elasticsearch에 처음 문서를 색인할 때, 색인이 없으면 자동으로 생성된다. 다만 매핑(필드 타입)을 미리 정의하려면 색인을 수동으로 생성하는 게 낫다.

@Component
public class ElasticsearchIndexInitializer {
    
    private final ElasticsearchClient esClient;
    
    public ElasticsearchIndexInitializer(ElasticsearchClient esClient) {
        this.esClient = esClient;
    }
    
    @PostConstruct
    public void initializeIndex() throws IOException {
        String indexName = "products";
        
        // 색인이 이미 있는지 확인
        boolean exists = esClient.indices().exists(e -> e.index(indexName)).value();
        if (!exists) {
            esClient.indices().create(c -> c
                .index(indexName)
                .mappings(m -> m
                    .properties("title", p -> p.text(t -> t))
                    .properties("description", p -> p.text(t -> t))
                    .properties("price", p -> p.integer(i -> i))
                )
            );
            System.out.println("Index " + indexName + " created");
        }
    }
}

@PostConstruct는 스프링이 빈을 초기화한 직후 메서드를 자동으로 호출한다. 이렇게 하면 애플리케이션 시작 시 Elasticsearch 색인이 자동으로 준비된다.

 

7단계. Docker로 Elasticsearch 로컬 테스트하기

Elasticsearch를 Docker로 띄워서 로컬에서 테스트하는 것이 가장 간단하다.

docker run -d \
  --name elasticsearch \
  -p 9200:9200 \
  -p 9300:9300 \
  -e discovery.type=single-node \
  -e xpack.security.enabled=false \
  docker.elastic.co/elasticsearch/elasticsearch:8.10.0

이 명령어는 보안을 비활성화한 싱글 노드 Elasticsearch 컨테이너를 띄운다. 로컬 테스트용이므로 프로덕션에서는 절대 쓰면 안 된다.

 

마무리

Elasticsearch 연동은 설정이 정확해야 한다. Connection refused 에러는 대부분 포트 번호, 타임아웃, 인증 정보 중 하나가 잘못됐다는 신호다. 환경 설정 파일로 연결 정보를 외부화하고, 타임아웃을 명시적으로 설정하고, 색인을 미리 생성하는 이 세 가지 습관이 모여서 안정적인 검색 시스템을 만든다. 이 글의 설정 클래스와 서비스 코드를 참고해서 프로젝트에 적용하면, Elasticsearch 연동 문제의 90%를 해결할 수 있을 것이다.