스노우 입사 전, 한 달 정도의 여유 기간 동안 영어 학습 뉴스레터 서비스를 만들어 운영을 시작했다. 친구 둘과 나를 포함해 총 세 명이 초기 사용자가 되어 서버 비용을 간신히 충당할 수 있었지만, 한 푼이 아쉬운 상황이라는 데에는 큰 차이가 없었다.

사이드 프로젝트라고 공부도 할 겸 처음 써보는 기술을 몇 개 쓰다 보니, 프론트엔드 개발에 생각보다 시간을 많이 쓰게 되어 백엔드는 익숙한 스택으로 빠르게 개발한 것이 문제라면 문제였다. 서버는 Ruby on Rails로 개발했고, 메일 발송을 백그라운드에서 처리하기 위해 Resque를 사용했다. Google Cloud Platform의 무료 크레딧을 사용하기 위해 App Engine, Cloud SQL, 그리고 Cloud Memorystore를 사용했다. 덕분에 시작부터 서비스 유지비로만 한 달에 $80 정도가 나가는 상황이 됐다.

Lambda

그러기를 몇 달, 무료 크레딧을 다 사용하고 본격적으로 돈이 빠져나가기 시작하자 머리가 아파졌다. 가장 작은 인스턴스를 사용해도 CPU나 메모리 사용량은 수%를 넘지 못하고 있었다. 서비스 특성상 API 호출이 많지 않기 때문이다. 바로 떠오른 게 Serverless였다. Lambda의 무료 사용량을 다 쓸 정도면 나는 백만장자가 될 게 분명했다. (왜 Cloud Functions가 아니라 Lambda냐고 묻는다면.. 나는 Ruby와 ActiveRecord, ActiveSupport를 사랑하는데, Cloud Functions는 Ruby 런타임을 지원하지 않는다.)

Rails 코드를 재작성하려고 보니, 일반적인 요청 - 응답은 간단했지만, 기존에 Resque와 Redis를 사용하여 백그라운드에서 처리하던 메일이나 슬랙 메시지 발송을 어떻게 수정할지가 문제였다. 구현을 위해 Resque의 대략적인 작동 방식을 생각해보면, 아마 작업을 처리하는데 필요한 인자들과 실행할 작업 이름을 직렬화해 Redis 등의 데이터베이스에 저장하고, 서버에서 일정 시간마다 질의해 작업 이름과 인자를 받아 작업을 실행하는 식일 것이다.

처음에는 Redis 대신 SQS를 사용하려 했지만, 메시지가 최대 14일까지만 보존되는 제한이 있었다. 구독 해지 작업을 예약하는 경우, 1년 이용권을 구입한 지 얼마 되지 않아 바로 해지한다면 약 1년 정도 뒤에 작업이 실행되어야 하기 때문에 SQS는 사용하기 어려웠다.

비용을 줄이는 게 목적이고 작업이 Redis를 사용해야 할 정도로 많거나 속도에 민감하지 않기 때문에 직렬화한 작업 데이터는 MySQL에 저장하기로 했다. 서버에서 일정 시간마다 질의하는 부분은 마침 Lambda가 Scheduling을 지원해서 5분마다 작업 대기열을 확인해 실행 시간이 지난 작업을 실행 후 삭제하도록 했다. 아래와 같은 구조로 작동한다고 생각하면 된다.

Architecture Diagram

여기까지 작업한 코드는 아래와 같다.

app/models/pending_job.rb

1
2
3
4
5
6
class PendingJob < ActiveRecord::Base
# klass : string
# params : text
# wait_until: datetime
scope :enqueued, -> { where(wait_until: 0...Time.now) }
end

실행할 작업 이름 (klass), 필요한 인자 (params), 그리고 실행 시간 (wait_until)을 저장하는 모델

functions/execute_pending_jobs.rb

1
2
3
4
5
6
7
8
9
10
def handler(event:, context:)
PendingJob.enqueued.each do |job|
params = JSON.parse(job.params).symbolize_keys
job.klass.constantize.execute(params)
job.destroy
end
rescue => e
logger = Logger.new(STDERR)
logger.error e.inspect
end

5분마다 작업 대기열을 확인해 실행 시간이 지난 작업을 실행 후 삭제하는 Lambda 함수

app/common/base_job.rb

