Skip to content

문제 해결 과정

Kwon NaYeon edited this page Nov 24, 2021 · 30 revisions

목차

💻 1차 스프린트

💻 2차 스프린트

💻 3차 스프린트 ➡️ 얌얌트랜드 프로젝트


1차 스프린트


🔍 레시피 검색 연산 속도/정확성 개선


검색 과정의 데이터를 List형에서 집합자료형(Set)으로 바꾸고 알고리즘을 개선하여, 검색 정확성과 속도를 높였습니다.

상세 과정
 # 기존 방식 (count 방식)
 for ids in RECIPE_IDs: 
     candidate = list(db.recipe_ingredient.find({"RECIPE_ID":ids})) 
     count = 0 
     for detail in candidate : 
         if detail["IRDNT_NM"] in INGREDIENT_LIST: 
             count += 1 
     if count == len(INGREDIENT_LIST): 
         DATA_WE_WANT.append(ids) 

기존 방식의(이하 count방식) 문제점을 예를 들어 설명하면 다음과 같습니다. 우리가 첫 페이지에서 [간장, 쌀, 소금]을 선택했다고 합시다. (일단 다른 조건은 고려하지 맙시다.) 검색 결과 판별은 3가지 재료를 선택했으니, 코드에서 count 변수가 3이 되면 옳은 검색 결과라고 판별합니다. 즉, count는 해당 레시피 속에 사용자가 선택한 재료가 얼마나 출현하는지 세는 변수입니다. 대조할 레시피의 재료는 다음과 같다고 합시다.

레시피A = [간장, 쌀, 소금, 안심, 콩나물]
레시피B = [간장, 쌀, 다진마늘, 다진파, 간장]
레시피C = [간장, 간장, 간장]
레시피D = [간장, 쌀, 소금, 후추, 간장]

우리의 목적은 레시피A, 레시피D만을 검색 결과로 가져오는 것입니다. 그러나 기존 방식의 검색을 실행하게 되면, 레시피 D를 제외한 A, B, C를 검색 결과로 가져옵니다. 이유는 각 레시피에 대한 count 변수 값에 있습니다.

레시피A : count = 3
레시피B : count = 3
레시피C : count = 3
레시피D : count = 4

기존의 방식으로 검색할 경우 중복되는 재료에 대한 검사를 하지 않기 때문에, 컴퓨터가 연산할 때는 간장이 1번 나오든 2번 나오든 어쨌든 우리가 선택한 재료 안에 포함되므로 그때마다 count가 됩니다. 따라서 간장만 3번 나와도 컴퓨터는 "옳은 검색 결과"라고 판별합니다.

다행히 새로운 방식(이하 차집합 방식)은 이러한 중복되는 재료를 걸러 보다 정확한 검색을 가능하게 했습니다. 사용자가 선택한 재료 set와 비교할 레시피의 재료들을 set로 묶은 다음, 전자에서 후자를 뺐을 때 결과가 set()라면 (즉, 후자에 전자 요소를 모두 가지고 있다면) 우리가 원하는 데이터라고 판단하는 방식입니다. 문제는 기존보다 상황에 따라 훨씬 느려진다는 것이었습니다.

 # 차집합 방식
 for IDs in RECIPE_IDs: 
     candidate = list(db.recipe_ingredient.find({"RECIPE_ID":IDs})) 
     RECIPE_IRDNTs = set() 
     for detail in candidate : 
         RECIPE_IRDNTs.add(detail["IRDNT_NM"]) 
     if INGREDIENT_LIST - RECIPE_IRDNTs == set() : 
         DATA_WE_WANT.append(IDs) 

[선택 재료 '간장', 한식, 나머지 모두 체크] 로 검색 시 걸리는 연산 시간은 다음과 같습니다.
count 방식 : 1.7959137000000016 (약 1.78 ~ 1.80 초) / 검색 결과 137개
차집합 방식 : 7.4863389 (약 7.4 ~ 7.6 초) / 검색 결과 142개

