Published on

Scala 3 given·extension로 Tagless Final DI

Authors

서버 애플리케이션에서 DI(의존성 주입)는 결국 “어떤 구현을 어디서 어떻게 조립할 것인가”의 문제입니다. Scala에서는 전통적으로 생성자 주입, 케이크 패턴, MacWire 같은 라이브러리, 또는 ZIO/Cats Effect 레이어로 풀어왔습니다.

Scala 3에서는 given/using(타입클래스/컨텍스트 파라미터)과 extension(확장 메서드)이 언어 차원에서 강화되면서, Tagless Final + 컴파일 타임 DI가 훨씬 자연스러워졌습니다. 이 글은 “효과 타입을 추상화한 알제브라(ports) + 구현(adapters) + 조립(wiring)”을 Scala 3 문법으로 깔끔하게 만드는 방법을 단계별로 설명합니다.

아래 패턴은 프레임워크에 덜 의존하고, 테스트가 쉽고, 모듈 경계가 명확해지는 장점이 있습니다. 특히 서비스가 커지면서 ‘무슨 구현이 주입되는지’가 흐려질 때, given 기반 조립은 코드 검색만으로도 의존성 그래프를 추적하기 쉽습니다.

관련해서 “성능/메모리 관점에서 컬렉션 처리 파이프라인을 어떻게 잡을까” 같은 고민은 언어가 달라도 비슷하게 반복됩니다. 대용량 처리 패턴은 Java 스트림 vs Kotlin Flow - 대용량 성능 튜닝도 같이 보면 사고방식이 연결됩니다.

Tagless Final DI의 핵심: 알제브라를 ‘능력(capability)’로 본다

Tagless Final은 대개 다음 형태를 가집니다.

  • 알제브라(인터페이스): trait Foo[F[_]] 처럼 효과 타입 F를 매개변수로 받음
  • 구현: Foo[IO], Foo[Future] 등 특정 효과에 대한 구현
  • 프로그램: def program[F[_]: Foo: Bar](...) 또는 def program[F[_]](using Foo[F], Bar[F])

이때 DI는 “프로그램이 요구하는 능력들을 using으로 받고, 애플리케이션 진입점에서 given으로 조립한다”로 귀결됩니다.

Scala 3에서는 컨텍스트 바운드보다 using/given을 직접 쓰는 편이 읽기 좋을 때가 많습니다. 특히 의존성이 많아질수록 using 블록이 “이 함수가 무엇을 필요로 하는지”를 한눈에 보여줍니다.

예제 도메인: 유저 가입(Email 발송 + 저장)

간단한 유저 가입 플로우를 예로 들겠습니다.

  • UserRepo[F]: 유저 저장/조회
  • Email[F]: 환영 이메일 발송
  • Clock[F]: 현재 시각
  • UserService[F]: 가입 유스케이스

효과 타입은 Cats Effect의 IO를 쓰는 예시로 가지만, 핵심은 “서비스는 IO를 직접 모르고 F[_]에 대해 작성된다”는 점입니다.

1) 알제브라 정의

package example

import java.time.Instant

final case class UserId(value: String)
final case class EmailAddress(value: String)
final case class User(id: UserId, email: EmailAddress, createdAt: Instant)

trait UserRepo[F[_]]:
  def findByEmail(email: EmailAddress): F[Option[User]]
  def insert(user: User): F[Unit]

trait Email[F[_]]:
  def sendWelcome(to: EmailAddress): F[Unit]

trait Clock[F[_]]:
  def now: F[Instant]

여기까지는 전형적인 Tagless Final입니다.

2) 도메인 에러 타입

DI와 별개로, “실패를 어떻게 표현하느냐”는 설계에 큰 영향을 줍니다. 예제에서는 간단히 ADT로 정의합니다.

package example

sealed trait SignupError extends Product with Serializable
object SignupError:
  case object EmailAlreadyExists extends SignupError

