본문 바로가기

[LG CNS] MSA 6:: RabbitMQ || 통신

@Salieri2026. 10. 19. 08:51

아침에 엔드포인트를 한 바퀴 돌려 보는 것으로 시작했다. 회원가입, 로그인, 상품 목록, 주문까지 전부 잘 됐다.


핵심 개념

동기와 비동기는 일관성의 문제

동기 비동기

수단 HTTP, REST API 메시지 브로커 (Kafka 예정)
기다리나 응답이 올 때까지 기다림 던지고 다음 일
일관성 강력한 일관성 최종적(결과적) 일관성
오늘 예 user → order 주문 내역 조회 order → catalog 재고 차감 (내일쯤)
강사님 예시가 은행 잔고였다. 만 원에서 3천 원을 이체하는데 비동기로 던져 놓고 바로 다음 작업을 하면, 아
직 만 원으로 보이는 상태에서 또 돈을 뺄 수 있다. 그래서 금융 거래는 동기다.

재고 차감은 반대다. 주문이 몰리면 메시지가 밀리고, 주문 직후에 재고를 보면 숫자가 안 맞을 수 있다. 그래도 시간이 지나면 맞는다. 그 잠깐을 허용하는 대신 빨리 처리하는 게 최종적 일관성이다.

"동기가 좋다, 비동기가 좋다가 아니라 기능에 따라 고른다." 6강 Bus 에서 들은 "ack 를 기다리지 않아서 빠르다" 가 여기서 반대편 설명이 됐다.

같은 기능을 네 번

# 방식 주소 실패하면

1 RestTemplate 게이트웨이 IP:포트 (Config) 500
2 RestTemplate + @LoadBalanced 유레카 서비스 이름 500
3 OpenFeign @FeignClient(name) 500, try-catch 로 200
4 OpenFeign + ErrorDecoder 같음 404 + 설정 메시지

RestTemplate 의 exchange

String orderUrl = String.format(env.getProperty("order-service.url"), userId);
ResponseEntity<List<ResponseOrder>> orderListResponse =
        restTemplate.exchange(orderUrl, HttpMethod.GET, null,
            new ParameterizedTypeReference<List<ResponseOrder>>() {});
List<ResponseOrder> orderList = orderListResponse.getBody();

인자 네 개는 URL, 메서드, 요청 바디(없어서 null), 응답 타입이다. userId 는 이미 경로에 들어 있으니 바디로 보낼 게 없다.

마지막 인자가 제일 낯설었다. List<ResponseOrder>.class 라고 쓰면 될 것 같은데 자바는 제네릭 타입 정보를 컴파일 후에 지운다. 그래서 끝에 {} 를 붙여 익명 클래스를 하나 만들고, 그 클래스의 부모 타입 정보에 List<ResponseOrder> 를 남겨 두는 요령을 쓴다. 이건 수업에서 말로 설명된 건 아니고 내가 찾아본 부분이다.

@LoadBalanced 와 DNS

RestTemplate 빈에 @LoadBalanced 하나를 붙이고, Config 저장소의 URL 을 이렇게 바꿨다.

order-service:
  url: http://order-service/order-service/%s/orders

order-service 가 두 번 나와서 처음엔 오타인 줄 알았다. 앞은 호스트 자리에 들어간 유레카 서비스 이름이고, 뒤는 OrderController 의 @RequestMapping("/order-service") 경로다. 게이트웨이를 안 거치고 직접 부르니 그 경로까지 써 줘야 한다.

 

어노테이션이 없으면 order-service 를 인터넷 도메인으로 보고 DNS 에 물어본다. 당연히 못 찾고 UnknownHostException 이 난다. 같은 문자열이 어노테이션 하나로 "유레카 이름" 이 되기도 하고 "도메인" 이 되기도 한다는 게 재밌었다.

OpenFeign

@FeignClient(name = "order-service")
public interface OrderServiceClient {
    @GetMapping("/order-service/{userId}/orders")
    List<ResponseOrder> getOrders(@PathVariable("userId") String userId);
}
List<ResponseOrder> orderList = orderServiceClient.getOrders(userId);

RestTemplate 에서 여러 줄이던 게 한 줄이 됐다. URL 조립, 메서드 지정, 응답 타입, getBody() 가 전부 인터페이스 선언으로 들어갔다. 구현 클래스는 내가 안 만든다. 강사님 표현으로 "훨씬 더 자바스러운 방식" 이다.

 

설정은 세 가지였다. spring-cloud-starter-openfeign 의존성, 기동 클래스에 @EnableFeignClients, client 패키지에 인터페이스. 전부 요청을 보내는 쪽(user-service)에 한다. 그리고 호출하는 서비스가 여럿이면 클라이언트 인터페이스도 서비스마다 하나씩 만드는 걸 권장한다고 했다.


새롭게 알게 된 점

404 를 보냈는데 500 이 왔다

Feign 경로를 일부러 /orders-ng 로 바꿨다. order-service 에는 그런 경로가 없으니 404 가 올 거라고 생각했다. 실제로는 500 이 왔다.

