본문 바로가기

서버

[Spring boot] 프론트 기반 웹 크롤링

SMALL

※ 프론트엔드 기반 웹 크롤링 주의사항

프론트엔드 기반 웹 크롤링(예: Playwright, Puppeteer 사용)은 JavaScript 동적 콘텐츠를 처리할 수 있지만,

서버 부하와 법적 위험을 높이므로 반드시 비상업적·개인 학습 용도로 제한하고 robots.txt를 준수해야 합니다.

요청 간격을 1-5초 이상 유지하며 User-Agent를 실제 브라우저처럼 설정해 탐지 피하세요.

 

실제 사례

https://www.shinkim.com/kor/media/newsletter/1843

 

크롤링 관련 최근 대법원 판결과 그 시사점

대법원은 지난 달 숙박업소 정보제공 서비스 업체의 데이터베이스 크롤링에 관한 형사 사건에서 피고인들에 대해 무죄를 선고한 원심의 판단을 인용하는 판결을 선고하였습니다(대법원 2022. 5.

www.shinkim.com

https://www.hani.co.kr/arti/society/society_general/1061963.html

 

형사는 무죄, 민사는 “10억 배상”…데이터 크롤링 어디까지 되나

공개된 데이터를 대량으로 수집해 활용하는 ‘데이터 크롤링’을 두고 야놀자와 여기어때가 벌인 4년8개월여 법정 다툼이 지난 8월말 막을 내렸다. ‘야놀자-여기어때 사건’은 빅데이터 시대

www.hani.co.kr

https://atlaw.kr/kr-blog/%EC%9B%B9%ED%81%AC%EB%A1%A4%EB%A7%81%EC%9D%98-%ED%98%95%EC%82%AC%EC%B2%98%EB%B2%8C-%EA%B0%80%EB%8A%A5%EC%84%B1-%EB%8C%80%EB%B2%95%EC%9B%90-2021%EB%8F%841533-%ED%8C%90%EA%B2%B0-%EC%99%84%EC%A0%84/

 

웹크롤링의 형사처벌 가능성: 대법원 2021도1533 판결 완전분석

자동 댓글 프로그램이 언제 악성프로그램으로 분류되는지 대법원 판례를 통해 분석합니다. 의정부지방법원 2017노309, 대법원 2017도16520 판결의 핵심 쟁점과 기업 리스크 관리 방안을 제시합니다.

atlaw.kr

 


크롤링을 하기 위한 타깃 페이지는 이것으로 설정하였다.

https://books.toscrape.com/

 

스크랩 연습을 할 수 있도록 제공되고 있는 페이지이다. 일반적인 페이지는 아래와 같이 스크랩 가능한 url과 아닌 것을 구분 지어 robots.txt에 작성해 두니, 반드시 작업 전에 확인하기 바란다

더보기

https://google.com/robots.txt 결과

User-agent: *
User-agent: Yandex
Disallow: /search
Allow: /search/about
Allow: /search/howsearchworks
Disallow: /sdch
Disallow: /groups
Disallow: /index.html?
Disallow: /?
Allow: /?hl=
Disallow: /?hl=*&
Allow: /?hl=*&gws_rd=ssl$
Disallow: /?hl=*&*&gws_rd=ssl
Allow: /?gws_rd=ssl$
Allow: /?pt1=true$
Disallow: /imgres
Disallow: /u/
Disallow: /setprefs
Disallow: /m?
Disallow: /m/
Allow:    /m/finance
Disallow: /wml?
Disallow: /wml/?
Disallow: /wml/search?
Disallow: /xhtml?
Disallow: /xhtml/?
Disallow: /xhtml/search?
Disallow: /xml?
Disallow: /imode?
Disallow: /imode/?
Disallow: /imode/search?
Disallow: /jsky?
Disallow: /jsky/?
Disallow: /jsky/search?
Disallow: /pda?
Disallow: /pda/?
Disallow: /pda/search?
Disallow: /sprint_xhtml
Disallow: /sprint_wml
Disallow: /pqa
Disallow: /gwt/
Disallow: /purchases
Disallow: /local?
Disallow: /local_url
Disallow: /shihui?
Disallow: /shihui/
Disallow: /products?
Disallow: /product_
Disallow: /products_
Disallow: /products;
Disallow: /print
Disallow: /books/
Disallow: /bkshp?*q=
Disallow: /books?*q=
Disallow: /books?*output=
Disallow: /books?*pg=
Disallow: /books?*jtp=
Disallow: /books?*jscmd=
Disallow: /books?*buy=
Disallow: /books?*zoom=
Allow: /books?*q=related:
Allow: /books?*q=editions:
Allow: /books?*q=subject:
Allow: /books/about
Allow: /books?*zoom=1
Allow: /books?*zoom=5
Allow: /books/content?*zoom=1
Allow: /books/content?*zoom=5
Disallow: /patents?
Disallow: /patents/download/
Disallow: /patents/pdf/
Disallow: /patents/related/
Disallow: /scholar
Disallow: /citations?
Allow: /citations?user=
Disallow: /citations?*cstart=
Allow: /citations?view_op=new_profile
Allow: /citations?view_op=top_venues
Allow: /scholar_share
Disallow: /s?
Disallow: /maps?
Allow: /maps?daddr=
Allow: /maps?entry=wc
Allow: /maps?f=
Allow: /maps?hl=
Allow: /maps?q=
Allow: /maps?saddr=
Allow: /maps?sid=
Allow: /maps?*output=classic
Allow: /maps?*file=
Disallow: /mapstt?
Disallow: /mapslt?
Disallow: /mapabcpoi?
Disallow: /maphp?
Disallow: /mapprint?
Disallow: /maps/
Allow: /maps/$
Allow: /maps/@
Allow: /maps/?daddr=
Allow: /maps/?entry=wc
Allow: /maps/?f=
Allow: /maps/?hl=
Allow: /maps/?q=
Allow: /maps/?saddr=
Allow: /maps/?sid=
Allow: /maps/search/
Allow: /maps/sitemap.xml
Allow: /maps/sitemaps/
Allow: /maps/dir/
Allow: /maps/d/
Allow: /maps/reserve
Allow: /maps/about
Allow: /maps/contrib/
Allow: /maps/match
Allow: /maps/place/
Allow: /maps/_/
Allow: /search?*tbm=map
Allow: /maps/vt?
Allow: /maps/preview
Disallow: /maps/api/js/
Allow: /maps/api/js
Disallow: /mld?
Disallow: /staticmap?
Disallow: /help/maps/streetview/partners/welcome/
Disallow: /help/maps/indoormaps/partners/
Disallow: /lochp?
Disallow: /ie?
Disallow: /uds/
Disallow: /transit?
Disallow: /trends?
Disallow: /trends/music?
Disallow: /trends/hottrends?
Disallow: /trends/viz?
Disallow: /trends/embed.js?
Disallow: /trends/fetchComponent?
Disallow: /trends/beta
Disallow: /trends/topics
Disallow: /trends/explore?
Disallow: /trends/embed
Disallow: /trends/api
Disallow: /musica
Disallow: /musicad
Disallow: /musicas
Disallow: /musicl
Disallow: /musics
Disallow: /musicsearch
Disallow: /musicsp
Disallow: /musiclp
Disallow: /urchin_test/
Disallow: /movies?
Disallow: /wapsearch?
Disallow: /reviews/search?
Disallow: /orkut/albums
Disallow: /cbk
Disallow: /recharge/dashboard/car
Disallow: /recharge/dashboard/static/
Disallow: /profiles/me
Disallow: /s2/profiles/me
Allow: /s2/profiles
Allow: /s2/oz
Allow: /s2/photos
Allow: /s2/search/social
Allow: /s2/static
Disallow: /s2
Disallow: /transconsole/portal/
Disallow: /gcc/
Disallow: /aclk
Disallow: /tbproxy/
Disallow: /imesync/
Disallow: /shenghuo/search?
Disallow: /support/forum/search?
Disallow: /reviews/polls/
Disallow: /hosted/images/
Disallow: /ppob/?
Disallow: /ppob?
Disallow: /accounts/ClientLogin
Disallow: /accounts/ClientAuth
Disallow: /accounts/o8
Allow: /accounts/o8/id
Disallow: /topicsearch?q=
Disallow: /xfx7/
Disallow: /squared/api
Disallow: /squared/search
Disallow: /squared/table
Disallow: /qnasearch?
Disallow: /sidewiki/entry/
Disallow: /quality_form?
Disallow: /labs/popgadget/search
Disallow: /compressiontest/
Disallow: /analytics/feeds/
Disallow: /analytics/partners/comments/
Disallow: /analytics/portal/
Disallow: /analytics/uploads/
Allow: /alerts/manage
Allow: /alerts/remove
Disallow: /alerts/
Allow: /alerts/$
Disallow: /phone/compare/?
Disallow: /travel/clk
Disallow: /travel/entity
Disallow: /travel/search
Disallow: /travel/flights/booking
Disallow: /travel/flights/s/
Disallow: /travel/flights/search
Disallow: /travel/hotels/entity
Disallow: /travel/hotels/*/entity
Disallow: /travel/hotels/stories
Disallow: /travel/hotels/*/stories
Disallow: /travel/story
Disallow: /hotelfinder/rpc
Disallow: /hotels/rpc
Disallow: /evaluation/
Disallow: /forms/perks/
Disallow: /shopping/suppliers/search
Disallow: /edu/cs4hs/
Disallow: /trustedstores/s/
Disallow: /trustedstores/tm2
Disallow: /trustedstores/verify
Disallow: /shopping?
Disallow: /shopping/product/
Disallow: /shopping/seller
Disallow: /shopping/ratings/account/metrics
Disallow: /shopping/ratings/merchant/immersivedetails
Disallow: /shopping/reviewer
Disallow: /shopping/search
Disallow: /shopping/deals
Disallow: /storefront
Disallow: /storepicker
Disallow: /about/careers/applications/candidate-prep
Disallow: /about/careers/applications/connect-with-a-googler
Disallow: /about/careers/applications/jobs/results?page=
Disallow: /about/careers/applications/jobs/results/?page=
Disallow: /about/careers/applications/jobs/results?*&page=
Disallow: /about/careers/applications/jobs/results/?*&page=
Disallow: /landing/signout.html
Disallow: /gallery/
Disallow: /landing/now/ontap/
Allow: /maps/reserve
Allow: /maps/reserve/partners
Disallow: /maps/reserve/api/
Disallow: /maps/reserve/search
Disallow: /maps/reserve/bookings
Disallow: /maps/reserve/settings
Disallow: /maps/reserve/manage
Disallow: /maps/reserve/payment
Disallow: /maps/reserve/receipt
Disallow: /maps/reserve/sellersignup
Disallow: /maps/reserve/payments
Disallow: /maps/reserve/feedback
Disallow: /maps/reserve/terms
Disallow: /maps/reserve/m/
Disallow: /maps/reserve/b/
Disallow: /maps/reserve/partner-dashboard
Disallow: /local/cars
Disallow: /local/cars/
Disallow: /local/dealership/
Disallow: /local/dining/
Disallow: /local/place/products/
Disallow: /local/place/reviews/
Disallow: /local/place/rap/
Disallow: /local/tab/
Disallow: /localservices/
Disallow: /nonprofits/account/
Disallow: /uviewer
Disallow: /landing/cmsnext-root/

# AdsBot
User-agent: AdsBot-Google
Disallow: /maps/api/js/
Allow: /maps/api/js
Disallow: /maps/api/place/js/
Disallow: /maps/api/staticmap
Disallow: /maps/api/streetview

# New user agent groups must also have a user agent reference in the global (*)
# group. See "Order of precedence" section in
# https://goo.gle/rep#order-of-precedence-for-user-agents
User-agent: Yandex
Disallow: /about/careers/applications/jobs/results
Disallow: /about/careers/applications-a/jobs/results

# Crawlers of certain social media sites are allowed to access page markup when
# google.com/imgres* links are shared. To learn more, please contact
# images-robots-allowlist@google.com.
User-agent: facebookexternalhit
User-agent: Twitterbot
Allow: /imgres
Allow: /search
Disallow: /groups
Disallow: /hosted/images/
Disallow: /m/

Sitemap: https://www.google.com/sitemap.xml

 

Spring Initializr 등을 이용하여 프로젝트를 생성한다

 

백엔드 코드 작성

데이터베이스 저장을 처리할 백엔드 설정 파일을 작성한다

-> src/resources/application.yml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/crawler
    username: postgres
    password: "0000"
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: update  # 테이블 없으면 생성 + 변경분만 반영 [web:121][web:122]
    show-sql: true      # 실행 SQL 로그 확인용
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.PostgreSQLDialect
  sql:
    init:
      mode: never

server:
  port: 8080

logging:
  level:
    com.tistory.glorygem.crawler: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
    org.jsoup: INFO

 

Playwright를 이용하여 웹 크롤링을 진행하기에 필요한 모듈을 설치한다

> Playwright(웹 제어), pg(PostgreSQL 연결)

npm install playwright pg
npm exec playwright install chromium

 

프로젝트 루트의 build.gradle에 종속성을 추가한다

plugins {
	id 'java'
	id 'org.springframework.boot' version '4.0.0'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.tistory.glorygem'
version = '0.0.1-SNAPSHOT'
description = 'Demo project for Spring Boot'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(21)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'org.postgresql:postgresql'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    // 디비 연결
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.postgresql:postgresql'
    // 웹 크롤링
    implementation 'org.jsoup:jsoup:1.17.2'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

백엔드 프로젝트 코드 구조는 아래 이미지와 같이 가져간다

 

domain/entity는 데이터베이스의 필드 구조와 동일하게 설정하며 Book.java, BookCategory.java는 아래와 같다

package com.tistory.glorygem.crawler.domain.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.math.BigDecimal;
import java.util.UUID;

@Entity
@Getter @Setter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "Book")
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = "uuid_book", columnDefinition = "UUID")
    private UUID uuidBook;

    @Column(name = "title", nullable = false)
    private String title;  // "It's Only the Himalayas"

    @Column(name = "price", precision = 10, scale = 2)
    private BigDecimal price;  // £45.17

    @Column(name = "upc")
    private String upc;  // "a22124811bfa8350"

    @Column(name = "product_type")
    private String productType;  // "Books"

    @Column(name = "availability")
    private String availability;  // "In stock (19 available)"

    @Column(name = "number_of_reviews")
    private Integer numberOfReviews;  // 0

    @Column(name = "description", columnDefinition = "TEXT")
    private String description;  // 긴 상품 설명

    @Column(name = "url", length = 500)
    private String url;  // "https://books.toscrape.com/catalogue/its-only-the-himalayas_981/index.html"

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "uuid_bookCategory")
    private BookCategory bookCategory;
}
package com.tistory.glorygem.crawler.domain.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.UUID;

