Docker로 서비스간 통신을 해보자 - 기초

StrayCat·2026년 3월 14일

본 포스팅은 Docker 실습을 본격적으로 시작하기 전, 우리가 무엇을 만들 것인지와 그 애플리케이션을 어떻게 구성하는지를 정리한 글이다.

챕터 1-4에서는 실습의 전체 그림을 보여주고, 챕터 1-5에서는 그 그림을 실제 Spring Boot 코드로 구현한다. 두 챕터를 합쳐서 정리하는 것이 흐름 이해에 훨씬 도움이 될 것 같아 하나의 글로 묶었다.


1. 실습 개요 — 우리가 만들 것은?

실습의 목표는 단순하다.

2개의 Spring Boot 애플리케이션(컨테이너)을 만들고, 하나가 다른 하나를 호출하게 만든다.

사용자 → service-a (포트: 18080) → service-b (포트: 18081)

최종적으로 사용자가 http://localhost:18080/hi에 접속하면 아래와 같은 응답을 받는다.

service-a: hi ###### service-b: hello

뒤에 붙는 "service-b: hello" 부분이 service-b가 내려준 응답이다. service-a가 service-b를 HTTP로 호출해서 받아온 값을 붙여서 응답한다는 것이 핵심이다.

왜 이런 구조인가?

마이크로서비스 아키텍처(MSA, Microservices Architecture)에서는 여러 독립된 서비스들이 서로 통신하며 하나의 기능을 완성시킨다. 이 실습은 그 가장 기초적인 형태를 보여준다. 이후 챕터에서 각 서비스를 Docker 컨테이너로 감싸고, Docker Compose로 묶고, 최종적으로 AWS ECS에 배포하는 과정으로 이어진다.


2. 프로젝트 생성

Spring Initializr에서 두 개의 프로젝트를 생성한다.

항목service-aservice-b
Spring Boot3.x3.x
LanguageJavaJava
필수 의존성Spring Web, OpenFeign, LombokSpring Web
포트1808018081

OpenFeign은 service-a에서 service-b를 호출하기 위해 필요하다. service-b는 단순히 요청을 받는 쪽이라 필요 없다.


3. service-a 구현

service-a는 세 가지 파일로 구성된다.

3.1 AApplication.java — 진입점

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

// @EnableFeignClients: 이 애노테이션이 없으면 FeignClient가 스프링 빈으로 등록되지 않아 동작하지 않는다
// 반드시 메인 애플리케이션 클래스에 선언해야 한다
@EnableFeignClients
@SpringBootApplication
public class AApplication {
    public static void main(String[] args) {
        SpringApplication.run(AApplication.class, args);
    }
}

포인트: @EnableFeignClients를 깜빡하면 BServiceClient를 주입받을 때 빈을 찾지 못해 애플리케이션이 아예 뜨지 않는다. 자주 실수하는 부분이니 기억해두자.


3.2 BServiceClient.java — Feign 클라이언트 인터페이스

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;

// @FeignClient: 이 인터페이스가 외부 서비스를 호출하는 HTTP 클라이언트임을 선언한다
// name: 클라이언트의 이름 (서비스 디스커버리 사용 시 서비스 이름으로 사용됨)
// url: 실제로 요청을 보낼 base URL. ${} 표기로 properties에서 값을 읽어온다
@FeignClient(name = "service-b", url = "${service.b.url}")
public interface BServiceClient {

    // service-b의 /hello 엔드포인트를 GET으로 호출하는 메서드 선언
    // 실제 구현체는 OpenFeign이 런타임에 자동으로 만들어준다
    @GetMapping("/hello")
    String getHello();
}

포인트: 인터페이스만 선언하면 된다. 구현 코드를 직접 작성할 필요가 없다. OpenFeign이 이 인터페이스를 보고 HTTP 요청 코드를 자동으로 생성해준다. 이것이 선언형(Declarative) REST Client의 핵심이다.


3.3 AController.java — 컨트롤러

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor // Lombok: final 필드를 생성자 주입으로 처리
public class AController {

    // BServiceClient를 생성자 주입. 실제 구현체는 OpenFeign이 주입해준다
    private final BServiceClient bServiceClient;

    @GetMapping("/hi")
    public String hello() {
        // service-b의 /hello를 호출하고 응답값을 받아온다
        String hello = bServiceClient.getHello();

        // service-a의 메시지와 service-b의 응답을 합쳐서 반환
        return "service-a: hi ###### service-b: " + hello;
    }
}

3.4 application.properties

# 스프링 애플리케이션 이름 설정 (로그, 모니터링 등에서 서비스를 구분할 때 사용)
spring.application.name=service-a

# 서버 포트 지정 (기본값 8080 대신 18080 사용)
server.port=18080

# BServiceClient의 url에서 참조하는 service-b의 base URL
# 로컬 환경에서는 localhost:18081, Docker 환경에서는 컨테이너 이름으로 바뀔 예정
service.b.url=http://localhost:18081