order-service: 404
user-service:  Feign 이 2xx 가 아니라서 FeignException$NotFound 를 throw
               아무도 안 잡음 → DispatcherServlet 밖까지 전파
               Tomcat: 상태를 모르는 예외라 500

trace 를 열어 보면 안에 FeignException$NotFound 와 404 가 그대로 있다. 원인 정보는 있는데 겉으로는 500 이라는 뭉뚱그린 숫자로 나간다. 상대가 404 를 줬으면 나도 404 를 주는 게 맞는 전달이다.

장애를 격리한다는 게 코드로는 이런 거였다

try-catch 로 감싸니 200 이 왔다. 사용자 정보는 나오고 주문 내역만 빠졌다.

강사님이 여기서 한 말이 오늘 제일 오래 남았다.

"주문 내역 못 가져온다라고 해서 사용자 정보까지 안 보여지면은 이거는 마이크로서비스의 장점이 날아간 거죠."

1강에서 MSA 장점으로 "장애 격리" 를 외웠는데, 그게 결국 남의 서비스가 실패했을 때 내 응답을 어디까지 살릴지 정하는 코드였다. try-catch 한 번이 그 경계선이었다.

ErrorDecoder 는 실패를 번역한다

try-catch 는 호출하는 곳마다 넣어야 한다. ErrorDecoder 는 한곳에서 처리하고, 상대의 오류 응답을 어떤 예외로 바꿀지 고를 수 있다.

@Component
public class FeignErrorDecoder implements ErrorDecoder {
    Environment env;
    public FeignErrorDecoder(Environment env) { this.env = env; }

    @Override
    public Exception decode(String methodKey, Response response) {
        switch (response.status()) {
            case 400:
                break;
            case 404:
                if (methodKey.contains("getOrders")) {
                    return new ResponseStatusException(HttpStatus.valueOf(response.status()),
                            env.getProperty("order-service.exception.order-is-empty"));
                }
                break;
            default:
                return new Exception(response.reason());
        }
        return null;
    }
}

methodKey 에는 OrderServiceClient#getOrders(String) 처럼 클래스와 메서드 이름이 들어 있다. 그래서 "404 중에서도 주문 조회에서 난 것만" 골라낼 수 있다. 클라이언트에는 @FeignClient(name = "order-service", configuration = FeignErrorDecoder.class) 로 지정했다.

 

결과는 404 와 내가 설정한 메시지. 메시지는 하드코딩하지 않고 Config 저장소 user-service.yml 에 넣었다. 문구를 바꿔도 코드를 안 건드린다. 5강부터 이어진 "설정은 밖으로" 가 에러 메시지까지 왔다.


헷갈렸던 점

로그 레벨이 두 개다

Feign 로그를 보려면 두 군데를 건드려야 했다.

logging:
  level:
    com.example.userservice.client: DEBUG
@Bean
public Logger.Level feignLoggerLevel() {
    return Logger.Level.FULL;   // feign.Logger
}

처음엔 같은 걸 두 번 하는 줄 알았다. 아니었다. yml 쪽은 "어느 레벨 이상을 출력할지" 이고 기본이 INFO 다. Feign 쪽은 "로그 한 줄에 무엇을 담을지" 이고 기본이 NONE 이다. Feign 로그는 DEBUG 레벨로 나오니 yml 을 DEBUG 로 내리지 않으면 FULL 로 해 봐야 안 보인다.

 

FULL 이면 메서드, URL, 헤더, 바디, 걸린 시간까지 다 찍힌다. 헤더에 Authorization 도 그대로 찍힐 테니 운영에서는 쓰면 안 되겠다. 강사님도 운영은 INFO 로 두라고 했다. import 할 때 다른 Logger 를 가져오면 안 되고 feign.Logger 여야 한다는 것도 주의.

슬라이드와 코드의 키 이름

슬라이드는 order_service.url, 강사님 저장소 코드는 order-service.url 이었다. Environment.getProperty 는 @ConfigurationProperties 처럼 밑줄과 하이픈을 너그럽게 맞춰 주지 않는다고 알고 있어서, yml 키와 문자열을 한 글자도 다르지 않게 맞췄다. 이건 직접 틀리게 넣어서 null 이 나오는지 한번 확인해 볼 생각이다.

400 에서 null 을 돌려주면

수업 코드는 400 일 때 break 후 return null 이다. Feign 은 decode 결과를 그대로 throw 하는 걸로 알고 있는데, null 을 throw 하면 자바에서는 NullPointerException 이 된다. 수업에서는 400 을 일으키지 않아서 확인이 안 됐다. 처리하지 않을 거면 기본 디코더(new ErrorDecoder.Default())에 넘기는 게 안전해 보인다. 이것도 확인할 목록에 넣었다.


내 말로 다시 설명

서비스는 각자 자기 창고(DB)만 볼 수 있다. 사용자 서비스가 "이 사람이 뭘 주문했나" 를 알려면

각기 주문 서비스 창구에 가서 물어봐야 한다.

 

 

