SPA
안녕하세요. 프론트엔드 개발자 나상권입니다. 어반베이스에서는 개발자사이트와 ARScale을 비롯한 사내 웹서비스 전반을 개발하고 관리하고 있습니다.

프레임워크의 폭풍 속에 프론트엔드 개발자들은 그야말로 하루하루가 마라톤 같습니다. 하루가 멀다하고 쏟아져 나오는 기술들은 새롭고 빠른 개발 경험과 흥미를 주는 한편 새로운 문제점을 동반하기도 합니다.

오늘은, 최근 프론트엔드 트렌드인 SPA가 가져온 골칫거리와 그것을 해결했던 이야기를 공유하려고 합니다.

SPA? MPA?

SPA는 Single Page Application 의 약자로, 말 그대로 한 페이지만 있는 웹 애플리케이션(혹은 웹사이트) 입니다.

반면 MPA는 Multiple Page Application, 여러 장의 페이지로 이루어져 있는 애플리케이션을 의미합니다. 사용자의 요청에 따라 새로운 페이지를 만들어 보여주는 전통적인 방식이죠.

그런데 오늘날 사용자들은 어느 것이 SPA인지 MPA인지 구분하지 못합니다. SPA는 페이지가 하나이기 때문에 브라우저가 보여주는 주소가 항상 똑같을 것 같지만,

그렇지 않습니다!

SPA 들도 다양한 주소를 가집니다. 다만 “가상의 주소” 이기 때문에 아래에 소개할 끔찍한 문제가 발생합니다.

그래서 어떤 문제가 생겼나요?

SPA는 기본적으로 우리가 사용하는 브라우저(클라이언트) 에서 렌더링합니다. 리소스도 크게 문제가 없는 한 한번만 로딩하고 이후에는 가상의 페이지 이동이나 컴포넌트의 상태 변경에 따라 수정된 페이지를 보여주게 됩니다.

컴포넌트 단위로 개발하기 때문에 생산성이 좋습니다. 또한, 리소스가 부분적으로 로딩되고 클라이언트가 연산을 처리해 성능 면에서도 매우 큰 이점이 있습니다. 그러나 기본적으로 한 페이지, 가상의 주소이기 때문에 지정된 경로를 통해 접속하지 않거나 검색엔진봇, SNS 스크랩봇은 허탕만 치고 갑니다.

자바스크립트 코드들은 실행도 되지 않습니다.

주소를 쳤는데 페이지를 찾을 수 없습니다.

SNS 스크랩봇에서는 이미지와 정보 아무것도 없습니다.

검색엔진에서는 서비스 메인페이지만 노출됩니다.

이미 일은 저질러졌습니다. 개발된 SPA 어플리케이션을 MPA로 바꾸기에는 이미 늦었습니다. 그럼 우리는 어떤 길로 가야할까요?

SSR의 유혹

Server Side Rendering 의 약자입니다. 윗부분에서 조용히 지나갔지만, CSR(Client Side Rendering)을 보완하기 위한 방안이었습니다.

“클라이언트가 꾹꾹 눌러 찾아들어 오기 전에 우리 서버에서 미리 페이지로 렌더링 해놓으면 문제들이 해결될 거야!”

맞습니다. 아주 친절하고 매우 바람직하며 쉬운 답입니다. 그런데 여기에는 단순한 조건이 붙습니다.

SERVER

웬걸, 우리가 추구하는 방향은 서버리스, S3 정적 호스팅 이네요.

SSR은 일단 서버가 필요합니다. (Lambda를 이용하면 Nuxt서버를 직접 올려 사용할 수 있다는데, 이러면 FE의 영역과 API의 영역 경계가 모호해집니다.)

S3에 동적페이지 모두를 Prerender 해서 넣을 수는 없습니다.

결론은…

우리 서비스에서는 쓸 수 없다.

그럼 우린 어디로 가야 하나요?

0. 개발환경

여기서는 VueJS + NuxtJS 프레임워크를 이용하고, AWS Lambda API를 호출하여 통신하며, S3에 클라이언트를 업로드하여 호스팅하는 구조를 가정해봅시다.

1. 가상의 주소를 쳐서 들어갈 수 있게 해보자.

신비롭게도 정적 파일로 컴파일하기 전, 개발용 서버(개인 PC)에서는 가상의 주소처럼 보이는 주소를 직접 쳐도 아주 잘 뜹니다. 아무래도 NuxtJS 내장 서버는 가상의 주소를 받아도 실제로 보여줄 수 있게 만드는 신비로운 힘을 가졌나 봅니다.

정적 호스팅용으로 컴파일해서 일반 호스팅으로 올리면, 페이지에 접속할 때 그저 404 Not Found 라는 문구만 만나게 됩니다. 이 부분은 살짝만 리서치해 보면 해결점이 보입니다.

