레스토랑 상세 엔티티, repository test

JungWooLee·2022년 9월 3일
0

SpringBoot ToyProject

목록 보기
13/14

이어서 하기

  • 음식점 정보만큼이나 중요한 음식점 메뉴 정보, 기존에는 DTO 클래스로 스크래핑한 값들을 매핑하는 것이였지만 이제는 엔티티로서 값을 저장하는 과정을 가집니다
  • JPA 에서 연관관계 매핑이란?

1. 음식점 메뉴 테스트

	@Test
    @DisplayName("음식점 ID가 주어졌을때 메뉴조회")
    public void givenIdListSearchAndSaveRestaurantDetail () throws Exception {
        // given

        List<Long> ids = Arrays.asList(
                11356993l,
                11477706l,
                11592593l,
                11592607l,
                11592643l,
                11592650l,
                11618393l,
                11618456l,
                11619941l,
                11618586l,
                11623970l,
                11664585l,
                11677524l,
                11677544l,
                11677741l,
                11678715l,
                11678758l,
                11678838l,
                11679306l,
                11679353l,
                11679393l,11679455l);
        for (Long id : ids) {
            String url = String.format("/restaurant/%d/menu/list",id);
            String _url = HOST_v1+url;


            HttpHeaders httpHeaders = utility.getDefaultHeader();

            HttpEntity requestMessage = new HttpEntity(httpHeaders);
            // when
            ResponseEntity response = restTemplate.exchange(
                    _url,
                    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("Menu") && Character.isDigit(possible.charAt(possible.length()-2))){
                    restaurantList.add(possible);
                }
            }
//
            List<RestaurantDetailResponse> results = new ArrayList<RestaurantDetailResponse>();
            for (String s : restaurantList) {
                // 해당 JObject와 Response 객체간의 매핑
                RestaurantDetailResponse mapped_data = gson.fromJson(target.get(s).toString(), RestaurantDetailResponse.class);
                results.add(mapped_data);
            }

            for (RestaurantDetailResponse result : results) {
                log.info(result.toString());
            }
        }
        // when

        // then

    }

결과


[2022-09-02 21:11:17:9759] INFO  452 --- [    Test worker] RestaurantServiceImplTest                : RestaurantDetailResponse(name=수육전골, price=50000, images=[], description=, id=11356993_11, priority=11)
[2022-09-02 21:11:17:9759] INFO  452 --- [    Test worker] RestaurantServiceImplTest                : RestaurantDetailResponse(name=고기전, price=25000, images=[], description=, id=11356993_12, priority=12)
[2022-09-02 21:11:17:9760] INFO  452 --- [    Test worker] RestaurantServiceImplTest                : RestaurantDetailResponse(name=사골국수, price=10000, images=[], description=, id=11356993_13, priority=13)
[2022-09-02 21:11:17:9760] INFO  452 --- [    Test worker] RestaurantServiceImplTest                : RestaurantDetailResponse(name=이북수육냉채 반접시, price=25000, images=[], description=, id=11356993_18, priority=18)
[2022-09-02 21:11:17:9760] INFO  452 --- [    Test worker] RestaurantServiceImplTest                : RestaurantDetailResponse(name=빈대떡 반접시, price=10000, images=[], description=, id=11356993_19, priority=19)
[2022-09-02 21:11:17:9760] INFO  452 --- [    Test worker] RestaurantServiceImplTest                : RestaurantDetailResponse(name=모듬전, price=35000, images=[], description=, id=11356993_14, priority=14)
  • 이를 바탕으로 엔티티를 설계합니다

2. 메뉴 엔티티 만들기

DTO 테스트의 결과값을 통해 엔티티를 생성해줍니다
※ 연관관계의 경우 밑에서 자세히 다룹니다

@Entity
@Getter
@NoArgsConstructor
public class Menu {
    @Id
    private String id;

    @Column(nullable = false)
    private String name;

    private String description;

    @Column(nullable = false)
    private int priority;

    @Type(type = "string-array" )
    @Column(name = "images")
    private String[] images;
}

연관 관계 매핑

  • 테이블 간의 연관 관계가 있을때 객체지향스럽게 사용하는 방법을 제공합니다.
  • 기존의 데이터베이스에서는 외래 키를 사용하나 JPA 에서는 객체를 참조하는 방식으로 연관 관계를 매핑할 수 있습니다.

🔧 객체 중심적 설계?

먼저 JPA 의 등장 배경으로 보았을때 크게는 데이터베이스에 종속되지 않고 객체지향적인 프로그래밍을 할 수 있다는 점에서 널리 쓰이게 되고 있습니다.

