티스토리 뷰

( 이 글이 올라올 때 쯤이면, 이미 프로그래머스에서 진행하는 웹개발 챌린지가 끝난 후에 올라와있을겁니다 :D )

 

[프로그래머스] 웹 개발 챌린지 : 19년 11월 06일 18:00 ~ 11월 27일 18:00

정말 고된 과제였습니다..

당근마켓에서 요구하는 기능들 대부분, 거의 1주일만에 구현을 했는데 유일하게 필터링 기능만이 4일 이상에 힘을 쏟은 것 같습니다. 그리고 개발에 있어 확장성+퍼포먼스 둘 다 잡고 싶어서 진짜 별에별 생각을 다하게 만든 개발 챌린지였던 것 같습니다.

 

이번 시간에는 개발 과정 중, 필터링 검색 구현에 대해 한번 이야기를 풀어나가볼까 합니다.

 

 

중간에 일부 내용은 생략되었지만, 위 내용이 당근마켓에서 요구하는 구현사항이었습니다.

일부 페이지에 대해선 미리 디자인을 해줬지만, 그래봤자 3 페이지(로그인, 상품목록, 상품상세) 밖에 안되는 상황이었습니다.

 

일단 저는 위 요구사항에 있어 저는 다음 사항을 포커스로 두고 개발을 하게되었습니다 :

  • 확장성

  • 퍼포먼스 (SQL N+1 문제가 최대한 발생되지 않게)

  • 코드는 간결하게 (Controller에 모든 코드를 표현하지 않고, 일부 코드는 헬퍼에 넣어서 표현)

 

일단 이번 이야기는 전체적인 개발에 대해선 생략하고, 필터링 검색구현 기능에 대해 소개해보겠습니다.

참고로 개발에 사용된 기술은 Ruby on Rails 입니다.

  • Ruby on Rails 에 대해 간단히 소개하자면, Django(Python), Spring(Java)와 같은 웹프레임워크 입니다.

  • Ruby on Rails는 Ruby 언어 기반 웹프레임워크 입니다.

  • 후에 코드를 보면 알겠지만 데이터 탐색에 있어 SQL 처리가 이루어지나, 실상 Rails 코드 속에는 SQL 코드가 없습니다.

  • Rails 코드 내에서 데이터 탐색을 표현할 때에는 SQL이 아닌 ORM, 즉 객체를 통해 코드가 이루어지고 서버 단에서 객체와 SQL 간의 맵핑 해석을 해줍니다.

 

 

  • 검색 필터링 구현

일단 시작 전 홈페이지의 간단 구조를 먼저 짚고 넘어가겠습니다.

 

1. 테이블 구조 (Cancancan 등 일부 테이블 생략)

 

 

2. 카테고리(Categorizeds)

카테고리에는 다양한 Option들이 존재합니다.

이 Option들은 나중에 중고물품 판매자가 글을 올리기 전, Option의 질문에 응답을 해야하는 컨셉입니다.

Option의 갯수는 필요에 따라 줄이거나 늘릴 수 있고, 객관식 옵션은 ;로 구분짓게 했습니다.

(차후에 객관식 리스트에 보여질 때에는 ; 를 구분자로 두어 split 처리를 합니다.)

 

3. Option_answer_[Model]s

  • 카테고리가 생성되면 자동으로 카테고리에 종속하는 테이블이 생성이 됩니다.

  • 해당 테이블에는 사용자가 Option에 응답한 대답이 기록이 됩니다.

  • OptionAnswer[Model] 은 Option에 종속되어 있습니다.

 

4. Options

  • 카테고리(Categorizeds)에 종속되어 있습니다.
  • Products에서 사용자가 판매글을 올리기 전, 보여질 질문입니다.

 

5. 상품판매 게시판 : Products

  • 사용자가 상품을 올리는 곳 입니다.

  • Products은 Categorized에 의존합니다.

  • 하단의 구매년도 / 주행거리 / 흡연여부 는 카테고리(Categorized)에서 작성된 양식을 그대로 사용자에게 보여줘서 질문에 답하게 합니다.