1
2
3
4
5
6
7
8
9
10
11
module Jobs
class Base
def self.schedule(with:, wait_until:)
PendingJob.create(
klass: self.to_s,
params: with.to_json,
wait_until: wait_until,
)
end
end
end

app/jobs/send_email_job.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Jobs::SendEmail < Jobs::Base
def execute(to:, subject:, body:)
client = Aws::SES::Client.new(region: 'us-east-1')
client.send_email({
destination: {
to_addresses: [to],
},
message: {
subject: {
charset: 'UTF-8',
data: subject,
},
body: {
html: {
charset: 'UTF-8',
data: body,
},
},
},
source: 'Mailenglish <service@mailenglish.co>',
})
end
end

그리고 실제로 메일을 발송하는 작업 코드이다. 아래처럼 사용할 수 있다.

1
2
3
4
5
6
7
8
Jobs::SendEmail.schedule(
with: {
to: 'mu29@yeoubi.net',
subject: 'Lorem Ipsum',
body: 'dolor sit amet, consectetur adipiscing elit',
},
wait_until: 1.days.from_now,
)

Simple Notification Service

이렇게 문제가 해결되면 좋았겠지만 이 방법은 확장성이 떨어진다. 메일 한 건 발송에는 약 1~2초 정도가 소요되기 때문에 정해진 시간에 한꺼번에 뉴스레터를 발송하면 Lambda의 900초 실행 시간 제한에 걸리게 된다. (사실 개발할 때는 30초인 줄 알았다. ㅠㅠ) 그래서 작업을 대기열에서 빼내는 함수작업을 실행하는 함수를 분리하기로 했다.

작업을 대기열에서 빼내는 함수는 앞의 execute_pending_jobs와 내용이 비슷하지만, 작업을 바로 실행하는 대신 SNS 주제에 메시지를 게시한다. 작업을 실행하는 함수는 SNS 주제를 구독하고, 메시지가 들어오면 그로부터 작업 이름과 인자를 받아내어 작업을 실행한다. 아래와 같은 구조로 작동한다고 생각하면 된다.

Architecture Diagram

SNS 주제에는 메시지를 빠르게 게시할 수 있고 순간적으로 많은 메시지가 들어와도 그만큼 많은 Lambda 함수를 실행할 것이기 때문에 다량의 작업을 처리하기에도 무리가 없는 구조라고 판단했다. 변경된 코드는 다음과 같다.

app/common/base_job.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module Jobs
class Base
def self.schedule(with:, wait_until:)
PendingJob.create(
klass: self.to_s,
params: with.to_json,
wait_until: wait_until,
)
end

def self.perform(params, id = nil)
@client ||= Aws::SNS::Client.new
@client.publish(
topic_arn: 'SNS 주제',
message: {
id: id,
klass: self.to_s,
params: params.to_json,
}.to_json,
)
end
end
end

functions/enqueue_pending_jobs.rb

1
2
3
4
5
6
7
8
def handler(event:, context:)
PendingJob.enqueued.each do |job|
job.klass.constantize.perform(job.params, job.id)
end
rescue => e
logger = Logger.new(STDERR)
logger.error e.inspect
end

functions/execute_pending_jobs.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def handler(event:, context:)
messages = event['Records'].map do |record|
JSON.parse(record['Sns']['Message']).symbolize_keys
end
messages.each do |message|
klass = message[:klass]
params = message[:params]
id = message[:id]&.to_i

while params.is_a? String
params = JSON.parse(params)
end

klass.constantize.new.execute(params.symbolize_keys)
PendingJob.destroy(id) unless id.nil?
end
rescue => e
logger = Logger.new(STDERR)
logger.error e.inspect
end

위의 아키텍처 다이어그램을 보면 알겠지만 SNS로 함수를 실행함으로써 얻는 또 하나의 이점이 있다. 이제 작업을 예약 없이 바로 백그라운드에서 처리할 수 있게 됐다. (API에서 바로 SNS 호출)

Lambda만 사용하는 버전에서 회원가입 후 환영 이메일을 발송하는 과정을 생각해 보자. 회원가입을 완료하면 Jobs::SendEmail.schedule()을 사용하여 작업 정보를 데이터베이스에 저장하고, 일정 시간 뒤(최대 5분)에 enqueue_pending_jobs 함수가 작업을 실행하여 메일을 보내게 된다. 물론 백그라운드에서 처리하지 않고 바로 Jobs::SendEmail.execute()를 할 수도 있지만 둘 다 권장하는 방법은 아니다.