@Entity
@Getter @Setter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "BookCategory")
public class BookCategory {

    // Getters/Setters
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = "uuid_bookCategory", columnDefinition = "UUID")
    private UUID uuidBookCategory = UUID.randomUUID();

    @Column(name = "category_name", nullable = false)
    private String categoryName;
}

 

domain/dto/BookDTO.java는 api 응답 시 사용할 데이터 형식이다

package com.tistory.glorygem.crawler.domain.dto;

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

import java.math.BigDecimal;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookDTO {
    private String title;
    private BigDecimal price;
    private String upc;
    private String productType;
    private String availability;
    private Integer numberOfReviews;
    private String description;
    private String url;
    private String categoryName;
}

 

domain/repository에 디비와 직접 소통할 BookRepository.java, BookCategoryRepository.java를 작성한다

package com.tistory.glorygem.crawler.domain.repository;

import com.tistory.glorygem.crawler.domain.entity.Book;
import com.tistory.glorygem.crawler.domain.entity.BookCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface BookRepository extends JpaRepository<Book, UUID> {

    Optional<Book> findByUrl(String url);

    List<Book> findByBookCategory(BookCategory bookCategory);

    List<Book> findByBookCategory_CategoryName(String categoryName);

    boolean existsByUrl(String url);
}
package com.tistory.glorygem.crawler.domain.repository;