한편, [선택 재료 '간장, 소금, 쌀', 한식, 나머지 모두 체크] 로 검색 시 걸리는 연산 시간은 다음과 같습니다.
count 방식 ; 1.769222499999998 (약 1.75 ~ 1.77 초) / 검색 결과 8개
차집합 방식 : 7.483455599999999 (약 7.4 ~ 7.6 초) / 검색 결과 1개

List형을 Set형으로 알고리즘을 바꾼 이유는, 특정 원소가 있는지 판별하는 연산이 List형보다 Set형이 훨씬 빠르기 때문입니다. 그러나 알고리즘의 문제인지, 정확도는 개선되었으나 시간 효율성 측면에서는 비효율적이었습니다.

이러한 문제를 팀원분들에게 공유하자 팀장님께서 아이디어를 내주셨습니다.
만약 사용자가 정한 재료가 [A, B, C] 라고 한다면, A를 포함한 레시피(집합 1), B를 포함한 레시피(집합 2), C를 포함한 레시피(집합 3)을 구한 후, 세 집합의 교집합을 찾는 방식입니다. 팀장님의 아이디어를 따른다면, 연산의 횟수도 줄이고 효율성도 제고할 수 있겠다는 생각이 들었습니다. 실제로 구현된 코드는 다음과 같습니다.

 # 교집합 방식
 first_irdnt_ids = list(db.recipe_ingredient.find({"IRDNT_NM": IRDNT_NM[0]}, {"_id": False, "RECIPE_ID": True}))
 INGREDIENT_SET = set([irdnt['RECIPE_ID'] for irdnt in first_irdnt_ids])
 for i in range(1, len(IRDNT_NM)):
    irdnt_ids = list(db.recipe_ingredient.find({"IRDNT_NM": IRDNT_NM[i]}, {"_id": False, "RECIPE_ID": True}))
    tmp_set = set([irdnt['RECIPE_ID'] for irdnt in irdnt_ids])
    INGREDIENT_SET = INGREDIENT_SET & tmp_set

 DATA_WE_WANT = list(RECIPE_IDs & INGREDIENT_SET)

위와 같은 알고리즘을 가졌을 때, 연산 속도는 차집합 방식보다 최소 7배에서 최대 700배까지 차이가 났습니다.
결국 자료형의 특성을 이해하고 알고리즘을 새로 설계함으로써 검색 연산 속도와 정확성을 모두 개선할 수 있었습니다.


🔍 Open API 데이터 전처리


공공 데이터 요청으로 전달받는 데이터에 오타가 있거나 같은 재료이나 다른 단어로 작성된 데이터가 많아, 매핑 테이블을 생성해서 데이터 전처리를 수행했습니다.

고민의 흔적

맛집 추천 서비스를 구현하면서 농림축산식품부에서 제공하는 Open API를 통해 레시피를 가져왔다. 하지만 생각보다 오타도 많고 같은 단어인데 다르게 표기된 데이터들이 있었다. 사용자에게 재료 정보를 바탕으로 재료를 선택할 수 있도록 정보를 제공해줘야 하는 상황인데, 데이터가 정제되어 있지 않아 사용자에게 제공되는 정보의 양이 많아질 뿐만 아니라 혼동을 줄 수 있는 정보들을 제공하는 상황이 되버렸다.

데이터를 일일이 수동으로 처리하고 싶지 않았기 때문에, 프로그래밍으로 자동으로 처리할 수 있는 방향을 계속 생각했다. 처음에는 단순히 일치하는 문자 수를 카운팅하려고 했는데 내가 생각했던 방법으로 처리할 경우 다진마늘 다진파를 비슷한 단어로 분류하게 되는 문제가 있었다.

    [방법1]
    
    1. distinct 로 DB에서 재료 리스트 가져오기: 재료들이 가나다 순으로 정렬
    
    2. 순차적으로 리스트를 확인하며 현재 인덱스(i)와 다음 인덱스(i+1)의 재료의 유사도(일치하는 문자수 / 단어 수) 계산 및 검사
       |__ 계산한 유사도가 0.5 이상일 경우 동일한 단어로 분류
       
    3. 동일한 단어로 분류되었을 경우 다음 다음 인덱스(i+2)의 재료의 유사도 계산 및 검사
    
    4. 유사도가 0.5 미만인 인덱스가 나올 때 까지 반복
    
    5. 유사도가 0.5 미만인 인덱스가 나오면 
       기준 재료(i)를 key로 갖고 동일한 단어로 분류된 재료들(i+1 ~ i+N) 를 value로 갖는 딕셔너리 생성
    
    6. 딕셔너리 리스트를 기반으로 value에 해당되는 재료 이름들을 key의 이름으로 변경
       |__ DB UPDATE

