프로젝트 설계, HTTP 패킷 분석을 통한 웹 스크래핑 테스트

JungWooLee·2022년 7월 6일
0

SpringBoot ToyProject

목록 보기
1/14

시작에 앞서

목적 ?

  • 회사에서 업무를 하다보면 일도 중요하지만 하루 중 가장 큰 고민거리는 오늘 뭐먹지? 가 아닐 수 없다
  • "짬밥"이 없다면 주변 맛집을 알기에는 다소 어려운 부분이 존재할 것인데 네이버 지도를 이용하여 주변 맛집 정보를 탐색하고 큰 카테고리 내에서 가볼 만한 음식점을 추천해주는 웹을 설계하려고 한다
  • 현업에서 자바를 다루는 일이 C#에 비해 부족한 면이 많아 조금이라도 개인적인 공부차원에서 토이 프로젝트를 기획하게 되었다

Flow

모든 프로젝트에서 가장 중요한 것은 초기 설계단계이다. 이 부분에 대해서는 아직까지 현업에서 설계, 기획을 맡아본적이 없어 나름의 도전이 될수도 있겠지만 노력으로 보강하려고 한다

현재 ERD, USERflow, RestAPI 설계서 를 설계중에 있는데 기본적으로 설계하기에 앞서 참고할 자료가 없기에 실현 가능성이 있는지 확인하기 위해 테스트를 먼저 진행하게 되었다

전체적인 틀을 설정하고 간단한 테스트를 통해 가능한지 여부를 판단한다

1. Enum 을 이용하여 식사메뉴를 설정하고 특정한 카테고리를 선택하고 싶다면 해당 카테고리를, 아니라면 랜덤으로 Enum 내부의 카테고리중 하나를 선정할 수 있도록 한다

  • Enum 클래스와 랜덤 메소드를 이용하여 카테고리를 특정할 수 있도록하고, 다중 선택또한 가능한지 여부를 확인한다

2. 서버는 AWS 또는 불가피할 경우 로컬환경에서 돌리되 업데이트 일자를 화면에 표기하여 버튼을 통하여 최신 정보를 스크래핑할 수 있도록 한다

  • Fiddler, PostMan을 이용하여 어떤 요청과 응답이 오가는지 확인하고 SSL 이슈나 기타 인증서 이슈가 있는지 확인하여 스크래핑 가능여부를 판단한다
  • AWS의 경우 배포경험이 적어 이 부분은 따로 공부를 해가면서 보강해간다

3. Module화 하여 MSA 로 구성할 수 있는지 여부를 판단한다

  • 따로 MSA에 대하여 관심이 있어 공부를 시작해볼까 하는데 이기회에 실습을 겸하고자 한다

4. 회원가입의 경우는 OAuth를 사용하여 최대한 사용자에게 간편함을 제공하여 스케쥴러를 통하여 당일이 아닌 이후에 먹을 음식을 탐색하고 스케쥴에 등록할 수 있도록 한다

  • OAuth 의 경우 카카오톡, 네이버, 구글등이 있는데 카카오톡은 한번도 적용해본적이 없긴하지만 최대한 구성할 수 있도록한다
  • 회원으로 관리하지 않을시 세션에 모든 정보가 담길경우 회원 개인의 달력, 스케쥴로 관리 할 수 없기 때문에 다소 어려움이 존재할 것같다고 판단하였다

5. BE 단에 집중하기 위해 프론트에 대한 시간적 부담을 줄이고자 적당한 템플릿을 선정한다

  • 무료로 풀리고 있는 템플릿중 도메인과 가장 알맞은 템플릿을 선정하고자 한다

6. 회원일 경우 네이버 지도 API를 통하여 좌표 x,y를 받아와 해당 좌표 내에서 boundary를 설정 할 수 있도록 하여 주변 맛집을 검색할 수 있도록한다

  • 네이버 지도 API에서 주소 정보를 입력받아 x,y좌표로 파싱할 수 있는 방법이 있는지 확인한다

대략적으로 위의 구성대로 Flow를 맞추어 차근차근 준비하고자 합니다.
추가적인 기능들이 추가될수도, 제외될수도 있겠지만 위의 틀에 맞추어 설계하고 그에 맞는 테스트 주도 개발을 경험하는 것이 목표입니다


Fiddler 를 통한 요청, 응답 분석

프로젝트 테스트에 앞서 미리 네이버에 이러한 정보를 제공하는 API가 있는지 확인해보았지만 확인해본 바로는 지도에서 주변 음식점을 찾아주는 API 를 제공하지 않는 것으로 판단하여 직접 Fiddler를 통하여 응답을 파싱하여 서비스를 만들고자 하였다

  1. 네이버지도에서 음식점을 검색하였을때 얻게 되는 값을 살핀다

Request

Response

해당 url 로 호출하게 되었을때 html의 형태로 응답을 받는것을 확인하였다
위 사진의 응답은 html script태그 안에 존재하는 형태로 파싱하는데에 도전이 될 수 있겠다 싶었따..

