F# Free Monad를 활용한 노래 추천 시스템
개요
함수형 프로그래밍의 대안적 설계 방식을 탐구하는 시리즈의 일부로, 대규모 스크로블 데이터셋에서 노래 추천을 계산하는 동일한 예제 문제를 다룬다. 이전 Haskell 구현에 이어 F#으로 free monad를 구현한다.
Git 저장소에서 fsharp-free 브랜치의 코드를 참조할 수 있으며, 시작점은 fsharp-port이다.
Functor
Free monad는 어떤 functor로부터도 monad를 정의할 수 있게 한다. 도메인 특화 언어(DSL)의 명령어 셋을 모델링하는 functor로 시작한다.
기존 인터페이스
type SongService =
abstract GetTopListenersAsync : songId : int -> Task<IReadOnlyCollection<User>>
abstract GetTopScrobblesAsync : userName : string -> Task<IReadOnlyCollection<Scrobble>>
Sum Type 정의
type SongInstruction<'a> =
| GetTopListeners of songId : int * (IReadOnlyCollection<User> -> 'a)
| GetTopScrobbles of userName : string * (IReadOnlyCollection<Scrobble> -> 'a)
인터페이스 메서드와 discriminated union 케이스 간의 유사성을 확인할 수 있다.
Map 함수 구현
module SongInstruction =
let map f = function
| GetTopListeners ( songId, next) -> GetTopListeners ( songId, next >> f)
| GetTopScrobbles (userName, next) -> GetTopScrobbles (userName, next >> f)
F#에서는 Haskell과 달리 Functor를 직접 구현해야 한다.
Monad
데이터 구조
type SongProgram<'a> =
| Free of SongInstruction<SongProgram<'a>>
| Pure of 'a
functor를 래핑하여 명령어들을 시퀀싱할 수 있게 한다.
Bind 함수
module SongProgram =
let rec bind f = function
| Free inst -> inst |> SongInstruction.map (bind f) |> Free
| Pure inst -> f inst
F# free monad 레시피를 충실히 따른 구현이다.
Computation Expression
Builder 클래스
type SongProgramBuilder () =
member _.Bind (x, f) = SongProgram.bind f x
member _.Return x = Pure x
member _.ReturnFrom x = x
member _.Zero () = Pure ()
member _.Delay f = f
member _.Run f = f ()
member this.While (guard, body) =
if not (guard ())
then this.Zero ()
else this.Bind (body (), fun () -> this.While (guard, body))
member this.TryWith (body, handler) =
try this.ReturnFrom (body ())
with e -> handler e
member this.TryFinally (body, compensation) =
try this.ReturnFrom (body ())
finally compensation ()
member this.Using (disposable : #System.IDisposable, body) =
let body' = fun () -> body disposable
this.TryFinally (body', fun () ->
match disposable with
| null -> ()
| disp -> disp.Dispose ())
member this.For (sequence : seq<_>, body) =
this.Using (sequence.GetEnumerator (), fun enum ->
this.While (enum.MoveNext, this.Delay (fun () -> body enum.Current)))
member _.Combine (x, y) = x |> SongProgram.bind (fun () -> y ())
명령형 언어 구조를 지원하기 위해 일반적인 요구사항보다 더 복잡하게 구현되었다.
Builder 인스턴스
let songProgram = SongProgramBuilder ()
Helper 함수
let getTopListeners songId = Free (GetTopListeners (songId, Pure))
let getTopScrobbles userName = Free (GetTopScrobbles (userName, Pure))
사용자 코드를 더 깔끔하게 만들어준다.
명령형 스타일 프로그램
초기 구현
let getRecommendations userName = songProgram {
// 1. Get user's own top scrobbles
// 2. Get other users who listened to the same songs
// 3. Get top scrobbles of those users
// 4. Aggregate the songs into recommendations
let! scrobbles = getTopScrobbles userName
let scrobblesSnapshot =
scrobbles
|> Seq.sortByDescending (fun s -> s.ScrobbleCount)
|> Seq.truncate 100
|> Seq.toList
let recommendationCandidates = ResizeArray ()
for scrobble in scrobblesSnapshot do
let! otherListeners = getTopListeners scrobble.Song.Id
let otherListenersSnapshot =
otherListeners
|> Seq.filter (fun u -> u.TotalScrobbleCount >= 10_000)
|> Seq.sortByDescending (fun u -> u.TotalScrobbleCount)
|> Seq.truncate 20
|> Seq.toList
for otherListener in otherListenersSnapshot do
// Impure
let! otherScrobbles = getTopScrobbles otherListener.UserName
// Pure
let otherScrobblesSnapshot =
otherScrobbles
|> Seq.filter (fun s -> s.Song.IsVerifiedArtist)
|> Seq.sortByDescending (fun s -> s.Song.Rating)
|> Seq.truncate 10
|> Seq.toList
otherScrobblesSnapshot
|> List.map (fun s -> s.Song)
|> recommendationCandidates.AddRange
let recommendations =
recommendationCandidates
|> Seq.sortByDescending (fun s -> s.Rating)
|> Seq.truncate 200
|> Seq.toList
:> IReadOnlyCollection<_>
return recommendations }
원래 GetRecommendationsAsync 메서드와 매우 유사하다. songService.GetTopScrobblesAsync 대신 getTopScrobbles를 사용한다.
참조 투명성
지역 상태 변이(recommendationCandidates)가 있지만 여전히 참조 투명하다. 참조 투명성은 특정 함수 호출을 반환값으로 대체할 수 있음을 의미한다. 함수가 반환값에 도달하기 위해 지역 변이를 사용했다는 것은 호출자에게 중요하지 않다.
Interpreter
구현
let rec interpret = function
| Pure x -> x
| Free (GetTopListeners (songId, next)) ->
users
|> Seq.filter (fun kvp -> kvp.Value.ContainsKey songId)
|> Seq.map (fun kvp -> user kvp.Key (Seq.sum kvp.Value.Values))
|> Seq.toList
|> next
|> interpret
| Free (GetTopScrobbles (userName, next)) ->
users.GetOrAdd(userName, ConcurrentDictionary<_, _> ())
|> Seq.map (fun kvp -> scrobble songs[kvp.Key] kvp.Value)
|> Seq.toList
|> next
|> interpret
FakeSongService 클래스에 추가된 메서드로, 원래 Fake 인터페이스 구현을 밀접하게 반영한다.
공개 메서드
member _.Interpret program = interpret program
테스트 리팩토링
FsCheck 프로퍼티 테스트
[<Property>]
let ``One user, some songs`` () =
gen {
let! user = Gen.userName
let! songs = Gen.arrayOf Gen.song
let! scrobbleCounts =
Gen.choose (1, 100) |> Gen.arrayOfLength songs.Length
return (user, Array.zip songs scrobbleCounts) }
|> Arb.fromGen |> Prop.forAll <| fun (user, scrobbles) ->
let srvc = FakeSongService ()
scrobbles |> Array.iter (fun (s, c) -> srvc.Scrobble (user, s, c))
let actual = getRecommendations user |> srvc.Interpret
Assert.Empty actual
원래 task 기반이었던 테스트가 이제 순수 함수로 변경되었다.
예제 기반 테스트
[<Fact>]
let ``One verified recommendation`` () =
let srvc = FakeSongService ()
srvc.Scrobble ("cat", song 1 false 6uy, 10)
srvc.Scrobble ("ana", song 1 false 5uy, 10)
srvc.Scrobble ("ana", song 2 true 5uy, 9_9990)
let actual = getRecommendations "cat" |> srvc.Interpret
Assert.Equal<Song> ([ song 2 true 5uy ], actual)
모든 테스트 마이그레이션 후 RecommendationsProvider 클래스와 SongService 인터페이스는 더 이상 필요 없어 삭제되었다. Strangler 패턴을 사용하여 점진적으로 이동했다.
Traverse
Map 함수
let map f = bind (f >> Pure)
Traverse 함수
let traverse f xs =
let concat xs ys = xs |> bind (fun x -> ys |> map (Seq.append x))
Seq.fold
(fun acc x -> concat acc (f x |> map Seq.singleton))
(Pure (Seq.empty))
xs
이중 중첩 루프를 리팩토링하기 위해 traversal이 필요하다.
리팩토링된 프로그램
최종 버전
let getRecommendations userName = songProgram {
// 1. Get user's own top scrobbles
// 2. Get other users who listened to the same songs
// 3. Get top scrobbles of those users
// 4. Aggregate the songs into recommendations
let! scrobbles = getTopScrobbles userName
let! otherlListeners =
getUsersOwnTopScrobbles scrobbles
|> SongProgram.traverse (fun s -> getTopListeners s.Song.Id)
let! otherScrobbles =
otherlListeners
|> Seq.collect getOtherUsersWhoListenedToTheSameSongs
|> SongProgram.traverse (fun u -> getTopScrobbles u.UserName)
return
otherScrobbles
|> Seq.collect getTopScrobblesOfUsers
|> aggregateTheSongsIntoRecommendations }
두 번의 traverse 적용이 이중 중첩 루프를 대체한다. getRecommendations의 타입은 변경되지 않았고 모든 테스트가 여전히 통과한다.
결론
Haskell과의 비교
예상대로 Haskell보다 더 많은 보일러플레이트 코드가 필요했다. 그럼에도 F# free monad 레시피를 따라 순조롭게 진행되었다. for 루프 지원을 위한 computation expression builder 구현 시 문제가 있었지만 free monad와는 무관한 문제였다.
실용적 고려사항
F# free monad 레시피의 결정 플로우차트를 참조하라. 이 글들은 규범적이기보다는 설명적이다. 가능한 것을 제시할 뿐, 선택은 독자의 몫이다.
타입 시스템의 이점
SongProgram<'a>는 인터페이스와 유사해 보이지만 훨씬 더 제한적이다. 의존성 주입을 사용하는 메서드는 본질적으로 불순하며 모든 종류의 버그를 포함할 수 있다. F# 타입 시스템은 제약되지 않은 부작용이나 비결정성을 체크하지 않지만, SongProgram<'a> 같은 타입은 SongProgram 관련 액션만 발생함을 강하게 시사한다.
언어별 적용 가능성
- F#: 주로 니치 유스케이스로 여전히 유용
- C#: 이런 종류의 프로그래밍을 가능하게 하는 언어 기능이 부족해 유용하지 않음
- 시연 목적으로는 C#에서도 가능
다음 글
Song recommendations with C# free monads