Skip to content

Commit 92e39a0

Browse files
add post 'SSR 없이 React에서 OG/SEO 처리하기'
Signed-off-by: jonghoonpark <dev@jonghoonpark.com>
1 parent fda5eee commit 92e39a0

File tree

2 files changed

+197
-0
lines changed

2 files changed

+197
-0
lines changed

_posts/2026-01-25-react-og.md

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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+
![thumbnail example](/assets/images/2026-01-25-react-og/thumbnail-example.png)
194+
195+
## 마무리
196+
197+
SSR이나 별도 프레임워크를 도입하지 않고도, 상황에 맞게 문제를 분리해서 해결할 수 있다는 점에서 나름 의미 있는 선택이었다. 모든 경우에 정답은 아니지만, 비슷한 고민을 하는 사람에게 하나의 참고 사례가 되었으면 한다.
21.2 KB
Loading

0 commit comments

Comments
 (0)