백엔드API
6

Jsoup으로 링크 프리뷰 자동화하기

Spring Boot + Jsoup으로 구현하는 Open Graph 크롤링 API

Jsoup으로 링크 프리뷰 자동화하기

들어가며 - 이게 왜 필요했냐면

Slack이나 Notion에 링크를 붙여넣으면 자동으로 나오는 예쁜 카드, 다들 익숙하시죠? 아래처럼 생긴 바로 그거요.

┌─────────────────────────────┐
│  [썸네일 이미지]             │
│  제목: 오늘의 뉴스 헤드라인   │
│  설명: 기사 요약 내용...      │
│  출처: news.example.com      │
└─────────────────────────────┘

저는 뉴스 링크를 블로그에 등록할 때마다 썸네일·제목·요약을 수동으로 복붙하고 있었어요. URL만 입력하면 이 정보를 자동으로 가져와주는 기능이 절실했습니다.

그래서 만든 것이 바로 Open Graph(OG) 태그 자동 수집 API입니다. 관리자가 URL 하나만 입력하면 백엔드가 해당 페이지의 OG 태그를 긁어와 JSON으로 돌려줍니다.


Open Graph 태그란?

Open Graph는 Facebook이 2010년에 만든 프로토콜로, HTML의 <head> 영역에 메타데이터를 심어두는 방식입니다. 슬랙·노션·카카오톡 등 대부분의 서비스가 이 태그를 읽어서 링크 프리뷰 카드를 그립니다.

태그역할예시
og:title카드에 표시될 제목오늘의 주요 뉴스
og:description카드에 표시될 설명기사 본문 요약...
og:image썸네일 이미지 URLhttps://.../thumb.jpg
og:site_name사이트 이름한겨레

💡 Twitter Card(twitter:title, twitter:image 등)도 같은 목적의 태그이며, OG 태그가 없는 사이트를 위한 대안으로 활용됩니다.


왜 Jsoup을 선택했나?

웹 크롤링 방법에는 크게 두 가지가 있습니다.

방식Selenium (헤드리스 브라우저)Jsoup (HTML 파서)
동작 방식실제 브라우저를 띄워 JS 실행 후 파싱HTTP 요청 → 정적 HTML 파싱
리소스무겁고 메모리 소비 큼가볍고 빠름
JS 렌더링가능 (SPA 대응 가능)불가능 (정적 HTML만)
적합한 상황JS로 동적 렌더링된 콘텐츠OG 태그 등 <head> 정적 메타데이터

OG 태그는 <head> 안에 정적으로 박혀 있는 데이터라 JavaScript 실행이 필요 없습니다. 그래서 Jsoup으로 충분하고, 오히려 더 빠르고 가볍게 처리할 수 있습니다.


전체 아키텍처 흐름

[ 관리자 화면 (Next.js) ]
        │
        │  POST /admin/news/crawl
        │  { url: "https://..." }
        │  Authorization: Bearer <JWT>
        ▼
[ Spring Boot 서버 ]
  1) JwtFilter: 토큰 검증
  2) SecurityConfig: ROLE_ADMIN 확인
  3) AdminNewsController: 요청 수신
  4) Jsoup: 외부 URL로 HTTP GET 요청
        │
        │  HTTP GET
        ▼
[ 외부 뉴스 사이트 ]
  HTML 응답 반환
        │
        ▼
[ Jsoup HTML 파싱 ]
  og:title / og:description / og:image / og:site_name 추출
        │
        ▼
[ NewsOgDto 반환 ]
  { title, description, imageUrl, sourceUrl, siteName }
        │
        ▼
[ 관리자 화면 ]
  OG 카드 미리보기 렌더링 → 수정 가능 → DB 저장

의존성 추가

build.gradle에 딱 한 줄만 추가하면 됩니다.

// build.gradle
dependencies {
    implementation 'org.jsoup:jsoup:1.18.1'
}

💡 2024년 기준 최신 안정 버전은 1.18.1입니다. Maven Central에서 최신 버전을 확인하세요.


응답 DTO — NewsOgDto

크롤링 결과를 담아 프론트로 내려줄 DTO입니다. 5개 필드로 구성됩니다.

// NewsOgDto.java
package newblog_back.domain.post;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class NewsOgDto {
    private String title;       // og:title
    private String description; // og:description
    private String imageUrl;    // og:image
    private String sourceUrl;   // 원본 URL (그대로 전달)
    private String siteName;    // og:site_name
}

핵심 로직 — AdminNewsController

전체 코드

// AdminNewsController.java
package newblog_back.domain.controller.admin;

import java.util.Map;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import newblog_back.domain.post.NewsOgDto;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/admin/news")
public class AdminNewsController {