import com.tistory.glorygem.crawler.domain.entity.BookCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.UUID;

@Repository
public interface BookCategoryRepository extends JpaRepository<BookCategory, UUID> {

    Optional<BookCategory> findByCategoryName(String categoryName);

    boolean existsByCategoryName(String categoryName);
}

 

service에 메소드의 동작 내용을 정리한 BookService.java, BookCategoryService.java를 작성한다

package com.tistory.glorygem.crawler.service;

import com.tistory.glorygem.crawler.domain.entity.Book;
import com.tistory.glorygem.crawler.domain.entity.BookCategory;
import com.tistory.glorygem.crawler.domain.repository.BookRepository;
import com.tistory.glorygem.crawler.domain.dto.BookDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class BookService {

    private static final String BASE_URL = "https://books.toscrape.com";
    private static final int TIMEOUT = 10000;

    private final BookRepository bookRepository;
    private final BookCategoryService bookCategoryService;

    /**
     * 특정 카테고리의 모든 책을 크롤링하여 저장합니다
     */
    @Transactional
    public List<Book> crawlAndSaveBooksByCategory(String categoryName) throws IOException {
        log.info("카테고리 '{}' 책 크롤링 시작", categoryName);

        BookCategory bookCategory = bookCategoryService.getCategoryByName(categoryName);
        List<Book> books = new ArrayList<>();

        // 카테고리 URL 생성 (실제로는 카테고리 엔티티에 URL을 저장하거나 매핑 로직 필요)
        String categoryUrl = getCategoryUrl(categoryName);
        String currentUrl = categoryUrl;

        while (currentUrl != null) {
            Document doc = Jsoup.connect(currentUrl)
                    .timeout(TIMEOUT)
                    .userAgent("Mozilla/5.0")
                    .get();

            Elements bookElements = doc.select("article.product_pod");

            for (Element bookElement : bookElements) {
                String bookUrl = extractBookUrl(bookElement);

                // 중복 체크
                if (!bookRepository.existsByUrl(bookUrl)) {
                    Book book = crawlBookDetail(bookUrl, bookCategory);
                    Book savedBook = bookRepository.save(book);
                    books.add(savedBook);
                    log.debug("새 책 저장: {}", book.getTitle());
                } else {
                    log.debug("이미 존재하는 책: {}", bookUrl);
                }
            }

            currentUrl = getNextPageUrl(doc, currentUrl);
        }

        log.info("카테고리 '{}'에서 총 {} 권의 책 저장 완료", categoryName, books.size());
        return books;
    }

    /**
     * 모든 카테고리의 모든 책을 크롤링하여 저장합니다
     */
    @Transactional
    public List<Book> crawlAndSaveAllBooks() throws IOException {
        log.info("전체 책 크롤링 시작");

        // 먼저 카테고리를 크롤링하여 저장
        List<BookCategory> categories = bookCategoryService.crawlAndSaveCategories();
        List<Book> allBooks = new ArrayList<>();

        for (BookCategory category : categories) {
            try {
                List<Book> books = crawlAndSaveBooksByCategory(category.getCategoryName());
                allBooks.addAll(books);

                // 서버 부담 방지
                Thread.sleep(1000);
            } catch (IOException e) {
                log.error("카테고리 '{}' 크롤링 실패: {}", category.getCategoryName(), e.getMessage());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                log.error("크롤링 중단됨");
                break;
            }
        }

        log.info("전체 크롤링 완료: 총 {} 권의 책", allBooks.size());
        return allBooks;
    }

    /**
     * 책 상세 정보를 크롤링합니다
     */
    private Book crawlBookDetail(String bookUrl, BookCategory bookCategory) throws IOException {
        Document doc = Jsoup.connect(bookUrl)
                .timeout(TIMEOUT)
                .userAgent("Mozilla/5.0")
                .get();

        Book book = new Book();
        book.setUrl(bookUrl);
        book.setBookCategory(bookCategory);

        // 제목
        Element titleElement = doc.selectFirst("div.product_main h1");
        if (titleElement != null) {
            book.setTitle(titleElement.text());
        }

        // 가격
        Element priceElement = doc.selectFirst("p.price_color");
        if (priceElement != null) {
            String priceText = priceElement.text().replaceAll("[^0-9.]", "");
            try {
                book.setPrice(new BigDecimal(priceText));
            } catch (NumberFormatException e) {
                log.warn("가격 파싱 실패: {}", priceText);
            }
        }

        // 상품 정보 테이블
        Elements infoRows = doc.select("table.table tr");
        for (Element row : infoRows) {
            Element th = row.selectFirst("th");
            Element td = row.selectFirst("td");

            if (th != null && td != null) {
                String key = th.text();
                String value = td.text();

                switch (key) {
                    case "UPC":
                        book.setUpc(value);
                        break;
                    case "Product Type":
                        book.setProductType(value);
                        break;
                    case "Availability":
                        book.setAvailability(value);
                        break;
                    case "Number of reviews":
                        try {
                            book.setNumberOfReviews(Integer.parseInt(value));
                        } catch (NumberFormatException e) {
                            log.warn("리뷰 수 파싱 실패: {}", value);
                        }
                        break;
                }
            }
        }

        // 설명
        Element descElement = doc.selectFirst("article.product_page > p");
        if (descElement != null) {
            book.setDescription(descElement.text());
        }

        return book;
    }

    /**
     * 책 목록 조회 (전체)
     */
    @Transactional(readOnly = true)
    public List<BookDTO> getAllBooks() {
        return bookRepository.findAll().stream()
                .map(this::convertToDTO)
                .collect(Collectors.toList());
    }

    /**
     * 카테고리별 책 목록 조회
     */
    @Transactional(readOnly = true)
    public List<BookDTO> getBooksByCategory(String categoryName) {
        return bookRepository.findByBookCategory_CategoryName(categoryName).stream()
                .map(this::convertToDTO)
                .collect(Collectors.toList());
    }

    /**
     * Entity를 DTO로 변환
     */
    private BookDTO convertToDTO(Book book) {
        return BookDTO.builder()
                .title(book.getTitle())
                .price(book.getPrice())
                .upc(book.getUpc())
                .productType(book.getProductType())
                .availability(book.getAvailability())
                .numberOfReviews(book.getNumberOfReviews())
                .description(book.getDescription())
                .url(book.getUrl())
                .categoryName(book.getBookCategory() != null ?
                        book.getBookCategory().getCategoryName() : null)
                .build();
    }

    /**
     * 책 URL 추출
     */
    private String extractBookUrl(Element bookElement) {
        Element linkElement = bookElement.selectFirst("h3 a");
        if (linkElement != null) {
            String href = linkElement.attr("href");
            return BASE_URL + "/catalogue/" + href.replace("../../../", "");
        }
        return null;
    }

    /**
     * 다음 페이지 URL 반환
     */
    private String getNextPageUrl(Document doc, String currentUrl) {
        Element nextButton = doc.selectFirst("li.next a");
        if (nextButton != null) {
            String nextHref = nextButton.attr("href");
            int lastSlashIndex = currentUrl.lastIndexOf('/');
            String baseUrl = currentUrl.substring(0, lastSlashIndex + 1);
            return baseUrl + nextHref;
        }
        return null;
    }

    /**
     * 카테고리 이름으로 URL 생성 (간단한 매핑)
     * 실제로는 더 정교한 매핑이 필요할 수 있습니다
     */
    private String getCategoryUrl(String categoryName) {
        String urlName = categoryName.toLowerCase().replace(" ", "-");
        return BASE_URL + "/catalogue/category/books/" + urlName + "_2/index.html";
    }
}
package com.tistory.glorygem.crawler.service;