Scala 3 given으로 구현체 제공하기

이제 IO 기반 구현을 만들고, 이를 given으로 노출합니다.

1) 구현체 예시(메모리 저장소)

package example

import cats.effect.IO
import cats.effect.Ref

object InMemoryUserRepo:
  def make(ref: Ref[IO, Map[EmailAddress, User]]): UserRepo[IO] =
    new UserRepo[IO]:
      def findByEmail(email: EmailAddress): IO[Option[User]] =
        ref.get.map(_.get(email))

      def insert(user: User): IO[Unit] =
        ref.update(_ + (user.email -> user))

2) Email/Clock 구현체

package example

import cats.effect.IO
import java.time.Instant

object ConsoleEmail:
  given Email[IO] with
    def sendWelcome(to: EmailAddress): IO[Unit] =
      IO.println(s"Welcome: ${to.value}")

object SystemClock:
  given Clock[IO] with
    def now: IO[Instant] = IO(Instant.now())

여기서 중요한 점은, 구현을 given으로 만들면 “조립 시점에 스코프에 들어오는 것”만으로 주입이 끝난다는 것입니다.

extension으로 알제브라 사용성을 끌어올리기

Tagless Final을 쓰다 보면 repo.findByEmail(...) 같은 호출이 반복되고, using으로 받은 인스턴스를 매번 명시하기 귀찮아집니다. Scala 3 extension을 이용하면 “알제브라를 문법적으로 더 가볍게” 만들 수 있습니다.

아래는 UserRepo[F]에 대한 확장 메서드로, using에 있는 인스턴스를 가져와 호출을 위임합니다.

package example

object syntax:
  extension [F[_]](using repo: UserRepo[F])
    def findUserByEmail(email: EmailAddress) = repo.findByEmail(email)
    def insertUser(user: User) = repo.insert(user)

  extension [F[_]](using emailer: Email[F])
    def sendWelcomeEmail(to: EmailAddress) = emailer.sendWelcome(to)

  extension [F[_]](using clock: Clock[F])
    def nowInstant = clock.now

이렇게 하면 서비스 코드에서 repo/emailer/clock 변수를 굳이 들고 다니지 않아도 됩니다. “능력 함수”처럼 쓸 수 있어 프로그램이 더 선언적으로 보입니다.

유스케이스 작성: using으로 DI 받기

이제 가입 로직을 작성합니다. 서비스는 F[_]에 대해 작성되고, 필요한 능력은 using으로 받습니다.

package example

import cats.MonadThrow
import cats.syntax.all.*
import example.SignupError
import example.syntax.*

object UserService:
  def signup[F[_]](email: EmailAddress)(using
      F: MonadThrow[F],
      repo: UserRepo[F],
      emailer: Email[F],
      clock: Clock[F]
  ): F[User] =
    for
      existing <- findUserByEmail(email)
      _ <- existing match
        case Some(_) => F.raiseError(new RuntimeException(SignupError.EmailAlreadyExists.toString))
        case None    => F.unit

      now <- nowInstant
      user = User(UserId(java.util.UUID.randomUUID().toString), email, now)
      _ <- insertUser(user)
      _ <- sendWelcomeEmail(email)
    yield user

여기서는 단순화를 위해 RuntimeException으로 감쌌지만, 실무에서는 다음 중 하나를 권합니다.

  • EitherT[F, SignupError, A]로 도메인 에러를 타입으로 고정
  • Cats Effect IO.raiseError 대신 F가 제공하는 에러 채널을 일관되게 사용
  • ZIO라면 ZIO[R, SignupError, A]로 에러를 별도 타입으로 유지

핵심은 “서비스는 구현체를 몰라도 된다”는 것과 “필요한 능력은 시그니처에 드러난다”는 점입니다.

조립(wiring): given 스코프로 애플리케이션 구성하기

이제 진입점에서 모든 구현을 만들어 given으로 제공하면 됩니다.