SNS까지 함께 사용하는 버전에서는 Jobs::SendEmail.perform()을 실행하면 된다! 🎉

마무리

지금은 백엔드를 전부 Lambda + SNS로 변경했고, 무료 사용량 내에서 쓰고 있다. 아직 RDS에 매달 $30씩 지불하고 있지만, 이마저도 dynamodb를 사용하면 돈 한 푼 들이지 않고 서비스를 운영할 수 있다. 물론 앞서 말했듯 나는 ActiveRecord와 ActiveSupport를 사랑하기 때문에 (..) 당분간은 이대로 서비스할 것 같다. 가볍게 시작한 글이 배경 설명과 코드 예제까지 들어가 길어졌다. 실제로 작동하는 예시 프로젝트는 Github에서 확인할 수 있다.

Comment and share

추천 시스템에 관심이 생겨 찾아봤다면 한 번쯤은 실시간 추천엔진 머신한대에 구겨넣기라는 슬라이드를 마주쳤을 거라고 생각한다. 워낙 유명한 발표라 당시 페이스북에서도 많이 공유 되었던 것 같다. 나 역시 씀 : 일상적 글쓰기를 개발하면서 사용자들에게 구독, 혹은 담아 갈 만한 글을 추천해주고 싶었고, 어떤 식으로 구현할 수 있을지 알아보다 이 슬라이드를 발견했다. 처음 1/3 정도까지는 고개를 끄덕이며 술술 읽어 나갔는데, MinHash, LSH 같은 단어가 보이면서 점점 잠이 오더니, 어떻게 다 읽기는 했다만 ‘그래서 어떻게 한다고?’라는 생각을 하며 브라우저를 닫아 버렸다.

그렇게 몇 달간 추천 시스템은 잊고 지내다가, 최근 Cake 앱 개편 작업을 준비하며 홈 화면 개인화(=추천)라는 주제가 회의에서 이야기되었고, 불현듯 한 번 읽고 넘겨버린 이 자료가 생각나 간단하게 구현해 보기로 했다. 본격적으로 개발을 시작하기 전에, 발표에서 나온 개념들을 하나씩 짚고 넘어가 보자.

Jaccard Similarity

발표 자료에 쉽게 설명이 되어 있다. A가 좋아한 상품이 [1, 2, 3], B가 좋아한 상품이 [1, 2, 4], C가 좋아한 상품이 [1, 4, 5] 일 때 각 사용자들이 좋아한 상품을 기반으로 서로 얼마나 비슷한지 알아보는 것이다. 두 사용자 A, B의 Jaccard similarity는 다음과 같이 측정할 수 있다.

$$J(A,B) = \frac{|A \cap B|}{|A \cup B|}$$

예를 들어, 앞서 말한 A와 B의 Jaccard similarity는 아래와 같이 구할 수 있다.

$$\frac{|A \cap B|}{|A \cup B|} = \frac{|{1, 2}|}{|{1, 2, 3, 4}|} = 0.5$$

Ruby로 간단하게 구할 수도 있다.

1
2
a = [1, 2, 3]; b = [1, 2, 4]
(a & b).length / (a | b).length

MinHash

LSH는 발표 자료에 잘 설명이 되어 있는데, MinHash에 관해서는 Jaccard similarity를 유지하는 타입의 LSH라는 것 말고는 별다른 내용이 없다. ‘그래서 MinHash가 구체적으로 뭐지?’ 하는 의문이 들 수 있는데 찾아보면 의외로 간단한 개념이다. n 개의 원소로 이루어진 집합 S에 대하여, 보통 MinHash를 구하기 위해 사용하는 해시 함수는 아래와 같다.

$$h(x) = (a x + b)\bmod p$$

여기서 a와 b는 n보다 작은 임의의 자연수이며, p는 n과 같거나 큰 가장 작은 소수이다.

이전에 설명한 예시를 다시 가져와 보면, 전체 상품이 1 ~ 10까지 있다고 가정하면 n이 10이고, a = 2, b = 8, p = 11로 했을 때, 우리의 첫 번째 해시 함수는 아래와 같을 것이다.

$$h(x) = (2x + 8) \bmod 11$$

