아침에 엔드포인트를 한 바퀴 돌려 보는 것으로 시작했다. 회원가입, 로그인, 상품 목록, 주문까지 전부 잘 됐다.
핵심 개념
동기와 비동기는 일관성의 문제
동기 비동기
| 수단 | 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)" 로 바꿔서 전해 준다. 그리고 주문 창구가 닫혀 있어도 사용자 정보는 보여 준다. 한 창구 때문에 가게 전체가 멈추면 안 되니까.
실습 / 적용
순서
- user-service 기동 클래스에 RestTemplate @Bean
- Config 저장소 user-service.yml 에 order-service.url, commit, push
- getUserByUserId 에서 빈 리스트를 지우고 exchange 로 주문 내역 받기
- RestTemplate 빈에 @LoadBalanced, URL 을 http://order-service/order-service/%s/orders 로
- spring-cloud-starter-openfeign, @EnableFeignClients, OrderServiceClient
- logging.level DEBUG + Logger.Level.FULL
- 경로를 /orders-ng 로 바꿔 FeignException 확인, try-catch
- 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 모듈에 커밋하기. 지금은 비어 있다
'컴퓨터 공학 > Cloud + DevOps + Framework' 카테고리의 다른 글
| [LG CNS] MSA 8일차 :: Kafka || 데이터 동기화 (0) | 2026.10.19 |
|---|---|
| [Spring] Tomcat과 Apache Maven은 대체 뭐하는 놈들인가? (0) | 2026.10.10 |
| [LG CNS] MSA 5:: Microservice Configuration :: Cross Cutting (0) | 2026.10.10 |
| [Spring] Spring boot 하고 Spring 차이점이 머에요? (0) | 2026.10.09 |
| [LG CNS] MSA 4일차 :: Microservice 띄어보기 (0) | 2026.10.07 |