"RestaurantAdSummary:120889xxx": {

        "__typename": "RestaurantAdSummary",

        "adClickLog": {

            "generated": true,

            "id": "$RestaurantAdSummary:1208890227.adClickLog",

            "type": "id",

            "typename": "AdClickLog"

        },

        "adDescription": "을지로 분위기 좋은 와인바",

        "adId": "nad-a001-06-000000172725487",

        "address": "수표동 32156-22324",

        "agencyId": null,

        "blogCafeReviewCount": "69",

        "bookingBusinessId": "582600",

        "bookingDisplayName": "visitor_review",

        "bookingHubButtonName": null,

        "bookingHubUrl": null,

        "bookingPickupId": null,

        "bookingReviewCount": "2",

        "bookingReviewScore": 0,

        "bookingUrl": "https:\u002F\u002Fm.booking.naver.com\u002Fbooking\u002F6\u002Fbizes\u002F582600\u002Fsearch",

        "bookingVisitId": null,

        "broadcastInfo": null,

        "broadcasts": null,

        "businessCategory": "bar",

        "businessHours": "토요일 18:00~22:00 | 금요일 18:00~22:00 | 목요일 18:00~22:00 | 수요일 18:00~22:00 | 화요일 18:00~22:00 | 월요일 18:00~22:00 | 일요일 휴무",

        "category": "와인",

        "commonAddress": "서울 중구 수표동",

        "dbType": null,

        "description": "을지로 시그니쳐 타워 맞은편 보리밥집 2층에 위치한 스너그입니다..직접 키운 채소와 계약을 맺은 농장에서 수급한 신선한 채소, 제철 해산물로 만든 요리를 만나볼 수 있습니다.조용하고 아늑한 분위기에서 내추럴 와인 한잔을 드시기 좋은 공간입니다. SNUG",

        "detailCid": {

            "generated": true,

            "id": "$RestaurantAdSummary:120889xxx.detailCid",

            "type": "id",

            "typename": "DetailCid"

        },

        "distance": "830m",

        "effectiveImpressionEventUrl": "https:\u002F\u002Ftivan.naver.com\u002Fed\u002FErNfBIJtY_v1m82uvHD3eoBB7Nvc4uDFDiqlmJkhOA-ZjAoqoPrqEVT_Xezxn5rzvFm4lejwnMMKFr7MH8thQ1WrQtP9qvD4hYFrVjpr0MA=",

        "hasBooking": true,

        "hasNPay": true,

        "id": "120889xxx",

        "imageCount": 2,

        "imageUrl": "https:\u002F\u002Fsearchad-phinf.pstatic.net\u002FMjAyMjAyMjhfMjQ0\u002FMDAxNjQ2MDA5Njc4MTM5.RHm7q1qGQ4gEBAsbZvt5xaiYgldrzkUimD-RFDyWxFog.yOGZh12J5W-2BYb2oknatbu0Uj0muoakX7MXvpQw8A0g.PNG\u002F2334487-ccddc0a2-a242-4475-b47d-7d8f268616aa.png",

        "imageUrls": {

            "json": ["https:\u002F\u002Fsearchad-phinf.pstatic.net\u002FMjAyMjAyMjhfMjQ0\u002FMDAxNjQ2MDA5Njc4MTM5.RHm7q1qGQ4gEBAsbZvt5xaiYgldrzkUimD-RFDyWxFog.yOGZh12J5W-2BYb2oknatbu0Uj0muoakX7MXvpQw8A0g.PNG\u002F2334487-ccddc0a2-a242-4475-b47d-7d8f268616aa.png", "https:\u002F\u002Fsearchad-phinf.pstatic.net\u002FMjAyMjAyMjhfOTQg\u002FMDAxNjQ2MDA5Njc4Mjc2.qb6vz68yRCOQINdPeACdUQLKMZQukVhOH96e79JxnNYg.gTQQtq7N9ZuFBvKOUYGIXey64K1R2r9Adoca1goZq2gg.PNG\u002F2334487-4ca5fdda-ed52-4ac2-8e7d-0d732895e2f7.png"],

            "type": "json"

        },

        "impressionEventUrl": "https:\u002F\u002Ftivan.naver.com\u002Fd\u002FEdCxOPNi6iVO3V-h53QA2yuSqyOjnzKsJsuIQAJ1Lfa80rUT4HB3CBFUXRfGDbZZ304V21oFJHekFMCPDX9ceUsQyeZyV6VrwaZM0EFAihYFX9adjA31KUX-8tiPMqWpcxXqiiLmYCICINvmBdZaQ26YKiIoDvPAwiWYLtWYkw5JovZp_ANBFfjO_aGZpipGDQ_mO-9XV7fbtnwSdMLjSa0N1X1HjlLxbpHiNQgM6fzHRhYOpP7gSKAe2-zpDeZ18u4xEWj9GfzYuIitYLjGt8ineV1OhRdQuHS0C_K4RNRm8KTxcQ3B8WWH7CKg0de24yyvXSQ9QjwldPakVE4v08a0TaBW2d5OdOS8kwpltaLPlYVAQnIMQ4Ng1H1MJoimuXQ-0tyKCilDsAUvTsG22HHjbrHRtLIZ8nzhwXGzItn4gwMSLTe9frpqToCoP8quXKKWOJP-OoCxWKA_Bkj2Ah2qO8vapJodigtHonhpeDtzDxq-D30MNKZxGsAqfhDIAVYt5GQEk-uLTfoP0f9m1zoKJqb-RhZSyXp-2tL5nvR5G5eVWhnU0711Eq11hj3iE1J6J4BOEyto_xEd2BnE8FAzY6VuDzDwaUf2EEwW5U8RBGLVAoPEDclFEVJdnU4cHRImp_yJ_9sHQBtCJnaUNSHqdZilUXTWX86fCGB_dG7nyv9OyC2KRHAKsrFfGYcblA_lgjUI0jngFSkFKfS-LidhRCMKj01aZEgMiucPJcYsRDcHJlLV4lOtvvjqU1DEjyyI905nGEcynox7ZcWIMTksUdxaA3WjmM_0EzGTpgOiI5Nzb6kFqOuZhTRl8eihXo9BOkSXhBYyrDzDcJPLjAa0ZoIbuXLzUUW53S8HjJpGioy1wNeQqwH6Tdg4mPJarojBZpEs37NEMObqDrD65IaxJvYExrSLmzATfoJotgxzys8JQk8Vwb_1afyjdsduqdENWNnvWZQe7ALyyFbrDCVTlAF9NYMYux-AP2dijQU=",

        "isCvsDelivery": false,

        "isDelivery": false,

        "isPreOrder": false,

        "isTableOrder": false,

        "isTakeOut": false,

        "michelinGuide": null,

        "microReview": "스몰플레이트가 맛있는 내추럴 와인바",

        "name": "스너그",

        "naverBookingCategory": "예약",

        "newBusinessHours": {

            "generated": true,

            "id": "$RestaurantAdSummary:120889xxx.newBusinessHours",

            "type": "id",

            "typename": "NewBusinessHours"

        },

        "newOpening": false,

        "options": "단체석,예약,남\u002F녀 화장실 구분,무선 인터넷,포장,배달,국민지원금",

        "phone": "010-0000-0000",

        "popularMenuImages": null,

        "priceCategory": "7만원 대",

        "promotionTitle": null,

        "roadAddress": "서울 중구 을지로 어딘가",

        "routeUrl": "http:\u002F\u002Fmap.naver.com\u002F?eText=%EC%8A%A4%EB%84%88%EA%B7%B8&elng=x&elat=y",

        "saveCount": null,

        "streetPanorama": {

            "generated": false,

            "id": "Panorama:yffjSCPUUL39a3afI7HTHw==",

            "type": "id",

            "typename": "Panorama"

        },

        "tags": null,

        "talktalkUrl": null,

        "totalReviewCount": "71",

        "tvcastId": null,

        "uniqueBroadcasts": null,

        "virtualPhone": null,

        "visitorReviewCount": "72",

        "visitorReviewScore": "4.71",

        "x": "xxxx",

        "y": "xxxx"

    }