import com.tistory.glorygem.crawler.domain.entity.BookCategory;
import com.tistory.glorygem.crawler.domain.repository.BookCategoryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class BookCategoryService {

    private static final String BASE_URL = "https://books.toscrape.com";
    private static final int TIMEOUT = 10000;

    private final BookCategoryRepository bookCategoryRepository;

    /**
     * 모든 카테고리 정보를 크롤링하여 DB에 저장합니다
     */
    @Transactional
    public List<BookCategory> crawlAndSaveCategories() throws IOException {
        log.info("카테고리 크롤링 시작");
        List<BookCategory> categories = new ArrayList<>();

        Document doc = Jsoup.connect(BASE_URL)
                .timeout(TIMEOUT)
                .userAgent("Mozilla/5.0")
                .get();

        Elements categoryElements = doc.select("div.side_categories ul.nav-list li ul li a");

        for (Element element : categoryElements) {
            String categoryName = element.text().trim();

            // 중복 체크
            if (!bookCategoryRepository.existsByCategoryName(categoryName)) {
                BookCategory category = new BookCategory();
                category.setCategoryName(categoryName);

                BookCategory savedCategory = bookCategoryRepository.save(category);
                categories.add(savedCategory);
                log.info("새 카테고리 저장: {}", categoryName);
            } else {
                BookCategory existingCategory = bookCategoryRepository
                        .findByCategoryName(categoryName)
                        .orElseThrow();
                categories.add(existingCategory);
                log.info("기존 카테고리 발견: {}", categoryName);
            }
        }

        log.info("총 {} 개의 카테고리 처리 완료", categories.size());
        return categories;
    }

    /**
     * 모든 카테고리 조회
     */
    @Transactional(readOnly = true)
    public List<BookCategory> getAllCategories() {
        return bookCategoryRepository.findAll();
    }

    /**
     * 카테고리명으로 조회
     */
    @Transactional(readOnly = true)
    public BookCategory getCategoryByName(String categoryName) {
        return bookCategoryRepository.findByCategoryName(categoryName)
                .orElseThrow(() -> new IllegalArgumentException("카테고리를 찾을 수 없습니다: " + categoryName));
    }

    /**
     * 카테고리가 존재하는지 확인
     */
    @Transactional(readOnly = true)
    public boolean existsCategory(String categoryName) {
        return bookCategoryRepository.existsByCategoryName(categoryName);
    }
}

 

controller에 엔드포인트 및 기능을 정의한 BookController.java, BookCategoryController.java를 작성한다

package com.tistory.glorygem.crawler.controller;