    @PostMapping("/crawl")
    public ResponseEntity<?> crawlOgTags(@RequestBody Map<String, String> body) {
        String url = body.get("url");
        if (url == null || url.isBlank()) {
            return ResponseEntity.badRequest().body(Map.of("error", "URL이 필요합니다."));
        }
        try {
            Document doc = Jsoup.connect(url)
                    .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                             + "AppleWebKit/537.36 (KHTML, like Gecko) "
                             + "Chrome/120.0.0.0 Safari/537.36")
                    .timeout(10_000)
                    .get();

            String title       = ogOrMeta(doc, "og:title",       "title",       "twitter:title");
            String description = ogOrMeta(doc, "og:description", "description", "twitter:description");
            String imageUrl    = ogOrMeta(doc, "og:image",       null,           "twitter:image");
            String siteName    = ogOrMeta(doc, "og:site_name",   null,           null);

            // OG title도 없으면 <title> 태그로 최종 fallback
            if (title == null || title.isBlank()) {
                title = doc.title();
            }

            NewsOgDto result = new NewsOgDto(title, description, imageUrl, url, siteName);
            return ResponseEntity.ok(result);

        } catch (Exception e) {
            log.warn("OG 크롤링 실패 url={} error={}", url, e.getMessage());
            return ResponseEntity.status(422)
                    .body(Map.of("error", "해당 URL을 불러올 수 없습니다: " + e.getMessage()));
        }
    }

    private String ogOrMeta(Document doc, String ogProp,
                            String metaName, String twitterProp) {
        // 1순위: og: property
        String val = doc.select("meta[property=" + ogProp + "]").attr("content");
        if (!val.isBlank()) return val;

        // 2순위: twitter: name
        if (twitterProp != null) {
            val = doc.select("meta[name=" + twitterProp + "]").attr("content");
            if (!val.isBlank()) return val;
        }

        // 3순위: 일반 meta name
        if (metaName != null) {
            val = doc.select("meta[name=" + metaName + "]").attr("content");
            if (!val.isBlank()) return val;
        }
        return null;
    }
}

포인트 1 — userAgent를 Chrome으로 설정하는 이유

Jsoup의 기본 userAgent는 "Java/버전번호" 형태입니다. 많은 뉴스 사이트들이 이런 봇 형태의 userAgent를 보면 403 Forbidden이나 429 Too Many Requests로 차단합니다.

Chrome 브라우저처럼 위장하면 대부분의 사이트에서 정상적으로 HTML을 받아올 수 있습니다.

// ❌ 기본 Jsoup → 많은 사이트에서 차단됨
Jsoup.connect(url).get();

// ✅ Chrome userAgent 설정 → 정상 응답
Jsoup.connect(url)
     .userAgent("Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36")
     .timeout(10_000)
     .get();

포인트 2 — OG 태그 우선순위(Fallback) 전략

모든 사이트가 OG 태그를 완벽하게 달아두지는 않습니다. 어떤 사이트는 OG 태그 대신 Twitter Card 태그만 달거나, 아예 둘 다 없는 경우도 있어요. 그래서 아래 순서로 순차적으로 시도합니다.

1순위: meta[property=og:title]      → Open Graph 표준
2순위: meta[name=twitter:title]     → Twitter Card
3순위: meta[name=title]             → 일반 meta 태그
최후:  <title> 태그                  → HTML 문서 제목 (title만 해당)

ogOrMeta() 헬퍼 메서드가 이 우선순위 로직을 담당합니다. attr("content")는 해당 태그가 없으면 빈 문자열("")을 반환하므로, isBlank()로 체크해 다음 순위로 넘어가는 방식입니다.


포인트 3 — 에러 처리

외부 URL 크롤링은 다양한 이유로 실패할 수 있습니다 (타임아웃, 봇 차단, 잘못된 URL 등). 이 경우 HTTP 422 Unprocessable Entity를 반환해 프론트에서 "불러오기 실패" 처리를 할 수 있게 합니다.

} catch (Exception e) {
    log.warn("OG 크롤링 실패 url={} error={}", url, e.getMessage());
    return ResponseEntity.status(422)
            .body(Map.of("error", "해당 URL을 불러올 수 없습니다: " + e.getMessage()));
}

💡 400(Bad Request)이 아닌 422를 쓴 이유: URL 형식은 올바르지만 서버가 처리할 수 없는 상황이기 때문입니다. HTTP 시맨틱에 더 맞는 선택입니다.


보안 — /admin/** 경로 보호

크롤링 API는 관리자만 호출할 수 있어야 합니다. SecurityConfig에서 /admin/** 경로에 ROLE_ADMIN 권한을 요구하고, JwtFilter가 모든 요청의 JWT 토큰을 검증합니다.

// SecurityConfig.java (핵심 부분)
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated()
)

프론트(Next.js)에서는 요청 시 Authorization 헤더에 Bearer 토큰을 포함해야 합니다.

// Next.js 프론트 요청 예시
const res = await fetch('/admin/news/crawl', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`,
  },
  body: JSON.stringify({ url }),
});

왜 프론트가 아닌 서버에서 크롤링하는가?

"프론트에서 직접 fetch 하면 안 되나요?"라는 질문이 생길 수 있습니다. 두 가지 이유로 서버에서 처리합니다.

  • CORS(Cross-Origin Resource Sharing) 문제: 브라우저는 보안상 다른 도메인으로의 fetch 요청을 막습니다. 외부 뉴스 사이트들은 대부분 CORS를 허용하지 않아서 프론트에서 직접 요청하면 에러가 납니다.
  • userAgent 조작: 브라우저에서 fetch를 보내면 브라우저가 userAgent를 강제 설정하므로 마음대로 바꿀 수 없습니다. 서버에서 처리하면 Jsoup이 Chrome으로 위장할 수 있습니다.

마무리

정리하면, 이번에 구현한 기능의 핵심은 단순합니다.

URL을 받아 → Jsoup으로 HTML을 긁고 → CSS 선택자로 OG 태그를 뽑아 → DTO로 반환

하지만 그 과정에서 userAgent 설정, OG 태그 우선순위 fallback 처리, CORS 회피를 위한 서버사이드 크롤링, 적절한 에러 코드 반환 등 실무에서 꼭 고려해야 할 포인트들이 녹아 있습니다.

비슷한 기능을 구현하려는 분들께 도움이 됐으면 좋겠습니다.

#크롤링#Jsoup#Selenium

댓글

(0)
Jsoup으로 링크 프리뷰 자동화하기 | 강민석의 개발블로그