의존성 주입 코드 관점에서 맛보기

CHEESE·2023년 8월 7일
0

OSSCA 2023

목록 보기
5/7
post-thumbnail

의존성 주입

(의존성 주입을 처음 접하는 분들은) 의존성 주입 또는 DI 라는 키워드로 검색하면 많은 자료가 있습니다.
참고 자료를 읽고 생각을 정리해보자.
우리는 좀 더 코드의 관점으로 살펴보자.

식당 예약하는 프로그램을 개발할 때

아래 usecase대로 동작하는 함수를 작성한다고 가정하자.

1. 식당 목록을 조회해서 보여준다.
2. 예약 정보를 입력받는다.
3. 입력받은 예약 정보를 기록한다.

1번의 식당 목록을 조회는 데이터베이스를 통해서,
2번의 입력받은 예약 정보를 기록은 파일에 기록한다면?
우리의 프로그램은 데이터베이스와 파일 2가지 의존성을 갖는 프로그램이다.

두 가지의 의존성을 주입하는 코드를 아래와 같이 작성할 수 있다.

usecase(식당가져오기, 예약정보기록하기)

위 코드를 provide를 통한 의존성 주입으로 표현하면 아래와 같이 코드를 작성할 수 있다.

usecase.provide(식당가져오기, 예약정보기록하기)

뭐가 달라?

기본적으로 동작하는 방식은 동일하다.
의존성 주입을 통해 코드를 표현하면 함수에 넘기는 파라미터와 분리해서 표현할 수 있기 때문에 코드의 가독성이 높아진다.

// 사용자 이름 파라미터(userName)가 추가된다면
// 어느 코드의 가독성이 더 좋아보이나요?
usecase(userName, 식당가져오기, 예약정보기록하기)         // (1)
usecase(userName).provide(식당가져오기, 예약정보기록하기) // (2)

더 나아가기

(1) 공덕식당저장소

import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault}

object DiExam extends ZIOAppDefault {
  class 공덕식당저장소 {
    def 모든식당이름가져오기() = ZIO.succeed(List("김밥집", "피자집", "샌드위치집"))

    def 식당메뉴가져오기(name: String) = ZIO.succeed(List("참치김밥", "계란김밥", "매운김밥"))
  }

  def 예약정보등록하기(input: String) = ZIO.succeed(true)

  // (2)
  def useCase(repo: 공덕식당저장소) = for {
    _ <- ZIO.unit
    list <- repo.모든식당이름가져오기()
    _ <- zio.Console.printLine(list)
    input <- zio.Console.readLine("입력 : ")
    _ <- 예약정보등록하기(input)
  } yield ()

  override def run = for {
    _ <- ZIO.unit
    _ <- useCase(new 공덕식당저장소) // (1)
  } yield ()
}

(1)에서 주입한 의존성은 (2)에서 필요할 때 자유롭게 사용할 수 있게 된다.
다만, 이 프로그램은 공덕에 있는 식당만 조회할 수 있어서 불편한데? 🤔

(2) 공덕식당저장소와 판교식당저장소

import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault}

object DiExam extends ZIOAppDefault {
  class 공덕식당저장소 {
    def 모든식당이름가져오기() = ZIO.succeed(List("김밥집", "피자집", "샌드위치집"))

    def 식당메뉴가져오기(name: String) = ZIO.succeed(List("참치김밥", "계란김밥", "매운김밥"))
  }

  class 판교식당저장소 {
    def 모든식당이름가져오기() = ZIO.succeed(List("카카오국밥", "네이버국밥", "엔씨국밥"))

    def 식당메뉴가져오기(name: String) = ZIO.succeed(List("라이언국밥", "춘식국밥"))
  }

  def 예약정보등록하기(input: String) = ZIO.succeed(true)

  def useCase(repo: 공덕식당저장소) = for { // (1)
    _ <- ZIO.unit
    list <- repo.모든식당이름가져오기()
    _ <- zio.Console.printLine(list)
    input <- zio.Console.readLine("입력 : ")
    _ <- 예약정보등록하기(input)
  } yield ()

  override def run = for {
    _ <- ZIO.unit
    _ <- useCase(new 공덕식당저장소) // (2)
  } yield ()
}

공덕식당저장소를 그대로 복사해서 판교식당저장소 class를 만들었다.
프로그램을 수정해서 판교에 있는 식당을 조회하려면 2가지 작업을 해줘야 한다.

  • (1)에 있는 useCase의 파라미터 타입을 판교식당저장소로 변경
  • (2)에서 생성하는 타입을 판교식당저장소로 변경
    조금 간단하게 할 수는 없을까? 🤔

공덕과 판교 말고도 새로운 지역의 식당 저장소를 여러 개 만들고 싶은데,
식당 저장소마다 식당 이름 가져오기와, 메뉴 가져오기를 공통적으로 갖고 있다면
이를 표준으로 만들 수는 없을까? 🤔

