C# 기준 구현을 바탕으로 노래 추천 기능을 Haskell로 옮기며, 데이터 구조, 테스트 더블, QuickCheck 속성, 예제, 구현을 소개합니다.
F# 코드 베이스를 Haskell로 이식.
이 글은 더 큰 연재물의 일부로, 함수형 아키텍처를 사용해 만만치 않은 문제를 해결하는 다양한 방식을 살펴봅니다. 이전 글에서는 기준이 되는 C# 코드 베이스를 마련했습니다. 이후 글들은 그 C# 코드 베이스를 리팩터링의 출발점으로 사용할 것입니다. 한편으로는 F#이나 Haskell 같은 언어에서 그런 해법이 어떤 모습일지도 보여주고자 합니다. 이 글에서는 그 기준 구현을 Haskell로 포팅하는 과정을 보실 수 있습니다. 사실은 먼저 C# 코드를 F#으로 포팅했고, 그 F# 코드를 가이드로 삼아 동등한 Haskell 코드를 구현했습니다.
Git 저장소로 따라오시는 분들을 위해 말씀드리면, 이 코드는 .NET 저장소와는 별개의 저장소입니다. 여기 보이는 코드는 해당 저장소의 master 브랜치에서 가져왔습니다.
Haskell에 관심이 없으시다면 언제든 ‘루트’ 글의 목차로 돌아가 관심 있는 다음 주제로 넘어가셔도 됩니다.
정적 타입의 함수형 언어인 Haskell에서 작업할 때는 보통 데이터 구조 선언부터 시작하는 게 가장 자연스럽습니다.
haskelldata User = User { userName :: String , userScrobbleCount :: Int } deriving (Show, Eq)
이는 F#이나 C#의 레코드 선언과 유사하며, F#과 C#의 해당 타입을 그대로 반영합니다. 가장 큰 차이점은 여기서 사용자의 총 스크로블 수를 TotalScrobbleCount가 아니라 userScrobbleCount라고 부른다는 점입니다. 그렇게 한 이유는 Haskell의 데이터 ‘게터’가 사실상 최상위 함수이기 때문입니다. 따라서 보통 그 함수가 다루는 데이터 구조의 이름을 접두사로 붙이는 게 좋습니다. 이 데이터 구조의 이름이 User이므로, 두 ‘게터’ 모두 user 접두사를 가집니다.
userTotalScrobbleCount는 제 취향에 다소 장황하게 느껴졌기 때문에 Total을 생략했습니다. 그게 적절한지 여부는 두고 볼 일입니다. 프로그래밍에서 이름 짓기는 늘 어렵고, 처음부터 정답을 맞추지 못할 위험이 있습니다. 다만 재사용 라이브러리로 공개하는 게 아니라면 나중에 이름을 바꿀 여지는 남아 있습니다.
다른 두 데이터 구조도 꽤 비슷합니다:
haskelldata Song = Song { songId :: Int , songHasVerifiedArtist :: Bool , songRating :: Word8 } deriving (Show, Eq) data Scrobble = Scrobble { scrobbledSong :: Song , scrobbleCount :: Int } deriving (Show, Eq)
scrobbledSong이 scrobbleSong보다 더 설명적이라고 생각해서, 관용적 네이밍에서 약간 일탈했습니다. 문제가 되지는 않았지만, 여전히 좋은 결정이었는지는 확신이 없습니다.
C#의 인터페이스를 Haskell로 어떻게 옮길까요? 타입 클래스는 C#이나 Java의 인터페이스와 완전히 같지는 않지만, 그 역할을 수행하기에는 충분히 가깝습니다. Haskell에서 이런 타입 클래스를 관용적이라 보지는 않지만, C# 인터페이스의 대응물로는 제법 잘 맞습니다.
haskellclass SongService a where getTopListeners :: a -> Int -> IO [User] getTopScrobbles :: a -> String -> IO [Scrobble]
SongService 클래스의 어떤 인스턴스든 특정 곡의 상위 리스너 조회와 특정 사용자의 상위 스크로블 조회를 지원합니다.
다시 말해, 가능하다면 이 타입 클래스를 오래 유지할 생각은 없지만, 교육적 이유로 향후 리팩터링에서도 한동안 유지할 예정입니다. 그렇게 하면 Haskell 코드를 C#과 F# 동료 코드와 비교·대조해 보실 수 있습니다.
테스트를 지원하기 위해 테스트 더블이 필요했고, 결정론적인 인메모리 인스턴스일 뿐인 페이크 서비스를 정의했습니다. 이 타입 자체는 두 개의 맵을 감싼 래퍼일 뿐입니다.
haskelldata FakeSongService = FakeSongService { fakeSongs :: Map Int Song , fakeUsers :: Map String (Map Int Int) } deriving (Show, Eq)
동등한 C# 클래스와 마찬가지로, fakeSongs는 곡 ID에서 Song으로의 맵이고, fakeUsers는 조금 더 복잡합니다. 사용자 이름을 키로 하는 맵이며 값은 또 다른 맵입니다. 그 내부 맵의 키는 곡 ID이고, 값은 해당 사용자가 그 곡을 스크로블한 횟수입니다.
FakeSongService 데이터 구조는 다음과 같이 명시적으로 구현해 SongService 인스턴스가 됩니다:
haskellinstance SongService FakeSongService where getTopListeners srvc sid = do return $ uncurry User <$> Map.toList (sum <$> Map.filter (Map.member sid) (fakeUsers srvc)) getTopScrobbles srvc userName = do return $ fmap (\(sid, c) -> Scrobble (fakeSongs srvc ! sid) c) $ Map.toList $ Map.findWithDefault Map.empty userName (fakeUsers srvc)
특정 곡의 상위 리스너를 찾기 위해서는, 내부 맵에 해당 곡 ID(sid)가 있는 모든 fakeUsers를 찾아 그 사용자들의 스크로블 수를 합산하고, 그 데이터로 User 값을 만듭니다.
특정 사용자의 상위 스크로블을 찾기 위해서는, fakeUsers 맵에서 사용자를 찾고 그 사용자가 스크로블한 각 곡을 fakeSongs에서 조회하여 Scrobble 값을 만듭니다.
마지막으로, 테스트 코드는 FakeSongService 값에 데이터를 추가하는 방법이 필요합니다. 아래 테스트 전용 도우미 함수가 그 역할을 합니다:
haskellscrobble userName s c (FakeSongService ss us) = let sid = songId s ss' = Map.insertWith (\_ _ -> s) sid s ss us' = Map.insertWith (Map.unionWith (+)) userName (Map.singleton sid c) us in FakeSongService ss' us'
사용자 이름, 곡, 스크로블 수, 그리고 FakeSongService를 받으면, 기존 데이터에 새 데이터를 추가한 새로운 FakeSongService 값을 반환합니다.
F# 테스트 코드에서는 FsCheck을 사용해 좋은 커버리지를 얻었습니다. Haskell에서는 QuickCheck을 사용할 것입니다.
F# 테스트의 아이디어를 옮겨와 사용자 이름용 QuickCheck 제너레이터를 정의합니다:
haskellalphaNum :: Gen Char alphaNum = elements (['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9']) userName :: Gen String userName = do len <- choose (1, 19) first <- elements $ ['a'..'z'] ++ ['A'..'Z'] rest <- vectorOf len alphaNum return $ first : rest
이 알고리즘이 ‘반드시’ 영숫자이며 첫 글자가 문자이고 길이가 20 이하인 사용자 이름에서만 동작하기 때문은 아닙니다. 다만 어떤 성질이 위배되었을 때, 줄바꿈이나 출력 불가능한 문자가 섞인 값보다는, 차라리 "Yvj0D1I"나 "tyD9P1eOqwMMa1Q6u" 같은 값을 보는 편이 낫습니다(이것들만 해도 이미 충분히 안 좋긴 합니다).
QuickCheck을 사용할 때는 테스트 대상 시스템(SUT)의 타입을 테스트 전용 Arbitrary 래퍼로 감싸는 것이 종종 유용합니다:
haskellnewtype ValidUserName = ValidUserName { getUserName :: String } deriving (Show, Eq) instance Arbitrary ValidUserName where arbitrary = ValidUserName <$> userName
또 Song에 대해 더 단순한 Arbitrary 인스턴스인 AnySong도 정의했습니다.
FakeSongService를 준비한 뒤 F# 테스트 코드의 맨 위부터 내려오며 가능한 한 충실히 하나씩 번역했습니다. 첫 번째는 시스템이 존재하고 호출 시 크래시하지 않는지만 확인하는 아이스 브레이커 테스트입니다.
haskelltestProperty "No data" $ \ (ValidUserName un) -> ioProperty $ do actual <- getRecommendations emptyService un return $ null actual
적어도 2019년부터 그랬던 것처럼, 이번에도 익명 함수로 테스트 케이스를 인라인했습니다. 이번에는 QuickCheck 속성으로요. 위 속성은 데이터가 전혀 없는 FakeSongService를 만들고 추천을 요청합니다. 추천할 것이 없으므로 결과 actual은 비어 있어야(null) 한다고 기대합니다.
조금 더 복잡한 속성은 추천을 요청하기 전에 서비스에 일부 데이터를 추가합니다:
haskelltestProperty "One user,some songs" $ \ (ValidUserName user) (fmap getSong -> songs) -> monadicIO $ do scrobbleCounts <- pick $ vectorOf (length songs) $ choose (1, 100) let scrobbles = zip songs scrobbleCounts let srvc = foldr (uncurry (scrobble user)) emptyService scrobbles actual <- run $ getRecommendations srvc user assertWith (null actual) "Should be empty"
몇 가지 주목할 점이 있습니다. 우선 이 속성은 뷰 패턴을 사용해 Arbitrary 목록에서 곡 목록을 투영합니다. 여기서 getSong은 AnySong newtype 래퍼에 속한 ‘게터’입니다.
뷰 패턴은 단일 Arbitrary 인스턴스를 리스트로 ‘승격’하는 선언적 방법으로 꽤 유용합니다. 세 번째 속성에서는 한 걸음 더 나아갑니다:
(fmap getUserName -> NonEmpty users)
이는 단일 ValidUserName 래퍼를 리스트로 바꿔 줄 뿐만 아니라, NonEmpty로 투영함으로써 users가 공백이 아닌 리스트라고 선언합니다. QuickCheck은 이를 반영해 값을 생성합니다.
이 더 발전된 뷰 패턴을 실제로 어떻게 쓰는지 궁금하시다면 Git 저장소를 참고하세요.
둘째, "One user, some songs" 테스트는 monadicIO에서 실행됩니다. 이 글을 쓰기 전까지는 이런 것이 있는 줄 몰랐습니다. pick, run, assertWith와 함께 monadicIO는 Test.QuickCheck.Monadic에 정의되어 있습니다. 이를 통해 IO에서 동작하는 속성을 작성할 수 있습니다. 본문의 속성들은 getRecommendations가 IO에 묶여 있기 때문에 그렇게 해야 합니다.
코드베이스에는 QuickCheck 속성이 하나 더 있지만, 여기서 이미 보여준 기법을 반복할 뿐입니다. 필요하시다면 모든 세부사항은 Git 저장소에서 확인하십시오.
속성(property)들 외에 F#의 예제(즉, ‘보통’의 단위 테스트)도 포팅했습니다. 그중 하나는 다음과 같습니다:
haskell"One verified recommendation" ~: do let srvc = scrobble "ana" (Song 2 True 5) 9_9990 $ scrobble "ana" (Song 1 False 5) 10 $ scrobble "cat" (Song 1 False 6) 10 emptyService actual <- getRecommendations srvc "cat" [Song 2 True 5] @=? actual
이 예제는 간단합니다. 하지만 원래 코드의 특성화에서 이미 논의했듯, 몇몇 예제는 구현의 특이점을 사실상 문서화하고 있습니다. 해당 테스트를 Haskell로 옮기면 다음과 같습니다:
haskell"Only top-rated songs" ~: do -- 평점을 10 이하로 유지하기 위해 스케일링합니다. let srvc = foldr (\i -> scrobble "hyle" (Song i True (toEnum i `div` 2)) 500) emptyService [1..20] actual <- getRecommendations srvc "hyle" assertBool "Should not be empty" (not $ null actual) -- 사용자 한 명이지만 곡이 20개이므로, 구현은 동일한 곡들을 20번 순회하여 -- 총 400개의 곡(중복 포함)을 얻게 됩니다. 평점으로 정렬한 후에는 상위 200개만 남고, -- 즉 평점 5~10에 해당하는 곡들만 남습니다. 이는 특성화 테스트로, -- 실제 추천 시스템이 반드시 이렇게 동작해야 한다는 의미는 아닙니다. assertBool "Should have 5+rating" (all ((>= 5) . songRating) actual)
이 테스트는 한 사용자에 대해 20개의 스크로블을 만듭니다. 평점 0인 곡 하나, 평점 1인 곡 둘, 평점 2인 곡 둘, …, 평점 10인 곡 하나까지요.
GetRecommendationsAsync의 구현은 이 20개의 곡을 사용해, 해당 상위 곡들을 함께 들은 ‘다른 사용자’를 찾습니다. 이 경우 사용자는 하나뿐이므로, 그 스무 곡 각각에 대해 동일한 스무 곡을 얻게 되어 총 400개가 됩니다.
이 외에도 단위 테스트가 더 있습니다. 모두 Git 저장소에서 확인하실 수 있습니다.
C#과 F#의 ‘기준 구현’을 제가 생각하기에 가장 직접적으로 번역하면 다음과 같습니다:
haskellgetRecommendations srvc un = do -- 1. 사용자의 상위 스크로블 가져오기 -- 2. 같은 곡을 들은 다른 사용자 찾기 -- 3. 그 사용자들의 상위 스크로블 가져오기 -- 4. 곡을 집계하여 추천 목록 만들기 -- 비순수(IO) scrobbles <- getTopScrobbles srvc un -- 순수 let scrobblesSnapshot = take 100 $ sortOn (Down . scrobbleCount) scrobbles recommendationCandidates <- newIORef [] forM_ scrobblesSnapshot $ \scrobble -> do -- 비순수(IO) otherListeners <- getTopListeners srvc $ songId $ scrobbledSong scrobble -- 순수 let otherListenersSnapshot = take 20 $ sortOn (Down . userScrobbleCount) $ filter ((10_000 <=) . userScrobbleCount) otherListeners forM_ otherListenersSnapshot $ \otherListener -> do -- 비순수(IO) otherScrobbles <- getTopScrobbles srvc $ userName otherListener -- 순수 let otherScrobblesSnapshot = take 10 $ sortOn (Down . songRating . scrobbledSong) $ filter (songHasVerifiedArtist . scrobbledSong) otherScrobbles forM_ otherScrobblesSnapshot $ \otherScrobble -> do let song = scrobbledSong otherScrobble modifyIORef recommendationCandidates (song :) recommendations <- readIORef recommendationCandidates -- 순수 return $ take 200 $ sortOn (Down . songRating) recommendations
원래 구현을 가능한 한 가깝게 반영하기 위해, 중첩 루프를 돌며 점진적으로 값을 추가할 수 있도록 recommendationCandidates를 IORef로 선언했습니다. 코드 맨 끝부분의 modifyIORef가 리스트에 곡 하나를 추가합니다.
모든 루프가 끝나면 readIORef로 IORef에서 recommendations를 꺼냅니다.
보시면 알겠지만, 원래 C# 코드의 주석도 함께 옮겼습니다.
이 코드를 Haskell의 관용적 코드라고 보지는 않습니다. 이 글의 목표는 C# 코드를 가능한 한 충실히 반영하는 것이었습니다. 리팩터링을 시작하면 더 관용적인 구현들을 보시게 될 겁니다.
이 글을 포함해 앞선 두 편까지 합치면, 이후 코드 리팩터링의 기준점이 마련됩니다. 원래의 C# 코드는 관용적이라 볼 수 있지만, 이 Haskell 포팅본은 그렇지 않습니다. 그럼에도 C#과 F# 동료 코드와 충분히 비슷해서 세 가지를 비교·대조해 볼 수 있습니다.
특히 두 가지 설계 선택 때문에 이 Haskell 구현은 관용적이지 못합니다. 하나는 곡 리스트를 갱신하기 위해 IORef를 사용한 점이고, 다른 하나는 외부 의존성을 모델링하기 위해 타입 클래스를 사용한 점입니다.
이 연재에서 다양한 대안 아키텍처를 다루면서, 이 둘을 어떻게 제거할 수 있는지도 보여드리겠습니다.