블로그

Spring Boot 2.7 MVC 환경에서 프록시 게이트웨이 구축기

레거시 서버에 투명 프록시를 얹어 신규 서비스로 트래픽을 넘기기까지의 여정 — 기술 선정, 구현, 그리고 삽질의 기록본 글에 등장하는 서비스명, 도메인, 테이블/칼럼명 등은 보안상의 이유로 익명화하여 작성하였습니다. 구현 패턴과 문제 해결 과정은 실제 경험을 그대로 반영하고 있습니다. 1. 배경: 왜 프록시가 필요했는가우리 팀은 기존 레거시 서버에서 신규 서비스로 점진적으로 마이그레이션하는 작업을 진행하고 있었다. 흔히 말하는 Strangler Fig 패턴 — 레거시를 한 번에 걷어내는 게 아니라, 새로운 서비스를 나란히 세워두고 트래픽을 조금씩 옮기면서 레거시를 서서히 "교살"하는 전략이다.여기서 핵심 제약이 하나 있었다. 인증(Authentication)은 레거시 서버가 전담한다는 것이다. 클라이언트의 모든 요청은 반드시 레거시 서버를 먼저 거쳐야 했고, 레거시에서 인증을 완료한 뒤에야 신규 서비스로 넘길 수 있었다. 단순히 nginx나 로드밸런서 레벨에서 라우팅을 바꾸는 것으로는 해결이 안 되는 구조였다.그래서 레거시 서버 안에 프록시 레이어를 만들기로 했다. 인증을 마친 요청에 사용자 컨텍스트(x-user-id, x-request-service 등)를 HTTP 헤더에 실어서 신규 서비스로 투명하게 포워딩하는 구조다.2. 기술 선정: 생각보다 간단하지 않았던 선택처음 떠올린 것: Spring Cloud Gateway프록시라고 하면 가장 먼저 떠오르는 건 Spring Cloud Gateway다. 라우팅, 필터, 서킷 브레이커까지 다 갖춰져 있으니 이걸 쓰면 되겠다 싶었다.그런데 문제가 있었다. 우리는 게이트웨이를 별도 서버로 띄울 계획이 아니었다. 레거시 서버 안에 모듈 형태로 넣어서, 레거시가 인증 처리 후 바로 프록시까지 수행하는 구조를 원했다. 그런데 Spring Cloud Gateway는 Reactive 기반, WebFlux(Netty) 위에서 돌아간다. 우리 레거시 서버는 Spring Boot 2.7 + Spring MVC(Tomcat) 환경이다.Servlet 기반 애플리케이션에 Reactive 기반 게이트웨이를 같이 올리는 건 불가능하다. WebServerFactory가 충돌하고, ReactiveWebApplicationContext와 ServletWebApplicationContext는 공존할 수 없다. 별도 마이크로서비스로 분리해서 앞단에 배치하는 구조라면 가능하겠지만, 그건 우리가 원하는 "레거시 안에서 인증 후 바로 포워딩"이라는 요구사항과 맞지 않았다.대안 탐색: OpenFeign vs RestTemplate vs ProxyExchange여기서 세 가지 선택지를 놓고 고민했다.Option A: Spring Cloud OpenFeign신규 서비스의 각 API에 대응하는 Feign Client 인터페이스를 레거시에 선언하고, Controller에서 이를 호출하는 방식이다. 타입 안정성도 좋고, 서킷 브레이커와도 잘 붙는다. 하지만 치명적인 단점이 있었다. 단순 패스스루(pass-through) API조차 레거시에 Controller 메서드와 Feign 메서드를 중복으로 만들어야 한다. API가 100개면 100개의 껍데기 코드가 필요하다. 마이그레이션이 진행될수록 레거시가 줄어드는 게 아니라 오히려 비대해지는 모순이 발생한다.Option B: RestTemplate (수동 프록시)프로젝트에서 외부 API 호출에 이미 쓰고 있는 RestTemplate으로 직접 프록시를 구현하는 방법이다. 익숙한 도구이긴 하지만, 요청/응답 바디를 직접 읽고 다시 써야 하고, 헤더 복사, 에러 핸들링, 바이너리 응답 처리 등을 전부 수동으로 구현해야 한다. 단순 패스스루를 위해 작성해야 할 보일러플레이트 코드가 상당하다.Option C: Spring Cloud Gateway MVC의 ProxyExchange서치를 하다가 발견한 것이 spring-cloud-gateway-mvc였다. Spring Cloud Gateway의 서블릿(MVC) 버전으로, ProxyExchange라는 유틸리티를 제공한다. 요청 본문(body)을 파싱하지 않고 그대로 포워딩하는 투명 프록시를 간단하게 구현할 수 있다. DTO 매핑이나 Controller 추가 없이, 와일드카드 기반으로 수십 개의 API를 한 번에 라우팅할 수 있다.결론: ProxyExchange결국 ProxyExchange를 선택했다. Strangler Fig 패턴의 본질은 "레거시를 껍데기(라우터)로 만들고 서서히 죽이는 것"이다. 단순 전달만 하는 API를 위해 Feign Client와 DTO를 계속 추가하거나, RestTemplate으로 보일러플레이트를 양산하는 건 기술 부채를 늘리는 안티 패턴이다. ProxyExchange로 깔끔하게 투명 프록시를 구현하는 것이 가장 합리적인 선택이었다.3. 구현: 프록시 게이트웨이 공통 모듈모듈 구조레거시 서버의 여러 서브 모듈에서 공통으로 쓸 수 있도록, 프록시 게이트웨이 공통 모듈을 만들었다.utils/proxy-gateway/ ├── build.gradle ├── src/main/java/.../ │ └── ProxyService.java # 핵심 프록시 서비스 └── src/main/resources/ └── proxy-gateway.yml # 환경별 대상 서버 URL 설정 의존성api 'org.springframework.cloud:spring-cloud-gateway-mvc' implementation project(path: ':domain') compileOnly 'org.springframework.boot:spring-boot-starter-security' spring-cloud-gateway-mvc가 핵심이다. WebFlux가 아닌 Servlet 기반이므로, 기존 Spring MVC 환경과 전혀 충돌하지 않는다.ProxyService — 헤더 주입의 핵심프록시의 가장 중요한 역할은 인증 정보와 사용자 컨텍스트를 헤더에 실어 보내는 것이다. ProxyService.withHeaders()가 이 역할을 담당한다.public ProxyExchange<byte[]> withHeaders(ProxyExchange<byte[]> proxy) { proxy.header("x-request-service", serviceName); setUserHeader(getCurrentUserId(), proxy); String traceId = MDC.get("trace_id"); if (traceId != null) { proxy.header("x-trace-id", traceId); } String clientIp = MDC.get("client_ip"); if (clientIp != null) { proxy.header("x-client-ip", clientIp); } forwardClientHeaders(proxy); return proxy; } withHeaders()가 하는 일은 크게 세 가지다. 첫째, x-request-service 헤더에 서비스명을 주입한다. 이 값은 application.yml의 service-name 설정에서 가져온다.둘째, setUserHeader()를 통해 사용자 관련 헤더를 주입한다. Spring Security의 SecurityContextHolder에서 인증된 사용자 ID를 꺼내고, 그 ID로 DB에서 소속 정보를 조회해서 x-user-id, x-user-company-id 등의 헤더를 세팅한다. 이때 null 체크와 Long 파싱 가능 여부를 꼼꼼하게 검증한다 — Spring Security가 classpath에 없는 모듈에서도 에러 없이 동작해야 하기 때문이다.private void setUserHeader(String userId, ProxyExchange<byte[]> proxy) { if (userId == null || userId.isEmpty() || !canParseLong(userId)) { return; } proxy.header("x-user-id", userId); memberRepository.findMemberProxyById(Long.parseLong(userId)).ifPresent(member -> { if (member.getCompanyId() != null) { proxy.header("x-user-company-id", String.valueOf(member.getCompanyId())); } if (member.getOrganizationId() != null) { proxy.header("x-organization-id", String.valueOf(member.getOrganizationId())); } }); } 소속 정보 관련 헤더는 사용자의 역할에 따라 값이 달라진다. 이 분기 로직은 Repository의 네이티브 쿼리에서 CASE WHEN으로 처리하여, 서비스 레이어에서는 단순히 결과를 헤더에 넣기만 하면 된다.select m.id, m.company_id as companyId, case c.type when 'ADMIN' then c.id else c.parent_id end as organizationId from members m left join companies c on m.company_id = c.id and c.deleted_at is null where m.id = :id and m.deleted_at is null limit 1 셋째, forwardClientHeaders()로 클라이언트의 원본 헤더 중 필요한 것들을 패스스루한다. 여기서 authorization 헤더는 x-authorization이라는 별도 키로 변환해서 전달하는데, 이유는 뒤의 삽질 기록에서 다룬다.private void forwardClientHeaders(ProxyExchange<byte[]> proxy) { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) { return; } HttpServletRequest request = attrs.getRequest(); for (String headerName : FORWARDED_HEADERS) { String value = request.getHeader(headerName); if (value != null) { proxy.header(headerName, value); } } String authorization = request.getHeader("authorization"); proxy.header("x-authorization", authorization != null ? authorization : ""); } Controller에서의 사용프록시 Controller는 /** 와일드카드로 모든 하위 경로를 잡아서 HTTP 메서드별로 포워딩한다. 개별 endpoint를 일일이 매핑하는 게 아니라, 경로 전체를 통째로 넘기는 구조다.환경별 설정대상 서버 URL은 설정 파일에서 프로필별로 관리한다.target: base-url: http://localhost:8090 --- spring.config.activate.on-profile: develop target: base-url: https://api-dev.example.com --- spring.config.activate.on-profile: staging target: base-url: https://api-stage.example.com --- spring.config.activate.on-profile: prod target: base-url: https://api.example.com 각 서브 모듈에서는 이 설정 파일을 import만 하면 된다.spring: config: import: - classpath:proxy-gateway.yml 4. 삽질의 기록구현 자체는 깔끔했지만, 실제로 동작시키는 과정에서 세 가지 문제를 만났다. 하나같이 "문서에는 안 나와 있고, 직접 겪어봐야 아는" 류의 문제들이었다.삽질 1: x-authorization 헤더가 null로 들어온다증상: 레거시 서버에서 x-authorization 헤더에 값을 분명히 넣어서 보내는데, 신규 서비스 쪽에서는 x-authorization: null로 들어왔다. 아무리 값을 추가해도 null. 디버깅을 해봐도 레거시 쪽에서는 분명히 값이 세팅되어 있었다.원인: Spring Cloud Gateway MVC는 기본적으로 Authorization, Cookie 등 민감한 헤더(sensitive headers)를 자동으로 제거한다. 프록시 대상 서버로 전달하기 전에 필터링해버리는 것이다. x-authorization이라는 커스텀 헤더명을 썼지만, 내부적으로 Authorization 패턴에 매칭되어 함께 제거된 것이었다.해결: 설정 파일에 sensitive 헤더 목록을 빈 배열로 지정하여 해결했다.spring: cloud: gateway: proxy: sensitive: [] 이 설정은 "어떤 헤더도 민감 헤더로 취급하지 않겠다"는 의미다. 이렇게 하면 클라이언트의 모든 헤더가 대상 서버로 그대로 전달된다.설정 동작기본값(미설정)Authorization, Cookie 등 민감 헤더 자동 제거sensitive: []모든 헤더 통과sensitive: [Cookie]Cookie만 제거, 나머지 통과이 문제가 까다로웠던 이유는, 에러가 나지 않는다는 점이다. 헤더가 조용히 제거될 뿐 예외를 던지거나 로그를 남기지 않는다. 레거시 쪽 로그에서는 값이 잘 들어가고, 신규 서비스 쪽에서만 null이 들어오니 문제의 위치를 특정하기가 어려웠다.삽질 2: 로컬은 되는데 dev에서 GET만 502 Bad Gateway증상: 로컬 환경에서는 모든 API가 정상 동작했다. 그런데 dev 환경에 배포하자마자 GET 요청만 502 Bad Gateway가 떴다. POST는 잘 됐다. nginx 에러 로그에는 이런 메시지가 찍혔다.upstream sent invalid chunked response while reading upstream 원인: 이 문제의 근본 원인은 HTTP의 hop-by-hop 헤더 처리에 있었다.신규 서비스가 응답을 보낼 때 Transfer-Encoding: chunked 헤더를 포함한다. ProxyExchange는 이 응답 헤더를 그대로 레거시 서버의 응답에 복사한다. 그런데 레거시 서버의 서블릿 컨테이너(Tomcat)가 자체적으로 또 다시 chunked 인코딩을 적용한다. 결과적으로 이중 chunked 인코딩이 발생하고, 이를 받은 nginx가 "invalid chunked response"라고 판단하여 502를 반환한 것이다.POST가 괜찮았던 이유는 단순하다. 해당 POST API의 응답 body가 void(빈 응답)이었기 때문이다. body가 없으니 chunked 인코딩이 적용될 대상 자체가 없었다.로컬에서 문제가 없었던 이유도 명확하다. 로컬에서는 nginx를 거치지 않고 Tomcat에 직접 접근하기 때문이다. Tomcat이 이중 chunked를 내보내더라도, 브라우저나 Postman 같은 클라이언트는 이를 관대하게 처리한다. 하지만 nginx는 엄격하게 HTTP 스펙을 준수하기 때문에 거부한 것이다.Transfer-Encoding은 HTTP 스펙에서 hop-by-hop 헤더로 분류된다. 즉, 중간 프록시가 전달해서는 안 되는 헤더다. Spring Cloud Gateway(reactive 버전)에서는 RemoveHopByHop Headers Filter가 자동으로 이런 헤더들을 제거해주지만, spring-cloud-gateway-mvc의 ProxyExchange는 단순 프록시 유틸리티라서 이런 필터 체계가 없다.해결: 프록시 응답에서 Transfer-Encoding 헤더를 수동으로 제거하는 메서드를 만들었다.private ResponseEntity<?> stripTransferEncoding(ResponseEntity<?> response) { return ResponseEntity.status(response.getStatusCode()) .headers(headers -> { headers.putAll(response.getHeaders()); headers.remove("Transfer-Encoding"); }) .body(response.getBody()); } 모든 프록시 응답에 이 메서드를 감싸주는 것으로 해결했다. GET, POST, PUT, DELETE 전부 적용했다. 지금은 GET만 문제가 되지만, 향후 body가 있는 다른 메서드에서도 같은 문제가 발생할 수 있기 때문이다.교훈: spring-cloud-gateway-mvc의 ProxyExchange를 쓸 때는, reactive 버전에서 자동으로 해주는 것들(hop-by-hop 헤더 제거 등)을 직접 챙겨야 한다. 편리한 만큼, 내부에서 뭘 안 해주는지를 정확히 알고 있어야 한다.삽질 3: Query String이 사라진다증상: 목록 조회 API에서 페이징이나 필터 파라미터를 넘겼는데, 신규 서비스 쪽에서 query parameter가 전부 null로 들어왔다. 예를 들어 /items?page=0&size=10으로 요청하면, 신규 서비스에서는 page와 size 둘 다 null이었다.원인: ProxyExchange의 proxy.path()는 경로(path)만 반환한다. Query string은 포함하지 않는다. 즉, /items?page=0&size=10으로 요청해도 proxy.path()는 /items만 돌려준다. 우리는 이 path에 대상 서버 base URL을 붙여서 URI를 만들고 있었으므로, query string이 통째로 날아간 것이다."ProxyExchange가 쿼리 파라미터도 알아서 넘겨주겠지"라고 기대했는데, 그렇지 않았다.해결: HttpServletRequest에서 query string을 직접 꺼내서 URI에 붙이도록 수정했다.private String buildUri(ProxyExchange<byte[]> proxy, HttpServletRequest request) { String path = proxy.path().replaceFirst("/old-path", "/api/v1/new-path"); String uri = proxyService.getBaseUrl() + path; String queryString = request.getQueryString(); if (queryString != null) { uri += "?" + queryString; } return uri; } Controller 메서드에 HttpServletRequest를 파라미터로 추가하고, getQueryString()으로 원본 쿼리 스트링을 그대로 가져와서 붙이는 단순한 방법이다. 인코딩 변환 없이 원본을 그대로 넘기므로, 한글이나 특수문자가 포함된 파라미터도 문제없이 전달된다.5. 최종 형태세 번의 삽질을 거쳐 정착한 프록시 Controller의 최종 형태는 이렇다.@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/items") public class ItemProxyController { private final ProxyService proxyService; @GetMapping("/**") public ResponseEntity<?> proxyGet(ProxyExchange<byte[]> proxy, HttpServletRequest request) { return stripTransferEncoding(proxyService.withHeaders(proxy).uri(buildUri(proxy, request)).get()); } @PostMapping("/**") public ResponseEntity<?> proxyPost(ProxyExchange<byte[]> proxy, HttpServletRequest request) { return stripTransferEncoding(proxyService.withHeaders(proxy).uri(buildUri(proxy, request)).post()); } @PutMapping("/**") public ResponseEntity<?> proxyPut(ProxyExchange<byte[]> proxy, HttpServletRequest request) { return stripTransferEncoding(proxyService.withHeaders(proxy).uri(buildUri(proxy, request)).put()); } @DeleteMapping("/**") public ResponseEntity<?> proxyDelete(ProxyExchange<byte[]> proxy, HttpServletRequest request) { return stripTransferEncoding(proxyService.withHeaders(proxy).uri(buildUri(proxy, request)).delete()); } private ResponseEntity<?> stripTransferEncoding(ResponseEntity<?> response) { return ResponseEntity.status(response.getStatusCode()) .headers(headers -> { headers.putAll(response.getHeaders()); headers.remove("Transfer-Encoding"); }) .body(response.getBody()); } private String buildUri(ProxyExchange<byte[]> proxy, HttpServletRequest request) { String path = proxy.path().replaceFirst("/items", "/api/v1/items"); String uri = proxyService.getBaseUrl() + path; String queryString = request.getQueryString(); if (queryString != null) { uri += "?" + queryString; } return uri; } } 새로운 API를 신규 서비스로 프록시하고 싶다면, 이 패턴을 그대로 복사해서 path 변환 규칙만 바꾸면 된다. 비즈니스 로직은 전부 신규 서비스에 있고, 레거시는 인증과 라우팅만 담당한다.전달되는 헤더 전체 목록헤더 설명 소스x-user-id인증된 사용자 IDSpring Security principalx-user-company-id사용자 소속 조직 IDRepository 조회x-organization-id상위 조직 IDRepository 조회 (역할에 따라 분기)x-authorization원본 인증 토큰클라이언트 Authorization 헤더x-request-service요청 서비스명service-name 설정값x-trace-id분산 추적 IDMDCx-client-ip클라이언트 IPMDC추가로 클라이언트의 다음 헤더도 그대로 전달된다:accept, accept-encoding, accept-language, content-type, x-accept-language, x-requested-with, x-time-zone6. 회고: 배운 것들spring-cloud-gateway-mvc는 "라이트" 버전이다Spring Cloud Gateway(reactive)의 풍부한 필터 체인, hop-by-hop 헤더 자동 제거, 자동 쿼리 파라미터 전달 — 이런 것들을 기대하면 안 된다. ProxyExchange는 말 그대로 "프록시 유틸리티"일 뿐이다. 편리하지만, 내부에서 뭘 안 해주는지를 정확히 알고 보완해야 한다."로컬에서 됩니다"를 믿지 마라이번 Transfer-Encoding 이슈는 로컬에서는 절대 재현되지 않는 문제였다. 로컬은 nginx를 거치지 않고, 브라우저와 Postman은 잘못된 chunked 응답도 관대하게 처리하기 때문이다. 프록시 구현 후에는 실제 배포 환경(nginx → Tomcat 구조)에서 반드시 검증해야 한다.투명 프록시의 가치OpenFeign이었다면 API 하나를 넘길 때마다 Controller, Feign Client, Request DTO, Response DTO를 전부 만들어야 했을 것이다. ProxyExchange 덕분에 레거시에는 얇은 라우팅 코드만 남기고, 실제 비즈니스 로직은 신규 서비스에서 깔끔하게 관리할 수 있게 되었다. Strangler Fig 패턴의 취지에 가장 부합하는 선택이었다고 생각한다.삽질도 자산이다sensitive 헤더 설정, hop-by-hop 헤더 처리, query string 수동 전달 — 이 세 가지는 공식 문서에서 찾기 어려운 실전 지식이다. 특히 gateway-mvc는 reactive 버전에 비해 레퍼런스가 적다. 이런 경험들을 정리해두면 팀 내 다른 개발자들이 같은 삽질을 반복하지 않을 수 있다.

SpringBootgatewayproxymvc

채널톡 아이콘