S3뿐만 아니라 Apache, NginX 같은 정적 호스팅(서버)에는 호스팅 조건을 설정할 수 있습니다. 주소를 치고 들어왔을 때, “조건에 맞으면 특정 페이지를 대신 열어줘”라고 설정해놓고, 이 특정 페이지에서는 “아 어떤 조건 따라 들어왔구나!” 하고 가상의 페이지로 다시 보내줍니다.

Index.html을 ‘일단은 받기 때문에’ 잠시 메인페이지가 뜨는 현상이 생길 수 있습니다. 이는 로딩화면이나 빈 화면을 넣으면 티 안 나게 넘어갑니다.

S3 에서는 정적 사이트 호스팅에서 리디렉션 규칙을 다음과 같이 설정합니다.

<RoutingRules>
  <RoutingRule>
    <Condition>
      <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
    </Condition>
    <Redirect>
      <HostName>${HOSTNAME}</HostName>
      <ReplaceKeyPrefixWith>#!/</ReplaceKeyPrefixWith>
    </Redirect>
  </RoutingRule>
</RoutingRules>

VueJS의 메인 페이지 라이프사이클에 다음과 같이 등록합니다.

const { hash } = this.$route;
if (!hash || hash.length <= 2) {
  this.$router.replace('/');
  return;
}
this.$router.replace(hash.substring(2));

그럼 기가 막히게, 분명히 파일이 없는 가상의 주소일 텐데도 잘 열립니다.

2. SNS 공유하기를 누르면 미리 보기 이미지와 디스크립션이 나오게 하자.

이걸 어떻게 해야 하지 고민하던 그 시점에 애플리케이션 팀에서 어떤 요청이 들어왔습니다.

“파이어베이스 다이내믹 링크 좀 적용해주세요.”

구글의 파이어베이스에서 제공하는 다이내믹 링크는 멀티플랫폼(iOS, Android, Desktop)에 대응하여 원하는 대로 작동하는 링크라고 합니다.

아래는 구글 공식문서 내용입니다.

Firebase 동적 링크는 앱 설치 여부에 관계없이 여러 플랫폼에서 원하는대로 작동하는 링크입니다.

동적 링크를 사용하면 링크를 연 플랫폼에서 사용자에게 최상의 환경을 제공할 수 있습니다. iOS 또는 Android에서 동적 링크를 연 사용자를 기본 앱의 링크된 콘텐츠로 직접 이동시킬 수 있습니다. 같은 동적 링크를 데스크톱 브라우저에서 열었다면 웹사이트의 해당 콘텐츠로 안내할 수 있습니다.

또한 동적 링크는 앱 설치 여부에 따라 적절히 작동합니다. iOS 또는 Android에서 앱을 설치하지 않은 사용자가 동적 링크를 열면 앱을 설치하는 화면으로 안내되고, 앱을 설치하고 시작하면 최초 링크에 그대로 액세스할 수 있습니다.

그러니까 파이어베이스로 생성된 동적링크로 들어가면,

이런 절차를 따라 각 플랫폼에 맞는 애플리케이션을 로드하게 됩니다. 이는 내부적으로 파이어베이스 서버의 redirection(301) 기능을 이용하기 때문에 매우 빠릅니다.

단일링크를 통해 여러 플랫폼에 대응하는 주소를 만든다는 게 좋다는 건 알겠는데, 이게 글의 내용과 무슨 관련이 있을까요?

바로 다음에 소개할 기능 때문입니다.

소셜 메타데이터로 링크 미리보기 생성

동적 링크를 만들때 소셜 메타데이터를 지정하면 앱 및 사이트에서 동적 링크가 표시되는 방식을 개선할 수 있습니다. 이 메타데이터는 지원 서비스에 소셜 메타 태그 형태로 전달되며, 서비스에서는 이 태그를 사용하여 링크를 보기 좋게 표현합니다.

예를 들어, 소셜 애플리케이션은 공유된 동적 링크를 표현할 때 메타데이터를 사용하여 제목, 링크된 콘텐츠의 설명, 미리보기 이미지가 포함된 카드로 표현할 수 있습니다.

또한 iOS에서 동적 링크를 열 때 표시되는 앱 미리보기 페이지에서는 메타데이터가 제공될 경우 이 메타데이터를 사용하여 링크 콘텐츠의 미리보기를 표시합니다.

바로 이부분!

컨텐츠의 제목과 설명 그리고 대표이미지를 설정하면, SNS 공유할 때 아래 이미지처럼 잘 표시됩니다.

하지만 위에 언급한 “1. 가상의 주소를 쳐서 들어갈 수 있게 해보자” 가 꼭 선행되어야 합니다.

최근에는 구글의 파이어베이스 외에도, 브랜치(branch) 라는 서비스가 있어 각자의 요구사항에 맞추어 선택하면 SNS 공유하기까지는 어느 정도 해결할 수 있습니다.