import com.tistory.glorygem.crawler.domain.dto.BookDTO;
import com.tistory.glorygem.crawler.service.BookService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/books")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class BookController {

    private final BookService bookService;

    /**
     * 특정 카테고리의 책 크롤링 및 저장
     */
    @PostMapping("/crawl/category/{categoryName}")
    public ResponseEntity<?> crawlBooksByCategory(@PathVariable String categoryName) {
        try {
            var books = bookService.crawlAndSaveBooksByCategory(categoryName);

            Map<String, Object> response = new HashMap<>();
            response.put("message", "책 크롤링 완료");
            response.put("category", categoryName);
            response.put("count", books.size());

            return ResponseEntity.ok(response);
        } catch (IOException e) {
            log.error("책 크롤링 실패: {}", categoryName, e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("책 크롤링에 실패했습니다: " + e.getMessage()));
        } catch (IllegalArgumentException e) {
            log.warn("존재하지 않는 카테고리: {}", categoryName);
            return ResponseEntity.badRequest()
                    .body(createErrorResponse(e.getMessage()));
        }
    }

    /**
     * 모든 카테고리의 책 크롤링 및 저장 (전체 크롤링)
     */
    @PostMapping("/crawl/all")
    public ResponseEntity<?> crawlAllBooks() {
        try {
            log.info("전체 책 크롤링 시작");
            var books = bookService.crawlAndSaveAllBooks();

            Map<String, Object> response = new HashMap<>();
            response.put("message", "전체 크롤링 완료");
            response.put("totalCount", books.size());

            return ResponseEntity.ok(response);
        } catch (IOException e) {
            log.error("전체 크롤링 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("크롤링에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 저장된 모든 책 조회
     */
    @GetMapping
    public ResponseEntity<?> getAllBooks() {
        try {
            List<BookDTO> books = bookService.getAllBooks();

            Map<String, Object> response = new HashMap<>();
            response.put("count", books.size());
            response.put("books", books);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("책 조회 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("책 조회에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 카테고리별 책 조회
     */
    @GetMapping("/category/{categoryName}")
    public ResponseEntity<?> getBooksByCategory(@PathVariable String categoryName) {
        try {
            List<BookDTO> books = bookService.getBooksByCategory(categoryName);

            Map<String, Object> response = new HashMap<>();
            response.put("category", categoryName);
            response.put("count", books.size());
            response.put("books", books);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("카테고리별 책 조회 실패: {}", categoryName, e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("책 조회에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 책 개수 조회 (전체)
     */
    @GetMapping("/count")
    public ResponseEntity<?> getBookCount() {
        try {
            int count = bookService.getAllBooks().size();

            Map<String, Object> response = new HashMap<>();
            response.put("totalCount", count);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("책 개수 조회 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("개수 조회에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 카테고리별 책 개수 조회
     */
    @GetMapping("/count/category/{categoryName}")
    public ResponseEntity<?> getBookCountByCategory(@PathVariable String categoryName) {
        try {
            int count = bookService.getBooksByCategory(categoryName).size();

            Map<String, Object> response = new HashMap<>();
            response.put("category", categoryName);
            response.put("count", count);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("카테고리별 책 개수 조회 실패: {}", categoryName, e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("개수 조회에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 크롤링 상태 확인 (통합 정보)
     */
    @GetMapping("/status")
    public ResponseEntity<?> getCrawlingStatus() {
        try {
            Map<String, Object> response = new HashMap<>();
            response.put("status", "running");
            response.put("totalBooks", bookService.getAllBooks().size());
            response.put("message", "크롤링 시스템이 정상 작동 중입니다");

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("상태 조회 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("상태 조회에 실패했습니다: " + e.getMessage()));
        }
    }

    private Map<String, String> createErrorResponse(String message) {
        Map<String, String> error = new HashMap<>();
        error.put("error", message);
        return error;
    }
}
package com.tistory.glorygem.crawler.controller;

import com.tistory.glorygem.crawler.domain.entity.BookCategory;
import com.tistory.glorygem.crawler.service.BookCategoryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/categories")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class BookCategoryController {

    private final BookCategoryService bookCategoryService;

    /**
     * 카테고리 크롤링 및 저장
     */
    @PostMapping("/crawl")
    public ResponseEntity<?> crawlCategories() {
        try {
            List<BookCategory> categories = bookCategoryService.crawlAndSaveCategories();

            Map<String, Object> response = new HashMap<>();
            response.put("message", "카테고리 크롤링 완료");
            response.put("count", categories.size());
            response.put("categories", categories);

            return ResponseEntity.ok(response);
        } catch (IOException e) {
            log.error("카테고리 크롤링 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("카테고리 크롤링에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 저장된 모든 카테고리 조회
     */
    @GetMapping
    public ResponseEntity<?> getAllCategories() {
        try {
            List<BookCategory> categories = bookCategoryService.getAllCategories();

            Map<String, Object> response = new HashMap<>();
            response.put("count", categories.size());
            response.put("categories", categories);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("카테고리 조회 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("카테고리 조회에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 특정 카테고리 조회 (카테고리명으로)
     */
    @GetMapping("/{categoryName}")
    public ResponseEntity<?> getCategoryByName(@PathVariable String categoryName) {
        try {
            BookCategory category = bookCategoryService.getCategoryByName(categoryName);

            Map<String, Object> response = new HashMap<>();
            response.put("category", category);

            return ResponseEntity.ok(response);
        } catch (IllegalArgumentException e) {
            log.warn("카테고리를 찾을 수 없음: {}", categoryName);
            return ResponseEntity.notFound().build();
        } catch (Exception e) {
            log.error("카테고리 조회 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("카테고리 조회에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 카테고리 존재 여부 확인
     */
    @GetMapping("/exists/{categoryName}")
    public ResponseEntity<?> checkCategoryExists(@PathVariable String categoryName) {
        try {
            boolean exists = bookCategoryService.existsCategory(categoryName);

            Map<String, Object> response = new HashMap<>();
            response.put("categoryName", categoryName);
            response.put("exists", exists);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("카테고리 존재 여부 확인 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("확인에 실패했습니다: " + e.getMessage()));
        }
    }

    /**
     * 카테고리 개수 조회
     */
    @GetMapping("/count")
    public ResponseEntity<?> getCategoryCount() {
        try {
            int count = bookCategoryService.getAllCategories().size();

            Map<String, Object> response = new HashMap<>();
            response.put("count", count);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("카테고리 개수 조회 실패", e);
            return ResponseEntity.internalServerError()
                    .body(createErrorResponse("개수 조회에 실패했습니다: " + e.getMessage()));
        }
    }

    private Map<String, String> createErrorResponse(String message) {
        Map<String, String> error = new HashMap<>();
        error.put("error", message);
        return error;
    }
}

 

그리고 src/main/패키지명/CrawlerApplication을 작성한다

package com.tistory.glorygem.crawler;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CrawlerApplication {

	public static void main(String[] args) {
		SpringApplication.run(CrawlerApplication.class, args);
	}

}

 

위 과정을 통해 백엔드 세팅이 끝나면, 프로젝트 루트 위치에서 아래 명령으로 Spring boot 서버를 실행한다

./gradlew bootRun

프론트엔드 코드 작성

이제 프론트 코드가 들어갈 폴더를 생성한 뒤, npm을 초기화한다

cd playwright-crawler // 프론트 코드가 작성될 폴더
npm init -y

 

웹 페이지에서 카테고리를 수집하는 category-crawler.js, 책 정보를 수집하는 crawler.js,

수집해서 디비에 저장된 데이터를 출력하는 crawler_result.js를 생성한다

const { chromium } = require('playwright');
const { Client } = require('pg');

async function crawlCategories() {
  const client = new Client({
    host: 'localhost', port: 5432, database: 'crawler',
    user: 'postgres', password: '0000',
  });

  let browser = null;
  let context = null;

  try {
    await client.connect();
    console.log('✅ PostgreSQL 연결 성공');

    // 1️⃣ BookCategory 테이블 생성 + UNIQUE 제약조건 명시적 생성
    await client.query(`
      CREATE TABLE IF NOT EXISTS "BookCategory" (
        uuid_bookcategory UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        category_name VARCHAR(255) NOT NULL
      )
    `);
    // 2️⃣ UNIQUE 인덱스 추가 (중복 체크용)
    await client.query(`
      CREATE UNIQUE INDEX IF NOT EXISTS idx_bookcategory_name
      ON "BookCategory" (category_name)
    `);
    console.log('✅ BookCategory 테이블 + UNIQUE 인덱스 생성 완료');

    browser = await chromium.launch({
      headless: false,
      slowMo: 100
    });

    context = await browser.newContext({
      userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      viewport: { width: 1920, height: 1080 }
    });

    const page = await context.newPage();

    // 페이지로드 및 DOM 완전 대기
    await page.goto('https://books.toscrape.com/', { waitUntil: 'networkidle' });
    await page.waitForSelector('.side_categories', { state: 'visible' });
    await page.waitForFunction(() =>
      document.querySelectorAll('.side_categories li a').length > 10
    );

    // 카테고리 추출
    const categories = await page.evaluate(() => {
      const categoryLinks = Array.from(document.querySelectorAll('.side_categories li a'));
      return categoryLinks
        .map(link => link.innerText.trim())
        .filter(name => name && name !== 'Books')
        .slice(0, -2);
    });

    console.log('📂 발견된 카테고리:', categories.length, '개');

    // 중복, 신규, 오류 카운트 변수
    let insertedCount = 0;
    let duplicateCount = 0;
    let errorCount = 0;

    for (const categoryName of categories) {
      try {
        const result = await client.query(`
          INSERT INTO "BookCategory" (category_name)
          VALUES ($1)
          ON CONFLICT (category_name) DO NOTHING
          RETURNING uuid_bookcategory
        `, [categoryName]);

        if (result.rowCount > 0) {
          insertedCount++;
          console.log(`📂 신규 저장: ${categoryName} (${insertedCount}/${categories.length})`);
        } else {
          duplicateCount++;
          console.log(`⏭️  중복 스킵: ${categoryName}`);
        }
      } catch (e) {
        errorCount++;
        console.error(`❌ 저장 오류: ${categoryName} -`, e.message);
      }
    }

    console.log(`✅ 총 ${categories.length}개 중 신규 ${insertedCount}개, 중복 ${duplicateCount}개, 오류 ${errorCount}개 저장 완료!`);

  } catch (error) {
    console.error('❌ 크롤링 오류:', error);
  } finally {
    if (context) await context.close().catch(() => {});
    if (browser) await browser.close().catch(() => {});
    await client.end().catch(() => {});
    console.log('🔒 모든 리소스 정리 완료');
  }
}

crawlCategories().catch(console.error);
const { chromium } = require('playwright');
const { Client } = require('pg');

// 1. 상수 정의
const BROWSER_OPTIONS = {
  headless: false,
  slowMo: 100 // 100ms 지연으로 인간다운 속도
};

const CONTEXT_OPTIONS = {
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  viewport: { width: 1920, height: 1080 }
};

const DB_CONFIG = {
  host: 'localhost',
  port: 5432,
  database: 'crawler',
  user: 'postgres',
  password: '0000',
};
const BASE_URL = 'https://books.toscrape.com/';


// 2. 헬퍼 함수 정의

/**
 * 주어진 범위 [minMs, maxMs] 내에서 랜덤하게 딜레이합니다.
 * @param {number} minMs 최소 딜레이 시간 (밀리초, 기본값: 1500)
 * @param {number} maxMs 최대 딜레이 시간 (밀리초, 기본값: 4000)
 */
async function randomDelay(minMs = 1500, maxMs = 4000) {
    // Math.random() * (max - min) + min
    const delayTime = Math.random() * (maxMs - minMs) + minMs;
    console.log(`⏳ 다음 작업 전 ${delayTime.toFixed(0)}ms 대기...`);
    return new Promise(resolve => setTimeout(resolve, delayTime));
}


/**
 * DB에 필요한 테이블이 없으면 생성합니다. (스키마 오류 해결을 위해 DROP 후 CREATE)
 * @param {Client} client
 */
async function setupDatabase(client) {
    // 💡 스키마 오류 해결: 기존 테이블 삭제 후 재생성
    await client.query(`DROP TABLE IF EXISTS "Book"`);
    await client.query(`DROP TABLE IF EXISTS "BookCategory"`);

    // 1. BookCategory 테이블 (카테고리 정보) - url_path 컬럼 포함
    await client.query(`
        CREATE TABLE "BookCategory" (
            uuid_bookcategory UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            category_name VARCHAR(255) NOT NULL UNIQUE,
            url_path TEXT NOT NULL
        )
    `);

    // 2. Book 테이블 (책 정보)
    await client.query(`
        CREATE TABLE "Book" (
            uuid_book UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            title VARCHAR(500) NOT NULL,
            price NUMERIC(10,2),
            upc VARCHAR(100) UNIQUE,
            availability VARCHAR(100),
            description TEXT,
            category_name VARCHAR(255) NOT NULL,
            url TEXT NOT NULL
        )
    `);
    console.log('✅ DB 테이블 (Book, BookCategory) 재생성/확인 완료');
}

/**
 * 메인 페이지에서 모든 카테고리를 추출하여 DB에 저장합니다.
 * @param {import('playwright').Page} page
 * @param {Client} client
 */
async function crawlAndSaveCategories(page, client) {
    await page.goto(BASE_URL, { waitUntil: 'networkidle' });
    await page.waitForSelector('.side_categories', { state: 'visible' });

    // 카테고리 추출 (이름과 상대 URL 경로)
    const categories = await page.evaluate(() => {
        const categoryLinks = Array.from(document.querySelectorAll('.side_categories li a'));
        return categoryLinks
            .map(link => ({
                name: link.innerText.trim(),
                path: link.getAttribute('href')
            }))
            .filter(cat => cat.name && cat.name !== 'Books' && cat.path);
    });

    console.log(`\n📂 발견된 카테고리: ${categories.length}개`);
    let insertedCount = 0;

    for (const cat of categories) {
        try {
            // ON CONFLICT를 사용하여 중복 카테고리는 업데이트 (url_path) 하거나 무시
            const result = await client.query(`
                INSERT INTO "BookCategory" (category_name, url_path)
                VALUES ($1, $2)
                ON CONFLICT (category_name) DO UPDATE SET url_path = EXCLUDED.url_path
                RETURNING uuid_bookcategory
            `, [cat.name, cat.path]);

            if (result.rowCount > 0) {
                insertedCount++;
            }
        } catch (e) {
            console.error(`❌ 카테고리 저장 오류: ${cat.name} -`, e.message);
        }
    }
    console.log(`✅ 카테고리 ${insertedCount}개 신규 저장/확인 완료.`);
}

/**
 * 개별 책 상세 페이지에서 정보를 추출합니다.
 * @param {import('playwright').Page} page
 * @returns {object} 추출된 책 데이터
 */
async function extractBookDetail(page) {
    // 상세 페이지로 이동 후 DOM이 안정화될 때까지 대기
    await page.waitForSelector('.product_main h1', { state: 'visible', timeout: 30000 });

    const bookData = await page.evaluate(() => {
        const title = document.querySelector('h1').innerText.trim();
        const priceText = document.querySelector('.product_main .price_color').innerText.trim();
        const upc = document.querySelector('.table.table-striped tr:nth-child(1) td').innerText.trim();
        const availability = document.querySelector('.table.table-striped tr:nth-child(6) td').innerText.trim();
        const description = document.querySelector('#product_description + p')?.innerText?.trim() || '';

        // 카테고리 이름 추출
        const breadcrumbs = Array.from(document.querySelectorAll('.breadcrumb li a'));
        const categoryName = breadcrumbs.length >= 3 ? breadcrumbs[2].innerText.trim() : null;

        return {
            title,
            price: parseFloat(priceText.replace('£', '')),
            upc,
            availability,
            description,
            categoryName
        };
    });

    bookData.url = page.url(); // 현재 URL 추가
    return bookData;
}


/**
 * 특정 카테고리의 모든 페이지를 순회하며 책 정보를 수집하고 페이지 단위로 DB에 Flush 합니다.
 * @param {import('playwright').Page} page
 * @param {Client} client
 * @param {object} category
 */
async function crawlCategoryPages(page, client, category) {
    let nextUrl = new URL(category.url_path, BASE_URL).href;
    let bookCount = 0;
    let duplicateCount = 0;
    const categoryName = category.category_name;

    console.log(`\n======================================================`);
    console.log(`🚀 카테고리 크롤링 시작: ${categoryName}`);
    console.log(`======================================================`);

    while (nextUrl) {
        const currentUrl = nextUrl;
        console.log(`\n🌐 페이지 이동: ${currentUrl}`);

        // 목록 페이지 로드
        try {
            await page.goto(currentUrl, { waitUntil: 'networkidle' });
        } catch (e) {
            console.error(`❌ 목록 페이지 로딩 오류 (${currentUrl}): ${e.message}. 현재 카테고리 크롤링을 종료합니다.`);
            break;
        }

        // 책 목록 추출 (href 속성만 추출)
        const bookHrefs = await page.evaluate(() => {
            return Array.from(document.querySelectorAll('.product_pod h3 a'))
                .map(a => a.getAttribute('href'));
        });

        const bookUrls = bookHrefs.map(href =>
            new URL(href, currentUrl).href
        );

        if (bookUrls.length === 0) {
            console.log('ℹ️ 현재 목록 페이지에 책 정보가 없습니다. 현재 카테고리 크롤링을 종료합니다.');
            break;
        }

        console.log(`📚 현재 페이지에서 ${bookUrls.length}권의 책 발견`);

        // 개별 책 상세 페이지 크롤링 및 수집
        const pageBookDetails = [];
        let pageSuccessCount = 0;
        let pageDuplicateCount = 0;

        for (const detailUrl of bookUrls) {

            // 💡 랜덤 딜레이 적용 (책 상세 페이지 간 전환 속도 제어)
            await randomDelay();

            // 최대 3회 재시도 루프
            for (let attempt = 1; attempt <= 3; attempt++) {
                try {
                    await page.goto(detailUrl, { waitUntil: 'networkidle', timeout: 30000 });
                    const bookDetail = await extractBookDetail(page);

                    if (!bookDetail.categoryName) {
                        console.error(`❌ 경고: 카테고리 정보를 찾을 수 없습니다. 스킵합니다. URL: ${detailUrl}`);
                        break; // 재시도 없이 다음 책으로 이동
                    }

                    // 💡 DB에 즉시 삽입 대신 배열에 수집
                    pageBookDetails.push(bookDetail);

                    // 성공했으므로 재시도 루프 탈출
                    break;

                } catch (e) {
                    const errorMsg = e.message;
                    console.error(`❌ 상세 페이지 크롤링 오류 (시도 ${attempt}/3): ${detailUrl} - ${errorMsg.substring(0, Math.min(errorMsg.length, 100))}...`);

                    // 오류 복구 로직: 'closed', '404', 'Timeout' 발생 시 현재 목록 페이지로 돌아가 재시도
                    if (attempt < 3 && (errorMsg.includes('closed') || errorMsg.includes('404') || errorMsg.includes('Timeout'))) {
                        console.log(`🔄 오류 복구 시도: 현재 목록 페이지 (${currentUrl})를 다시 로드합니다.`);
                        await page.goto(currentUrl, { waitUntil: 'networkidle' });
                    } else {
                        console.error(`🛑 치명적인 오류 발생 또는 재시도 횟수 초과. 다음 책으로 이동합니다.`);
                        break;
                    }
                }
            } // end of attempt loop
        } // end of bookUrls loop

        // 💡 여기서 페이지 단위 FLUSH (DB 트랜잭션 시작)
        console.log(`\n💾 페이지 단위 FLUSH 시작: ${pageBookDetails.length}권`);

        try {
            await client.query('BEGIN');

            for (const bookDetail of pageBookDetails) {
                // Book 테이블에 삽입 (UPC 중복 시 스킵)
                const bookQuery = `
                    INSERT INTO "Book" (title, price, upc, availability, description, category_name, url)
                    VALUES ($1, $2, $3, $4, $5, $6, $7)
                    ON CONFLICT (upc) DO NOTHING
                    RETURNING uuid_book;
                `;

                const bookRes = await client.query(bookQuery, [
                    bookDetail.title,
                    bookDetail.price,
                    bookDetail.upc,
                    bookDetail.availability,
                    bookDetail.description,
                    bookDetail.categoryName,
                    bookDetail.url
                ]);

                if (bookRes.rowCount > 0) {
                    pageSuccessCount++;
                } else {
                    pageDuplicateCount++;
                }
            }

            await client.query('COMMIT');
            console.log(`✅ FLUSH 완료: 신규 ${pageSuccessCount}권, 중복 ${pageDuplicateCount}권`);

            // 누적 카운트 업데이트
            bookCount += pageSuccessCount;
            duplicateCount += pageDuplicateCount;

        } catch (flushError) {
            await client.query('ROLLBACK');
            console.error(`❌ FLUSH 오류 발생 (ROLLBACK):`, flushError.message);
        }


        // 다음 페이지 URL 찾기
        const nextButton = await page.locator('.pager .next a');
        if (await nextButton.isVisible()) {
            const nextHref = await nextButton.getAttribute('href');
            nextUrl = new URL(nextHref, currentUrl).href;
        } else {
            nextUrl = null;
        }

        // 목록 페이지 전환 전 딜레이
        await randomDelay(500, 1000);
    } // end of while (nextUrl)

    console.log(`✅ 카테고리 ${categoryName} 크롤링 완료. 신규 ${bookCount}권 저장.`);
    return { bookCount, duplicateCount };
}


// 3. 메인 함수 정의
async function crawlAllBooks() {
    // [가정] DB_CONFIG 상수는 이 스크립트 상단에 정의되어 있다고 가정합니다.
    const DB_CONFIG = { host: 'localhost', port: 5432, database: 'crawler', user: 'postgres', password: '0000' };
    const client = new Client(DB_CONFIG);
    let browser = null;
    let context = null;
    let page = null;
    let totalBookCount = 0;
    let totalDuplicateCount = 0;

    try {
        await client.connect();
        await setupDatabase(client);

        const { chromium } = require('playwright');
        // [가정] BROWSER_OPTIONS, CONTEXT_OPTIONS 상수는 정의되어 있다고 가정합니다.
        const BROWSER_OPTIONS = { headless: false, slowMo: 100 };
        const CONTEXT_OPTIONS = {
            userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            viewport: { width: 1920, height: 1080 }
        };

        browser = await chromium.launch(BROWSER_OPTIONS);
        context = await browser.newContext(CONTEXT_OPTIONS);
        page = await context.newPage();

        // 1단계: 카테고리 정보 수집 및 DB 저장
        await crawlAndSaveCategories(page, client);

        console.log('\n--- 책 상세 정보 크롤링 시작 ---');

        // 2단계: DB에서 저장된 카테고리 목록을 로드
        const categoryRes = await client.query('SELECT category_name, url_path FROM "BookCategory"');
        const categories = categoryRes.rows;

        // 3단계: 카테고리별로 순회하며 책 정보 수집 (카테고리별 Flush 효과)
        for (const category of categories) {
             // 각 카테고리 루프가 끝날 때마다 데이터는 페이지 단위로 DB에 완전히 반영됨
             const result = await crawlCategoryPages(page, client, category);
             totalBookCount += result.bookCount;
             totalDuplicateCount += result.duplicateCount;
        }

        console.log('\n--- 최종 크롤링 완료 ---');
        console.log(`✅ 총 ${totalBookCount}권의 신규 책 정보 저장 완료.`);
        console.log(`ℹ️ ${totalDuplicateCount}건의 중복 책 정보 스킵됨.`);

    } catch (error) {
        console.error('❌ 전체 크롤링 중 치명적인 오류 발생:', error);
    } finally {
        if (page) await page.close().catch(() => {});
        if (context) await context.close().catch(() => {});
        if (browser) await browser.close().catch(() => {});
        if (client) await client.end().catch(() => {});
        console.log('🔒 모든 리소스 정리 완료');
    }
}

// 실행
crawlAllBooks().catch(console.error);
// crawler_result.js

// PostgreSQL 클라이언트 모듈만 사용
const { Client } = require('pg');

// 데이터베이스 설정 (기존 크롤러에서 사용된 설정과 동일해야 함)
const DB_CONFIG = {
  host: 'localhost',
  port: 5432,
  database: 'crawler',
  user: 'postgres',
  password: '0000',
};

/**
 * BookCategory 테이블의 모든 데이터를 조회하여 Pretty JSON으로 출력합니다.
 */
async function dumpBookCategoriesToJson() {
    const client = new Client(DB_CONFIG);
    try {
        await client.connect();
        console.log('✅ PostgreSQL 연결 성공');

        const query = 'SELECT * FROM "BookCategory"';
        const result = await client.query(query);
        const categories = result.rows;

        console.log(`\n--- BookCategory 데이터 (${categories.length}개) ---\n`);
        // JSON.stringify(data, replacer, space)를 사용하여 Pretty 출력
        console.log(JSON.stringify(categories, null, 2));

    } catch (error) {
        console.error('❌ BookCategory 데이터 덤프 중 오류 발생:', error);
    } finally {
        if (client) await client.end().catch(() => {});
        console.log('\n🔒 DB 연결 종료');
    }
}

// ----------------------------------------------------

/**
 * Book 테이블의 모든 데이터를 조회하여 Pretty JSON으로 출력합니다.
 */
async function dumpBooksToJson() {
    const client = new Client(DB_CONFIG);
    try {
        await client.connect();
        console.log('✅ PostgreSQL 연결 성공');

        const query = 'SELECT * FROM "Book"';
        const result = await client.query(query);
        const books = result.rows;

        console.log(`\n--- Book 데이터 (${books.length}개) ---\n`);
        // Pretty JSON 출력
        console.log(JSON.stringify(books, null, 2));

    } catch (error) {
        console.error('❌ Book 데이터 덤프 중 오류 발생:', error);
    } finally {
        if (client) await client.end().catch(() => {});
        console.log('\n🔒 DB 연결 종료');
    }
}

// ----------------------------------------------------

// 명령줄 인수에 따라 실행할 함수를 결정합니다.
const command = process.argv[2];

if (command === 'dump:categories') {
    dumpBookCategoriesToJson().catch(console.error);
} else if (command === 'dump:books') {
    dumpBooksToJson().catch(console.error);
} else {
    console.log('사용법: node crawler_result.js [dump:categories | dump:books]');
}

 

그리고 터미널에서 실행 인자를 넘겨 간단하게 실행할 수 있도록 package.json을 수정한다

{
  "name": "playwright-crawler",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "crawl:categories": "node category-crawler.js",
    "crawl:books": "node crawler.js",
    "crawl:watch": "nodemon crawler.js",
    "dump:categories": "node crawler_result.js dump:categories",
    "dump:books": "node crawler_result.js dump:books"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "pg": "^8.16.3",
    "playwright": "^1.57.0",
    "uuid": "^13.0.0"
  }
}

 

이제 playwright-crawler/ 에서 터미널로 아래 명령들을 입력하면, 카테고리&책 데이터 수집 및 출력을 진행할 수 있다

명령 설명
npm run crawl:categories 웹사이트에서 모든 카테고리 정보를 수집하여 DB(BookCategory 테이블)에 저장
npm run crawl:books DB의 카테고리 목록을 기반으로 웹 페이지를 순회하며 책 상세 정보를 수집하여 DB(Book 테이블)에 저장
npm run dump:categories DB에 저장된 BookCategory 테이블 데이터를 조회하여 JSON Pretty로 출력 (브라우저 사용 안 함)
npm run dump:books DB에 저장된 Book 테이블 데이터를 조회하여 JSON Pretty로 출력 (브라우저 사용 안 함)

 

 

IP 차단 회피

프론트 웹 크롤링을 사용하다 보면 과도한 요청으로 일시차단되는 경우가 발생할 수 있다

그런 경우 아래의 아이디어를 적용해 볼 수 있다

 

랜덤 딜레이: 페이지 이동이나 데이터 추출 전후에는 불규칙한 대기 시간을 적용하여 사람처럼 보이도록 한다

User Agent 설정: 실제 브라우저와 유사한 userAgent를 설정하여 봇임을 감춘다

내비게이션 패턴: 웹사이트는 비정상적인 탐색 패턴을 봇으로 간주하고 IP를 차단한다.

  사용자의 자연스러운 패턴을 모방해야 한다

더보기

내비게이션 패턴

| 패턴 | 설명 | IP 차단 방지 유리도 |
| :--- | :--- | :--- |
| **`a -> b -> c`** | 목록(a)에서 상세(b)로 갔다가, 바로 다른 상세(c)로 이동 | **불리** ❌<br/>(Referer 불일치, 봇 행동으로 간주) |
| **`a -> b -> a -> c`** | 목록(a)에서 상세(b)로 갔다가, **목록(a)으로 돌아가** 상세(c)로 이동 | **유리** ✅<br/>(사용자가 목록을 스캔하는 자연스러운 패턴) |

 

마무리

프론트엔드 웹 크롤링(Playwright 등)은 동적 콘텐츠 처리에 유리하나, robots.txt 필수 준수, 요청 간격 2-5초 유지, User-Agent 실제 브라우저 설정으로 서버 부하·차단 방지해야 합니다.

상업적 용도(데이터 판매·분석 서비스)는 절대 금지하며, 사람인·잡코리아 사례처럼 부정경쟁·저작권 소송 위험이 존재한다.​

 

더 나은 대안들

공식 API 우선 사용: 대부분 사이트(RSS, Open API)가 무료 데이터 제공. 예: 네이버 오픈 API, 공공데이터포털.​
웹 아카이브 활용: Wayback Machine으로 과거 페이지 무료 수집.​
데이터 구매/제휴: 상업 프로젝트 시 정식 라이선스 계약 권장.​

법적 책임은 전적으로 본인에게 있으며, 야놀자-여기어때처럼 민사 10억 배상 판결도 존재합니다.

학습 목적 외 사용 시 변호사 법률 검토 필수.

크롤링 대신 API 개발 문화로 전환하세요.

 

 

LIST

'서버' 카테고리의 다른 글

[CI/CD] GitHub 브랜치 기반 자동 배포  (0) 2025.12.28
PostgreSQL 17 설치 및 증분백업  (0) 2025.10.09
[가비아] ssh 키 설정  (0) 2025.09.09
[가비아] 원격 GUI 설치  (0) 2025.09.07
[가비아] 가비아 클라우드 ssh 접속  (0) 2025.08.20