이 해시 함수에 사용자의 아이템을 전부 집어넣어 나온 값 중 가장 작은 값이 바로 MinHash 값이다(정확히는 해당 사용자에 대한 이 해시 함수의 MinHash 값). 예시에서 사용자 A의 경우 이 해시 함수를 사용했을 때 MinHash 값이 1, 사용자 B의 경우에도 1, 사용자 C의 경우에는 5가 된다. 그러면 우리는 사용자 A와 B가 같고, C는 다르다고 생각할 수 있다.

Signiture

MinHash 값을 구하는 과정을 보면 알겠지만, 두 사용자가 서로 다른 아이템을 가지고 있을 때도 MinHash 값은 같을 수 있다. 그래서 실제로 사용할 때는 a, b를 다르게 설정한 해시 함수를 많이 만들어서, MinHash를 여러 개 구하고, 이를 이어 붙여서 사용자의 Signiture를 만든다. 발표 자료에서는 100개를 추천하고 있다. 이렇게 만들어진 Signiture는 사용자 간의 유사도를 구하는데 사용된다. 예를 들어, 사용자 A의 Signiture가 [1, 0, 1, 6, 0]이고, 사용자 B의 Signiture가 [1, 0, 0, 6, 0]이면 둘은 80% 일치한다고 보는 것이다.

Secondary Index

이렇게 구한 Signiture로 사용자 간의 유사도를 구한다는 것은 알겠는데, 추천할 때마다 모든 사용자와 비교해서 비슷한 사용자를 찾는다면 속도에 이점이 전혀 없다. 그래서 발표에서 제시한 것이 Secondary Index이다. 사용자의 각 Signiture와 그 Signiture의 인덱스를 합친 키에, 값을 해당 사용자로 하는 Key-Value 저장소를 만드는 것이다.

글로만 보면 이해하기 어려우니 예시를 통해 더 알아보자. 앞서 예로 든 사용자 A의 Signiture [1, 0, 1, 6, 0]를 각각의 Index와 함께 묶어 키로 만들고, 값에는 사용자 A를 넣어서 Secondary Index를 만든다.

1
2
3
4
5
6
7
{
'1-0' => ['A'],
'0-1' => ['A'],
'1-2' => ['A'],
'6-3' => ['A'],
'0-4' => ['A'],
}

잘 보면, 키가 Signiture-Index로 구성되어 있다. 여기에 사용자 B의 Signiture [1, 0, 0, 6, 0]도 넣어 보자.

1
2
3
4
5
6
7
8
{
'1-0' => ['A', 'B'],
'0-1' => ['A', 'B'],
'1-2' => ['A'],
'6-3' => ['A', 'B'],
'0-4' => ['A', 'B'],
'0-2' => ['B'],
}

사용자 B의 3번째 Signiture인 0이 0-2로 마지막에 들어갔다. 이제 임의의 사용자와 비슷한 사용자를 찾으려면 해당 사용자의 Signiture를 map 함수를 사용하여 Signiture-Index 형태로 변형하고, Secondary Index에서 가져오면 되겠다.

사용자 A의 Signiture [1, 0, 1, 6, 0]를 Signiture-Index 형태로 변형하면 ['1-0', '0-1', '1-2', '6-3', '0-4']가 되고, 이 키들을 가지고 Secondary Index 저장소에서 값을 가져오면 ['A', 'B'], ['A', 'B'], ['A'], ['A', 'B'], ['A', 'B']가 나올 텐데, 여기서 A를 제외하면 비슷한 사용자 B를 빠르게 찾을 수 있는 것이다. 또, 둘의 유사도는 B의 등장 횟수 4를 Signiture의 길이 5로 나눈 80%가 된다.

비슷한 사용자, 비슷한 아이템

발표 자료와 이 글을 함께 보고 있으면 이상한 점을 하나 발견할 수 있다. 이쯤이면 발표 자료는 아이템의 Signiture에 관한 설명을 하고 있기 때문이다. (54페이지) 분명히 처음에는 사용자 기반 협업 필터링이었던 것 같은데?

생각해 보면 이 방법은 비슷한 사용자를 추천하는데도 사용할 수 있고, 비슷한 아이템을 추천하는데도 사용할 수 있다. 다만 한 사용자가 100개 이상의 아이템을 선호하는 경우가 많을지, 한 아이템을 100명 이상의 사용자가 선호하는 경우가 많을지 생각해보면 아이템 추천 시스템을 만드는 것이 좀 더 일리 있어 보이기는 하다.