package example

import cats.effect.*

object Main extends IOApp.Simple:
  def run: IO[Unit] =
    for
      ref <- Ref.of[IO, Map[EmailAddress, User]](Map.empty)

      // 로컬 given 스코프를 만들어 조립
      given UserRepo[IO] = InMemoryUserRepo.make(ref)
      given Email[IO] = ConsoleEmail.given_Email_IO
      given Clock[IO] = SystemClock.given_Clock_IO

      _ <- UserService.signup[IO](EmailAddress("dev@example.com")).attempt.flatMap(IO.println)
    yield ()

여기서 ConsoleEmail.given_Email_IO 같은 심볼은 컴파일러가 생성한 이름입니다. 실무에서는 다음처럼 given을 명시적으로 이름 붙여 노출하는 편이 더 깔끔합니다.

object ConsoleEmail:
  given consoleEmailIO: Email[IO] with
    def sendWelcome(to: EmailAddress): IO[Unit] =
      IO.println(s"Welcome: ${to.value}")

그리고 조립에서는 given Email[IO] = ConsoleEmail.consoleEmailIO처럼 가져오면 됩니다.

테스트: given 오버라이드로 대역 주입하기

Tagless Final DI의 가장 큰 실전 이점은 테스트가 단순해진다는 점입니다. 테스트 스코프에서 given을 바꿔치기하면 됩니다.

아래는 이메일 발송을 기록만 하는 테스트 대역입니다.

package example

import cats.effect.*
import cats.effect.Ref

final class RecordingEmail(ref: Ref[IO, Vector[EmailAddress]]) extends Email[IO]:
  def sendWelcome(to: EmailAddress): IO[Unit] =
    ref.update(_ :+ to)

object UserServiceSpec:
  def testSignupSendsEmail: IO[Unit] =
    for
      repoRef <- Ref.of[IO, Map[EmailAddress, User]](Map.empty)
      sentRef <- Ref.of[IO, Vector[EmailAddress]](Vector.empty)

      given UserRepo[IO] = InMemoryUserRepo.make(repoRef)
      given Email[IO] = new RecordingEmail(sentRef)
      given Clock[IO] with
        def now = IO.pure(java.time.Instant.parse("2020-01-01T00:00:00Z"))

      _ <- UserService.signup[IO](EmailAddress("a@b.com"))
      sent <- sentRef.get
      _ <- IO.raiseUnless(sent == Vector(EmailAddress("a@b.com")))(new Exception("email not sent"))
    yield ()

테스트에서 전역 컨테이너 설정, 리플렉션 기반 주입, 애노테이션 스캔 같은 것이 필요 없습니다. “그냥 스코프에 given을 올려놓으면 끝”입니다.

실무 패턴: 모듈 단위로 given 묶기

서비스가 커지면 Main에서 given을 일일이 나열하는 것이 지저분해집니다. 이때는 모듈 오브젝트를 만들고 “조립 단위”를 분리합니다.

package example

import cats.effect.*
import cats.effect.Ref

object LiveModule:
  final case class Resources(userRef: Ref[IO, Map[EmailAddress, User]])

  def resources: IO[Resources] =
    Ref.of[IO, Map[EmailAddress, User]](Map.empty).map(Resources.apply)

  def givens(res: Resources): Unit =
    given UserRepo[IO] = InMemoryUserRepo.make(res.userRef)
    given Email[IO] = ConsoleEmail.consoleEmailIO
    given Clock[IO] = SystemClock.given

다만 Scala에서 given은 “정의된 스코프”가 중요합니다. 위처럼 Unit을 반환하는 함수 안에서 given을 정의하면, 호출 지점의 스코프로 새어 나가지 않습니다. 보통은 다음 중 하나로 해결합니다.

  • object LiveModule 안에 given을 직접 두고, 필요한 리소스는 given으로도 제공
  • final case class Env(...)를 만들고, given 변환을 제공

