식음료(F&B)·리테일·디지털 채널 같이 오랜 기간 운영된 서비스는 화면 한 장을 들춰보면 거의 항상 JSP/JSTL과 jQuery로 짜인 레거시 페이지가 나온다. 그 위에 모바일 앱과 SPA(React/Vue), 키오스크, 사이니지, 외부 파트너 연동, 그리고 사내 운영툴까지 겹겹이 쌓여 있다. 같은 주문/회원/쿠폰 도메인을 두고 서버 사이드 렌더링과 R...
식음료(F&B)·리테일·디지털 채널 같이 오랜 기간 운영된 서비스는 화면 한 장을 들춰보면 거의 항상 JSP/JSTL과 jQuery로 짜인 레거시 페이지가 나온다. 그 위에 모바일 앱과 SPA(React/Vue), 키오스크, 사이니지, 외부 파트너 연동, 그리고 사내 운영툴까지 겹겹이 쌓여 있다. 같은 주문/회원/쿠폰 도메인을 두고 서버 사이드 렌더링과 REST/JSON API가 동시에 살아 있고, 세션 인증과 토큰 인증이 한 시스템에서 같이 돌아간다.
CJ푸드빌 디지털 채널처럼 "기존 채널을 깨면 안 되면서, 새 채널을 빨리 붙여야 하는" 환경에서는 백엔드의 진짜 역량은 새 기능을 잘 짜는 게 아니라 레거시를 망가뜨리지 않으면서 새 흐름을 점진적으로 끼워 넣는 능력으로 평가된다. 면접에서도 "JSP를 잘 쓸 줄 아느냐"가 아니라 "JSP가 살아 있는 와중에 어떻게 안전하게 SPA/앱을 붙이고, 어떻게 잘라낼 계획을 가지고 있느냐"를 본다. 이 문서는 그 관점에서 한 번에 정리한다.
JSP/jQuery 운영 경험이 적은 Spring 백엔드 개발자에게도 실용적이도록 구성했다. 핵심은 두 가지다. 첫째, 레거시 화면을 깊게 모르는 상태에서도 통제 가능한 경계를 긋는 방법. 둘째, "써본 적 없다"가 아니라 "공존시키는 운영 전략을 안다"로 면접 답을 만드는 법.
전통적인 JSP/jQuery 시스템은 보통 다음 형태다.
$.ajax로 같은 서버의 /admin/order/list.do 같은 엔드포인트를 호출한다. 이 엔드포인트는 흔히 JSON을 반환하지만 응답 포맷, 에러 코드, 상태 코드 규약이 일관성 없다.HttpSession 기반. 로그인 시 세션에 사용자 정보가 들어가고, 인터셉터/필터가 세션을 보고 인가를 결정한다.여기에 모바일 앱과 SPA가 추가되면 다음이 한 시스템에 겹친다.
/api/v1/orders 같이 REST 규약을 지키는 새 API. 인증은 JWT 또는 OAuth2 Bearer./order/list.do처럼 JSP 안에서만 부르던 ajax 엔드포인트./order/detail.jsp 같은 렌더링 라우트.이 상태에서 잘못된 결정의 대부분은 경계를 명확히 긋지 않았을 때 발생한다.
먼저 분리해야 할 두 가지를 구분한다.
레거시 환경에서 자주 일어나는 사고는 이 둘을 같은 컨트롤러, 같은 서비스, 같은 응답 DTO로 처리하다가, JSP 화면 편의를 위해 응답을 살짝 바꾼 게 모바일 앱 빌드를 깨뜨리는 식이다. 따라서 공존 전략의 1번 원칙은 두 부류의 엔드포인트를 URL prefix, 모듈, 인증 체계, 응답 규약으로 분리하는 것이다.
가장 빈번한 결정 포인트다. 세 가지 패턴이 있다.
/api/**)는 토큰 인증 필터, 레거시 경로는 기존 세션 필터. SecurityFilterChain을 두 개 만든다.처음에는 1번이 현실적이다. 두 번째와 세 번째는 트래픽이 일정 수준 이상이거나 도메인 분리가 명확해진 다음에 의미가 있다.
Strangler Fig 패턴은 마틴 파울러가 제안한 점진적 마이그레이션 전략으로, 레거시를 한 번에 갈아엎는 대신 새 시스템을 옆에 두고 요청을 라우팅으로 나누어 점차 새 쪽으로 옮긴 뒤, 마지막에 레거시를 제거하는 방식이다. 핵심은 "잘라낼 단위"를 정확히 잡는 것이다.
좋은 단위 예: 회원가입, 비밀번호 변경, 주문 조회 화면, 쿠폰 발급 페이지. 나쁜 단위 예: "주문 도메인 전체", "관리자 페이지 전체" — 너무 크고, 안에 비표준이 너무 많아 한 번에 끊을 수 없다.
라우팅은 보통 다음 중 하나로 한다.
/order/new 만 새 SPA로 보내고 나머지는 톰캣으로.레거시가 살아 있는 동안 코드 베이스가 누더기가 되는 이유는, 새로 만드는 컨트롤러를 옛 패키지 옆에 그냥 끼워넣기 때문이다. 권장 구조는 다음과 같다.
src/main/java/com/foo
├── legacy
│ ├── controller // JSP forward + 화면 ajax
│ ├── interceptor // 세션 기반 인터셉터
│ └── support // 옛 코드가 의존하는 헬퍼
├── api
│ ├── v1
│ │ └── order // REST 컨트롤러, 토큰 인증
│ └── v2
└── domain // 두 쪽이 공유하는 도메인 서비스핵심은 도메인 서비스(domain.OrderService)는 한 곳에 두고, 컨트롤러 계층만 두 갈래로 나누는 것이다. 두 컨트롤러 모두 같은 서비스를 호출하므로 비즈니스 규칙은 한 번만 작성·검증된다.
레거시 ajax는 다음 같은 응답을 흔히 쓴다.
{ "result": "OK", "data": {...}, "msg": "" }새 API는 표준 HTTP 상태 코드 + Problem Details(application/problem+json) 또는 일관된 envelope을 쓴다. 두 규약을 한 컨트롤러가 동시에 만족시키려고 하면 어디선가 깨진다. 응답 어드바이스를 prefix별로 다르게 적용한다.
@RestControllerAdvice(basePackages = "com.foo.api")
public class ApiExceptionAdvice { ... }
@ControllerAdvice(basePackages = "com.foo.legacy")
public class LegacyAjaxExceptionAdvice { ... }이렇게 두면 새 API는 RFC 7807 형식의 에러를 던지면서, 레거시 화면은 기존 jQuery 코드가 기대하는 {result, msg}를 유지할 수 있다.
Spring Security 6 기준 두 체인 분리 예시다.
@Configuration
public class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/v1/auth/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain legacyChain(HttpSecurity http) throws Exception {
http.securityMatcher("/**")
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(a -> a
.requestMatchers("/login", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated())
.formLogin(f -> f.loginPage("/login"))
.sessionManagement(s -> s
.maximumSessions(1)
.expiredUrl("/login?expired"));
return http.build();
}
}@Order(1)로 API 체인을 먼저 매칭시키고 STATELESS로 두면, 새 채널에 세션이 새로 발급되는 사고를 막을 수 있다. securityMatcher와 requestMatchers를 헷갈려 정책이 엉키는 게 흔한 실수다.
레거시 페이지의 jQuery ajax는 같은 도메인에서 호출하므로 세션 쿠키가 자동으로 실린다. 여기에 CSRF는 필수다. 반대로 토큰 기반 API는 쿠키가 아니라 Authorization 헤더를 쓰므로 CSRF는 불필요하지만, 만약 SPA가 세션 쿠키로 인증한다면 CSRF는 다시 살려야 한다. 또한 모바일 웹뷰에서 도메인이 분리되면 SameSite=None; Secure 설정과 CORS preflight가 정상 동작하는지 확인해야 한다.
잘못된 예
@Controller
public class OrderController {
@RequestMapping("/order/list")
public String list(Model model, HttpServletRequest req,
@RequestParam(required = false) String format) {
var orders = orderService.find(currentUser(req));
if ("json".equals(format)) {
// 모바일 앱이 ?format=json 으로 호출
req.setAttribute("orders", orders);
return "forward:/order/listJson";
}
model.addAttribute("orders", orders);
return "order/list"; // JSP
}
}같은 URL이 화면 렌더링과 모바일 JSON 응답을 query 파라미터로 분기한다. 곧 누군가 format=json 응답에 화면 편의를 위한 필드를 추가했다가 앱이 깨진다.
개선된 예
// 화면 전용
@Controller
@RequestMapping("/order")
public class OrderViewController {
@GetMapping("/list")
public String list(Model model, @AuthenticationPrincipal SessionUser user) {
model.addAttribute("orders", orderService.find(user.id()));
return "order/list";
}
}
// API 전용
@RestController
@RequestMapping("/api/v1/orders")
public class OrderApiController {
@GetMapping
public OrderListResponse list(@AuthenticationPrincipal JwtUser user) {
return OrderListResponse.from(orderService.find(user.id()));
}
}JSP <c:if>나 스크립틀릿 안에서 가격 계산, 권한 체크 같은 로직이 굴러다니는 경우가 흔하다. 새 API에서 같은 화면 데이터를 만들면 결과가 달라진다.
개선 방향: 이런 로직은 도메인 서비스로 끌어내려 한 번만 구현하고, JSP는 결과만 출력하게 만든다. 면접에서 "JSP 안의 비즈니스 로직을 발견하면 어떻게 할 거냐"는 질문이 자주 나온다. 답은 "당장 다 옮기는 게 아니라, 새 채널이 같은 화면을 그릴 때 양쪽이 같은 결과가 되도록 도메인 함수로 추출하고, JSP는 그것만 호출하게 점진적으로 정리한다"이다.
$.ajax({ url: "/admin/coupon/issue.do", data: {...} });이 엔드포인트가 내부에서 외부 결제 API, 푸시 발송, 통계 적재까지 동시에 호출하면, 새 API에서 같은 동작을 재현할 때 부작용을 빠뜨리기 쉽다.
개선 방향: 컨트롤러가 직접 부수 효과를 일으키지 않게 한다. 도메인 서비스가 이벤트를 발행하고(ApplicationEventPublisher 또는 Kafka), 부수 효과는 별도 리스너에서 처리한다. 그러면 새 API 컨트롤러는 같은 서비스만 호출해도 동일한 후속 흐름을 보장할 수 있다.
신규 API와 레거시 JSP가 공존하는 환경을 단일 머신에서 재현하려면 다음 정도면 충분하다.
spring-boot-starter-web + org.apache.tomcat.embed:tomcat-embed-jasper, jakarta.servlet:jstlhttpie 또는 Postmanapplication.yml 핵심:
spring:
mvc:
view:
prefix: /WEB-INF/views/
suffix: .jsp
session:
store-type: redis
server:
servlet:
session:
cookie:
same-site: lax
http-only: true
secure: true@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repo;
public List<OrderSummary> find(long userId) {
return repo.findRecent(userId).stream()
.map(OrderSummary::from)
.toList();
}
}JSP 컨트롤러와 REST 컨트롤러가 같은 서비스만 호출하도록 강제하는 것이 첫 번째 실습 목표다.
nginx 설정으로 /order/new 만 새 SPA로 보내본다.
location /order/new {
proxy_pass http://spa-upstream;
}
location / {
proxy_pass http://legacy-tomcat;
}이렇게 두면 한 페이지만 새 화면으로 잘라낸 효과를 볼 수 있다. 세션 쿠키가 두 업스트림에서 같이 통하도록 도메인을 동일하게 유지하는 부분이 실전에서 중요하다.
/order/list.jsp 진입 → 200, JSP 렌더링./api/v1/orders 호출 → 401(쿠키 무시, 토큰 없음). 의도한 동작이다./api/v1/auth/login으로 토큰 발급 후 Authorization: Bearer ...로 호출 → 200./order/list.jsp 호출 → 302 리다이렉트. 화면용 경로는 모바일에서 호출하지 않는다는 계약을 코드로 못 박은 셈이다.이 네 시나리오가 바뀌지 않게 하는 통합 테스트를 두면 인증 체인이 흐트러지는 사고를 거의 다 잡는다.
회원가입 한 화면만 SPA로 옮긴다고 가정하고 다음 순서로 진행한다.
/api/v1/signup 작성, 도메인 서비스는 기존 것 재사용./signup만 SPA로 라우팅. 기존 JSP 경로는 잠시 유지.이 4단계를 머리로만 그리지 말고 한 번 손으로 굴려보면, 면접에서 "Strangler Fig를 어떻게 적용했나"라는 질문에 구체적인 사례로 답할 수 있다.
레거시 공존 환경의 테스트는 단위 테스트보다 계약 테스트와 회귀 테스트가 핵심이다.
MockMvc로 응답 envelope과 상태 코드 회귀 테스트.모니터링은 레거시 경로와 새 경로의 메트릭을 분리하는 게 핵심이다. URL 패턴별 응답시간/에러율을 따로 보고, 새 API로 트래픽이 옮겨갈수록 레거시 쪽 호출이 줄어드는지를 추적한다. 그래프가 그려지지 않으면 점진 마이그레이션을 했다고 말할 수 없다.
장애 대응 측면에서는 다음을 미리 정해 둔다.
<c:out> 또는 ${fn:escapeXml(...)}이 들어가 XSS가 막히는가.${} 바인딩이 남아 있는지 grep로 한 번 훑기.JSP/jQuery 실무 경험이 적은 Java/Spring 백엔드 지원자가 정직하면서 설득력 있게 답하려면, 답을 "기술 사용 경험" 축이 아니라 "공존 운영 전략" 축으로 옮긴다.
예상 질문: "JSP 환경 운영 경험이 있느냐?"
좋은 답: "JSP를 깊이 운영해본 경험은 많지 않다. 다만 현 회사에서 모놀리식 Spring MVC 위에 모바일 API를 추가하는 작업을 하며, 화면 렌더링 경로와 외부 API 경로를 어떻게 분리해 운영해야 하는지에 집중해서 다뤘다. 구체적으로는 Spring Security 체인을 두 개로 나눠 STATELESS API와 세션 기반 화면을 격리했고, 응답 어드바이스를 prefix 단위로 분리해 외부 계약과 내부 ajax 응답이 서로의 변경에 휘둘리지 않도록 했다. JSP 자체에 대해서는 사내에서 스크립틀릿 비즈니스 로직을 도메인 서비스로 빼낸 작업과 점진적 마이그레이션 패턴(Strangler Fig)을 학습해 두었기 때문에, 합류 초기에는 기존 화면을 학습하면서 새 채널 쪽 변경부터 책임지는 식으로 신뢰를 쌓고 싶다."
예상 질문: "레거시 화면이 살아 있는데 모바일 앱을 새로 붙이라면 어떻게 시작하겠냐?"
좋은 답 골격:
/api/v1/**).예상 질문: "레거시 변경 리스크를 어떻게 줄이겠는가?"
핵심 단어는 **"건드리지 않을 자유"**다. 레거시는 가능하면 변경 없이 두고, 새 흐름을 옆에 붙인다. 부득이하게 손대야 하면 도메인 서비스 단위로만 손대고, 컨트롤러/JSP는 그대로 둔다. 회귀 테스트와 카나리 라우팅으로 변경 영향 범위를 좁힌다.
예상 질문: "jQuery ajax 코드를 다 걷어내야 한다고 생각하느냐?"
"필요하지 않다"가 정직한 답이다. 비즈니스 가치가 있는 곳부터 잘라내고, 자주 안 바뀌고 안정적으로 도는 화면은 굳이 손대지 않는다. 마이그레이션 자체가 목적이 되면 비용만 늘고 사고가 난다. 이 판단을 할 줄 안다는 점이 시니어 백엔드 답변의 차별점이다.