|
| 1 | +--- |
| 2 | +layout: "post" |
| 3 | +title: "SSR 없이 React에서 OG/SEO 처리하기" |
| 4 | +categories: |
| 5 | + - "개발" |
| 6 | +tags: |
| 7 | + - "React" |
| 8 | + - "SSR" |
| 9 | + - "Server Side Rendering" |
| 10 | + - "CSR" |
| 11 | + - "Client Side Rendering" |
| 12 | + - "OG" |
| 13 | + - "OpenGraph" |
| 14 | + - "Thumbnail" |
| 15 | + - "Bot" |
| 16 | + - "SEO" |
| 17 | + - "SPA" |
| 18 | +date: "2026-01-25 00:00:00 +0900" |
| 19 | +toc: true |
| 20 | +--- |
| 21 | + |
| 22 | +최근 React로 개인적으로 프로젝트를 진행해보고 있다. React는 주로 코드를 정적 리소스들로 빌드하여, 이 리스소들을 Client Side 에서 렌더링 한다. |
| 23 | + |
| 24 | +여기서 발생된 문제는 정적 리소스 상태에서는 api를 통한 데이터도 받아오지 않은 상태라는 것이다. 그렇기 때문에 단순히 html만 받아와서는 해당 페이지에 어떤 내용이 올지 알지 못한다. |
| 25 | + |
| 26 | +그래서 카카오톡이나 각종 Social Media 에서 링크에 대한 메타정보를 가져올 때 실패하였다. |
| 27 | +2026년에는 될 줄 알았는데 안되는것이 충격적이긴 했다. 뭔가 수집하는쪽에서 렌더링을 한 후 가져오거나 하지는 않았었다. |
| 28 | + |
| 29 | +아무튼 그래서 어떻게 해결해야 하나 좀 찾아보았다. |
| 30 | + |
| 31 | +## 해결방법 고민 |
| 32 | + |
| 33 | +첫번째로는 SSR 을 사용할 수 있다. |
| 34 | +앞서 이야기 한 대로 React의 전통적인 방식은 SSR을 사용하지 않는다. |
| 35 | +이를 보완하기 위해 Next.js 같은 프레임워크가 SSR(서버 사이드 렌더링)을 주도해 왔고, 최근에는 React에서 서버 컴포넌트(RSC)라는 기능을 엔진 자체에 도입하면서, 프레임워크와 밀접하게 결합된 표준화된 서버 렌더링 환경을 제공하게 되었다고 한다. |
| 36 | + |
| 37 | +하지만 이 방법은 사용하고 싶지 않았다. SSR을 사용하면 FE를 위해서도 서버를 띄워줘야 했기 때문이다. 나는 FE 영역을 가능하면 Client Side에서 해결하고 싶었기 때문에, 이 방법은 채택하지 않았다. |
| 38 | + |
| 39 | +다음으로 `Prerender.io` 와 같은 솔루션을 사용하는 방법이 있었다. 추가적인 비용을 내고 싶지 않았기 때문에 마찬가지로 사용하지 않았다. 아직 그 정도 사이즈의 트래픽이 있는 것도 아니었다. |
| 40 | + |
| 41 | +그 결과 다음과 같은 컨셉을 구상하여 도입하였다. be 에서 컨텐츠의 변동이 발생했을 경우, 해당 컨텐츠를 기반으로 og 처리를 위한 html을 생성한다. nginx 에서는 요청한 주체가 bot 일 경우, 해당 요청을 react 로 보내는게 아니라, og 처리를 위해 생성된 html로 보낸다. 그러면 bot은 og 정보를 읽어갈 수 있다. |
| 42 | + |
| 43 | +## 구현 및 설정 |
| 44 | + |
| 45 | +방향성은 정하였기 때문에, Code Assistant 를 통해 빠르게 구현하였다. |
| 46 | + |
| 47 | +구현된 실제 코드는 다음과 같다. |
| 48 | + |
| 49 | +### BE |
| 50 | + |
| 51 | +Spring 에서 다음과 같이 기능을 만들었다. 내용들은 본인의 내용에 맞게 수정하면 된다. |
| 52 | +generateOgHtml 를 게시글이 생성/수정 되었을 때 처리해주면 된다. |
| 53 | + |
| 54 | +```java |
| 55 | +/** |
| 56 | + * 특정 게시글에 대한 OG HTML 수동 생성 |
| 57 | + */ |
| 58 | +@Async("ogHtmlTaskExecutor") |
| 59 | +public void generateOgHtml(Post post) { |
| 60 | + try { |
| 61 | + log.info("Starting OG HTML generation for post id: {}", post.getId()); |
| 62 | + |
| 63 | + String html = buildOgHtml(post); |
| 64 | + Path outputPath = getOutputPath(post.getId()); |
| 65 | + |
| 66 | + Files.createDirectories(outputPath.getParent()); |
| 67 | + Files.writeString(outputPath, html, StandardCharsets.UTF_8); |
| 68 | + |
| 69 | + log.info("OG HTML generated successfully for post id: {} at {}", post.getId(), outputPath); |
| 70 | + } catch (IOException e) { |
| 71 | + log.error("Failed to generate OG HTML for post id: {}", post.getId(), e); |
| 72 | + } |
| 73 | +} |
| 74 | +``` |
| 75 | + |
| 76 | +```java |
| 77 | +private String buildOgHtml(Post post) { |
| 78 | + String title = escapeHtml(post.getTitle()); |
| 79 | + String description = extractDescription(post.getContent()); |
| 80 | + String image = extractFirstImage(post.getContent()); |
| 81 | + String url = ogHtmlProperties.getBaseUrl() + "/channel/" + post.getChannel().getId() + "/post/" + post.getId(); |
| 82 | + |
| 83 | + return """ |
| 84 | + <!DOCTYPE html> |
| 85 | + <html lang="ko"> |
| 86 | + <head> |
| 87 | + <meta charset="UTF-8"> |
| 88 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 89 | +
|
| 90 | + <title>%s | %s</title> |
| 91 | + <meta name="title" content="%s"> |
| 92 | + <meta name="description" content="%s"> |
| 93 | +
|
| 94 | + <meta property="og:type" content="article"> |
| 95 | + <meta property="og:url" content="%s"> |
| 96 | + <meta property="og:title" content="%s"> |
| 97 | + <meta property="og:description" content="%s"> |
| 98 | + <meta property="og:image" content="%s"> |
| 99 | + <meta property="og:site_name" content="%s"> |
| 100 | + <meta property="og:locale" content="ko_KR"> |
| 101 | +
|
| 102 | + <meta property="twitter:card" content="summary_large_image"> |
| 103 | + <meta property="twitter:url" content="%s"> |
| 104 | + <meta property="twitter:title" content="%s"> |
| 105 | + <meta property="twitter:description" content="%s"> |
| 106 | + <meta property="twitter:image" content="%s"> |
| 107 | +
|
| 108 | + <meta http-equiv="refresh" content="0; url=%s"> |
| 109 | + </head> |
| 110 | + <body> |
| 111 | + <h1>%s</h1> |
| 112 | + <p>%s</p> |
| 113 | + <p><a href="%s">%s</a></p> |
| 114 | + </body> |
| 115 | + </html> |
| 116 | + """.formatted( |
| 117 | + title, ogHtmlProperties.getSiteName(), |
| 118 | + title, |
| 119 | + description, |
| 120 | + url, |
| 121 | + title, |
| 122 | + description, |
| 123 | + image, |
| 124 | + ogHtmlProperties.getSiteName(), |
| 125 | + url, |
| 126 | + title, |
| 127 | + description, |
| 128 | + image, |
| 129 | + url, |
| 130 | + title, |
| 131 | + description, |
| 132 | + url, url |
| 133 | + ); |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +### compose.yml |
| 138 | + |
| 139 | +아래와 같은 식으로 생성된 og-html 에 대한 volume 설정을 추가해주었다. |
| 140 | +nginx 에서 접근 가능하도록 컨테이너 외부의 공간과 연결시켜준다. |
| 141 | + |
| 142 | +```yaml |
| 143 | +spring-server: |
| 144 | + image: spring-server:latest |
| 145 | + container_name: spring-server |
| 146 | + ports: |
| 147 | + - "8080:8080" |
| 148 | + environment: |
| 149 | + - ... |
| 150 | + restart: "unless-stopped" |
| 151 | + volumes: |
| 152 | + - ./og-html:/app/og-html:U |
| 153 | +``` |
| 154 | +
|
| 155 | +### nginx |
| 156 | +
|
| 157 | +`/channel/{channelId}/post/{postId}` 형태로 요청이 들어왔을 경우 봇인지 체크한 후, 봇일경우에는 BE 에서 생성한 정적 html 파일과 연결되도록 한다. |
| 158 | + |
| 159 | +```conf |
| 160 | +location /internal-og/ { |
| 161 | + internal; |
| 162 | + alias /var/www/html/og-html/posts/; # 마지막에 / 필수 |
| 163 | +} |
| 164 | +
|
| 165 | +location ~ ^/channel/([0-9]+)/post/([0-9]+)$ { |
| 166 | + set $channel_id $1; |
| 167 | + set $post_id $2; |
| 168 | + set $is_bot 0; |
| 169 | +
|
| 170 | + # 1. 봇 체크 (카카오톡, 페이스북, 트위터 등) |
| 171 | + if ($http_user_agent ~* "facebookexternalhit|twitterbot|kakaotalk-scrap|slackbot|googlebot|bingbot|linkedinbot") { |
| 172 | + set $is_bot 1; |
| 173 | + } |
| 174 | +
|
| 175 | + # 봇인 경우 정적 OG HTML 서빙 |
| 176 | + if ($is_bot = 1) { |
| 177 | + rewrite ^/channel/[0-9]+/post/([0-9]+)$ /internal-og/$1.html last; |
| 178 | + } |
| 179 | +
|
| 180 | + # 일반 유저는 React(Vite) 앱으로 프록시 |
| 181 | + proxy_pass http://localhost:5173; |
| 182 | + proxy_set_header Host $host; |
| 183 | + proxy_set_header X-Real-IP $remote_addr; |
| 184 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| 185 | + proxy_set_header X-Forwarded-Proto $scheme; |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +## 결과 |
| 190 | + |
| 191 | +카카오톡에서 링크를 올리면 다음과 같이 잘 나오게 된 것을 볼 수 있다. |
| 192 | + |
| 193 | + |
| 194 | + |
| 195 | +## 마무리 |
| 196 | + |
| 197 | +SSR이나 별도 프레임워크를 도입하지 않고도, 상황에 맞게 문제를 분리해서 해결할 수 있다는 점에서 나름 의미 있는 선택이었다. 모든 경우에 정답은 아니지만, 비슷한 고민을 하는 사람에게 하나의 참고 사례가 되었으면 한다. |
0 commit comments