단순히 단어의 개수로 파악하면 앞서 말했던 전혀 다른 재료지만 같은 재료로 분류하게 된다는 점이며, 즉 단어의 의미에 대한 유사도는 측정할 수 없다는 것이다. 그래서 단어 유사도 측정으로 검색을 해봤더니 자연어 처리에 대한 내용이 나왔다. Word2Veckonlpy 를 이용하면 한글 단어의 유사도 측정이 가능할 것 같아 시도해봤는데 잘 안됐다. 예상하건데 관련 글들에서는 모델의 훈련 데이터로 유사한 단어 뭉치들을 최대한 많이 끌어온 후, 모델을 학습시키고 단어를 입력하면 단어와 유사도가 높은 단어 리스트를 보여준다. (예를들어, 영화 리뷰 데이터들로 모델을 학습시킨 다음에 영화 배우 A를 검색하면 관련된 영화배우 B,C,D.. 를 보여준다.) 이 방법을 이용하면 확실이 의미적으로 유사한 단어들의 리스트를 알 수 있는데, 지금은 데이터베이스 안에 있는 데이터들 간의 유사도를 알아야하므로 패스했다. 또한 음식 관련 데이터로 모델을 학습한다해도 유사한 재료값들을 보여줄텐데 오타다르게 표기된 재료 단어들은 구분하기 쉽지 않을 것이라 판단했다. 그래서 결국엔 수작업으로 매핑 테이블을 만들었다.

    [방법2]
    
    1. 애플리케이션 실행 시 Open API로 부터 데이터를 가져와 DB를 초기하는 과정에서 재료 정보 업데이트
    
    2. DB에서 재료 매핑 테이블의 'IRDNT_NM'과 일치하는 재료를 찾아서
       해당 재료 이름을 'NEW_IRDNT_NM'으로 업데이트

결국 이 방법으로 구현을 하고, 현재 팀원들 각자 로컬 DB를 가지고 개발과 테스트를 하고 있기 때문에 팀원들이 코드를 데이터베이스 초기화 코드를 수행했을 때 매핑 테이블이 생기도록 하기 위해 Studio 3T 에서 매핑 테이블을 JSON Export했다. 하지만 매핑 테이블 레코드 정보가 들어있는 .json 을 코드에서 읽어들여 JSON 형식으로 DB에 삽입하려는 단계에서 오류가 났다. 추출한 JSON 형식과 MongoDB 에서 자동으로 부여하는 Object ID 때문인 것 같아, csv 파일 형식으로 레코드 Id 값을 제거하고 추출한 다음에 온라인에서 csv to json 을 수행했다. 이렇게 처리한 json 파일은 코드상에서 읽어들일 수 있었고 DB에도 성공적으로 삽입됐다.

💡 추출한 DB 내용을 코드상에서 DB에 저장할 수 있도록 하는 방법
|__ [추출]
|__ Studio 3T에서 csv 파일로 추출   👻 Object Id 필드는 제외하고 추출
|__ csv 파일을 json 파일로 변경
|__ 프로젝트 폴더에 .json 파일 넣기
|
|__ [코드]
|__ json 파일로 부터 데이터 읽어오기
|__ db.collection.insert_many(json_data)

🔍 재료 선택지 개선


재료를 선택하는 방식에 있어 초기 버전인 셀렉트 기능을 사용하게 됬을 경우 사용자가 원하는 재료를 선택하는데 불편할 거라고 판단하여 사용자가 직접 재료를 검색하여 사용할 수 있도록 JQuery UI의 autocomplete기능을 사용하여 자동완성 검색기능으로 변경하여 문제를 해결하였습니다.

세부정보

• 변경 전


• 변경 후

레시피 선택

