|
| 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-21 16: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 | + |
| 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 | + |
| 160 | + |
| 161 | +#### 썸네일 이미지 생성하기 |
| 162 | + |
| 163 | +`awt` 패키지의 `Graphics2D` 를 사용하여 원하는 구성으로 리소스들을 배치한 후 저장한다. |
| 164 | + |
| 165 | +바로 전에서 다운로드한 이미지를 배경으로 깔고 투명도를 조절 해 준 후, 중앙에 텍스트로 제목을 배치하도록 하였다. |
| 166 | + |
| 167 | +코드가 꽤 기므로 링크 첨부로 대체한다. |
| 168 | + |
| 169 | +[DrawUtil.java](https://github.com/dev-jonghoonpark/jekyll-seo-helper/blob/main/src/main/java/org/example/jekyllseohelper/util/DrawUtil.java) |
| 170 | + |
| 171 | +사용해보니 web의 canvas 와 비슷한 느낌이 들었다. 생각보다 사용이 쉽지만은 않다. 생각보다 많은 시간이 소요되었다. |
| 172 | + |
| 173 | +### yaml 파서 |
| 174 | + |
| 175 | +우리가 오늘 생성할 description 과 thumbnail 은 윗 부분(메타데이터)에 업데이트 되어야 한다. |
| 176 | +그래서 yaml 부분을 쉽게 파싱할수 있도록 파서를 도입하였다. |
| 177 | + |
| 178 | +Java 에서 사용할 수 있는 yaml 파서를 찾아보니 SnakeYAML 이라는 애가 먼저 검색에 나왔으나, |
| 179 | +spring boot 의 경우 기본적으로 `jackson` 을 이미 포함하고 있으므로 jackson 을 사용하기로 결정하였다. |
| 180 | + |
| 181 | +spring boot 에 jackson 은 포함되어 있으므로, 거기에 추가적으로 `jackson-dataformat-yaml` 의존성을 추가한다. |
| 182 | + |
| 183 | +```groovy |
| 184 | +implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.2' |
| 185 | +``` |
| 186 | + |
| 187 | +yaml을 read, write 하기 위한 메소드를 `YamlUtil` 이라는 클래스에 구현하였다. ObjectMapper의 생성자에 `YAMLFactory` 인스턴스를 전달한게 포인트 이다. |
| 188 | + |
| 189 | +```java |
| 190 | +public class YamlUtil { |
| 191 | +
|
| 192 | + private static final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); |
| 193 | +
|
| 194 | + static { |
| 195 | + mapper.findAndRegisterModules(); |
| 196 | + } |
| 197 | +
|
| 198 | + public static PostInfo getPostInfo(String filename) throws IOException { |
| 199 | + return mapper.readValue(new File(filename), PostInfo.class); |
| 200 | + } |
| 201 | +
|
| 202 | + public static String toYamlString(PostInfo postInfo) throws IOException { |
| 203 | + return mapper.writeValueAsString(postInfo); |
| 204 | + } |
| 205 | +
|
| 206 | +} |
| 207 | +``` |
| 208 | + |
| 209 | +markdown 파일 상단의 메타데이터를 파싱 할 수 있도록 다음과 같이 클래스를 작성하였다. |
| 210 | +record 의 경우 java 14 부터 지원하는 기능인데, 만약 그것보다 낮은 버전을 쓰고 있다면 직접 객체를 구현해도 된다. |
| 211 | + |
| 212 | +```java |
| 213 | +@Data |
| 214 | +@JsonIgnoreProperties |
| 215 | +public class PostInfo { |
| 216 | + private String layout; |
| 217 | + private String title; |
| 218 | + private String description; |
| 219 | + private String[] categories; |
| 220 | + private String[] tags; |
| 221 | + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss Z") |
| 222 | + private ZonedDateTime date; |
| 223 | + private Boolean toc; |
| 224 | + private Image image; |
| 225 | +
|
| 226 | + public record Image(String path) {} |
| 227 | +
|
| 228 | + // ... |
| 229 | +} |
| 230 | +``` |
| 231 | + |
| 232 | +### 외부에서 실행시키기 |
| 233 | + |
| 234 | +rest api 가 아닌 cli 에서 호출이 되었다는 생각이 들어서 찾아보니 `CommandLineRunner` 라는 것이 있어 적용해보았다. |
| 235 | + |
| 236 | +`CommandLineRunner` 는 spring boot application 을 cli 환경에서 실행시킬 수 있도록 돕는다. 해당 인터페이스에 있는 `run(String... args)` 메소드를 구현시켜 주면 된다. |
| 237 | + |
| 238 | +```java |
| 239 | +@Slf4j |
| 240 | +@AllArgsConstructor |
| 241 | +@SpringBootApplication |
| 242 | +public class JekyllSEOHelperApplication implements CommandLineRunner { |
| 243 | +
|
| 244 | + private final ApplicationContext applicationContext; |
| 245 | + private final DescriptionService descriptionService; |
| 246 | + private final ThumbnailService thumbnailService; |
| 247 | +
|
| 248 | + public static void main(String[] args) { |
| 249 | + SpringApplication.run(JekyllSEOHelperApplication.class, args); |
| 250 | + } |
| 251 | +
|
| 252 | + @Override |
| 253 | + public void run(String... args) throws Exception { |
| 254 | + log.debug(Arrays.toString(args)); |
| 255 | + Arrays.stream(args).filter((filename) -> filename.startsWith("/_posts/")) |
| 256 | + .map((filename) -> filename.substring(1)) |
| 257 | + .forEach((filename) -> { |
| 258 | + try { |
| 259 | + PostInfo postInfo = YamlUtil.getPostInfo(filename); |
| 260 | + log.debug(postInfo.toString()); |
| 261 | +
|
| 262 | + String description; |
| 263 | + if (postInfo.getDescription() == null) { |
| 264 | + description = descriptionService.createDescription(filename); |
| 265 | + } else { |
| 266 | + description = postInfo.getDescription(); |
| 267 | + } |
| 268 | +
|
| 269 | + if (postInfo.getImage() == null) { |
| 270 | + thumbnailService.createThumbnail(filename, postInfo.getTitle()); |
| 271 | + } |
| 272 | +
|
| 273 | + postInfo.update(filename, description); |
| 274 | +
|
| 275 | + JekyllUtil.updatePost(filename, postInfo); |
| 276 | + } catch (IOException e) { |
| 277 | + throw new RuntimeException(e); |
| 278 | + } |
| 279 | + }); |
| 280 | +
|
| 281 | + SpringApplication.exit(applicationContext, () -> 0); |
| 282 | + } |
| 283 | +
|
| 284 | +} |
| 285 | +``` |
| 286 | + |
| 287 | +`ApplicationContext`을 넣은 이유는 `SpringApplication.exit` 명령어를 통해 필요한 로직 수행이 종료되었다면 SpringApplication 을 종료시키기 위해서이다. |
| 288 | + |
| 289 | +### spring boot 어플리케이션 jar 파일로 빌드하기 |
| 290 | + |
| 291 | +빌드시 jar 파일이 생성되게 하기 위해 `build.gradle` 에 다음과 같은 영역을 추가해주었다. |
| 292 | + |
| 293 | +```groovy |
| 294 | +jar { |
| 295 | + manifest { |
| 296 | + attributes 'Main-Class': 'org.example.jekyllseohelper.JekyllSEOHelperApplication' |
| 297 | + } |
| 298 | +} |
| 299 | +``` |
| 300 | + |
| 301 | +## 실행 |
| 302 | + |
| 303 | +최종적으로 생성된 jar 파일로 다음과 같이 명령어를 작성하여 cli에서 실행시킬 수 있다. |
| 304 | + |
| 305 | +```sh |
| 306 | +OPENAI_API_KEY=YOUR_OPENAI_API_KEY java -jar jekyll-seo-helper-0.0.1-SNAPSHOT.jar "/_posts/post1.md" "/_posts/post2.md" ... |
| 307 | +``` |
| 308 | + |
| 309 | +`YOUR_OPENAI_API_KEY` 는 본인의 API KEY 값으로 대체하면 된다. |
| 310 | + |
| 311 | +## 최종 결과물 |
| 312 | + |
| 313 | +먼저 결과물은 여기서 확인할 수 있다. |
| 314 | + |
| 315 | +[https://github.com/dev-jonghoonpark/jekyll-seo-helper](https://github.com/dev-jonghoonpark/jekyll-seo-helper) |
| 316 | + |
| 317 | +### 테스트 입력 |
| 318 | + |
| 319 | +입력글: [[Spring] 스프링에서 jwt를 이용한 인증시스템 만들기](/2025/02/22/creating-an-authentication-system-with-jwt-in-java-spring) |
| 320 | + |
| 321 | +### 생성된 description |
| 322 | + |
| 323 | +> 스프링에서 JWT(Json Web Token)를 이용한 인증 시스템 구축 방법을 소개합니다. 이 글에서는 세션 토큰 방식과 JWT의 차이점을 설명하고, JWT의 구조, 대칭 및 비대칭 키 암호화 방식, 그리고 실제 구현 방법을 다룹니다. 또한, Spring Security와 의 통합 방법을 통해 JWT 기반 인증을 설정하는 방법도 안내합니다. JWT는 효율적이고 확장 가능한 인증 방식이지만, 보안상의 고려가 필요하므로 프로젝트 요구사항에 맞는 적절한 인증 방식을 선택하는 것이 중요합니다. |
| 324 | + |
| 325 | +### 생성된 thumbnail |
| 326 | + |
| 327 | + |
| 328 | + |
| 329 | +## 마무리 |
| 330 | + |
| 331 | + |
| 332 | + |
| 333 | +이제 내 블로그도 썸네일이 잘 보인다. 이번 시간을 통해 평소에 해보고 싶었던 프로젝트를 마무리 하였다. Spring 을 이용하여 간단한 프로그램을 만들어 볼 수 있어서 재밌었다. |
| 334 | + |
| 335 | +## 기타 |
| 336 | + |
| 337 | +github action 과 연동하면 좋을 것 같아서 graal vm 을 이용한 native image 를 생성해보려고 했는데 아쉽게도 native image 에서 awt 에 대한 문제를 해결하지 못한 것으로 보인다. |
| 338 | + |
| 339 | +[no awt in java.library.path](https://github.com/oracle/graal/issues/4124) |
| 340 | + |
| 341 | +그래서 그냥 jar 를 만들어서 실행시키는 것으로 스스로 합의를 보았다. |
0 commit comments