Project

[프로젝트 회고] 대용량 데이터 처리 프로젝트(최저 가격 검색 시스템)

yerinpark 2024. 8. 11. 22:57

프로젝트 개요

최근 클라우드 엔지니어링 프로젝트에서 공연 예매 서비스를 고가용성 클라우드 시스템으로 구축했습니다. 이 과정에서 대용량 데이터 처리에 대한 관심이 생겨, 성능 이슈를 파악하고 해결하는 경험을 하고자 이번 프로젝트를 진행했습니다.

 

개발 환경은 Java 17, Spring Boot 3.3.0을 활용했습니다.

 

한국소비자원의 생필품 가격 정보 데이터는 약 21만 건의 데이터가 있어 부하 테스트에 적합하다고 판단하여 이 데이터를 사용하기로 결정했습니다. 데이터는 아래 링크에서 다운로드할 수 있습니다.

한국소비자원 데이터

 

 

프로젝트의 목표는 사용자의 위치나 지정된 지역을 기준으로 최저 가격을 검색하는 기능을 제공하는 것이었습니다. 시/도, 구, 동 단위로 검색하여 최저 가격으로 구매할 수 있는 곳을 쉽게 찾을 수 있도록 했습니다.

 

이를 위해 다음 두 가지 기술 과제를 해결해야 했습니다.

  1. CSV 파일을 읽어 설계한 ERD(Entity-Relationship Diagram)에 데이터를 넣는 작업
  2. '현대백화점 판교점'과 같은 매장 이름에서 지역 정보를 추출하기 위해 주소 API를 활용하여 검색하고, ERD에 지역 코드를 삽입하는 작업

 

1. ERD

ERD는 다음과 같이 작성했습니다.

 

2. 주소 API 활용

 

활용하려는 데이터에는 판매업소명만 있기 때문에 주소 검색 API를 활용하여 주소 정보를 ERD에 삽입했습니다.

 

예를 들면, 아래의 과정을

세븐일레븐(본사) -> 서울특별시 중구 수표동 99 -> 시, 구, 동, 행정동코드

 

이러한 방식으로 변환 작업을 거쳤습니다.

엑셀 데이터 -> Naver API 호출 -> Kakao API 호출

 

 

아래는 주소 검색 API를 활용하여 작성한 코드입니다.

 

Naver API 호출(e.g. 세븐일레븐(본사) -> 서울특별시 중구 수표동 99)

/**
     * Request : 세븐일레븐(본사)
     * Response : 서울특별시 중구 수표동 99
     * */
    @GetMapping("/map")
    public ResponseEntity<?> naverSearchApi(@RequestParam String martName) throws JsonProcessingException {
        URI uri = UriComponentsBuilder
                .fromUriString("https://openapi.naver.com")
                .path("/v1/search/local.json")
                .queryParam("query", martName)
                .queryParam("display", 1)
                .queryParam("start", 1)
                .queryParam("sort", "random")
                .encode(Charset.forName("UTF-8"))
                .build()
                .toUri();

        HttpHeaders headers = new HttpHeaders();
        headers.set("X-Naver-Client-Id", clientId);
        headers.set("X-Naver-Client-Secret", clientSecret);
        headers.setContentType(MediaType.APPLICATION_JSON);

        HttpEntity<Void> requestEntity = new HttpEntity<>(headers);

        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> responseEntity = restTemplate.exchange(uri, HttpMethod.GET, requestEntity, String.class);

        String responseBody = responseEntity.getBody();

        log.info("응답 : {}", responseBody);

        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);

        String address = jsonNode.get("items").get(0).get("address").asText();
        log.info("주소: {}", address);

        return ResponseEntity.ok(responseBody);
    }

 