// 자동완성 검색창
function search_show() {
    $("#searchInput").autocomplete({
        autoFocus: true,
        source: ingre_list,    // 재료가 담겨있는 배열변수
        select: function (event, ui) {
            let ingredient = ui.item.value

            if (IRDNT_NM.indexOf(ingredient) == -1) {
                let temp_html = `<input type="button" class="btn btn-outline-primary" id="selected-ingredient-button-${index}" value="" style="margin: auto 5px 3px auto;" onclick="cancleSelectingIngredientAdded(this)"/>`
                $('#selected-ingredient-display-main').append(temp_html)
                let temp = 'selected-ingredient-button-' + index
                document.getElementById(temp).value = ingredient;
                index += 1;
                IRDNT_NM.push(ingredient);
            }
        },
        focus: function (event, ui) {
            return false;
        },
        minLength: 1,
        delay: 100,
        disabled: false
    });
};

2차 스프린트


🔍 로그인을 위한 데이터 선정


회원 인증 기능을 도입하면서 사용자에 따라 처리해야할 기능이 많아졌습니다. 그에 따라 어떻게 하면 효과적으로 데이터를 관리할 수 있을지 팀원들과 논의하는 과정을 거쳤고, 로그인에는 이메일을 사용하고 내부적으로 사용자 구분을 위해 사용하는 값은 사용자 고유의 아이디 값(MongoDB ObjectId)으로 식별하기로 결정했습니다.

Issue
    로그인 방식 변경 건 #63

🔍 배포 환경 구축


배포 환경에서 테스트하고 싶을때마다 프로젝트 파일을 전송하고 터미널로 인스턴스에 접속해서 내용을 수정하고 서버를 실행시켜야하는 번거로움이 있었습니다. CI/CD 개발 환경을 만들기 위해 Github Action 기능을 이용했으며, 배포 환경은 AWS 서비스인 EB, S3를 사용해 구축했습니다.

고민의 흔적

생각하고 있는 CI/CD 환경을 구축하기 위한 방법은 2가지이다. EC2에 파일을 직접 옮긴 다음에 서비스를 수동으로 실행시키는 방법과 Elastic Beanstalk 를 구축하고 배포하는 방법이 있다. EC2 에 직접 올리는 방법은 여태까지 해왔던 방법이라 손에 익었지만, 클라우드 환경에서 애플리케이션이 어떻게 동작하는지 확인하고 싶을 때마다 터미널로 접속해야하고 코드의 변경 사항을 직접 반영해야 한다는 번거로움이 있었다. 그래서 Elastic Beanstalk 를 사용해 자동 배포 환경을 구축하는 방법을 선택했다. EB 를 사용하면 앞서 언급한 EC2 를 사용했을 시 수행하는 번거로운 작업들을 하지 않아도 되고, 무엇보다 서비스를 확장하고 싶을때 EB Configuration 을 변경해주기만 하면 얼마든지 확장시킬 수 있기 때문이다. 더불어 Github Action 기능을 이용하면 브랜치에 Push 함과 동시에 자동으로 배포가 이루어지고 수작업으로 서버를 실행시키지 않아도 된다.

EB + Github Action 으로 CI/CD 환경 구축하기로 결정 탕탕탕!! 근데 또 문제가 생겼다. 그럼 데이터베이스는 어디서 관리해야하지? EC2 인스턴스에 MongoDB를 설치했듯이 EB가 만들어놓은 인스턴스에 MongoDB를 설치하면 되겠다! 하지만 또 문제! EB는 필요에 따라 인스턴스를 자동으로 만들어주는데 그럼 그때마다 새로 만들어지는 인스턴스에 들어가서 MongoDB를 설치해야하나!? 결론은 데이터베이스는 EB 와 분리된 환경에 구축하는 것을 권장한다고 한다. 자세한 이유는 모르겠지만, EB 가 자동으로 환경을 만들고 관리하는 도중에 내가 들어가서 작업을 수행한다면 망가질 수 있기 때문인 것 같다.