제 홈페이지의 구조는 위와같은 틀로 되어있습니다.

그리고 이제 본격적으로 보여드릴 Dynamic 필터링 검색에 대해 알아보겠습니다.

 

1. 필터링에 보여지는 옵션들이 Dynamic 할 수 있는건 사전에 Categorized에 설정되었던 Option과의 Relation 관계인 덕분입니다.

2. 필터링 검색 시 보여지는 옵션에 대해선 주로 객관식 혹은 주관식(integer Only) 형에 대해서만 필터링 검색을 지원을 했습니다.

Dynamic 필터링 (카테고리에 따라 필터링 옵션 갯수, 종류가 서로 다르다.)

 

3. 처음에 검색을 구현했을 땐 너무 아쉬웠습니다. 너무 확장성에만 생각했다보니 퍼포먼스를 전혀 고려하지 않은 채로 구현을 했습니다. 그 덕분에 SQL N+1문제까지 발생하는 상황에 이르렀었습니다.

 참고  SQL N+1 간단 개념 [클릭]

위와같이 SQL N+1 문제가 발생했던 이유는 다음과 같습니다.

- 초반에 필터링 작업은 검색 요청 질의 분석과 데이터탐색을 동시에 했었습니다.

요청이 Controller로 넘어가면

## Controller로 넘어온 질의(Parameters)를 분석 및 SQL 탐색작업을 동시에 실행
@searchResult = Product.eager_load(:user, :categorized).where(categorized_id: @categoryId)

for i in @number.min..@number.max
  eval("@productsList_#{i} = []")
  @searchResult.each do |t|
    ## 필터링 조건 분석
    if params[:search]["condition_#{i}".to_sym].include?(",")
      object = params[:search]["condition_#{i}".to_sym].split(",")

      @minimum = option_maximum(object)
      @maximum = option_minimum(object)

      ## SQL 탐색 (Range)
      if (@minimum != nil || @minimum != "") && t.option_answers.where(["categorized_id = ? and option_id = ?", t.categorized_id, i]).find_by("integer_content >= ? AND integer_content <= ?", @minimum, @maximum)
          eval("@productsList_#{i}").push(t)
      end

	## SQL 탐색 (객관식)
	elsif params[:search]["condition_#{i}".to_sym] != "" && t.option_answers.find_by("option_answers.content LIKE ?", params[:search]["condition_#{i}".to_sym])
        	eval("@productsList_#{i}").push(t)
    	end
    end
  end
end

* each do 문법은 변수가 가진 배열/데이터 객체 length만큼 반복을 돕니다.

each do 문법이 언뜻 보면 반복문과 비슷하긴 하지만, 차이가 하나 있다면 each do에서 선언되는 상속변수(람다) 내에 index 값도 함께 갖고 있다는 점입니다.

 

- 그러나 이는 나중에 돌이켜보면 너무 낭비가 심했었습니다.

지금 위 코드만 봐도 반복문이 거의 2중으로 쓰입니다.

첫 번째 params 탐색이 이루어질 때 마다 전체적인 데이터 탐색이 이루어지고,

두 번째 params 탐색이 이루어질 때에도 또 전체적인 데이터 탐색이 이루어지고...

거의 고난의 연속입니다..

 

4. 그래서 이번엔 퍼포먼스를 고려해서 전략을 바꿔야 했습니다.

우선 효율적인 테이블 탐색을 위해 테이블을 나눠야 할 필요가 있었습니다.

과거의 테이블 관리 방식

사실 원래 과거에는 Option에 대한 응답을 Option_Answers 테이블에 전부 다 넣는 방식이었습니다.

지금 돌이켜보면 해당 방법은 도저히 아닌 것 같아 아래와 같이 Option에 대한 응답을 테이블을 세분화 해서 Option에 대한 응답이 기록되게 했습니다.

현재의 테이블 관리 방식

 

5. 그리고 필터링 검색 알고리즘에 있어선 과거와는 다르게 parameter 분석 따로, SQL 작업 따로 역할을 나뉘어봤습니다.

## 1) 요청이 들어온 검색 질의 요청에 대해 배열에 담아두기