예시로 파싱할 모델을 선정하여 쓰이게될 엔티티의 인스턴스를 설정하였다
민감정보가 될수있어 정보를 수정하여 실제 정보와는 차이가 있다는 점을 고려

전체적으로 본다면 하나의 JsonArray로 JsonObject의 형태로 각 음식점의 값들이 있음을 알 수 있다.

사실 처음에 거리 정보를 계산하게 될때에 네이버 지도를 통하여 다익스트라를 써서 최소 거리를 표현해야하나 고민이 있었는데 다행히 distance 값이 있어 이 값을 활용하고자 한다

사용하고자 하는 속성은 다음과 같다 :

	@Id
    private long id;

    @Column(nullable = false)
    private String address;

    @Column(nullable = false)
    private String category;

    @Column(nullable = false)
    private String imageUrl;

    @Column(nullable = true)
    private String name;

    @Column(nullable = false)
    private Long distance;

    @Column(nullable = false)
    private String businessHours;

    // 아래 셋은 종합하여  맛집 랭킹에 사용될 예정
    @Column(nullable = true)
    private Double visitorReviewScore;

    @Column(nullable = false)
    private Long saveCount;

    @Column(nullable = false)
    private Double bookingReviewScore;

!validation 은 고도화 과정에서 추가할 예정!

saveCount 같은 경우 조사한 바로는 사용자 임의로 가고자 하는 음식점을 저장할 수 있는데 이것이 맛집 판단의 요소가 될 수 있다 판단하여 넣게 되었다

id 값의 경우 이후 쓰이게 되는 상세 음식점 정보를 얻기 위해 지정하였으며 해당 id는 고유한 음식점 번호이기 때문에 pk로서 활용하기로 하였다

distance와 reviewScore 같은 경우 String 형태이지만 이후 맛집 랭킹을 매길때에 번거로울것같아 매핑과정에서 해당 부분을 Convert해줄 예정이다

그리고 자세한 메뉴를 클릭했을때 나타나는 변화를 알아본다


밥집 선정의 기준으로서 가격또한 고려하지 않을 수 없다
식대를 제공한다면 최대 해당 식대가격을 초과하지 않는 선에서 필터링을 해야하기 때문에 이점을 고려하여 메뉴 세부사항을 분석한다