Github Action을 통해 배포를 할때마다 이전에 테스트하며 업로드했던 이미지가 안보였다. 현재 이미지는 프로젝트 폴더 내에 저장되도록 구현한 상태이다. 배포를 하더라도 이전에 저장된 이미지들은 그대로 폴더 내에 존재할 것이라 생각했는데, EB 환경에서 자동으로 생성한 S3 에 들어가 배포된 내용을 확인해봤더니 그동안 업로드한 이미지는 없었다. 하나의 브랜치에 반영하면 이전 브랜치 내용과 병합해서 변경사항을 추가하는 Git 시스템처럼 동작할 것이라 예상했으나, 배포마다 현재 프로젝트 폴더 상태로 업로드 되고있었다. 각자 로컬에서 테스트하는 상황이었기 때문에 테스트 시 사용한 이미지를 Github 상에 올리지 않게 하기위해 .gitignore 파일에 이미지 폴더 내용을 무시하도록 설정했으니, 배포 시점에 EB 로 올라가는 이미지는 없는 상태다.

프로젝트 폴더 내에 이미지를 저장 했기 때문에 발생한 문제라, 이미지를 저장소를 대체해야만 한다! 그래서 S3 를 사용하기로 했다. 프론트 배포 환경을 만들기 위해서만 S3 + Cloud Front 생각을 했었는데, 결국 프론트엔드&백엔드 구분없이 EB에 프로젝트 통째로 옮기기로 결정했던 터라 S3 는 사용해볼 생각을 못했다. 정적 웹 호스팅 용도가 아닌 단순히 이미지 저장소 로서의 용도로 사용하기로 했다.

변경하는 방법은 생각보다 간단했다. 기존에 프로젝트 폴더에 이미지를 저장하고 삭제하는 코드를 S3 에 업로드하고, 업로드 후 생긴 객체 URL을 데이터베이스에 저장하고 그 값을 프론트로 넘겨주어 <img> 태그의 src 속성을 변경했다. 그리고 이미지가 삭제될 경우에는 S3 에 저장된 객체를 삭제하도록 구현했다.


🔍 Mixed Content 문제


EB HTTPS 도메인을 연결한 후 공공 데이터 레시피 이미지를 가져오는데, 공공 데이터 서버에서 HTTP만 지원해서 Mixed Content 문제를 겪었습니다. 공공 데이터 이미지를 S3에 다운로드 받는 방법으로 해결했습니다.

고민의 흔적

HTTPS ➡️ HTTP Mixed Content 문제가 있어서, 저걸 어떻게 해결해야하나 팀원들과 고민하다가 S3 에 이미지를 업로드하면 객체 URL 이 생기는데, 이 객체를 HTTPS 로 접근할 수 있으니까 Open API 서버에서 이미지를 가져온 다음 S3에 저장해두면 우리 사이트를 HTTPS로 설정 해도 문제가 없겠다. 라고 생각했었다. 그래서 바로 기존에 데이터베이스에 저장된 이미지 URL을 S3 객체 URL 로 수정했다.

# recipe_image_data_init.py

import requests
import boto3
import os
from pymongo import MongoClient

client = MongoClient(os.environ['MONGO_DB_PATH'])
db = client.dbname

data = db.collection.find({}, {"IMG_URL":1, "_id":0})

# [1] Open API 서버에서 제공하는 이미지 가져오기
# [2] S3에 저장하기
for i in data :
    url = i['IMG_URL'] # img URL만 뽑아옴

    r = requests.get(url, stream=True)
    session = boto3.Session()
    s3 = session.resource('s3')

    bucket_name = os.environ["BUCKET_NAME"] # 버킷이름
    file_name = url.split("/")[-1]          # 파일이름
    key = f'folder1/{file_name}'       # 폴더명/파일이름

    bucket = s3.Bucket(bucket_name)
    bucket.upload_fileobj(r.raw, key)

# [3] S3에 저장한 이미지 객체 URL로 데이터베이스 내용 변경해주기
for i in data:
    origin_url = i['IMG_URL']

    fname = origin_url.split('/')[-1]
    new_url = f'{os.environ["BUCKET_ENDPOINT"]}/folder1/{fname}'

    db.collection.update_one({"IMG_URL": origin_url}, {"$set": {"IMG_URL": new_url}})