@ModelName = @categorized.table_name.titleize.gsub(" ", "")
@conditionRange = Array.new
@conditionWord = Array.new

@index = 1
for i in @number.min..@number.max
  begin
    if params[:search]["condition_#{i}".to_sym].include?(",")
      object = params[:search]["condition_#{i}".to_sym].split(",")

      @minimum = option_maximum(object)
      @maximum = option_minimum(object)

      # [검색범위] 에 대한 조건을 배열에 담아둔다.
      @conditionRange.push(["range", @minimum, @maximum])
    else
      # 객관식 조건에 대해 배열에 담아둔다.
      @conditionWord.push(["word", params[:search]["condition_#{i}".to_sym]])
    end
  rescue
    next
  end
end

## 2) 객관식/범위(range) 조건이 담긴 배열 합치기
# @totalSearchConditionArray[0] : 범위/객관식 문자 구분, @totalSearchConditionArray[1] : Option ID, @totalSearchConditionArray[2] : (Range인 경우)최솟값 및 (word인 경우)문자, @totalSearchConditionArray[3] (Range인 경우) 최댓값
@totalSearchConditionArray = @conditionRange + @conditionWord

## 3) 조건에 맞는 SQL 탐색
@index = 0
@totalSearchConditionArray.each do |t|
  # 하나의 PARAMTER에 따른 분석이 시작될 때 마다 배열 생성
  eval("@productsList_#{@index} = []")

  if (t[0] == "range" && t[2] != "")
    eval("@productsList_#{@index} = instance_eval('OptionAnswer#{@ModelName}').where(option_id: t[1]).where('integer_content >= ? AND integer_content <= ?', t[2], t[3]).eager_load(:product => [:user, :categorized]).map { |t| t.product }")
    @index += 1

  elsif (t[0] == "word" && t[2] != "")
    eval("@productsList_#{@index} = instance_eval('OptionAnswer#{@ModelName}').where(option_id: t[1]).where(content: t[2]).eager_load(:product => [:user, :categorized]).map { |t| t.product }")
    @index += 1
  end
end

이제 데이터 탐색 때에는 반복문 횟수가 크게 줄었습니다.

반복문은 딱 1번 하고, 반복문이 돌아가는 횟수는 parameter 갯수만큼 입니다.

 

왜 진작에 이렇게 안했나 싶네요..ㅠ

이렇게 구현하니까 성능개선이 확 이루어졌습니다.

 

그리고 하면서 이슈가 하나 더 있었습니다.

## 필터링 : SQL 탐색 작업 때 쓰인 옛날 코드 (SQL N+1 발생)

if (t[0] == "range" && t[2] != "")
    eval("@productsList_#{@index} = instance_eval('OptionAnswer#{@ModelName}').where(option_id: t[1]).where('integer_content >= ? AND integer_content <= ?', t[2], t[3])map { |t| t.product }")
    @index += 1

elsif (t[0] == "word" && t[2] != "")
    eval("@productsList_#{@index} = instance_eval('OptionAnswer#{@ModelName}').where(option_id: t[1]).where(content: t[2]).map { |t| t.product }")
    @index += 1
end

위 코드는 4번 설명의 코드 중 일부로서, 예전에 쓰였던 코드였습니다.

처음에는 코드를 짤 때 위 코드처럼 짰었고, 실제로 과거와는 다르게 성능이 개선된 채로 SQL 탐색작업이 이루어졌었습니다.

 

여전히 발생하는 SQL N+1

하지만 여전히 SELECT에 대한 N+1이 여전히 발생되는 상황이었습니다.

한 5분정도 멍하니 코드를 보니까.. 제가 Join 처리를 안했던게 큰 화근이었습니다.

## 필터링 : SQL 탐색 작업 때 쓰인 현재의 코드

if (t[0] == "range" && t[2] != "")
  eval("@productsList_#{@index} = instance_eval('OptionAnswer#{@ModelName}').where(option_id: t[1]).where('integer_content >= ? AND integer_content <= ?', t[2], t[3]).eager_load(:product => [:user, :categorized]).map { |t| t.product }")
  @index += 1