예를 들어 Env를 중심으로 설계하면 조립이 안정적입니다.

package example

import cats.effect.*
import cats.effect.Ref

final case class Env(
  repo: UserRepo[IO],
  email: Email[IO],
  clock: Clock[IO]
)

object Env:
  def make: IO[Env] =
    for
      ref <- Ref.of[IO, Map[EmailAddress, User]](Map.empty)
    yield Env(
      repo = InMemoryUserRepo.make(ref),
      email = ConsoleEmail.consoleEmailIO,
      clock = SystemClock.given
    )

  given fromEnv(using env: Env): UserRepo[IO] = env.repo
  given fromEnvEmail(using env: Env): Email[IO] = env.email
  given fromEnvClock(using env: Env): Clock[IO] = env.clock

그리고 Main에서는 다음처럼 씁니다.

object Main extends IOApp.Simple:
  def run: IO[Unit] =
    Env.make.flatMap { env =>
      given Env = env
      UserService.signup[IO](EmailAddress("dev@example.com")).attempt.flatMap(IO.println)
    }

이 패턴은 “리소스 생명주기”와도 잘 맞습니다. 예를 들어 DB 풀, HTTP 클라이언트처럼 Resource[IO, A]로 관리해야 하는 것들을 Env.make에서 안전하게 조립할 수 있습니다.

흔한 함정과 디버깅 포인트

1) given 충돌(ambiguous given)

같은 타입에 대해 given Email[IO]가 두 개 스코프에 들어오면 컴파일 에러가 납니다. 해결책은 보통 다음입니다.

  • 더 좁은 스코프에서 필요한 것만 given으로 열기
  • import를 선택적으로 하거나, given을 이름 붙여 명시적으로 선택
  • “테스트 전용 given”은 테스트 파일 내부 로컬 스코프로 제한

2) extension 남용으로 의존성이 숨겨지는 문제

extension은 편하지만, 과도하게 쓰면 “이 함수가 무엇을 요구하는지”가 코드만 봐서는 흐려질 수 있습니다. 권장 기준은 이렇습니다.

  • 유스케이스/서비스의 공개 함수 시그니처에는 using을 명시적으로 유지
  • 내부 구현에서 반복 호출을 줄이는 용도로 extension 사용

3) 에러 채널을 예외로 뭉개지 않기

앞 예제처럼 RuntimeException으로 감싸면, 나중에 관측/리트라이/메트릭에서 고생합니다. 도메인 에러는 타입으로 남겨두는 편이 유지보수에 유리합니다. 인프라 장애(DB 타임아웃 등)와 비즈니스 오류(이미 존재하는 이메일 등)를 분리해두면 운영 시나리오가 단순해집니다.

운영 관점의 “원인 추적”은 결국 관측 가능성으로 귀결됩니다. 예를 들어 DB 데드락 같은 이슈를 파고드는 방식은 MySQL InnoDB 데드락(1213) 로그로 범인 찾기처럼 로그/상태를 구조적으로 읽는 습관이 중요합니다.

정리: Scala 3에서 Tagless Final DI를 쓰는 이유

  • given/using으로 컴파일 타임 DI를 구현할 수 있다
  • Tagless Final로 효과 타입을 추상화해 테스트/구현 교체가 쉽다
  • extension으로 호출부를 정리하되, 공개 API에서는 의존성을 숨기지 않는 균형이 필요하다
  • 조립은 Main에 몰아넣기보다 Env 같은 구성 객체로 모듈화하면 스케일링이 쉽다

이 접근은 “프레임워크가 주입해준다”가 아니라 “내가 스코프에 올린 given이 주입된다”라는 단순한 규칙 위에 서 있습니다. 규모가 커질수록 이 단순함이 디버깅 비용을 줄이고, 테스트를 빠르게 만들며, 의존성 경계를 더 명확하게 해줍니다.