BE : Spring + FE : React 프로젝트를 작은 규모의 cloud server(aws : t2.micro, ncloud : Standard)에 배포하며 마주한 이슈
1. 마주했던 고민
외주 프로젝트에서 Backend는 Spring 프레임 워크로 Frontend는 React라이브러리를 활용해 mvp 모델을 개발하던 중, 우선 가장 작은 규모의 cloud server(aws기준 t2.micro - RAM 1GiB)에 배포하면서 어떤 구조로 서버를 배포해야 할지 고민하게 되었다. FE, BE 서버를 각각 run 시킬 것인지, 아니면 하나의 서버에서 각각 다른 포트에 FE, BE server를 구동할 것인지.. 혹은 mvc패턴으로 react 앱을 빌드한 뒤 spring 서버에서 정적 리소스로 제공할 것인지 결정이 필요해졌다.
2. 배포 아키텍쳐 결정
아래와 같은 이유로, react로 개발한 FE 프로젝트를 빌드한 후 spring 프로젝트의 정적 리소스로 제공하는 방법을 채택했었다.
- 시험용 mvp 모델이기에 "가능한 적은 리소스를 활용하면서 빠르게 개발해주었으면 한다"는 요구사항이 있었다.
- 기존에 팀프로젝트 배포 시 mvc패턴으로 react 앱을 빌드한 뒤 spring 서버에서 정적 리소스로 제공했던 경험이 있기에 관련 스크립트를 빠르게 짤 수 있었다.
- 작은 서버에 Spring과 React 프로젝트를 각각 실행하는 방법을 채택한다면, RAM 1GiB짜리 서버에서 java 프로젝트와 node프로젝트가 모두 돌아가야 했기에 서버에 부담이 생길 것이라고 판단했다.
- mvp 모델에서 요구사항이 계속해서 변화했기 때문에, 매번 각각의 프로젝트를 배포하는것은 비효율적이라고 생각했다.
위의 아키텍쳐에 맞추어 아래 더보기와 같은 (야나의 야매)배포 파이프라인을 구성했다.
1. build.gradle에 아래의 스크립트를 추가해 SpringBoot 프로젝트가 build 될 때 React 프로젝트가 먼저 build되고, 결과물을 SpringBoot 프로젝트 build 결과물에 포함시키도록 한다.
def frontendDir = "$projectDir/src/main/frontend"
sourceSets {
main {
resources { srcDirs = ["$projectDir/src/main/resources"]
}
}
}
processResources { dependsOn "copyReactBuildFiles" }
task installReact(type: Exec) {
workingDir "$frontendDir"
inputs.dir "$frontendDir"
group = BasePlugin.BUILD_GROUP
if (System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows')) {
commandLine "npm.cmd", "audit", "fix"
commandLine 'npm.cmd', 'install' }
else {
commandLine "npm", "audit", "fix" commandLine 'npm', 'install'
}
}
task buildReact(type: Exec) {
dependsOn "installReact"
workingDir "$frontendDir"
inputs.dir "$frontendDir"
group = BasePlugin.BUILD_GROUP
if (System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows')) {
commandLine "npm.cmd", "run-script", "build"
} else {
commandLine "npm", "run-script", "build"
}
}
task copyReactBuildFiles(type: Copy) {
dependsOn "buildReact"
from "$frontendDir/build"
into "$projectDir/src/main/resources/static"
}
2. docker buildx를 통해 multi platform image(amd, arm)를 build 함으로써, Docker-Hub에 image가 업로드 되도록 한다.
- 물론 나의 경우엔 개발 환경은 맥북 M1(arm)이고, 배포서버는 ubuntu(amd)였기 때문에 multi platform image를 build 하였는데, 이와 같은 상황이 아니라면, docker 이미지를 build한 후 docker-Hub에 push 하는 절차가 추가로 필요하다.
- Docker의 Multi platform image build에 대해서는 이 글에서 자세하게 다뤄본다.
3. Docker Hub에 webhooks을 추가해 "prod" 태그가 붙은 이미지가 push 되면, 배포서버의 특정 port로 HTTP POST 요청을 날리도록 한다.
4. 아래 스크립트를 통해 서버의 특정 포트에 요청이 들어오면, 서버에서 기존에 run 중이던 docker image를 내리고 새로운 image를 띄우도록 하였다.
const http = require('http');
const {exec} = require('child_process');
const server = http.createServer((req, res) => {
if (req.url === '/hooks/deploy') {
console.log('Webhook received')
let requestBody = '';
req.on('data', (chunk) => {
requestBody += chunk.toString();
})
req.on('end', () => {
const payload = JSON.parse(requestBody);
console.log(payload);
if (payload.push_data.tag === 'prod') {
let step = 'start';
try {
//Stop and Restart compose
step = 'down';
exec('docker compose down', (err, stdout, stderr) => {
console.log('docker compose down: ', stdout, stderr)
});
//Delete images
step = 'cleanup';
exec('docker rmi -f \$(docker images 도커허브레포/이미지명:prod -q) \$(docker images yana94ko/cuokkamap:prod -q) \$(docker images yana94ko/weallriding:prod -q)', (err, stdout, stderr) => {
console.log('docker rmi ... : ', stdout, stderr)
})
step = 'restart';
exec('docker compose up -d', (err, stdout, stderr) => {
console.log('docker compose up -d: ', stdout)
});
//Send Response
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('OK');
console.log('Pull And Restart Container is Sucessfuly Done!!');
} catch (err){
res.statusCode = 500;
res.end('Failed');
console.log('Failed to.. : ', step);
console.log('caused by: ', err)
}
} else {
console.log('Received image tag is not \'prod\', ignored.')
}
})
}
})
server.listen(8283, () => {
console.log('Webhook server is running..')
})
야나의 야매 배포에 대해서는 이 게시글 에서 더 자세히 다뤄볼 예정이다!
3. 마주한 이슈
루트 경로를 제외한 다른 경로에서 url 입력을 통해 화면을 이동을 시도하는 경우 resource not found 에러가 발생했다.
React앱을 Build하여 나온 정적 파일이 "index.html"이기 때문에 root 경로("/")에 대한 요청에 대해서는 spring boot 어플리케이션의 ViewTemplate Engine 의해 welcompage로 사용되기 때문에 문제없이 화면을 로드할 수 있었다.
* 참고 spring docs - web.servlet.spring-mvc.welcome page
또한 React 앱이 SPA(Single Page Application)이었기 때문에, 마우스 클릭 등 클라이언트와의 상호작용을 통한 페이지 이동의 경우에도 문제없이 수행되었다. 해당 방법으로 이동하는 경우 React앱의 history api를 통해 url 입력란에 보이는 url도 변경되어 보였기에 초반에는 아래의 문제를 인지하지 못했었다.
하지만 루트경로가 아닌 경로들에 대해서 url입력을 통해 이동을 시도하는 경우, Resource Not Found 에러가 발생했다. 해당 경로 요청을 위한 정적 리소스를 발견하지 못했다는 뜻이다. 즉, 들어온 요청에 대해 spring의 dispatcher servlet단에서 애초에 servlet이 해당 url 요청에 대한 view reslover를 찾지 못해 Resoure Not Found 에러가 발생된 것이었다.
4. 해결방법 탐색
해결해야 하는 문제를 한 문장으로 말하면 "루트 경로뿐 아니라 모든 경로에 대해서 index.html파일을 serving 하도록 구현"해야 한다. index.html이 클라이언트에게 던져지기만 하면, 이후에는 react의 react router에 의해서 view가 전환될 것이라고 예상했다. 따라서 아래의 방법 중 한 가지를 선택해서 spring 어플리케이션에서 react app을 viewTemplate로 이용하고자 했다.
- 모든 요청에 대해서 index.html을 view로 serving하도록 controller를 변경하던가,
- ViewTemplate Engine이 view 파일을 find 할 때 모든 요청을 index.html파일로 forward하도록 설정해주거나,
- spring security 를 사용 중이기 때문에, custom filter를 생성해 모든 요청에 대해 응답을 index.html로 매핑해 주도록 한다.
1번 방법의 경우, 가장 쉬운 방법임과 동시에 모든 mapping에 대해서 mvc를 불러와 view를 setting해주어야 하기에 가장 비효율적인 방법이라고 생각했다. 특히 @RestController를 사용하고 있던 상황에서 가장 지양하고 싶던 방법이기에, 다른 방법을 우선적으로 시도해보고자 했다.
해당 프로젝트가 spring security를 이용 중이었기에 Filter에 대한 학습을 진행 중이기도 했으며, 2번 방법의 경우 spring의 viewtemplate에 대해서 제대로 이해한 후 사용해야겠다고 판단이 들었으나, mvp 개발을 진행 중인 현황에서는 개발 지연이 일어날 수 있다고 판단해 우선 3번 방법을 사용해 mvc 모델을 개발하고 추후에 스터디를 통해 더 효율적이고 설득되는 방법으로 리팩터링 하기로 결정했다.
5. 해결 : spring servlet custom filter ? 가능한가?
1차 해결방법 : custom filter 생성
아래와 같이 /api 혹은 /resource로 시작하지 않는 요청들에 대해서는 "index.html"파일을 view로 리디섹션 해주는 코드를 추가해 줌으로써 위 문제를 해결할 수 있었다.
이러한 해결 방법은 "클라이언트(FE)의 라우팅과, BE의 라우팅이 겹치지 않을 때"에만 가능했다. 해당 프로젝트의 경우에는 기존에 rest-api를 시도하며 백엔드의 모든 controller의 요청 라우팅의 시작을 "/api/*"로 설정해 두었기에 FE의 view routing과 겹치는 라우팅이 없어서 활용 가능했던 부분이었다.
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class AddDefaultViewFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse res = (HttpServletResponse) servletResponse;
// 예외 경로 체크 (API 호출이나 리소스 접근 등을 위한 경로는 제외)
String path = req.getRequestURI();
if (!path.startsWith("/api")) {
// index.html로 리디렉션
req.getRequestDispatcher("/index.html").forward(req, res);
} else {
// 다른 필터나 리소스에 요청을 계속 진행
filterChain.doFilter(servletRequest, servletResponse);
}
}
}
하지만, 이러한 방법은 "모든 요청에 대해 view 파일을 응답한다"는 문제점이 있었다. favicon과 manifest, static의 js파일들을 요청해올때에도 view를 setting해주려 했기 때문에 화면을 정상적으로 로딩해주지 못했다.
2차 해결방법
staticResources들에 대한 경로를 별도도 표기하고, path가 staticResources로 시작하지 않는 경우에만 forward하도록 구현했다.
package growth.soft.jobconsulting.global.filter;
import org.springframework.stereotype.Component;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.List;
@Component
public class RoutingFilter implements Filter {
private static final List<String> staticResources = Arrays.asList( "/favicon-32x32.png", "/manifest.json", "/static/");
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String path = req.getRequestURI().substring(req.getContextPath().length());
if(staticResources.stream().noneMatch(path::startsWith)){
// API 경로가 아니면 index.html로 리디렉션
if (!path.startsWith("/api")) {
req.getRequestDispatcher("/index.html").forward(req, res);
}
}
filterChain.doFilter(request, response);
}
}
위처럼 staticResource가 아닌경우에만 view 를 지정해 주었더니, 프론트엔드 react router에 없는 요청의 경우엔 자동으로 "/404"로 페이지 전환이 일어났다.
하지만 위의 방법도 완벽한 방법은 아니었다.
위의 방법으로 filter를 세팅한 뒤, allowedRoutes 경로에 없는 요청을 날릴 경우 아래와 같은 에러 로그가 찍히기 시작했다.
2024-04-17T16:52:29.751+09:00 ERROR 74282 --- [nio-8080-exec-5] g.s.j.g.e.GlobalExceptionHandler : 예외 발생, 들어온 요청 :ServletWebRequest: uri=/404;client=0:0:0:0:0:0:0:1
2024-04-17T16:52:29.752+09:00 WARN 74282 --- [nio-8080-exec-5] .m.m.a.ExceptionHandlerExceptionResolver : Failure in @ExceptionHandler growth.soft.jobconsulting.global.exception.GlobalExceptionHandler#handleException(Exception, WebRequest)
org.springframework.http.converter.HttpMessageNotWritableException: No converter for [class growth.soft.jobconsulting.global.dto.ApiResponse] with preset Content-Type 'text/html'
at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:319) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.mvc.method.annotation.HttpEntityMethodProcessor.handleReturnValue(HttpEntityMethodProcessor.java:245) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:78) ~[spring-web-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:136) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver.doResolveHandlerMethodException(ExceptionHandlerExceptionResolver.java:432) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.handler.AbstractHandlerMethodExceptionResolver.doResolveException(AbstractHandlerMethodExceptionResolver.java:74) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver.resolveException(AbstractHandlerExceptionResolver.java:161) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.handler.HandlerExceptionResolverComposite.resolveException(HandlerExceptionResolverComposite.java:80) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.DispatcherServlet.processHandlerException(DispatcherServlet.java:1357) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1160) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1106) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) ~[spring-webmvc-6.1.1.jar:6.1.1]
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903) ~[spring-webmvc-6.1.1.jar:6.1.1]
forward를 해준 이후에도 serviet의 filter가 해당 필터 이후의 dofilter를 실행하게 되어, 들어온 경로에 대한 static resource를 찾을수 없어 발생시키는 에러였다.
3차 해결방법
따라서 마지막으로 forward이후에는 이후의 필터를 실행하지 않도록 return 구문을 추가해주었다.
package growth.soft.jobconsulting.global.filter;
import org.springframework.stereotype.Component;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.List;
@Component
public class RoutingFilter implements Filter {
//
private static final List<String> staticResources = Arrays.asList( "/favicon-32x32.png", "/manifest.json", "/static/");
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String path = req.getRequestURI().substring(req.getContextPath().length());
if(staticResources.stream().noneMatch(path::startsWith)){
// API 경로가 아니면 index.html로 리디렉션
if (!path.startsWith("/api")) {
req.getRequestDispatcher("/index.html").forward(req, res);
return;
}
}
filterChain.doFilter(request, response);
}
}
우선 필터를 통해 해결하긴 했지만, staticResources를 list로 추가해준 부분이 마음에 걸린다. 추후 스터디를 통해 ViewTemplate관련 설정으로 위와같은 하드코딩을 제거할 수 있다면 개선이 필요할것같다.
6. MVC 아키텍처는 지양되어야 하는가?
위 프로젝트 구조를 고민하던 당시, 멘토링 세션에서 관련 질문을 멘토님께 드렸던 적 있다. 그때 멘토님께서 "MVC 패턴이 지양되는 이유를 고민해 보라"며 조언해 주셨고, 이와 관련해서 왜 최근 들어서 "프론트엔드와 백엔드 서버를 분리하거나, 마이크로 아키텍처가 도입되는 등의 패러다임 변화가 일어나는가"에 대해서 고민해 보게 되었다.
결론만 우선 말하자면.. MVC 패턴 자체가 지양되어야 한다기보다는, 현대적인 소프트웨어 개발의 요구와 트렌드에 따라 보다 유연하고 확장 가능한 아키텍처로의 전환되고 있는 것 같다는 판단을 내리게 되었다. 따라서 각 프로젝트마다 요구사항이나 환경에 맞추어 어떠한 패턴을 가지고 가는 게 맞는지 고민이 필요하다는 점과, 여러 소프트웨어 아키텍처에 대해서 공부해야 할 필요성을 느꼈다..!
아래에 나열된 내용은 사실에 입각한 정보가 아닌, 최근 동향(사실)들과 그에 연관된 "개인적인 추론"을 정리한 것이다..!
1) 분산 시스템과 클라우드 컴퓨팅의 부상
클라우드 기반의 인프라가 보편화되면서, 애플리케이션을 여러 서비스로 나누어 각각 독립적으로 배포하고 관리할 수 있게 되었다. 따라서 이러한 환경 하에 확장성과 유연성 측면에서의 장점을 극대화하기 위한 아키텍처들이 나오게 된 것이 아닐까..?
단적인 예시로 하나의 비즈니스 서비스를 온전히 하나의 spring MVC에 담게 되는 경우에는 서비스의 규모가 커지거나 비즈니스 로직이 복잡해지는 경우 유지보수, 확장이 어려울 텐데 여러 개의 모듈로 분리되어 있는 경우에는 확장성도 전체적인 규모에서의 서비스 안정성도 늘어날 것 같다.(특정 로직의 서버가 죽더라도 다른 로직들은 사용이 가능하니까! + dispatcher servlet도 처리해주어야 하는 요청의 수가 분산될 수 있으니 servlet단의 병목 현상이 있다면 그 부분도 줄어들 수 있지 않을까..!)
2) 프론트엔드와 백엔드의 분리
기존에는 "웹개발자"라는 하나의 직군으로 이루어져 있던 부분이, 최근에는 유지보수와 개발 확장성 등을 이유로 프론트엔드(클라이언트 사이드)와 백엔드(서버 사이드)를 분리하여 개발하는 것이 일반적이게 되었으며 이에 따라 "프론트엔드 개발자"와 "백엔드 개발자"라는 별도의 직군으로 나뉘는 방향으로 변화가 일어났다.(참고로 REST API는 이러한 분리된 아키텍처에서 백엔드 로직과 프론트엔드 간의 통신을 위한 표준적인 방법을 제공하는 하나의 약속이라고 보는 관점들이 있다고 한다.)
이러한 변화에 발맞추어 프론트엔드(클라이언트 사이드)와 백엔드(서버 사이드) 개발자들이 각각의 분야에만 집중할 수 있도록 아키텍처 또한 분리되기 시작한게 아닐까...?
3) 트래픽과 서버의 수 관련 이슈
사실 위의 과정들을 거쳐, 내가 내린 결론은 이번 이야기와 가장 연관이 깊다.
백엔드와 프론트엔드 서버의 트래픽은 서로 다른 특성을 가지고 있고, 이러한 트래픽에 "효율적"으로 대응하는 방법이 서로 달랐을 것 같다.
- MVC 패턴에서는 단순히 "사용자의 접속이 늘어난 경우"에도 수평적으로 동일한 서버를 복사해 증가시키는 대응방법밖에 없는데, 이러한 경우에 단순히 view를 띄워주기 위한 로직만 필요함에도 불구하고 복잡한 로직을 돌리기 위한 리소스까지 함께 증시 켜야 한다는 문제와 더불어 데이터의 정합성까지 처리하기 위해서는 더욱더 많은 "굳이 필요하지 않았던 리소스들과, 데이터 정합성을 위한 복잡한 작업들"이 필요 해질 것이다.
- 즉, 백엔드 트래픽과 프론트엔드 트래픽이 아래와 같은 차이점을 가지고 있음에도 불구하고 MVC 패턴에서는 동일한 수의 증가만을 할 수 있기에 관련되어 낭비되는 리소스가 생길 수 밖에 없는 구조를 가지고 있는 것 같다.
- 프론트엔드 트래픽의 특성: 정적 리소스를 응답. 대규모 사용자 기반을 가진 서비스에서는 비교적 프론트엔드 트래픽이 높음. 특히, 프로모션 이벤트나 특별 할인 기간과 같이 짧은 시간 동안 사용자의 방문이 급증하는 경우, 트래픽이 매우 커질 수 있음.
- 백엔드 트래픽의 특성: 백엔드 트래픽은 프론트엔드에 비해 개별 요청의 복잡성이 높고, 데이터 처리량이 크며, 처리 시간이 비교적 긴 편.
따라서 오늘날 많은 웹 서비스들은 정적 리소스를 응답하는 프론트엔드와, 동적 리소스와 비즈니스 로직을 수행하는 WAS서버인 백엔드 서버로 분리하는 형태를 가지고 간다고 한다. 현재 속해있는 개발자 커뮤니티의 많은 현업자분들께서도 프론트엔드의 경우 "SPA"형태로 구축하고 CDN형태로 서비스하는 형식을 많이들 가지고 가신다고 하니, 추후에 시간이 난다면 CDN에 대해서도 한 번쯤 공부해보고 싶다.(CND 배포를 함에도 트래픽이 몰려 백지가 나왔다는 배민의 이벤트에서는 또 어떠한 이슈가 있던 걸까?)
더불어서 이러한 MSA 혹은 서버의 스케일아웃 형태의 서버 증설 때에, BE단에서 데이터 정합성과 데이터 동기화는 어떤 식으로 처리하는지 제대로 공부해보고 싶어 졌다..!
'Project' 카테고리의 다른 글
야나의 코딩 일기장 :) #코딩블로그 #기술블로그 #코딩 #조금씩,꾸준히
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!