오늘은 서비스에 하스켈에서 어니언 아키텍처를 어떻게 구현했는지 적어보려고 한다. 어니언 아키텍처는 헥사고날 아키텍처, 포트 어뎁터 아키텍처, 클린 아키텍처 등 여러 가지 아키텍처로 불리지만 사실 나는 세부적인 차이는 잘 모른다. (하지만 우리 서비스는 포트 어뎁터 아키텍처를 따르고 있다) 다만 이러한 아키텍처의 공통적으로 추구하는 방향은 전통적인 계층형 아키텍처에서 서비스라고 부르는 부분이 코드에서 인프라라고 볼 수 있는 코드에 의존성을 갖지 않도록 만드는 것이다. 이렇게 하면 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 타입 클래스 인스턴스를 만들어서 서비스 함수인 createUserUserRepository를 인자로 넘겼다는 점이다. 인자로 넘기는 것이 좋지 않아 보인다면 하스켈에서 많이 사용하는 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를 사용했다. 중요한 점은 createUserUserRepository를 인자로 받지 않고 모나드 컨텍스트 안에서 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으로 컴파일러에 의해 결정된다. 왜냐하면 unAppApp 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 ()
profile
Functional Programmer @Constacts, Inc.

0개의 댓글