포인트: service.b.url을 하드코딩하지 않고 properties로 분리한 이유가 있다. 나중에 Docker 환경에서는 localhost가 아닌 컨테이너 이름(예: http://service-b:18081)으로 바꿔야 하는데, 이렇게 분리해두면 코드 수정 없이 설정 파일만 바꾸면 된다.


4. service-b 구현

service-b는 훨씬 단순하다. 그냥 "hello"를 반환하는 컨트롤러 하나가 전부다.

4.1 BController.java

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BController {

    // service-a가 OpenFeign을 통해 이 엔드포인트를 호출한다
    @GetMapping("/hello")
    public String hello() {
        return "hello"; // 단순하게 "hello" 문자열 반환
    }
}

4.2 application.properties

spring.application.name=service-b

# service-a와 포트가 겹치지 않도록 18081로 설정
server.port=18081

5. 실행 및 확인

두 애플리케이션을 모두 실행한 뒤 브라우저나 curl로 접속한다.

curl http://localhost:18080/hi
# 응답: service-a: hi ###### service-b: hello

이 응답이 나오면 service-a → service-b 호출이 성공적으로 이루어진 것이다.


6. 전체 흐름 정리

[사용자]
    |
    | GET /hi
    ↓
[service-a : 18080]
    |
    | OpenFeign → GET /hello
    ↓
[service-b : 18081]
    |
    | return "hello"
    ↑
[service-a]
    |
    | return "service-a: hi ###### service-b: hello"
    ↑
[사용자]

7. OpenFeign 조금 더 들여다보기

왜 OpenFeign인가?

서비스 간 HTTP 통신 방법은 여러 가지다. Spring에서 대표적으로 사용되는 방법을 비교하면 다음과 같다.

RestTemplateWebClientOpenFeign
방식동기 (Blocking)비동기 (Non-blocking)동기 (Blocking)
코드량많음보통적음 (인터페이스만)
직관성보통복잡높음
적합한 상황레거시반응형(Reactive) 아키텍처MSA 서비스 간 통신

참고: Spring Boot 3.x에서 RestTemplate은 공식적으로 deprecated 권고 상태다. 신규 프로젝트라면 OpenFeign이나 WebClient를 선택하는 것이 적절하다.


OpenFeign은 선언형(Declarative) REST 클라이언트로, 인터페이스와 애노테이션만으로 정의하면 Spring Boot가 구현체를 자동으로 생성해준다. 덕분에 HTTP 요청 코드를 손으로 작성하지 않아도 된다.

OpenFeign은 보일러플레이트 코드 감소, 내장 로드밸런싱 지원, Spring Cloud와의 자연스러운 통합, 서킷 브레이커(Circuit Breaker, 장애 전파 차단 패턴) 연동이라는 강점을 가진다.


8. 추가 내용

8.1 URL 하드코딩의 한계와 서비스 디스커버리

이번 실습에서는 service.b.url=http://localhost:18081처럼 URL을 직접 지정했다. 이는 학습 목적으로는 문제없지만, 실제 개발 환경에서는 서비스 인스턴스가 여러 개일 수 있고 IP/포트가 동적으로 변할 수 있다.

Eureka Server(서비스 디스커버리)와 함께 사용하면 하드코딩된 URL 없이 서비스 이름만으로 동적 통신이 가능하다.

// URL 하드코딩 없이 서비스 이름만으로 통신 (Eureka 사용 시)
@FeignClient(name = "service-b")
public interface BServiceClient {
    @GetMapping("/hello")
    String getHello();
}

이 강의에서는 나중에 Docker와 AWS ECS를 다루기 때문에, 이 단계에서는 URL 직접 지정 방식으로 진행한다. 충분히 합리적인 선택이다.


8.2 OpenFeign vs WebClient — 어느 쪽을 선택할까?

전통적인 MSA 시스템에서 Spring Cloud를 사용한다면 OpenFeign이 명확한 선택지다. 반응형 프로그래밍이나 수천 건의 동시 연결을 처리해야 한다면 WebClient가 더 적합하다.

현재 학습 중인 Docker + ECS 배포 실습 수준에서는 OpenFeign이 충분하며, 오히려 코드가 단순해서 핵심에 집중할 수 있다.


8.3 실제 개발 환경에서의 추가 고려 사항

실제 개발 환경에서는 Feign Client에 다음 요소들을 함께 고려하는 경우가 많다.

  • 타임아웃 설정: 응답이 오래 걸리는 서비스를 무한정 기다리지 않도록
  • 재시도(Retry) 설정: 일시적 네트워크 오류에 대응
  • 서킷 브레이커(Resilience4j): 특정 서비스가 연속으로 실패할 때 호출 자체를 차단해 장애 전파 방지
# application.yml 예시 (참고용)
feign:
  client:
    config:
      service-b:           # FeignClient name과 일치해야 한다
        connect-timeout: 3000   # 연결 타임아웃 (ms)
        read-timeout: 5000      # 응답 타임아웃 (ms)

지금 단계에서 전부 구현할 필요는 없다. 하지만 이런 설정이 왜 필요한지 머릿속에 넣어두면 나중에 훨씬 빠르게 이해할 수 있다.


정리

챕터핵심 내용
1-4service-a → service-b를 호출하는 2-서비스 구조 이해
1-5OpenFeign으로 선언형 HTTP 클라이언트 구현, properties로 URL 분리

이 실습에서 가장 중요한 것은 "코드를 어떻게 쓰느냐"보다 "왜 이런 구조로 나누느냐"를 이해하는 것이다. 이후 챕터에서 이 두 애플리케이션을 Docker 컨테이너로 감싸고, Docker Compose로 연결하는 과정이 이어진다. 지금 이 구조를 잘 이해해두면 그 과정이 훨씬 자연스럽게 느껴질 것이다.


참고 자료

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글