elsif (t[0] == "word" && t[2] != "")
  eval("@productsList_#{@index} = instance_eval('OptionAnswer#{@ModelName}').where(option_id: t[1]).where(content: t[2]).eager_load(:product => [:user, :categorized]).map { |t| t.product }")
  @index += 1
end

그래서 eager_load 메소드를 통해 LEFT 조인을 시켜서 N+1 문제를 해결을 했습니다.

 

5. 이후의 과정을 더 얘기해보자면,

이제 SQL 검색 결과가 담긴 변수들을 일단 하나로 뭉치는 작업을 진행했습니다.

지금은 이게 뭔소린지 싶을 수 있지만 일단은 '그냥 이렇게 작업했다' 라고는 알아봐주시면 됩니다.

## @commonArray = "@productsList_1@productsList_2@productsList_3..."
@commonArray = ""
for i in 0..@index-1
  if eval("@productsList_#{i}") != ""
    @commonArray += "@productsList_#{i}"
  end
end

 

6. @commonArray 변수에 초기화 된 변수 사이에 & 기호를 추가합니다.

& 기호는 변수가 가진 객체에 있어 공통적인 것만을 추출해냅니다.

"@" + @commonArray.split("@")[1..].join("&@")

이제 6번 과정을 보고, 왜 5번 과정을 진행을 했는지 이해가 갈겁니다.

 

7. @commonArray 변수를 eval 함수를 이용해서 일반적인 string 형에서 코드화 합니다.

@productsList = eval(@commonArray)

 

8. 이제 @productsList 변수는 검색결과 조건에 있어 모든 조건을 만족하는 데이터를 가지고 있는 변수가 되었습니다.

해당 변수를 이용해서 뷰에다 띄어주면 끝납니다.

<%= @productsList.each do |t| %>
...
<%= end %>

드디어 끝났다....

 

진짜 고되고 고된 홈페이지 설계였습니다.

DB 설계부터 시작해서 홈페이지 운영에 있어 미래의 확장성, 퍼포먼스까지 구현하고 개발하기란 쉽지 않았더군요..

이번 과제에 들어간 제 코드가 제 모든 노하우를 표현한 것 같습니다.

 

긴 글 읽어주셔서 감사합니다.

 

댓글
  • 프로필사진 비밀댓글입니다 2019.11.28 18:28
  • 프로필사진 마음 따뜻한 개발자, 나른한 하루 안녕하세요! 시비라뇨..ㄷ 오히려 좋은 피드백을 받을 수 있어서 좋았습니다!
    일단 eval 같은 경우는 string을 코드로 변환시켜주는 함수인데, 사실.. 저걸 쓴 이유가 코드 안에 유동적으로 바뀌는 변수(@index) 때문에 eval 함수를 쓰게 되었습니다. (string 속 Dynamic하게 변하는 숫자변수를 코드로 적용하기 위해)

    그런데 사실 지금 돌이켜보면.. 일부 방법은 저 방법이 필요했으나(Dynamic하게 바뀌는 Data Modal 이름을 이용한 SQL 탐색),

    eval("@productsList_#{@index} = []")
    이 코드에 있어선 저 방법은 썩 좋지 않은것 같아요, 다음날 생각해보니까 그냥 배열에 담아버리는 방법이 있었는데...

    그래도 잊지않고 피드백주셔서 정말 감사합니다!
    2019.11.29 03:47 신고
  • 프로필사진 Sam 안녕하세요~ 당근마켓 유저가 지나가다가 들렀습니다. ㅎㅎ
    근데 카테고리화 지금은 안되어 있는거 맞나요? 저런 필터링은 본적이 없는거같아요~!
    2020.08.06 14:29
  • 프로필사진 마음 따뜻한 개발자, 나른한 하루 안녕하세요, 저거는 코딩테스트 과제문제(필터링 구현)이었습니다. 2020.08.10 15:09 신고
댓글쓰기 폼
공지사항
Total
44,672
Today
13
Yesterday
141
링크
«   2020/09   »
    1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30      
글 보관함