처음엔 창구 주소를 종이에 적어 들고 다녔다(RestTemplate + 고정 URL). 주소가 바뀌면 종이를 고쳐야 했다. 그래서 안내 데스크(유레카)에 이름만 대면 지금 주소를 알려 주게 했다(@LoadBalanced). 그다음엔 매번 질문 양식을 손으로 쓰는 대신, 질문 양식을 인쇄해 두고 빈칸만 채우게 했다(OpenFeign 인터페이스).

 

창구가 "그런 서류 없어요" 라고 하면, 예전엔 사용자 서비스가 당황해서 손님한테 "알 수 없는 오류" 라고만 했다(500). 이제는 통역사(ErrorDecoder)가 한곳에 앉아 "주문 내역을 찾을 수 없습니다(404)" 로 바꿔서 전해 준다. 그리고 주문 창구가 닫혀 있어도 사용자 정보는 보여 준다. 한 창구 때문에 가게 전체가 멈추면 안 되니까.


실습 / 적용

순서

  1. user-service 기동 클래스에 RestTemplate @Bean
  2. Config 저장소 user-service.yml 에 order-service.url, commit, push
  3. getUserByUserId 에서 빈 리스트를 지우고 exchange 로 주문 내역 받기
  4. RestTemplate 빈에 @LoadBalanced, URL 을 http://order-service/order-service/%s/orders 로
  5. spring-cloud-starter-openfeign, @EnableFeignClients, OrderServiceClient
  6. logging.level DEBUG + Logger.Level.FULL
  7. 경로를 /orders-ng 로 바꿔 FeignException 확인, try-catch
  8. FeignErrorDecoder + configuration =, 메시지는 Config 로, try-catch 제거

코드는 강사님 저장소 geoblo/new-toy-msa-study 의 ch11 브랜치 커밋 3ed4f55, 1cc071f, 28b75db 순서와 같다.

테스트 순서는 매번 같았다

user-service 를 재기동할 때마다 H2 가 초기화되니까 회원가입, 로그인, 상세 조회, 주문, 상세 조회를 반복했다. 토큰은 오전에 받은 걸 계속 썼다. 지금 코드가 유효기간만 보기 때문에 시크릿이 같으면 예전 토큰도 통과한다. 6강에서 시크릿을 바꿨을 때만 401 이 났던 것과 같은 이유다.


문제와 해결

주문했는데 orders 가 비어 있음 (강사님 화면)

  • 증상: RestTemplate 연결 후에도 상세 조회 orders: []
  • 원인: 주문은 사용자 A 의 userId 로, 상세 조회는 사용자 B 로 했음
  • 해결: 같은 userId 로 맞춤
  • 메모: order-service 는 그 userId 가 진짜 있는 사용자인지 검증하지 않는다. 그 정보는 user-service DB 에 있으니까. 검증하려면 또 통신이 필요하다

재기동 직후 503

  • 증상: user-service 를 재기동하고 바로 요청하면 503
  • 원인: 게이트웨이가 유레카 목록을 아직 갱신 못 함 (3강의 30초 캐시)
  • 해결: 잠깐 기다림

RabbitMQ 연결 에러가 계속 찍힘

  • 원인: 어제 Bus 실습의 spring-cloud-starter-bus-amqp 가 남아 있는데 컨테이너가 꺼져 있음
  • 해결: 컨테이너를 다시 띄우거나 bus 의존성과 spring.rabbitmq 설정을 지움. 이후 수업에서는 Bus 를 안 쓴다고 함. 대신 Bus 를 지우면 busrefresh 도 못 쓰니 설정 반영은 refresh 나 재기동으로

Postman 401

  • 원인: 예전 요청의 인증 탭이 Basic 인증으로 남아 있었음
  • 해결: Bearer Token 으로 바꾸고 로그인 때 받은 JWT 를 넣음

다음 할 일

  • [ ] order_service.url 처럼 키를 틀리게 넣고 getProperty 가 null 을 주는지 직접 확인
  • [ ] ErrorDecoder 400 분기에서 null 을 반환하면 어떻게 되는지 Feign 소스로 확인. ErrorDecoder.Default 로 넘기는 버전 써 보기
  • [ ] 슬라이드 p22 의 @Value + @RefreshScope 로 에러 메시지를 무중단 갱신해 보기 (RabbitMQ 를 다시 띄워야 함)
  • [ ] RestTemplate 대신 Spring 6.1 의 RestClient 로 같은 호출을 써 보기
  • [ ] order-service 를 두 대 띄워 주문이 두 DB 로 갈라지는 것 직접 보기 (내일 아침 실습)
  • [ ] 오늘 코드를 내 저장소(inspire-6th) msa 모듈에 커밋하기. 지금은 비어 있다

 

Salieri
@Salieri :: 살리에리의 인생살이 채널

평소에 내가 좋아하는것과 싫어하는 것들 그리고 왜 안되나 싶은 것들을 업로드 하는 블로그입니다 인생살이도 업로드 하고있어요. 제가 걸어온 길이 당신에게 있어서 도움이 되는 이정표였으면 좋겠네요. 질문은 항상 넓은 마음으로 받고있답니다. 대답하기 곤란한 질문이 아닌 이상 제에게 이메일을 써주세요 트위터 : https://twitter.com/@Salieri1845799 깃허브 : https://github.com/salieri009

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차