그렇다면 객체간의 관계는 어떻게 형성하느냐 ??
실제 테이블에서는 양방향 연관 관계가 존재하지 않지만 JPA에서는 이러한 양방향 연관 관계를 통해 양쪽에서 객체간 참조를 가능하게 해줍니다

권장 : 단방향 관계 (외래키가 있는 테이블을 대변하는 엔티티를 연관 관계의 주인으로 지정)

일반적으로 식당과 메뉴는 1:N의 구조를 갖게 됩니다
이를 객체지향적으로 설계한다면 Restaurant Table은 다음과 같이 수정됩니다

  • 새롭게 추가된 menus 와 menu를 추가해주는 newMenu()
  • 객체의 양방향 관계란 서로 다른 두개의 단방향 관계 존재, 즉 양방향 참조를 위해서는 단방향 연관 관계를 2개 만들어야 함
  • 두 개의 단방향일때, 누가 외래 키를 관리할 것인가 ? 권장되는 방법은 연관 관계의 주인만 외래 키를 관리하도록 지정, 주인이 아닌 쪽은 읽기만 가능하도록 → 주인은 mappedBy 속성 X, 주인이 아닌 경우 mappedBy 지정

One to Many

@Entity
@Getter
@NoArgsConstructor
public class Restaurant extends BaseTimeEntity {

    // 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, length = 1500)
    private String businessHours;

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

    @Column(nullable = false)
    private Long saveCount;

    @Column(nullable = false)
    private Double bookingReviewScore;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private RestaurantType restaurantType;

    @Column(nullable = false)
    private Double x;

    @Column(nullable = false)
    private Double y;

    @OneToMany(mappedBy = "restaurant")
    private List<Menu> menus = new ArrayList<>();

    public void newMenu(Menu menu){
        this.menus.add(menu);
    }

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

외래 키를 관리하는 것은 메뉴입니다. 그렇기에 OneToMany 어노테이션을 통하여 1:N 의 관계임을 명시하고 mappedBy 를 통하여 주인인 메뉴를 지정해줍니다.
※ 다른 컬럼과는 달리 초기화 작업이 이루어지는데 이는 Null pointer Error 를 피하기 위함입니다

쉽게 말해 음식점의 입장에서 많은 메뉴를 가지므로 @OneToMany 어노테이션을 달아주고, restaurant 필드는 menu에 의해 매핑되므로, mappedBy = “restaurant”로 작성한 것입니다.

Many to One

@Entity
@Getter
@NoArgsConstructor
public class Menu {
    @Id
    private String id;

    @ManyToOne
    @JoinColumn(name ="restaurant_id")
    private Restaurant restaurant;

    @Column(nullable = false)
    private String name;

    private String description;

    @Column(nullable = false)
    private int priority;

    @Type(type = "string-array" )
    @Column(name = "images")
    private String[] images;
}

더 쉬운 이해를 돕고자 메뉴를 주체로 연관관계 매핑할때의 예시입니다

@ManyToOne 어노테이션은 이름 그대로 다대일( N : 1 ) 관계 매핑 정보입니다.

메뉴 입장에서는 음식점과 다대일 관계이므로 @ManyToOne이 됩니다.

연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션(@ManyToMany, @OneToOne 등…)은 필수로 사용해야 하며, 엔티티 자신을 기준으로 다중성을 생각해야 합니다.

@JoinColumn(name="restaurant_id")

  • @JoinColumn 어노테이션은 외래 키를 매핑 할 때 사용합니다.
  • name 속성에는 매핑 할 외래 키 이름을 지정합니다.
  • Menu 엔티티의 경우 Restaurant 엔티티의 id 필드를 외래 키로 가지므로, restaurant_id로 작성하면 됩니다.

참고로 JoinColumn 을 생략할 경우에 필드명 + “_” + 참조하는 테이블의 기본 키(@Id) 컬럼명 을 통하여 외래키를 매핑합니다


위의 방법대로 단방향 관계를 통하여 외래키를 지정할 수 있었습니다.
1. Many to One 의 경우 Menu에서 Restaurant 정보들을 가져올 수 있음
2. One to Many 의 경우 Restaurant 에서 Menu 정보들을 가져올 수 있음

하지만 단방향의 경우 : 1번의 경우 Restaurant 에서 Menu 정보들을 가져올 수 없고, 2번의 경우 Menu에서 Restaurant 정보들을 가져올 수 없습니다.
그렇기에 JPA 에서는 양방향 관계를 형성시켜줘야 합니다

데이터 모델링에서는 1:N 관계만 설정해주면 자동으로 양방향 관계가 되기 때문에 어느 테이블에서든 join만 해주면 원하는 칼럼들을 가져올 수 있습니다. 이점에서는 JPA 가 불편한 점으로 생각되네요.

Test