(3) trait 사용하기

import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault}

object DiExam extends ZIOAppDefault {

  trait 식당저장소 {
    def 모든식당이름가져오기(): ZIO[Any, Nothing, List[String]]

    def 식당메뉴가져오기(name: String): ZIO[Any, Nothing, List[String]]
  }

  class 공덕식당저장소 extends 식당저장소 {
    def 모든식당이름가져오기() = ZIO.succeed(List("김밥집", "피자집", "샌드위치집"))

    def 식당메뉴가져오기(name: String) = ZIO.succeed(List("참치김밥", "계란김밥", "매운김밥"))
  }

  class 판교식당저장소 extends 식당저장소 {
    def 모든식당이름가져오기() = ZIO.succeed(List("카카오국밥", "네이버국밥", "엔씨국밥"))

    def 식당메뉴가져오기(name: String) = ZIO.succeed(List("라이언국밥", "춘식국밥"))
  }

  def 예약정보등록하기(input: String) = ZIO.succeed(true)

  // (1)
  def useCase(repo: 식당저장소) = for {
    _ <- ZIO.unit
    list <- repo.모든식당이름가져오기()
    _ <- zio.Console.printLine(list)
    input <- zio.Console.readLine("입력 : ")
    _ <- 예약정보등록하기(input)
  } yield ()

  override def run = for {
    _ <- ZIO.unit
    _ <- useCase(new 공덕식당저장소) // (2)
  } yield ()
}

식당저장소 trait을 만들어서 모든 식당저장소가 갖춰야 할 메소드의 목록과 표준을 정했다. (추상화)
모든 식당저장소 class들은 식당저장소 trait을 extends하여 기능을 구현한다.
trait을 extends하는 클래스는 trait에 선언된 모든 함수들을 구현할 의무가 있다.

(1)에서 useCase가 받는 파라미터 타입이 식당저장소 trait 타입으로 변경되었다.
(2)에 넘겨주는 인자를 식당저장소를 extends하는 어느 클래스를 넣어도 정상적으로 동작하는 것을 확인할 수 있다.

  • 이제 새로운 식당저장소 class를 만들어서 동작하는지 확인해보자.

(4) zLayer 사용하기

import zio.{ZIO, ZIOAppDefault, ZLayer}
import java.io.IOException

object DiExam2 extends ZIOAppDefault {

  trait 식당저장소 {
    def 모든식당이름가져오기(): ZIO[Any, Nothing, List[String]]

    def 식당메뉴가져오기(name: String): ZIO[Any, Nothing, List[String]]
  }

  class 공덕식당저장소 extends 식당저장소 {
    def 모든식당이름가져오기() = ZIO.succeed(List("김밥집", "피자집", "샌드위치집"))

    def 식당메뉴가져오기(name: String) = ZIO.succeed(List("참치김밥", "계란김밥", "매운김밥"))
  }

  class 판교식당저장소 extends 식당저장소 {
    def 모든식당이름가져오기() = ZIO.succeed(List("카카오국밥", "네이버국밥", "엔씨국밥"))

    def 식당메뉴가져오기(name: String) = ZIO.succeed(List("라이언국밥", "춘식국밥"))
  }

  object 판교식당저장소 {
    val layer = ZLayer.succeed(new 판교식당저장소)
  }

  def 예약정보등록하기(input: String) = ZIO.succeed(true)

  def useCaseUsingZLayer(): ZIO[식당저장소, IOException, Unit] = for {
     repo <- ZIO.service[식당저장소]
    _ <- ZIO.unit
    list <- repo.모든식당이름가져오기()
    _ <- zio.Console.printLine(list)
    input <- zio.Console.readLine("입력 : ")
    _ <- 예약정보등록하기(input)
  } yield ()

  override def run = for {
    _ <- ZIO.unit
    _ <- useCaseUsingZLayer().provideLayer(판교식당저장소.layer)
  } yield ()
}

판교식당저장소 objectuseCaseUsingZLayer라는 ZIO 타입을 리턴하는 함수를 새로 만들었다.
식당저장소 타입을 인자로 받던 useCase 함수와는 다르게 이제 의존성과 파라미터를 분리할 수 있게 되었다.

useCaseUsingZLayer 함수의 리턴 타입을 잘 보면 ZIO[식당저장소, IOException, Unit], ZIO의 R타입이 식당저장소 타입인 것을 확인할 수 있다. 이 함수를 호출하는 방법은 평소와는 조금 다른데...?

_ <- useCaseUsingZLayer().provideLayer(판교식당저장소.layer)

이와 같이 평소 쓰던 함수 호출 뒤에 .provideLayer로 ZIO의 R타입에 해당하는 식당저장소 layer를 넘겨주는 것을 볼 수 있다.

0개의 댓글