1) 비슷한 사용자를 추천하는 경우에는 임의의 사용자에게 좋아할 만한 아이템을 추천해 줄 수 있고, 2) 비슷한 아이템을 추천하는 경우에는 사용자가 임의의 아이템을 선호한다는 표현을 했을 때, 그와 비슷한 다른 아이템들을 소개해 줄 수 있을 것이다.

원래 만들고자 한 기능이 홈 화면에서 볼 만한 영상을 추천해주는 것이기 때문에 이 글에서는 첫 번째 방법으로 임의의 사용자에게 좋아할 만한 다른 아이템을 추천해 주는 기능을 구현할 예정이지만, 원한다면 반대로 구현해도 좋다.

50줄로 구현하기

Redis를 사용하는 부분을 제외하고, 발표에서 이야기하는 실시간 추천 엔진이 어떻게 돌아가는 것인지 Ruby로 간단하게 구현해 보자. 마지막의 전체 코드를 제외한 이 글의 모든 코드는 irb를 사용해서 테스트할 수 있도록 작성했다. 맥 사용자라면 터미널에서 irb를 실행하고 복사-붙여넣기만 해도 작동한다. 테스트 데이터는 발표 자료에 나온 것을 그대로 사용하면 되겠다. (14페이지)

1
2
3
4
5
6
@likes_data = [
[2, 3],
[1, 4, 5], # 2번째 사용자 (나)
[1, 2, 4, 5],
[2, 5],
]

Signiture의 길이는 10으로 하고, p = 7로 잡아서 MinHash를 구하기 위한 해시 함수를 만들어 보자. Signiture의 길이만큼 해시 함수가 필요하다. a는 소수, b는 2의 배수로 정해서 해시 함수의 결과를 반환하는 함수를 보자. 소수를 쉽게 가져오기 위해 prime 젬을 사용했다.

1
2
3
4
5
6
7
require 'prime'

@coefficient = Prime.take(10) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

def min_hash(index, value)
(@coefficient[index] * value.to_i + 2 * index) % 7
end

2번째 사용자의 첫 MinHash 값은

1
2
3
4
5
[
min_hash(0, 1),
min_hash(0, 4),
min_hash(0, 5),
].min # = 1

이고, 그 다음 Minhash 값은

1
2
3
4
5
[
min_hash(1, 1),
min_hash(1, 4),
min_hash(1, 5),
].min # = 0

이다. 이런 식으로 구한 2번째 사용자의 Signiture는 [1, 0, 1, 6, 0, 2, 1, 4, 3, 1] 이다. 전체 사용자의 Signiture를 구하는 코드는 다음과 같다.

1
2
3
4
5
6
7
@signitures = @likes_data.map do |likes|
[*(0...10)].map do |i|
likes.map do |like|
min_hash(i, like)
end.min
end
end

각 사용자의 Signiture는 아래와 같다.

1
2
3
4
5
6
[
[4, 1, 0, 6, 2, 0, 0, 1, 1, 0],
[1, 0, 1, 6, 0, 2, 1, 4, 3, 1], # 2번째 사용자 (나)
[1, 0, 0, 6, 0, 1, 1, 3, 3, 1],
[3, 1, 0, 6, 0, 1, 4, 3, 5, 2],
]

잠깐 멈춰서 계산을 해보자. 2번째 사용자의 Signiture는 1번째 사용자와 10%, 3번째 사용자와 70%, 4번째 사용자와는 20% 일치한다. 그러므로 우리는 3번째 사용자가 좋아한 아이템 중, 2번째 사용자가 좋아하지 않은 것을 추천해 주면 되겠다. (likes_data[2] - likes_data[1])

발표 자료에도 나와 있듯, 모든 사용자를 하나하나 비교하는 것은 비효율적이다. Signiture의 위치와 값으로 Secondary Index를 만들어, Secondary Index lookup 만으로 유사도를 계산해 보자. 우선 Signiture를 Secondary Index key로 변환하는 함수를 만들자.

1
2
3
4
5
def signiture_to_key(signiture)
signiture.map.with_index do |sig, index|
"#{sig}-#{index}"
end
end

2번째 사용자의 Signiture를 이 함수에 넣고 돌리면

1
["1-0", "0-1", "1-2", "6-3", "0-4", "2-5", "1-6", "4-7", "3-8", "1-9"]