그리고 PostMan으로 확인 절차를 가진다

파라미터 없이도 가게의 id값만으로 세부사항을 파싱할 수 있을것 같다!

왜 굳이 요청 html 로 파싱하는것인가?

사실 크롤링, 스크래핑 과정에서 Document로 해당 url의 html을 통하여 테그를 통하여 파싱후 모델링 하는것이 가장 쉽고 보편적이다.

하지만 .. 위 그림과 같이 네이버에서는 각 테그의 class나 id 값이 어떤 패턴인지 유추하기 힘들며 숨겨진 html 등이 많아 response 를 분석하는 방향으로 진행하였다


Spring Boot Model, Jpa Test

우선 스프링에서 JPA 를 이용할 것이기 때문에 위에서 필요했던 인스턴스를 갖는 엔티티를 만들고, 레포지토리를 생성합니다

Entity

package com.app.lunchsolver.entity.restaurant;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@NoArgsConstructor
@Entity
@Getter
public class Restaurant {

// pk
@Id
private long id;

@Column(nullable = false)
private String address;

@Column(nullable = false)
private String category;

@Column(nullable = false)
private String imageUrl;

@Column(nullable = false)
private String name;

@Column(nullable = false)
private String distance;

@Column(nullable = false)
private String businessHours;

// 아래 셋은 종합하여 맛집 랭킹에 사용될 예정
@Column(nullable = false)
private Double visitorReviewScore;

@Column(nullable = false)
private String saveCount;

@Column(nullable = false)
private Double bookingReviewScore;

@Builder
public Restaurant(long id, String address, String category, String imageUrl, String name, String distance, String businessHours, Double visitorReviewScore, String saveCount, Double bookingReviewScore) {
this.id = id;
this.address = address;
this.category = category;
this.imageUrl = imageUrl;
this.name = name;
this.distance = distance;
this.businessHours = businessHours;
this.visitorReviewScore = visitorReviewScore;
this.saveCount = saveCount;
this.bookingReviewScore = bookingReviewScore;
}
}

JPA

package com.app.lunchsolver.entity.restaurant;

import org.springframework.data.jpa.repository.JpaRepository;

public interface RestaurantsRepository extends JpaRepository<Restaurant, Long> {
}

엔티티에서 Setter 를 설정하지 않은 이유는 값 변경이 자주 일어날 경우 이를 트래킹하기 힘들어지며 애초에 엔티티가 생성될때 이 값이 수정되는 일이 최소화 되도록 설계 되는것이 좋은 코드라 할 수 있기 때문입니다

왜 생성자가 아니라 Builder 를 사용하나요?

→ 생성자의 경우 필드에 인자를 넣어 생성할 때 잘못 추가한 경우 코드를 실행하기 전에는 오류를 찾기 힘듭니다. 하지만 빌더의 경우 어느필드에 어떤 값을 채워야 할지 명확하게 인지 할 수 있기 때문에 이러한 실수를 덜어낼 수 있습니다.

다음은 레포지토리입니다

보통 SQL Mapper 를 사용할때의 Dao 라고 불리는 DB Layer 입니다.

JPA에서는 Repository 라고 부르며 인터페이스로 생성합니다.

단순히 인터페이스를 생성후 JpaRepository<Entity 클래스, PK 타입> 를 상속하면 기본적인 CRUD 메소드가 자동으로 생성됩니다

※ Entity 클래스와 기본 Entity Repository는 함께 위치해야 합니다. 둘은 밀접한 관계를 갖고있으며, Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없기 때문입니다. ( 도메인 패키지에서 함께 관리 )

현재는 테스트 과정중에 있기 때문에 다른 일회성 DB인 H2 DB를 활용하였습니다

dependencies

