오늘은 서비스에 하스켈에서 어니언 아키텍처를 어떻게 구현했는지 적어보려고 한다. 어니언 아키텍처는 헥사고날 아키텍처, 포트 어뎁터 아키텍처, 클린 아키텍처 등 여러 가지 아키텍처로 불리지만 사실 나는 세부적인 차이는 잘 모른다. (하지만 우리 서비스는 포트 어뎁터 아키텍처를 따르고 있다) 다만 이러한 아키텍처의 공통적으로 추구하는 방향은 전통적인 계층형 아키텍처에서 서비스라고 부르는 부분이 코드에서 인프라라고 볼 수 있는 코드에 의존성을 갖지 않도록 만드는 것이다. 이렇게 하면 HTTP나 GraphQL, gRPC와 같은 인터페이스 프로토콜 또는 데이터베이스와 같은 인프라 코드를 쉽게 교체할 수 있다.
서비스 또는 유즈 케이스라고 부르는 영역이 인프라 코드에 의존성을 갖지 않게 만드는 가장 쉬운 방법은 사용하는 인프라 코드를 인터페이스로 사용하는 것이다. 그리고 객체 지향 프로그래밍에서는 구현체를 외부에서 받아 사용하면 된다. 대충 자바로 예제를 만들어 봤다. (마크 다운 에디터에서 그냥 친 것이라 동작하지 않을 수도 있다.)
class User {
private Int id;
private String name;
public User(Int id, String name) {
this.id = id;
this.name = name;
}
public Int getId() {
return id;
}
public String getName() {
return name;
}
}
interface UserRepository {
boolean save(User user);
}
class UserService {
private UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public boolean createUser(Int id, String name) {
User user = new User(id, name);
return userRepository.save(user);
}
}
class StubUserRepository implements UserRepository {
boolean save(User user) {
System.out.println(user + "가 잘 저장되었다.");
return true;
}
}
class Main {
static void main() {
UserRepository userRepository = new StubUserRepository();
UserService service = new UserService(userRepository);
service.createUser(1, "Eunmin");
}
}
위 예제에서 UserRepository
를 인터페이스로 UserService
에서 사용한다면 UserService
가 구현체의 직접 의존하지 않는다. 그리고 쉽게UserRepository
를 바꿀 수 있다.
자바의 예제에서 어니언 아키텍처를 구성하는 핵심적인 기술은 인터페이스이다. 하스켈도 타입에 따른 다형성을 구현하기 위해 인터페이스와 유사한 타입 클래스라는 기술을 제공한다. 위의 자바 예제를 하스켈 타입 클래스로 그대로 옮겨보면 다음과 같다.
data User = User
{ userId :: Int
, userName :: String
} deriving (Eq, Show)
class UserRepository a where
save :: a -> User -> IO Bool
-- UserService
createUser :: (UserRepository a) => a -> Int -> String -> IO Bool
createUser repo userId userName = save repo user
where
user = User userId userName
data MockUserRepository = MockUserRepository
instance UserRepository MockUserRepository where
save repo user = do
putStrLn $ show user ++ "가 잘 저장되었다."
pure True
main :: IO ()
main = do
let userRepository = MockUserRepository
createUser userRepository 1 "Eunmin"
pure ()
하스켈은 값을 클래스로 나눠 담지 않고 모듈로 나눠서 담는데 여기서는 편의상 하나의 모듈에 다 작성했다. 여기서 핵심적인 부분은 UserRepository
타입 클래스를 만들고 MockUserRepository
라는 타입의 UserRepository
타입 클래스 인스턴스를 만들어서 서비스 함수인 createUser
에 UserRepository
를 인자로 넘겼다는 점이다. 인자로 넘기는 것이 좋지 않아 보인다면 하스켈에서 많이 사용하는 Reader 모나드를 이용하면 된다. 다음은 Reader 모나드를 이용해 UserRepository
를 인자로 받지 않고 모나드 컨택스트에서 가져오는 예제이다.
data User = User
{ userId :: Int
, userName :: String
} deriving (Eq, Show)
class UserRepository a where
save :: a -> User -> IO Bool
type App = ReaderT MockUserRepository IO
-- UserService
createUser :: Int -> String -> App Bool
createUser userId userName = do
repo <- ask
liftIO $ save repo user
where
user = User userId userName
data MockUserRepository = MockUserRepository
instance UserRepository MockUserRepository where
save repo user = do
putStrLn $ show user ++ "가 잘 저장되었다."
pure True
main :: IO ()
main = do
let userRepository = MockUserRepository
runReaderT (createUser 1 "Eunmin") userRepository
pure ()
createUser
함수의 모나드 컨텍스트에서 IO
효과와 Reader
효과를 함께 사용하기 위해 IO
모나드를 베이스 모나드로 한 Reader
모나드 트랜스포머인 ReaderT
를 사용했다. 중요한 점은 createUser
가 UserRepository
를 인자로 받지 않고 모나드 컨텍스트 안에서 Reader
모나드 함수인 ask
를 통해 가져와서 사용한다는 점이다.
이 정도로 바꾼 코드는 자바의 의존성 주입과 비슷하다. 하지만 실제 하스켈 개발자라면 이렇게 구성하지는 않을 것이다. 하스켈은 타입 시스템이 잘 되어 있고 타입에 따른 다형성도 다른 언어보다 더 자유롭게 사용할 수 있다. 만약 UserRepository
의 결정을 런타임에 하지 않을 것이라고 가정한다면 UserRepository
구현체를 타입에 따라 컴파일 시점에 결정할 수 있다. 물론 런타임에 UserRepository
구현체를 변경해야 한다면 이 방법을 사용할 수는 없다. 다음은 타입에 따라 컴파일 시점에 값이 결정되는 간단한 예제다.
class Foo a where
foo :: a
instance Foo Int where
foo = 42
instance Foo Double where
foo = 36.5
-- >>> foo :: Int
-- 42
-- >>> foo :: Double
-- 36.5
이러한 타입 시스템의 장점을 활용해 어니언 아키텍처를 다른 방법으로 구현할 수 있다. 다음 코드를 보자.
data User = User
{ userId :: Int
, userName :: String
} deriving (Eq, Show)
class UserRepository m where
save :: User -> m Bool
-- UserService
createUser :: (UserRepository m) => Int -> String -> m Bool
createUser userId userName = do
save user
where
user = User userId userName
instance UserRepository App where
save user = do
liftIO $ putStrLn $ show user ++ "가 잘 저장되었다."
pure True
newtype App a = App { unApp :: IO a }
deriving (Functor, Applicative, Monad, MonadIO)
main :: IO ()
main = do
unApp $ createUser 1 "Eunmin"
pure ()
이 코드에서는 createUser
의 모나드 컨텍스트가 타입 변수화되어 있다. 그리고 타입 변수는 main
함수에서 unApp
함수에 createUser
의 결과 인 m Bool
을 넘길 때 App
으로 컴파일러에 의해 결정된다. 왜냐하면 unApp
은 App a -> ReaderT MockUserRepository IO a
이기 때문이다. 이렇게 하면 save
라는 메서드는 인자 값이나 Reader
모나드에서 받는 값에 따라 결정되지 않고 m
타입 변수의 타입에 따라 구현체가 결정된다.
한 가지 주목해야 할 점은 타입 변수 m
은 타입 변수를 하나 받는 타입 생성자여야 한다는 점이다. 왜냐하면 m
을 사용하는 곳에 m Bool
처럼 타입 변수를 하나 받기 때문이다. 아쉽게도 많은 언어에서 타입 생성자를 타입 변수로 받을 수 없어 사용하기가 불편하다.
이러한 방법을 Tagless Final이라고도 하는데 보통 함수형 프로그래밍 언어에서 내부 DSL을 만들 때 Tagless Final 기법을 많이 사용한다. 스칼라도 타입 생성자를 타입 변수로 쓸 수 있기 때문에 비슷한 방식으로 어니언 아키텍처를 구성한다. 다만 타입 추론에 의해 구현체를 결정하지 않고 implicit를 통해 구현체를 결정할 수 있다는 점이 하스켈과 조금 다른 점이다.
위 예제에서 만약 다른 구현체를 사용하려고 한다면 App
대신 다른 타입을 만들고 해당 타입에 대한 구현체를 구현해서 연결해주면 된다.
newtype TestApp a = TestApp { unTestApp :: IO a }
deriving (Functor, Applicative, Monad, MonadIO)
instance UserRepository TestApp where
save user = do
liftIO $ putStrLn $ show user ++ "가 잘 저장되었다. (테스트!!!)"
pure True
main :: IO ()
main = do
unTestApp $ createUser 1 "Eunmin"
pure ()