이렇게 뒤에 Index가 붙게 되는 것을 확인할 수 있다. 다음으로 이렇게 만든 키에 사용자 목록을 값으로 가지는 Secondary Index를 만들어 보자.

1
2
3
4
5
6
7
8
9
@secondary_index = {}

@signitures.map.with_index do |signiture, user|
keys = signiture_to_key(signiture)
keys.each do |key|
@secondary_index[key] ||= []
@secondary_index[key] << user
end
end

만들어진 Secondary Index 목록은 아래와 같다. 2번째 사용자의 Secondary Index key를 찾기 쉽도록 순서를 약간 바꿨다.

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
{
"1-0"=>[1, 2],
"0-1"=>[1, 2],
"1-2"=>[1],
"4-0"=>[0],
"6-3"=>[0, 1, 2, 3],
"0-4"=>[1, 2, 3],
"2-5"=>[1],
"1-6"=>[1, 2],
"4-7"=>[1],
"3-8"=>[1, 2],
"1-9"=>[1, 2],
"1-1"=>[0, 3],
"0-2"=>[0, 2, 3],
"2-4"=>[0],
"0-5"=>[0],
"0-6"=>[0],
"1-7"=>[0],
"1-8"=>[0],
"0-9"=>[0],
"1-5"=>[2, 3],
"3-7"=>[2, 3],
"3-0"=>[3],
"4-6"=>[3],
"5-8"=>[3],
"2-9"=>[3],
}

이제 우리가 임의의 사용자를 집어넣으면, 그 사용자와 비슷한 다른 사용자를 반환해 주면 된다. 이미 Secondary Index까지 만들어 두었으니, 할 일은 그저 임의의 사용자의 Signiture를 Secondary Index key로 변환하고, Secondary Index에서 값을 읽어 와서 모두 합치는 것뿐이다. 두 번째 사용자와 비슷한 사용자를 찾는 코드는 다음과 같다.

1
2
3
4
signiture_to_key(@signitures[1]).reduce([]) do |users, key|
users << @secondary_index[key]
end
# [[1, 2], [1, 2], [1], [0, 1, 2, 3], [1, 2, 3], [1], [1, 2], [1], [1, 2], [1, 2]]

각 Signiture 별로 일치하는 사용자 목록을 가져온 것이다. 이제 사용자 아이디의 등장 횟수를 세어 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
signiture_to_key(@signitures[1]).reduce([]) do |users, key|
users << @secondary_index[key]
end.flatten.
# [1, 2, 1, 2, 1, 0, 1, 2, 3, 1, 2, 3, 1, 1, 2, 1, 1, 2, 1, 2]
group_by(&:itself).
# {
# 1=>[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
# 2=>[2, 2, 2, 2, 2, 2, 2],
# 0=>[0],
# 3=>[3, 3]
# }
transform_values(&:count)
# {1=>10, 2=>7, 0=>1, 3=>2}

실행하면 아래와 같이 출력된다.

1
{1=>10, 2=>7, 0=>1, 3=>2}

자기 자신과는 100% 일치, 3번 사용자와 70% 일치, 1번 사용자와 10% 일치, 4번 사용자와 20% 일치한다. 앞서 우리가 계산한 결과와 같다. 조금 더 욕심을 부려 보자면, 가장 비슷한 한 명을 찾아볼 수 있겠다.

1
2
3
4
5
6
7
8
9
signiture_to_key(@signitures[1]).reduce([]) do |users, key|
users << @secondary_index[key]
end.flatten.
group_by(&:itself).
transform_values(&:count).
sort_by { |key, value| -value }.
# [[1, 10], [2, 7], [3, 2], [0, 1]]
slice(1)
# [2, 7]

2번 유저가 70% 일치한다고 나올 것이다. 마지막으로 비슷한 사용자를 기반으로 아이템을 추천해 주는 함수까지 만들어 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def recommended_for(user)
neighbor = signiture_to_key(@signitures[user]).reduce([]) do |users, key|
users << @secondary_index[key]
end.flatten.
group_by(&:itself).
transform_values(&:count).
sort_by { |key, value| -value }.
slice(1).
first

@likes_data[neighbor] - @likes_data[user]
end

# puts recommended_for(1)

전체 코드는 다음과 같다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
require 'prime'

@coefficient = Prime.take(10) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

def min_hash(index, value)
(@coefficient[index] * value.to_i + 2 * index) % 7
end