그런데, 이렇게까지 했는데도 찝찝합니다.

  • SNS 공유용으로 주소를 생성해야 하고 꼭 그 주소를 사용해야만 합니다.

  • 다른 사이트들처럼 주소창 복붙, 공유를 하면 공유가 안 됩니다.

  • 검색엔진에서는 메인페이지 말고는 못 읽어갑니다.

어쩌면 좋죠?

3. 크롤러(검색엔진봇, SNS 스크랩봇 등) 에 다른 정보를 보여주자.

다행히 파이어베이스에서 얻은 힌트가 있습니다. 서버의 리디렉션 규칙을 이용해 내부적으로 다른 페이지를 보여주는 것입니다. 아쉽게도 이 부분에서는 Lambda의 서버역할이 필요합니다.

위와 같은 순서로 진행될 것입니다.

동적데이터가 필요하지 않은 페이지 부분은 위의 1번에서 적용한 내용과 같습니다. 동적데이터가 필요한 페이지 부분이 핵심입니다.

<RoutingRules>
<RoutingRule>
  <Condition>
    <KeyPrefixEquals>{Lambda를 적용할 페이지 경로 Prefix}</KeyPrefixEquals>
  </Condition>
  <Redirect>
    <Protocol>{Lambda의 네트워크 프로토콜(http(s))}</Protocol>
    <HostName>{Lambda 호스트}</HostName>
    <ReplaceKeyPrefixWith>{Lambda 엔드포인트}{Lambda를 적용할 페이지 경로 Prefix}</ReplaceKeyPrefixWith>
  </Redirect>
</RoutingRule>
<RoutingRule>
  <Condition>
    <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
  </Condition>
  <Redirect>
    <HostName>{hostname}</HostName>
    <ReplaceKeyPrefixWith>#!/</ReplaceKeyPrefixWith>
  </Redirect>
</RoutingRule>
</RoutingRules>

첫번째 라우팅 룰을 보면 경로를 통째로 치환하여 Lambda 로 보내버립니다. 이 요청을 받은 Lambda 는 페이지 경로를 받아 분석하고 사용합니다.

코드 방식입니다.

// bot check
let bBot = false;
const userAgent = headers['User-Agent'];
if (userAgent.match(/baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest|slackbot|vkShare|W3C_Validator/i)) {
  bBot = true;
}
 
// 봇일 경우
{
  statusCode: 200,
  headers: {
    'Content-Type': 'text/html',
  },
  body: '<html><head><meta .... /></head>....</html>', // 여기에 meta open graph 를 넣으면 된다. db연동으로 동적인 meta tag를 넣어주고 og:url 만 원래 redirect전의 것으로 입력한다.
}
 
 
// 봇이 아닐경우
{
  statusCode: 301,
  headers: {
    Location: `${host}/#!/${원래가려던경로}`
  }
}

봇이 아닐 경우

사용자가 직접 들어간 경우로 볼 수 있기 때문에 단순 리디렉션을 진행해줍니다. 다만 이 경로 앞에는 #!/를 붙여 index.html이 가상 라우팅을 받아들일 수 있도록 합니다. 그럼 사용자가 들어갈 때는 원래 작동하던 그대로 작동할 것입니다.

봇이 맞을 경우

굳이 리디렉션을 해줄 필요가 없습니다. 봇의 목적은 페이지 HTML 문서의 내부를 읽어 정보를 도출하는 작업을 하기 때문입니다.

위의 작업을 진행해주면 봇이 접근할 때, “아 여기는 단순하게 html로 이루어진 페이지이구나!” 하고 정상적으로 읽어낼 것입니다. 여기서 html은 Lambda가 DB의 정보를 가져오는 등 여러 가공을 거친 동적 페이지 입니다.

Lambda가 생성할 html은 그 형식을 자유롭게 지정하는 것이 가능하기 때문에 SNS 스크래퍼가 이용하는 Open Graph 메타태그나, 그 외 HTML5 대표 엘리먼트 등을 이용해 SEO 를 적용할 수도 있습니다.

끝내며

  1. SSR의 유혹을 그대로 받아들여 보는 일을 고려하고 있습니다. Nuxt에는 동적페이지를 SSR 할 수 있는 방법을 제공하고는 있습니다. 다만, 주기적으로 컴파일(렌더링)하고, S3에 추가해야 하며, 이걸 언제 해야 할지 어떻게 올려야 할지 시점을 잡는 것은 고민입니다. 그렇지만 이게 된다면 오히려 1,3번 방법을 무시해도 해결이 될테니 매우 구미가 당기는 일입니다.

  2. 최근 검색엔진 봇이나 SNS-메신저 스크래퍼들이 개발중이라는 소식이 들려오고 있습니다. 어쩌면 위의 고민들이 자연스럽게 해결되는 시기가 올 수도 있을 것 같습니다.

  3. 지금 어반베이스 서비스는 여기까지 적용해보았습니다. 그리고 더 나은 방법을 고민하고 있습니다.