최근 진행하고 있는 프로젝트가 CSR 환경에 SSR을 도입한 경험을 공유하고자 한다.
오늘 내용은 상세한 구현보다도 리액트 사용을 전제로 한 전체적인 흐름을 소개하려고 한다.
앱이 커지게 되면 CSR의 한계에 점점 부딪히게 된다. 개인적인 생각에, 앱이 커지게 되면 SSR은 필연적인 것이 아닌가 생각한다.
CSR에 SSR을 일부 적용하게 되면 CSR의 단점을 SSR이 보완해주어 앱의 완성도가 높아진다.
추상적인 말로만 풀어낸 것 같은데 구체적으로 풀면 다음과 같다.
SSR을 쓴다는 것은 프론트 서버를 따로 둔다는 것과 같은 말이기 때문에 프론트에서 서버 쪽 자원도 관리 할 수 있게 된다.
예를 들어 refresh token이 탈취당하는 경우 access token을 계속 만들 수 있기 때문에 서버에 저장하는 것이 보안에 더 좋은데,
프론트 서버를 두게 되면 refresh token을 프론트 개발자가 관리할 수 있다. 물론 refresh token에 대한 관리를 백엔드에서도 할 수 있겠지만 프론트의 토큰 전략이 바뀔 때마다 백엔드와 논의를 거쳐야 한다는 단점이 있다.
뻔한 얘기겠지만 SEO가 향상된다. 하지만 하이퍼 링크로 문서에서 다른 문서로 이동한다는 웹의 특징을 고려해봤을 때 SEO는 굉장히 중요한 부분이라고 할 수 있다.
구글 크롤러는 자바스크립트를 해석할 수 있어서 CSR 페이지도 탐색할 수 있지만 네이버나 다음 같은 사이트에서는 아직 자바스크립트 해석 기능을 지원하지 않기 때문에 검색 엔진에 잡히지 않는다.
하지만 SSR을 통해서 각 라우팅 포인트에 대한 html 문서를 정의해두면 자바스크립트를 해석할 필요가 없으므로 구글 이외의 검색 엔진에서도 최적화가 가능하다.
초기 렌더링이 CSR보다 빨라지는 이유는, CSR은 첫 html을 빈 화면으로 받고 추가적인 js 파일을 가져와서 화면에 그리는 과정까지 완료되어야 첫 화면이 사용자에게 보여지지만 SSR은 일부 완성된 html 문서로 가져오기 때문에 사용자에게 첫 화면을 보여주기까지의 시간이 크게 단축되기 때문이다. 페이지 일부가 보이는 것이지만 그것만으로도 사용자 경험은 크게 나아진다.
전체적인 구조는 다음과 같다. 도메인 네임을 통해 NginX 웹서버에 접근하면 NginX는 EC2 내부의 다른 포트에서 동작하고 있는 express 서버에 접근한다. express 서버는 일부가 완성된 html 문서를 NginX로 다시 되돌려주고 브라우저에게 그대로 전달한다. 이렇게 NginX를 한번 거쳐서 express 같은 WAS와 연결하는 방식을 reverse proxy
라고 한다. reverse proxy를 사용하는 이유는 여러 가지가 있지만, 외부 사용자들에게 WAS가 어떤 포트에서 동작하는지 숨겨 보안 적으로 강화하기 위한 목적이 있다.
이런 흐름으로 사용자는 어느 정도 화면이 만들어진 html 문서를 처음 응답으로 받아볼 수가 있는데, 비어있는 body를 첫 html 응답으로 가져오는 CSR보다는 훨씬 빠른 화면 로딩 속도를 보여준다.
app.get('/', (req, res) => {
const indexFile = path.resolve(path.join(__dirname, '../client/index.html'));
const app = ReactDOMServer.renderToString(<App />);
fs.readFile(indexFile, 'utf8', (err, data) => {
if (err) {
console.error('Something went wrong:', err);
return res.status(500).send('Oops, better luck next time!');
}
const result = data.replace('<div id="root"></div>', `<div id="root">${app}</div>`)
return res.send(result);
});
}
리액트를 사용한다면 개발자는 express.js에서 대략 위와 같은 코드를 작성하는데 ReactDOMServer.renderToString
을 사용한다는 점을 빼면 CSR에서 렌더링하던 방식과 크게 다른 것은 없다. renderToString
은 리액트가 컴포넌트 구조를 파악하고 HTML string으로 바꿔주는 동작을 한다.
이렇게 작업을 마치면 개발자 도구에서 html을 받아온 결과가 일부 완성된 것을 확인할 수 있다.
첫 html을 받아온 이 시점 이후부터는 더는 SSR이 아닌 CSR로 바뀐다. 페이지 이동도 SPA처럼 동작하는데 이게 어떻게 가능한 것일까?
일단 우리는 CSR + SSR의 환경이기 때문에 첫 html을 받아온 후에도 CSR 환경을 동작시키기 위한 javascript를 불러오는 script 태그가 여전히 들어가 있는 상태다. 그래서 html을 받아온 후 js파일을 가져와 CSR로 동작시키기 때문에 SSR에서 CSR로의 변경이 가능한 것이다.
그럼 또 다른 의문이 들 것이다.
이렇게 되면 SSR에 의해서 일부 렌더링 된 화면을 CSR에 의해 다시 그리는 것이 아닌가? 엄청나게 비효율적일 것 같은데?
그 의문은 ReactDOM.hydrate()
가 풀어준다. 우리가 이전에 CSR로 렌더링할 때 쓰던 ReactDOM.render()
메서드를 hydrate로 바꿔주기만 하면 리액트는 'SSR에 의해서 일부는 렌더링 되어서 오는구나! 나는 나머지 부분만 채워주면 되겠네?'라고 생각한 뒤 이벤트 리스너만 등록하거나 렌더링되지 않은 나머지 부분을 추가 렌더링해 주는 등 효율적으로 동작한다.
//index.js
ReactDOM.hydrate(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root'),
);
이미 SSR에 성공했지만 약간 문제가 있다. 이미 로그인되어있는 사용자는 첫 화면으로 로그인되어있는 화면을 볼 수 있어야 할 것이다. 하지만 현재 상태는 로그인 여부와는 상관없이 무조건 비회원 화면만을 응답으로 보내주고 있다.
로그인 상태일 때 위와 같은 화면을 서버에서 내려줄 순 없을까? 결론부터 말하면, 가능하다.
먼저 로그아웃 상태에서 로그인하는 경우부터 알아보자.
로그아웃 상태에서 처음 로그인하는 상황을 떠올려보자. 필자는 보안을 위해 refresh token을 브라우저에 저장하지 않고 서버의 세션에 저장하고 브라우저는 access token만을 private 변수로 가지고 있는 상황을 가정했다.
브라우저는 백엔드 서버로 로그인 요청을 보내고 access token과 refresh token을 받아온다. 여기서 access token은 사용자 권한이 필요한 다음 요청에서 사용해야 하니 브라우저에서 가지고 있어야 할 것이다. 문제는 누군가 내가 로그인한 상태를 기억하고 있어야 다음 웹페이지 접근 시 로그인 상태가 풀리지 않는다는 것인데, 이 역할을 프론트 서버가 하게 된다.
브라우저는 프론트 서버로 refresh token, access token 그리고 유저 정보를 보내고 프론트 서버는 이를 받아 세션에 저장한 후 세션 id를 브라우저에게 돌려준다. 브라우저는 이 세션 id를 만료 기간 전까지 쿠키로 가지고 있게 되고, 다음 웹페이지 접근 시 사용하게 된다.
이번에는 로그인 상태에서 새로 고침을 통해 웹페이지 진입을 한다고 생각해보자. 일단 로그인 상태
라는 것은 브라우저가 세션 id를 쿠키로 가지고 있다는 의미와 같다. 그렇다면 쿠키 특성상 웹페이지 진입 시 자동으로 html 요청에 쿠키가 담겨 프론트 서버로 넘어가게 될 것이고 express에서는 세션 id를 통해서 사용자를 식별할 수 있다. 식별된 사용자의 정보에 따라 첫 html을 구성하고, access token도 브라우저에게 넘겨 줄 수 있다.
access token을 넘겨주는 이유는 초기 웹페이지 진입 시 브라우저는 백엔드 서버와 통신에 필요한 access token은 가지고 있지 않기 때문이다. 브라우저가 프론트 서버를 거치지 않고 직접 백엔드 서버와 통신하기 위해서는 access token이 꼭 필요하다.
여기서 html 문서에 어떻게 access token과 같은 정보를 담아 보낼 수 있는지 의문이 들 것이다.
이 문제는 html 문서에 script 태그를 포함하는 것으로 해결한다. 예를 들면 다음과 같은 방법이다.
전달하는 정보가 객체면 JSON.stringify()
를 사용해 전달한다. 이러한 방법을 좀 더 편하게 사용할 수 있도록 리덕스나 리액트 쿼리 같은 상태 관리 라이브러리도 api 지원을 하고 있다. 리액트 쿼리 같은 경우에, 미리 프론트 서버에서 백엔드 서버로부터 데이터를 가져온 후 script 태그로 포함해 브라우저에서는 리액트 쿼리가 백엔드 서버로 추가 요청을 보내지 않는 것도 가능하다. (참고)
🎯 주의할 것은 이 방법이 XSS 공격에 취약할 수 있다는 사실이다. 하지만 간단한 해결법도 있으니 여기를 참고하여 XSS 공격을 방지할 수 있도록 하자. 요약하자면 script 태그에 정보를 담을 때 JSON.stringify()
로 정보를 담아서는 안 되고 serialize-javascript와 같은 라이브러리를 사용해서 담아야 한다는 것이다. 그 이유는 우리가 JSON.stringify()로 브라우저에 넘기는 정보가 악의적인 스크립트 태그를 가지고 있을 수도 있기 때문이다. 예를 들면 다음과 같다.
<script>
window.__userInJSON__ = {"username":"</script> <- 여기까지만 의도한 대로 실행 <script>alert(\"요건 몰랐지\")</script>"}
</script>
이 경우 유저네임에 악의적인 태그를 작성한 사용자에 의해 alert("요건 몰랐지")
메서드가 호출된다. 이 방법을 통해 민감한 작업을 수행한다면... 생각만 해도 아찔하다.
결과적으로는 위와 같이 로그인 상태의 html을 첫 응답으로 받아볼 수 있다.
오늘은 CSR에 SSR을 도입하는 흐름을 같이 따라가 봤다. 처음에 공부했을 때는 도대체 어떤 흐름인가 혼란도 많이 왔었고 특히 로그인 부분에서 많이 헤맸는데 이제 어느 정도 감이 잡히는 것 같다.
CSR + SSR 환경을 도입하면서 느낀 점은 굉장히 매력적인 구조라는 것이다. 특히 서버와 클라이언트 모두 하나의 프로젝트에서 javascript 언어로 개발할 수 있다는 것이었는데, 생산성이 굉장히 좋다고 느꼈다. 좀 더 개발을 해봐야 알겠지만, 앱이 커지게 되면 필연적으로 SSR은 도입해야 할 기술이라 느꼈다.
메일로 채용 제안 드렸습니다