Skip to content

Commit dbe359f

Browse files
add post '[Spring] Jekyll SEO 개선해보기 (description, thumbnail 자동 생성)'
1 parent 122b747 commit dbe359f

File tree

5 files changed

+337
-0
lines changed

5 files changed

+337
-0
lines changed

_posts/2025-02-28-jekyll-seo.md

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
---
2+
layout: "post"
3+
title: "[Spring] Jekyll SEO 개선해보기 (description, thumbnail 자동 생성)"
4+
description:
5+
"Spring Boot를 활용하여 Jekyll 블로그의 SEO를 개선하기 위해 description과 썸네일을 자동 생성하는\
6+
\ 프로그램을 개발한 경험을 공유합니다. Open Graph 메타데이터를 활용하여 링크 미리보기를 개선하고, AI를 통해 본문 요약 및 썸네일\
7+
\ 이미지를 생성하여 블로그의 시각적 매력을 높였습니다. 이 과정에서 Java와 Spring Boot를 사용하여 CLI 환경에서 실행 가능한 애\
8+
플리케이션을 구현하였으며, 최종적으로 자동 생성된 description과 썸네일을 확인할 수 있습니다."
9+
categories:
10+
- "개발"
11+
tags:
12+
- "Jekyll"
13+
- "SEO"
14+
- "Java"
15+
- "Spring"
16+
- "og"
17+
- "Open Graph"
18+
- "og:description"
19+
- "og:image"
20+
- "thumbnail"
21+
- "Spring AI"
22+
- "Spring Boot"
23+
- "CommandLineRunner"
24+
- "awt"
25+
date: "2025-02-28 23:00:00 +0000"
26+
toc: true
27+
image:
28+
path: "/assets/thumbnails/2025-02-28-jekyll-seo.jpg"
29+
---
30+
31+
# Jekyll SEO 개선해보기 (description, thumbnail 자동 생성)
32+
33+
이 글에서는 Spring Boot를 활용해 description과 썸네일을 자동 생성하는 프로그램을 만든 경험을 공유한다.
34+
Jekyll 기반 블로그를 운영하며 SEO 개선 필요성을 느꼈고, 이를 개선하기 위해 application 을 개발하여 보았다.
35+
36+
## 동기
37+
38+
평소에 [geeknews](https://news.hada.io/) 를 잘 보고 있는 편이다. geeknews 의 경우 링크를 공유할 경우 다음과 같이 해당 글과 관련된 썸네일이 함께 나오게 된다.
39+
40+
![geek news](/assets/images/2025-02-28-jekyll-seo/geeknews-thumbnail-example.png)
41+
42+
geeknew 를 예시로 들었지만 다른 사이트들도 내용에 따라 동적으로 썸네일이 생성되곤 한다. 그런 사이트들처럼 내 블로그에서도 썸네일이 나오면 좋겠다는 생각을 하였다.
43+
44+
그렇다고 썸네일을 수동으로 만드는건 번거로운 일이다. 디자인 작업할 재주도 딱히 없다. 따라서 자동으로 되게 하고 싶었다.
45+
46+
처음에는 jekyll 이 ruby로 구현되어 있기 때문에, ruby 기반의 플러그인 형태로 만들어보려고 했다.
47+
하지만 ruby 를 알지 못하다보니 꽤나 진입장벽이 있어서 금새 흥미를 잃어버렸었다.
48+
49+
최근에 다시 흥미가 생겨 다시 시도해보았고, 이번에는 반드시 개발하겠다는 생각으로 java 로 개발하였다.
50+
하는김에 LLM(AI)을 이용하여 description 도 자동으로 생성되게 하면 좋겠다 생각되어 함께 구현하였다.
51+
52+
## 개념
53+
54+
### Open graph
55+
56+
우선 Open Graph에 대해서 간단하게 설명해보겠다.
57+
58+
Open Graph(OG)는 웹페이지의 메타데이터를 표준화하기 위해 페이스북에서 개발한 프로토콜이다.
59+
60+
Open Graph를 적절히 사용하면 링크 미리보기에서 제목(og:title), 설명(og:description), 이미지(og:image)를 제공하여 사용자가 콘텐츠를 더 매력적으로 볼 수 있다.
61+
62+
여러 property 중 `og:image` property 는 소셜 미디어 플랫폼이나 메신저에서 웹페이지 링크가 공유될 때, 그 링크를 설명하는 미리보기(preview)를 생성하는 데 사용된다.
63+
64+
더 자세한 내용은 [Open Graph](https://ogp.me/) 페이지에서 확인할 수 있다.
65+
66+
### jekyll markdown 구조
67+
68+
jekyll 의 post 에 사용되는 markdown 파일은 다음과 같은 구조를 가지고 있다.
69+
70+
```yaml
71+
---
72+
layout: "post"
73+
title: "[Spring] 스프링에서 jwt를 이용한 인증시스템 만들기"
74+
categories: ["스터디-자바"]
75+
tags:
76+
- "Java"
77+
- "Spring"
78+
- "Spring Boot"
79+
- "Spring security"
80+
- ...
81+
date: "2025-02-21 15:00:00 +0000"
82+
toc: true
83+
---
84+
본문 시작
85+
```
86+
87+
두 번재 `---` 를 기준으로 위에는 yaml 을 통해 메타데이터를 기록하고 아래에는 본문 내용을 markdown 으로 작성한다.
88+
89+
## 구현
90+
91+
### 구상
92+
93+
구현은 다음과 같이 구상하였다.
94+
95+
1. Spring AI 를 통해 LLM 을 사용하여 본문을 한문단으로 요약한다.
96+
2. 썸네일 이미지를 생성하여 `/assets/thumbnails` 에 썸네일을 저장한다.
97+
3. markdown 상단의 메타데이터 영역을 업데이트 한다.
98+
99+
### description 생성하기
100+
101+
```java
102+
public class DescriptionService {
103+
104+
private ChatModel chatModel;
105+
106+
public String createDescription(String postAbsoluteFilePath) throws IOException {
107+
Path filepath = Paths.get(postAbsoluteFilePath);
108+
String content = Files.readString(filepath);
109+
String prompt = "SEO 를 위한 description 내용을 작성하려고합니다. `---` 아래 내용을 한문단으로 요약해주세요.\n" +
110+
"바로 description 으로 적용할 수 있도록 불필요한 말은 하지 말아주세요.\n" +
111+
"기본적으로 한글로 요약해주세요. 다만 본문이 영어일 경우에는 영어로 요약해주세요. \n" +
112+
"---\n" +
113+
content;
114+
115+
ChatResponse response = chatModel.call(
116+
new Prompt(prompt,
117+
OpenAiChatOptions.builder()
118+
.model(OpenAiApi.ChatModel.GPT_4_O_MINI)
119+
.temperature(0.4)
120+
.build()
121+
));
122+
123+
return response.getResult().getOutput().getText();
124+
}
125+
}
126+
```
127+
128+
적절히 prompt 를 작성한 후, `.md` 파일에서 내용을 가져와 미리 작성된 prompt 에 포함시켜 api 를 호출하였다.
129+
130+
ChatModel 이 자동 구성 될 수 있도록 다음과 같이 `application.yaml` 을 작성해준다.
131+
132+
```yaml
133+
spring:
134+
ai:
135+
openai:
136+
api-key: ${OPENAI_API_KEY}
137+
```
138+
139+
`${}`는 Spring의 Property Placeholder 기능이다. 환경 변수나 외부 설정 값을 가져와 연결시킨다.
140+
141+
### thumbnail 생성하기
142+
143+
#### 배경 이미지 다운로드 하기
144+
145+
배경 이미지를 어떻게 할까 리서치를 하다가 [picsum](https://picsum.photos/) 이라는 서비스를 발견하였다. 이미지를 랜덤하게 반환해준다.
146+
147+
여기서 반환해 주는 이미지는 Unsplash 에 등록된 이미지를 가져오는데 다음과 같은 정책을 따른다고 한다.
148+
149+
- 모든 이미지는 무료로 다운로드 및 사용 가능
150+
- 상업적 및 비상업적 목적 으로 사용 가능
151+
- 허가 불필요(저작자 표시를 할 경우 감사하게 생각함!)
152+
153+
그래서 맘놓고 사용하였다.
154+
155+
1200 x 630 사이즈의 미미지가 필요해서 `https://picsum.photos/1200/630` 를 GET 호출한 후, 나오는 랜덤한 이미지를 다운로드 하였다.
156+
157+
#### 썸네일 이미지 생성하기
158+
159+
`awt` 패키지의 `Graphics2D` 를 사용하여 원하는 구성으로 리소스들을 배치한 후 저장한다.
160+
161+
바로 전에서 다운로드한 이미지를 배경으로 깔고 투명도를 조절 해 준 후, 중앙에 텍스트로 제목을 배치하도록 하였다.
162+
163+
코드가 꽤 기므로 링크 첨부로 대체한다.
164+
165+
[DrawUtil.java](https://github.com/dev-jonghoonpark/jekyll-seo-helper/blob/main/src/main/java/org/example/jekyllseohelper/util/DrawUtil.java)
166+
167+
사용해보니 web의 canvas 와 비슷한 느낌이 들었다. 생각보다 사용이 쉽지만은 않다. 생각보다 많은 시간이 소요되었다.
168+
169+
### yaml 파서
170+
171+
우리가 오늘 생성할 description 과 thumbnail 은 윗 부분(메타데이터)에 업데이트 되어야 한다.
172+
그래서 yaml 부분을 쉽게 파싱할수 있도록 파서를 도입하였다.
173+
174+
Java 에서 사용할 수 있는 yaml 파서를 찾아보니 SnakeYAML 이라는 애가 먼저 검색에 나왔으나,
175+
spring boot 의 경우 기본적으로 `jackson` 을 이미 포함하고 있으므로 jackson 을 사용하기로 결정하였다.
176+
177+
spring boot 에 jackson 은 포함되어 있으므로, 거기에 추가적으로 `jackson-dataformat-yaml` 의존성을 추가한다.
178+
179+
```groovy
180+
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.2'
181+
```
182+
183+
yaml을 read, write 하기 위한 메소드를 `YamlUtil` 이라는 클래스에 구현하였다. ObjectMapper의 생성자에 `YAMLFactory` 인스턴스를 전달한게 포인트 이다.
184+
185+
```java
186+
public class YamlUtil {
187+
188+
private static final ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
189+
190+
static {
191+
mapper.findAndRegisterModules();
192+
}
193+
194+
public static PostInfo getPostInfo(String filename) throws IOException {
195+
return mapper.readValue(new File(filename), PostInfo.class);
196+
}
197+
198+
public static String toYamlString(PostInfo postInfo) throws IOException {
199+
return mapper.writeValueAsString(postInfo);
200+
}
201+
202+
}
203+
```
204+
205+
markdown 파일 상단의 메타데이터를 파싱 할 수 있도록 다음과 같이 클래스를 작성하였다.
206+
record 의 경우 java 14 부터 지원하는 기능인데, 만약 그것보다 낮은 버전을 쓰고 있다면 직접 객체를 구현해도 된다.
207+
208+
```java
209+
@Data
210+
@JsonIgnoreProperties
211+
public class PostInfo {
212+
private String layout;
213+
private String title;
214+
private String description;
215+
private String[] categories;
216+
private String[] tags;
217+
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss Z")
218+
private ZonedDateTime date;
219+
private Boolean toc;
220+
private Image image;
221+
222+
public record Image(String path) {}
223+
224+
// ...
225+
}
226+
```
227+
228+
### 외부에서 실행시키기
229+
230+
rest api 가 아닌 cli 에서 호출이 되었다는 생각이 들어서 찾아보니 `CommandLineRunner` 라는 것이 있어 적용해보았다.
231+
232+
`CommandLineRunner` 는 spring boot application 을 cli 환경에서 실행시킬 수 있도록 돕는다. 해당 인터페이스에 있는 `run(String... args)` 메소드를 구현시켜 주면 된다.
233+
234+
```java
235+
@Slf4j
236+
@AllArgsConstructor
237+
@SpringBootApplication
238+
public class JekyllSEOHelperApplication implements CommandLineRunner {
239+
240+
private final ApplicationContext applicationContext;
241+
private final DescriptionService descriptionService;
242+
private final ThumbnailService thumbnailService;
243+
244+
public static void main(String[] args) {
245+
SpringApplication.run(JekyllSEOHelperApplication.class, args);
246+
}
247+
248+
@Override
249+
public void run(String... args) throws Exception {
250+
log.debug(Arrays.toString(args));
251+
Arrays.stream(args).filter((filename) -> filename.startsWith("/_posts/"))
252+
.map((filename) -> filename.substring(1))
253+
.forEach((filename) -> {
254+
try {
255+
PostInfo postInfo = YamlUtil.getPostInfo(filename);
256+
log.debug(postInfo.toString());
257+
258+
String description;
259+
if (postInfo.getDescription() == null) {
260+
description = descriptionService.createDescription(filename);
261+
} else {
262+
description = postInfo.getDescription();
263+
}
264+
265+
if (postInfo.getImage() == null) {
266+
thumbnailService.createThumbnail(filename, postInfo.getTitle());
267+
}
268+
269+
postInfo.update(filename, description);
270+
271+
JekyllUtil.updatePost(filename, postInfo);
272+
} catch (IOException e) {
273+
throw new RuntimeException(e);
274+
}
275+
});
276+
277+
SpringApplication.exit(applicationContext, () -> 0);
278+
}
279+
280+
}
281+
```
282+
283+
`ApplicationContext`을 넣은 이유는 `SpringApplication.exit` 명령어를 통해 필요한 로직 수행이 종료되었다면 SpringApplication 을 종료시키기 위해서이다.
284+
285+
### spring boot 어플리케이션 jar 파일로 빌드하기
286+
287+
빌드시 jar 파일이 생성되게 하기 위해 `build.gradle` 에 다음과 같은 영역을 추가해주었다.
288+
289+
```groovy
290+
jar {
291+
manifest {
292+
attributes 'Main-Class': 'org.example.jekyllseohelper.JekyllSEOHelperApplication'
293+
}
294+
}
295+
```
296+
297+
## 실행
298+
299+
최종적으로 생성된 jar 파일로 다음과 같이 명령어를 작성하여 cli에서 실행시킬 수 있다.
300+
301+
```sh
302+
OPENAI_API_KEY=YOUR_OPENAI_API_KEY java -jar jekyll-seo-helper-0.0.1-SNAPSHOT.jar "/_posts/post1.md" "/_posts/post2.md" ...
303+
```
304+
305+
`YOUR_OPENAI_API_KEY` 는 본인의 API KEY 값으로 대체하면 된다.
306+
307+
## 최종 결과물
308+
309+
먼저 결과물은 여기서 확인할 수 있다.
310+
311+
[https://github.com/dev-jonghoonpark/jekyll-seo-helper](https://github.com/dev-jonghoonpark/jekyll-seo-helper)
312+
313+
### 테스트 입력
314+
315+
입력글: [[Spring] 스프링에서 jwt를 이용한 인증시스템 만들기](/2025/02/22/creating-an-authentication-system-with-jwt-in-java-spring)
316+
317+
### 생성된 description
318+
319+
> 스프링에서 JWT(Json Web Token)를 이용한 인증 시스템 구축 방법을 소개합니다. 이 글에서는 세션 토큰 방식과 JWT의 차이점을 설명하고, JWT의 구조, 대칭 및 비대칭 키 암호화 방식, 그리고 실제 구현 방법을 다룹니다. 또한, Spring Security와 의 통합 방법을 통해 JWT 기반 인증을 설정하는 방법도 안내합니다. JWT는 효율적이고 확장 가능한 인증 방식이지만, 보안상의 고려가 필요하므로 프로젝트 요구사항에 맞는 적절한 인증 방식을 선택하는 것이 중요합니다.
320+
321+
### 생성된 thumbnail
322+
323+
![thumbnail example](/assets/thumbnails/2025-02-22-creating-an-authentication-system-with-jwt-in-java-spring.jpg)
324+
325+
## 마무리
326+
327+
![my blog thumbnail example](/assets/images/2025-02-28-jekyll-seo/myblog-thumbnail-example.png)
328+
329+
이제 내 블로그도 썸네일이 잘 보인다. 이번 시간을 통해 평소에 해보고 싶었던 프로젝트를 마무리 하였다. Spring 을 이용하여 간단한 프로그램을 만들어 볼 수 있어서 재밌었다.
330+
331+
## 기타
332+
333+
github action 과 연동하면 좋을 것 같아서 graal vm 을 이용한 native image 를 생성해보려고 했는데 아쉽게도 native image 에서 awt 에 대한 문제를 해결하지 못한 것으로 보인다.
334+
335+
[no awt in java.library.path](https://github.com/oracle/graal/issues/4124)
336+
337+
그래서 그냥 jar 를 만들어서 실행시키는 것으로 스스로 합의를 보았다.
165 KB
Loading
155 KB
Loading
778 KB
Loading

thumbnail.jpg

778 KB
Loading

0 commit comments

Comments
 (0)