Kakao API 호출(서울특별시 중구 수표동 99 -> 시, 구, 동, 행정동코드)

 

    /**
     * Request : 서울특별시 중구 수표동 99
     * Response : Mart에 insert할 정보들(시, 구, 동, 우편번호)
     * */
    @GetMapping("/kakao")
    public RequestAreaDTO getKakaoApiFromAddress(String roadFullAddr) {
        String apiUrl = "https://dapi.kakao.com/v2/local/search/address.json";
        String jsonString = null;

        try {
            roadFullAddr = URLEncoder.encode(roadFullAddr, "UTF-8");

            String addr = apiUrl + "?query=" + roadFullAddr;

            URL url = new URL(addr);
            URLConnection conn = url.openConnection();
            conn.setRequestProperty("Authorization", "KakaoAK " + apiKey);

            BufferedReader rd = null;
            rd = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"));
            StringBuffer docJson = new StringBuffer();

            String line;

            while ((line=rd.readLine()) != null) {
                docJson.append(line);
            }

            jsonString = docJson.toString();
            rd.close();


            ObjectMapper objectMapper = new ObjectMapper();
            JsonNode rootNode = objectMapper.readTree(jsonString);
            JsonNode documents = rootNode.path("documents");

            if (documents.isArray() && documents.size() > 0) {
                JsonNode addressInfo = documents.get(0).path("address");
                JsonNode roadAddressInfo = documents.get(0).path("road_address");

                String region1DepthName = addressInfo.path("region_1depth_name").asText();
                String region3DepthHName = addressInfo.path("region_3depth_h_name").asText();
                String zoneNo = roadAddressInfo.path("zone_no").asText();

                return RequestAreaDTO.builder()
                        .fullAddr(addressInfo.path("address_name").asText())
                        .region1depthName(addressInfo.path("region_1depth_name").asText())
                        .region2depthName(addressInfo.path("region_2depth_name").asText())
                        .region3depthName(addressInfo.path("region_3depth_name").asText())
                        .region3depthHName(addressInfo.path("region_3depth_h_name").asText())
                        .hjdCode(addressInfo.path("h_code").asText())
                        .zoneNo(roadAddressInfo.path("zone_no").asText()).build();
            }

        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

 

데이터 삽입 이슈와 로그의 중요성

프로젝트 중 예상치 못했던 이슈 중 하나는 데이터 삽입에 예상보다 많은 시간이 소요되었다는 것입니다.

 

데이터 삽입을 시작하고 나면 현재 진행 상황을 조회할 수 없었고, 이 과정에서 하염없이 기다리다가 결국 네 시간만에 중단했습니다. 이후 로그도 남기고 데이터가 잘 들어가는지 여러 번 중간 점검을 마친 기억이 납니다. 밤새 켜두고 일어났을 때쯤 제발 잘 들어가있기를 바라며 한 시간쯤 지켜보다가 잠들었습니다.

 

그 결과,

 

 

약 21만 건 중 20만 4462건이 삽입된 것을 확인할 수 있었습니다.

 

 

매장명 주소 검색 이슈

 

DB 성능 개선에 활용할 충분한 데이터는 삽입되었지만, 나머지 약 1만 건은 삽입되지 않았습니다. 이는 '롯데슈퍼G철원점'처럼 매장 특성상 표기가 상이한 매장명이 검색되지 않아 발생한 문제였습니다. Naver 주소 검색 API에서 검색 결과가 없을 때 롯데슈퍼G철원점을 ‘롯데슈퍼’, ‘철원점’으로 파싱해서 검색하는 방안을 시도해보려고 했으나 이 문제는 대용량 데이터를 처리하고자 하는 취지에서는 중요도가 떨어져 보류해두었습니다.

 

만일 추후 이런 경우가 발생했을 때 누락 데이터를 어떻게 처리할 수 있을지 방법을 찾아보았습니다. 검색 엔진을 활용하거나 문자열 유사도 알고리즘을 활용하는 방법이 있습니다.

 

문자열 유사도 알고리즘

문자열 유사도 알고리즘은 두 문자열 간의 유사성을 계산하여 비슷한 문자열을 찾는 방법입니다. 일반적으로 Levenshtein 거리와 Jaro-Winkler 거리와 같은 알고리즘이 사용됩니다.


이전에 Python으로 머신러닝 라이브러리를 활용한 영화 추천 시스템 프로젝트에서 사용자가 선택한 영화와 가장 유사한 영화를 추천해줄 때 cosine 유사도를 활용한 기억이 납니다.

 

그러나 이번 프로젝트와 같이 문자열을 비교해야 할 때는 Levenshtein 거리와 Jaccard 유사도가 더 적합하다는 것을 알게 되었습니다. 코사인 유사도는 벡터 간의 유사성을 측정하기 때문에 영화 추천 시스템처럼 단어 빈도와 같은 데이터나 문서 간 유사도를 측정할 때 유리하기 때문입니다. Java에서는 Apache Commons Text 라이브러리에서   LevenshteinDistance 클래스와 JaroWinklerSimilarity 클래스를 제공합니다.

 

1. Levenshtein 거리

Levenshtein 거리 알고리즘은 두 문자열 간의 최소 편집 거리를 계산하여 문자열이 얼마나 유사한지를 측정합니다. 편집 거리는 문자 삽입, 삭제 또는 교체의 수를 기준으로 계산됩니다.

 

Levenshtein 거리 계산

import org.apache.commons.text.similarity.LevenshteinDistance;

public class StringSimilarityExample {

    public static void main(String[] args) {
        LevenshteinDistance levenshtein = new LevenshteinDistance();
        String str1 = "롯데슈퍼 철원점";
        String str2 = "롯데슈퍼G 철원점";

        int distance = levenshtein.apply(str1, str2);
        System.out.println("Levenshtein Distance: " + distance);
    }
}

 

 

2. Jaro-Winkler 거리

Jaro-Winkler 거리 알고리즘은 문자열의 유사성을 측정하는 데 사용되며, 특히 짧은 문자열에서 높은 정확도를 제공합니다.

 

Jaro-Winkler 거리 계산

import org.apache.commons.text.similarity.JaroWinklerSimilarity;

public class StringSimilarityExample {

    public static void main(String[] args) {
        JaroWinklerSimilarity jaroWinkler = new JaroWinklerSimilarity();
        String str1 = "롯데슈퍼 철원점";
        String str2 = "롯데슈퍼G 철원점";

        double similarity = jaroWinkler.apply(str1, str2);
        System.out.println("Jaro-Winkler Similarity: " + similarity);
    }
}

 

 

하지만 이 방법은 롯데슈퍼철원점, 롯데슈퍼G철원점 두 개의 파라미터를 제공해야 하기 때문에 DB에 이 데이터가 없으면 활용이 어렵다는 문제가 있었습니다. (DB insert 시 발생한 오류이기 때문에 해당 데이터는 DB에 없습니다😅)

 

추후 DB에 데이터가 있고 사용자의 입력을 받아 검색할 때 활용할 수 있을 것 같습니다.

 

결국 다른 해결 방법을 강구한 결과, 구글 지도에서는 롯데프레시&델리 철원점으로 검색이 되는 것을 발견했습니다.

네이버 주소 검색 API로 검색 결과가 없을 시 구글 지도 검색 API로 대체해서 검색하는 것이 프로젝트에서 가장 쉽게 해결할 수 있는 방법이 아닐까라는 생각이 들었습니다.

 

 

결론

이번 프로젝트를 통해 대용량 데이터를 처리하는 과정에서 발생할 수 있는 여러 가지 문제를 경험할 수 있었습니다. 특히 데이터 삽입 과정에서의 문제를 해결하기 위한 적절한 로그 사용, 그리고 누락 데이터를 처리하는 방법에 대해 생각해볼 수 있었습니다.