dependencies {
// 셀레니움
implementation 'org.seleniumhq.selenium:selenium-java:4.2.1'

// h2 DB, JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa:2.7.0'
implementation 'com.h2database:h2:2.1.212'

implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'junit:junit:4.13.1'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Request, Response DTO

@Getter
@Setter
public class GetRestaurantRequest {
    private String query;
    private String x;
    private String y;
    private String bounds;


    @Builder
    public GetRestaurantRequest(String query, String x, String y, String bounds) {
        this.query = query;
        this.x = x;
        this.y = y;
        this.bounds = bounds;
    }
}
@Getter
@Setter
@ToString
public class GetRestaurantResponse {
    private String id;
    private String address;
    private String category;
    private String imageUrl;
    private String name;
    private String distance;
    private String businessHours;
    private String visitorReviewScore;
    private String saveCount;
    private Double bookingReviewScore;
}

1. 우선 JPA가 잘 설정되었는지 레포지토리를 테스트합니다

package com.app.lunchsolver.entity.restaurant;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;


import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
class RestaurantsRepositoryTest {
    @Autowired
    RestaurantsRepository restaurantsRepository;

    @AfterEach
    public void cleanUp(){
        restaurantsRepository.deleteAll();
    }

    @Test
    @DisplayName("임의의 레스토랑 정보를 저장한후 불러온 매장 정보와 동일한지 확인")
    public void givenRestaurant_whenFindall_thenGetRestaurants () throws Exception {
        // given
        String address = "을지로3가 5926-25846";
        String category = "바(BAR)";
        Long id = 1790381706L;
        String name = "드레싱 베이비";
        Long distance = 810L;
        String businessHours = "평일 11:15~22:00 평일 break 15:00~17:00 | 주말 11:30~22:00 주말 주방재료준비 15:30~16:30";
        Double visitorReviewScore = 3.43;
        Long saveCount = 2000L;
        Double bookingReviewScore = 4.23;
        String imageUrl = "https:\\u002F\\u002Fldb-phinf.pstatic.net\\u002F20210906_227\\u002F16308926152264AxV2_JPEG\\u002FjZKBQcY8beh0d_9fPVHGOpc3.jpg";

        restaurantsRepository.save(Restaurant.builder()
                        .address(address)
                        .category(category)
                        .id(id)
                        .name(name)
                        .distance(distance)
                        .businessHours(businessHours)
                        .visitorReviewScore(visitorReviewScore)
                        .saveCount(saveCount)
                        .bookingReviewScore(bookingReviewScore)
                        .imageUrl(imageUrl)
                        .build()
                );
;
        // when
        List<Restaurant> restaurantList = restaurantsRepository.findAll();
        System.out.println(restaurantList.get(0));
        // then
        Restaurant restaurant = restaurantList.get(0);
        assertThat(restaurant.getAddress()).isEqualTo(address);
        assertThat(restaurant.getName()).isEqualTo(name);
    }
}

2. 대략적으로 Service와 SerivceImpl를 생성하고 구현체인 Impl를 테스트합니다

왜 굳이 Impl를 따로 두어 설계하게 되었는지?

이론상으로 위와 같은 Service, ServiceImpl 패턴으로 설계를 해야하는 이유는 인터페이스와 구현체를 분리함으로써 구현체를 독립적으로 확장할 수 있으며, 구현체 클래스를 변경하거나 확장해도 이를 사용하는 클라이언트의 코드에 영향을 주지 않도록 하기 위함입니다.

이 같은 추상화를 통한 구현 방식은 객체지향의 특징 중 하나인 다형성과 객체지향의 다섯 가지 원칙 중 하나인 OCP 원칙을 가장 잘 실현해주는 설계 방식이라고 할 수 있습니다.

하지만 실제로 대부분의 프로젝트에서는 인터페이스와 구현체 클래스 사이의 관계가 1:1의 관계로 구성되어 실질적으로 인터페이스, 클래스 구조를 사용하는 것에 대한 이점을 전혀 가져가지 못함에도 불구하고 관습적으로 이러한 추상 패턴을 적용하고 있습니다.

ref-https://wildeveloperetrain.tistory.com/49

Service

package com.app.lunchsolver.service;

public interface RestaurantService {
    void getRestaurantData(String url);
}

ServiceImpl

package com.app.lunchsolver.service;

import com.app.lunchsolver.util.BaseUtility;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class RestaurantServiceImpl implements RestaurantService{

    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private BaseUtility utility;

    private final String HOST = "pcmap.place.naver.com";

    public void getRestaurantData(String url){
        String _url = HOST +url;
        HttpHeaders httpHeaders = utility.getDefaultHeader();
        // when
        HttpEntity request = new HttpEntity(httpHeaders);
        ResponseEntity response = restTemplate.exchange(
                _url,
                HttpMethod.GET,
                request,
                String.class);


    }
}

서비스를 구성하였다면 응답 html 을 파싱하여 모델과 매핑하고 save 하는 테스트를 시작합니다

Test_v1

	@Test
    @DisplayName("응답 html 을 파싱하여 모델과 매핑하고 save")
    public void getRestaurantData_v1 () throws Exception {
        // given
        String url = "/restaurant/list";
        String _url = HOST_v1+url;
        GetRestaurantRequest request = GetRestaurantRequest.builder()
                .x(x)
                .y(y)
                .bounds("126.9738873;37.5502692;126.9980272;37.5696434")
                .query("음식점").
        build();
        Map<String, String> params =  objectMapper.convertValue(request,Map.class);
        System.out.println(params);
        String uriBuilder = utility.uriParameterBuilder(params,_url);

        HttpHeaders httpHeaders = utility.getDefaultHeader();

        HttpEntity requestMessage = new HttpEntity(httpHeaders);
        System.out.println(uriBuilder);
        // when
        ResponseEntity response = restTemplate.exchange(
                uriBuilder,
                HttpMethod.GET,
                requestMessage,
                String.class);

        // 음식점 정보들 파싱
        Document doc = Jsoup.parse((String) response.getBody());

        Element scriptElement = doc.getElementsByTag("script").get(2);
        String innerJson =  scriptElement.childNode(0).toString();
        int start = innerJson.indexOf("window.__APOLLO_STATE__");
        int end = innerJson.indexOf("window.__PLACE_STATE__");
        // JSON으로 파싱
        JSONObject target = new JSONObject(innerJson.substring(start,end).substring(25));

        JSONArray jsonArray = target.names();
        List<String> restaurantList = new ArrayList<>();
        for (int i = 0; i < jsonArray.length(); i++) {
            String possible = jsonArray.get(i).toString();
            // 레스토랑 정보를 갖고 있는 곳은 RestaurantListSummary:XXXXXX 의 형태를 띄며 한번의 스크래핑에서 50개의 결과값이 나오게 된다
            if(possible.contains("RestaurantListSummary") && Character.isDigit(possible.charAt(possible.length()-2))){
                restaurantList.add(possible);
            }
        }

        List<GetRestaurantResponse> results = new ArrayList<GetRestaurantResponse>();
        for (String s : restaurantList) {
            // 해당 JObject와 Response 객체간의 매핑
            GetRestaurantResponse mapped_data = gson.fromJson(target.get(s).toString(),GetRestaurantResponse.class);
            results.add(mapped_data);
        }
        // then

    }

Utility

	public HttpHeaders getDefaultHeader(){
        HttpHeaders httpHeaders = new HttpHeaders();
        MultiValueMap<String, String> headerValues = new LinkedMultiValueMap<>();
        headerValues.add(HttpHeaders.ACCEPT, "*/*");
        headerValues.add(HttpHeaders.HOST, HOST_v2);
        headerValues.add(HttpHeaders.USER_AGENT, USER_AGENT);
        headerValues.add("Referer", REFERER);
        headerValues.add("Connection","keep-alive");
        httpHeaders.addAll(headerValues);
        httpHeaders.setContentType(MediaType.APPLICATION_JSON);
        return httpHeaders;
    }

    public static UriComponentsBuilder getUriComponents (Map<String,String> parameters, String url){
        // query param 으로 파라미터를 넘겼을때 제대로 인식하는것을 확인
        UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
        for (Map.Entry<String, String> entry : parameters.entrySet()) {
            builder.queryParam(entry.getKey(), entry.getValue());
        }
        return builder;
    }

	public Long stringToLongDistance(String distance){
        distance = distance.replace(",","000");
        distance = distance.replace("km","");
        distance = distance.replace(".","000");
        distance = distance.replace("m","");
        return Long.parseLong(distance);
    }

    public Long stringToLongSaveCnt(String saveCount){
        saveCount = saveCount.replace(",","");
        saveCount = saveCount.replace("+","");
        return Long.parseLong(saveCount);
    }

uriComponents 를 통하여 uri 대체 코드로 변환한뒤 request 하였다. 하지만 결과값은 나오지만 대체코드로 작성된 query=음식점 부분이 적용되지 않는 문제를 발견하게 되었습니다

이상하게도 url 로 이동하게 되면 같은 페이지를 볼 수 있었는데 응답받은 html상으로는 그렇지 못했습니다. 해서.. 따로 만들게 된 url builder

	public String uriParameterBuilder (Map<String,String> parameters, String url) {
        String result = url+"?";

        Iterator<Map.Entry<String, String>> iterator = parameters.entrySet().iterator();
        while(iterator.hasNext()){
            Map.Entry<String,String>entry=iterator.next();
            result+=entry.getKey()+"="+entry.getValue();
            if(iterator.hasNext()) result+="&"; // 만약 다음 파라미터가 존재한다면 & 추가
        }
        return result;
    }

하지만 보시다시피 version 1 입니다.
파싱을 하는 과정에서 더 간편한 방법이 없을까 연구를 거쳐 Fiddler 를 통하여 분석한 결과 핵심적인 역할을 하는 요청은 따로 있음을 알게되었습니다

사실 무수한 디버깅을 통해 겨우 파싱했는데 이과정을 또해야하나 하는 생각 덕분에 더 나은 방법을 찾았습니다


Test_v2

	@Test
    @DisplayName("")
    public void getRestaurantData_v2 () throws Exception {

        // given
        String url = "/graphql";
        String _url = HOST_v2+url;
        GetRestaurantRequest request = GetRestaurantRequest.builder()
                .x(x)
                .y(y)
                .bounds("126.9738873;37.5502692;126.9980272;37.5696434")
                .query("음식점").
                build();
        String jsonOperation = naverUtility.getRestaurants(request);


        HttpHeaders httpHeaders = utility.getDefaultHeader();

        HttpEntity requestMessage = new HttpEntity(new JSONArray(jsonOperation).toString(),httpHeaders);

        // when
        ResponseEntity response = restTemplate.exchange(
                _url,
                HttpMethod.POST,
                requestMessage,
                String.class);
        List<Restaurant> entities = new ArrayList<>();

        JSONArray datas = new JSONArray(response.getBody().toString());
        datas.getJSONObject(0);

        JSONArray items = datas.getJSONObject(0).getJSONObject("data").getJSONObject("restaurants").getJSONArray("items");
        for (int i = 0; i < 100; i++) {
            GetRestaurantResponse mapped_data = gson.fromJson(items.getString(i),GetRestaurantResponse.class);
        //1. first map with entity : 엔티티와 매핑하기전 validation을 거친다
            Restaurant restaurant = Restaurant.builder()
                    .id(Long.parseLong(mapped_data.getId()))
                    .address(mapped_data.getAddress())
                    .category(mapped_data.getCategory())
                    .imageUrl(mapped_data.getImageUrl()==null?"":URLDecoder.decode(mapped_data.getImageUrl(),"UTF-8"))
                    .name(mapped_data.getName())
                    .distance(utility.stringToLongDistance(mapped_data.getDistance()))
                    .businessHours(mapped_data.getBusinessHours())
                    .visitorReviewScore(mapped_data.getVisitorReviewScore()==null? 0.0 : Double.parseDouble(mapped_data.getVisitorReviewScore()))
                    .saveCount(utility.stringToLongSaveCnt(mapped_data.getSaveCount()))
                    .bookingReviewScore(mapped_data.getBookingReviewScore())
                    .build();
            entities.add(restaurant);
        }
        for (Restaurant entity : entities) {
            try {
            restaurantsRepository.save(entity);

            }
            catch (Exception e){
                System.out.printf(entity.toString());
            }

        }
//        restaurantsRepository.saveAll(entities);

        System.out.println("저장 완료");
        // then  : save Entities

        List<Restaurant> restaurantList = restaurantsRepository.findAll();
        for (Restaurant restaurant : restaurantList) {
            System.out.println(restaurant);
        }
    }

기존에는 query 즉 검색어를 통하여 나오는 html에서 파싱하여 결과값을 따로 파싱하여 Json으로 만들고 그 값을 다시 객체와 매핑하는 불필요한 과정들이 소요 되었었는데

네이버 필터를 만지작 거리던 와중.. 정작 중요한 곳은 /graphql 임을 알게 되었습니다

다만 operation이라는 json body를 갖게 되는데 이친구는 특이하게도 String 그대로 하게 되면 매핑하지 못하는데 JsonArray로 바꾼뒤 toString을 해야 제대로 인식하는 것을 발견했습니다

NaverUtil

@Component
public class NaverUtility {
    private final String queries = "query getRestaurants($restaurantListInput: RestaurantListInput, $restaurantListFilterInput: RestaurantListFilterInput) {\n  restaurants: restaurantList(input: $restaurantListInput) {\n    total\n    items {\n      id\n      name\n      category\n      hasBooking\n      imageUrl\n      address\n      isTableOrder\n      isPreOrder\n      isTakeOut\n      dbType\n      businessCategory\n      description\n      hasNPay\n      x\n      y\n      distance\n      imageUrls\n      imageCount\n      phone\n      virtualPhone\n      routeUrl\n      streetPanorama {\n        id\n        tilt\n        pan\n        lat\n        lon\n        __typename\n      }\n      roadAddress\n      commonAddress\n      fullAddress\n      blogCafeReviewCount\n      bookingReviewCount\n      totalReviewCount\n      bookingReviewScore\n      bookingUrl\n      bookingHubUrl\n      bookingHubButtonName\n      bookingBusinessId\n      talktalkUrl\n      options\n      promotionTitle\n      agencyId\n      businessHours\n      microReview\n      tags\n      priceCategory\n      broadcastInfo {\n        program\n        date\n        menu\n        __typename\n      }\n      michelinGuide {\n        year\n        star\n        comment\n        url\n        hasGrade\n        isBib\n        alternateText\n        __typename\n      }\n      broadcasts {\n        program\n        menu\n        episode\n        broadcast_date\n        __typename\n      }\n      tvcastId\n      naverBookingCategory\n      saveCount\n      uniqueBroadcasts\n      isDelivery\n      isCvsDelivery\n      isTableOrder\n      isPreOrder\n      isTakeOut\n      bookingDisplayName\n      bookingVisitId\n      bookingPickupId\n      popularMenuImages {\n        name\n        price\n        bookingCount\n        menuUrl\n        menuListUrl\n        imageUrl\n        isPopular\n        usePanoramaImage\n        __typename\n      }\n      visitorReviewCount\n      visitorReviewScore\n      newOpening\n      newBusinessHours {\n        status\n        description\n        __typename\n      }\n      foryou {\n        tagName\n        label\n        tasteType\n        reviews {\n          imageUrl\n          profileUrl\n          authorId\n          authorName\n          maskedName\n          contentId\n          __typename\n        }\n        address\n        roadAddress\n        __typename\n      }\n      easyOrder {\n        easyOrderId\n        easyOrderCid\n        businessHours {\n          weekday {\n            start\n            end\n            __typename\n          }\n          weekend {\n            start\n            end\n            __typename\n          }\n          __typename\n        }\n        __typename\n      }\n      baemin {\n        businessHours {\n          deliveryTime {\n            start\n            end\n            __typename\n          }\n          closeDate {\n            start\n            end\n            __typename\n          }\n          temporaryCloseDate {\n            start\n            end\n            __typename\n          }\n          __typename\n        }\n        __typename\n      }\n      yogiyo {\n        businessHours {\n          actualDeliveryTime {\n            start\n            end\n            __typename\n          }\n          bizHours {\n            start\n            end\n            __typename\n          }\n          __typename\n        }\n        __typename\n      }\n      deliveryArea\n      __typename\n    }\n    queryString\n    siteSort\n    restaurantCategory\n    selectedFilter {\n      order\n      rank\n      tvProgram\n      region\n      brand\n      menu\n      food\n      mood\n      purpose\n      sortingOrder\n      takeout\n      orderBenefit\n      cafeFood\n      day\n      time\n      age\n      gender\n      foryouParentGroup\n      myPreference\n      hasMyPreference\n      cafeMenu\n      cafeTheme\n      theme\n      __typename\n    }\n    rcodes\n    location {\n      sasX\n      sasY\n      __typename\n    }\n    optionsForMap {\n      maxZoom\n      minZoom\n      includeMyLocation\n      maxIncludePoiCount\n      center\n      spotId\n      __typename\n    }\n    nlu {\n      ...NluFields\n      __typename\n    }\n    __typename\n  }\n  filters: restaurantListFilter(input: $restaurantListFilterInput) {\n    filters {\n      index\n      name\n      value\n      multiSelectable\n      defaultParams {\n        age\n        gender\n        day\n        time\n        __typename\n      }\n      items {\n        index\n        name\n        value\n        selected\n        representative\n        clickCode\n        laimCode\n        type\n        __typename\n      }\n      __typename\n    }\n    __typename\n  }\n}\n\nfragment NluFields on Nlu {\n  queryType\n  user {\n    gender\n    __typename\n  }\n  queryResult {\n    ptn0\n    ptn1\n    region\n    spot\n    tradeName\n    service\n    selectedRegion {\n      name\n      index\n      x\n      y\n      __typename\n    }\n    selectedRegionIndex\n    otherRegions {\n      name\n      index\n      __typename\n    }\n    property\n    keyword\n    queryType\n    nluQuery\n    businessType\n    cid\n    branch\n    forYou\n    franchise\n    titleKeyword\n    location {\n      x\n      y\n      default\n      longitude\n      latitude\n      dong\n      si\n      __typename\n    }\n    noRegionQuery\n    priority\n    showLocationBarFlag\n    themeId\n    filterBooking\n    repRegion\n    repSpot\n    dbQuery {\n      isDefault\n      name\n      type\n      getType\n      useFilter\n      hasComponents\n      __typename\n    }\n    type\n    category\n    menu\n    context\n    __typename\n  }\n  __typename\n}\n";

    public String getRestaurants(GetRestaurantRequest request){
        String query = request.getQuery();
        String x = request.getX();
        String y = request.getY();
        String bounds = request.getBounds();
        String operation = String.format("[{\"operationName\":\"getRestaurants\",\"variables\":{\"restaurantListInput\":{\"query\":\"%s\",\"x\":\"%s\",\"y\":\"%s\",\"start\":1,\"display\":100,\"takeout\":null,\"orderBenefit\":null,\"isCurrentLocationSearch\":null,\"deviceType\":\"pcmap\",\"bounds\":\"%s\",\"isPcmap\":true},\"restaurantListFilterInput\":{\"x\":\"%s\",\"y\":\"%s\",\"query\":\"%s\"}},\"query\":\"%s\"}]",
                query,
                x,
                y,
                bounds,
                x,
                y,
                query,
                queries);

        return operation;
    }


}

결과

본래 saveAll을 통하여 entities를 저장하려고 했는데 이상하게 하나씩 save는 가능하지만 saveAll 시에는 not-null property references a null or transient value 에러가 발생하게 되는 현상이 일어납니다

디버깅을 해보았음에도 null 값을 가지는 속성이 없으며 제 Entity에는 Embedded Value 또한 존재하지 않는데.. 이 부분은 참 이상합니다

! 해결 !
일부 카테고리가 null 인 경우를 stack-trace를 통해 확인하였으며 business_hours의 경우 default 255를 초과하는 경우가 있어 1000으로 설정해주었습니다

restaurantsRepository.saveAll(entities);

Entity 수정 (모두 notnull, 영업시간 length 늘림)

@Entity
@Getter
@ToString
@NoArgsConstructor
public class Restaurant {

    // pk
    @Id
    private long id;

    @Column(nullable = false)
    private String address;

    @Column(nullable = false)
    private String category;

    @Column(nullable = false)
    private String imageUrl;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Long distance;

    @Column(nullable = false, length = 1000)
    private String businessHours;

    // 아래 셋은 종합하여  맛집 랭킹에 사용될 예정
    @Column(nullable = false)
    private Double visitorReviewScore;

    @Column(nullable = false)
    private Long saveCount;

    @Column(nullable = false)
    private Double bookingReviewScore;

    @Builder
    public Restaurant(long id, String address, String category, String imageUrl, String name, Long distance, String businessHours, Double visitorReviewScore, Long saveCount, Double bookingReviewScore) {
        this.id = id;
        this.address = address;
        this.category = category;
        this.imageUrl = imageUrl;
        this.name = name;
        this.distance = distance;
        this.businessHours = businessHours;
        this.visitorReviewScore = visitorReviewScore;
        this.saveCount = saveCount;
        this.bookingReviewScore = bookingReviewScore;
    }
}

수정이후 JPA saveAll 이 잘 동작하는것을 확인하였습니다
다만 아직도 이상한것이 하나씩 save 했을때는 왜 에러가 발생하지 않은것인지.. 의문이 듭니다

다음 포스팅에는 이전에 작업하였던 VDI 프로젝트의 연장선이 될것같습니다

0개의 댓글