def signiture_to_key(signiture)
signiture.map.with_index do |sig, index|
"#{sig}-#{index}"
end
end

def recommended_for(user)
nearest_neighbor = signiture_to_key(@signitures[user]).reduce([]) do |users, key|
users << @secondary_index[key]
end.flatten
.group_by(&:itself)
.transform_values(&:count)
.sort_by { |key, value| -value }
.slice(1)
.first

@likes_data[nearest_neighbor] - @likes_data[user]
end

@likes_data = [
[2, 3],
[1, 4, 5], # 2번째 사용자 (나)
[1, 2, 4, 5],
[2, 5],
]

@signitures = @likes_data.map do |likes|
[*(0...10)].map do |i|
likes.map do |like|
min_hash(i, like)
end.min
end
end

@secondary_index = {}

@signitures.map.with_index do |signiture, user|
keys = signiture_to_key(signiture)
keys.each do |key|
@secondary_index[key] ||= []
@secondary_index[key] << user
end
end

puts recommended_for(1)

recommended_for 함수의 인자를 변경해 가며 각 사용자가 어떤 아이템을 추천받는지 확인해 볼 수 있다. 여기서 더 나아가면 비슷한 사용자 한 명이 아니라 여러 명을 가져와서 선호 데이터를 더 늘릴 수도 있고, @likes_data, @signitures, @secondary_index를 Redis를 통해 관리할 수도 있다. 여기까지 구현한 코드는 이곳에서 확인할 수 있다.

Comment and share

단일 테이블 상속 (Single Table Inheritance, STI)

단일 테이블 상속이란 관계형 데이터베이스에서 객체 지향 프로그래밍의 상속이라는 개념을 사용하기 위한 방법이다. 하나의 테이블에 기본 모델을 상속하는 여러 모델들의 데이터를 저장하고, 테이블의 특정 컬럼을 해당 데이터와 연결될 모델을 구분하기 위해 사용한다.

Active Record의 CoC (Convention over Configuration)

Rails(Active Record)에서는 모델이 단일 테이블 상속을 사용하는 경우, type 컬럼을 모델 식별을 위해 사용하는 것이 관례이다.

예를 들어, User 모델을 상속하는 WriterReader 가 있고, 우리가 STI를 사용하려 한다면, User 모델의 마이그레이션은 type 컬럼을 포함해야 한다.

1
2
3
4
5
6
7
8
9
10
class CreateUsers < ActiveRecord::Migration[5.1]
def change
create_table :users do |t|
t.string :email
t.string :password_digest
t.string :type # STI에 사용될 컬럼
t.timestamps
end
end
end

STI 사용하기

이제 User 모델을 상속받는 Writer, Reader 모델을 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class User < ApplicationRecord
...
end

class Writer < User
has_many :novels
before_destroy :can_leave?, prepend: true

def can_leave?
if novels.count > 0
errors[:base] << '연재중인 소설이 있어서 탈퇴할 수 없습니다.'
throw :abort
end

true
end
end

class Reader < User
has_many :payments
end

둘 다 User의 속성과 함수를 가지지만, Reader 는 탈퇴 조건이 따로 없는 반면, Writer 의 경우 연재중인 소설이 있는 경우에는 탈퇴할 수 없도록 설정되어 있다. WriterReader 모델은 따로 테이블을 가지지 않고, User 테이블에 type: 'Writer' 혹은 type: Reader 로 저장된다.
생성은 User#create 혹은 Reader#create 로 가능하다. User#create 로 생성하는 경우 type 을 명시해야 한다.

1
2
User.create(email: 'mu29@yeoubi.net', password: '1234', type: 'Reader')
Reader.create(email: 'mu29@yeoubi.net', password: '1234')

STI 관련 이슈

이러한 Rails의 STI에 관한 관례를 알지 못하고 type 컬럼을 사용하면 아래와 같은 오류가 발생하게 된다.

1
2
LoadError:
Unable to autoload constant Writer, expected /Users/injung/Github/ssm-api/app/models/writer.rb to define it

type 컬럼은 쓰고 싶지만, STI를 쓰고 싶지 않다면 해당 설정을 비활성화 할 수 있다.

1
2
3
class User < ApplicationRecord
self.inheritance_column = :_type_disabled
end

Comment and share

  • page 1 of 1

Injung Chung

author.bio


Software Engineer @SNOW


Seoul, Korea