	@Test
    @DisplayName("카테고리별 음식점 모두 스크래핑후 보내기")
    @BeforeEach
    public void getRestaurantData_v2 () throws Exception {
        List<Long> idList = new ArrayList<>();
        List<Restaurant> entities = new ArrayList<>();

        for (RestaurantType type : RestaurantType.values()) {
            // 카테고리내의 모든 음식들을 크롤링
            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("음식점")
                    .type(type)
                    .build();
            String jsonOperation = naverUtility.getRestaurants(request);
            HttpHeaders httpHeaders = utility.getDefaultHeader();

            HttpEntity requestMessage = new HttpEntity(jsonOperation,httpHeaders);
            ResponseEntity response = restTemplate.exchange(
                    _url,
                    HttpMethod.POST,
                    requestMessage,
                    String.class);

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

            JSONArray items = datas.getJSONObject(0).getJSONObject("data").getJSONObject("restaurants").getJSONArray("items");
            int total = Integer.parseInt(datas.getJSONObject(0).getJSONObject("data").getJSONObject("restaurants").get("total").toString());
            int maxCnt = total < 30 ? total : 30;


            for (int i = 0; i < maxCnt; i++) {
                GetRestaurantResponse mapped_data = gson.fromJson(items.get(i).toString(), GetRestaurantResponse.class);
                //1. first map with entity : 엔티티와 매핑하기전 validation을 거친다
                idList.add(mapped_data.getId()); // idList 에 id 값을 넣는다
                Restaurant restaurant = Restaurant.builder()
                        .id(mapped_data.getId())
                        .address(mapped_data.getAddress())
                        .category(mapped_data.getCategory() == null ? "없음" : 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())
                        .restaurantType(type)
                        .x(mapped_data.getX())
                        .y(mapped_data.getY())
                        .build();
                entities.add(restaurant);
            }
            restaurantsRepository.saveAll(entities);
        }
        // 아래에서 메뉴 save
        List<Menu> menus = new ArrayList<>();
         for (Restaurant entity : entities) {
            long id = entity.getId();
            String url = String.format("/restaurant/%d/menu/list",id);
            String _url = HOST_v1+url;

            HttpHeaders httpHeaders = utility.getDefaultHeader();

            HttpEntity requestMessage = new HttpEntity(httpHeaders);
            // when
            ResponseEntity response = restTemplate.exchange(
                    _url,
                    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("Menu")){
                    restaurantList.add(possible);
                }
            }
//
            List<RestaurantDetailResponse> results = new ArrayList<RestaurantDetailResponse>();
            for (String s : restaurantList) {
                // 해당 JObject와 Response 객체간의 매핑
                RestaurantDetailResponse mapped_data = gson.fromJson(target.get(s).toString(), RestaurantDetailResponse.class);
                Menu data = Menu.builder()
                                .id(mapped_data.getId())
                                        .restaurant(entity)
                                                .description(mapped_data.getDescription())
                                                        .name(mapped_data.getName())
                                                                        .priority(mapped_data.getPriority())
                                                                                .build();

                menuRepository.save(data);
                results.add(mapped_data);
            }

            for (RestaurantDetailResponse result : results) {
                log.info(result.toString());
            }
        }
    }

카테고리별 음식점 30개를 스크래핑하는 작업을 해주었다

하지만 디버깅 결과 치명적인 문제를 발견하였다

조회 하던중 스크래핑 타겟 사이트 (네이버) 에서 500 에러를 보내왔다

이러한 경우 두가지 방법으로 타개할 수 있다
1. 프록시를 사용하여 이러한 WebException 발생 시 프록시 서버를 바꾸어 다시 스크래핑 시도
2. 한번에 모든 스크래핑 정보를 얻는것이 아닌 개별 사이트에 진입할때에 스크래핑을 시도. 즉, 사용자가 음식점 정보가 보고 싶어서 음식점 상세 보기 요청시 if not exist update 실시

두가지 방안이 있지만 안타깝게도 비용적인 측면에서 프록시를 사서 사용하는 것은 무리일것이라 판단, 그리고 서비스의 목적이 싸고 평이 좋은 음식점을 소개하는 것을 도메인으로 하는데 음식점 정보를 사용자가 직접 채워야 한다는 것은 어불성설.. 이러한 문제로 번거롭지만 초기 데이터의 경우에는 차근 차근 DB 에 쌓아주고, 사용자가 음식점 클릭 시 또다시 추가적인 스크래핑을 이루어 채워가는 방향으로 바꾸었습니다.

0개의 댓글