Devin.KR

인터페이스 기반 설계 - 의존성 주입

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 불변 객체와 값 동등성을 살펴보았다. 도서 대출 기록을 불변 객체로 만들면 생성된 뒤의 상태를 안정적으로 다룰 수 있다. 그러나 기록을 어디서 가져오는지, 오늘을 어떤 날짜로 판단하는지는 객체의 불변성과 별개의 문제다. 같은 대출 기록이라도 실행 날짜나 저장소 상태가 달라지면 연체료 계산 결과가 달라질 수 있다.

이 장에서는 작은 동네 도서관의 연체료 계산 서비스를 인터페이스 기반으로 설계한다. 서비스가 직접 시계를 읽거나 저장소를 만들지 않게 하고, 필요한 객체를 생성할 때 전달한다. 이렇게 하면 실제 실행에 쓰는 객체와 확인 작업에 쓰는 객체를 같은 자리에 연결할 수 있다. 중요한 것은 인터페이스의 개수가 아니라, 업무 규칙과 외부 환경의 책임을 구분하는 일이다.

  • 업무 규칙이 의존하는 외부 기능을 찾아 인터페이스로 경계를 표현한다.
  • 생성자 주입으로 객체가 필요한 의존성을 빠짐없이 받게 한다.
  • 시계와 저장소의 구현을 바꾸어도 연체료 계산 규칙을 유지한다.
  • 테스트 대역으로 날짜와 저장소 호출을 통제하고 콘솔 검사로 결과를 확인한다.

문제 상황

도서관에서는 반납 예정일이 지난 도서에 하루 500원의 연체료를 부과한다. 반납 예정일 당일에는 연체료가 없고, 그다음 날부터 하루씩 계산한다. 직원은 도서 번호를 입력하여 현재 대출의 연체료를 확인한다. 여기서는 도서 한 권에 현재 대출 기록이 최대 하나 있다고 가정한다.

처음에는 계산 메서드 안에서 저장소를 생성하고 DateTime.Today를 읽어도 별다른 문제가 없어 보인다. 하지만 반납 예정일이 1월 12일인 기록을 1월 15일 기준으로 확인하려 하면 문제가 드러난다. 실제 컴퓨터 날짜가 다르면 기대한 1,500원이 나오지 않는다. 날짜를 확인하기 위해 컴퓨터 설정을 바꾸는 일도 적절하지 않다.

저장소도 비슷하다. 서비스가 특정 저장소를 내부에서 생성하면 확인용 대출 기록을 넣기 어렵다. 실제 운영 데이터에 임시 기록을 추가하거나, 저장소를 사용할 수 있는 환경에서만 계산을 확인하게 된다. 저장 방식이 바뀔 때 계산 코드까지 손대야 할 수도 있다.

이 문제는 연체료 공식이 복잡해서 생긴 것이 아니다. 간단한 공식과 실행 환경이 한곳에 섞여 있기 때문에 생긴다. 날짜 제공과 기록 조회를 바깥으로 옮기고, 서비스에는 계산에 필요한 기능의 약속만 남긴다. 이후 구현을 선택하는 책임은 프로그램의 시작 부분으로 모은다.

인터페이스로 업무 경계를 나누기

의존성은 어떤 객체가 일을 수행하기 위해 필요로 하는 다른 객체다. 연체료 서비스에는 대출 기록을 조회하는 객체와 기준 날짜를 제공하는 객체가 필요하다. 여기서 인터페이스는 서비스가 요청할 수 있는 기능을 나타낸다. 저장소의 내부 자료구조나 날짜를 얻는 방법까지 알려 주지는 않는다.

인터페이스를 먼저 크게 설계하지 않는다. 현재 업무에 필요한 질문부터 적는다. 연체료 계산에는 “이 도서의 현재 대출 기록은 무엇인가”와 “오늘은 며칠인가”가 필요하다. 따라서 저장소에는 GetByBookId 메서드를, 시계에는 읽기 전용 Today 속성을 둔다. 대출 추가, 예약 변경, 파일 저장은 이 계산에 필요하지 않으므로 계약에 넣지 않는다.

연체료 계산에 필요한 책임과 경계
역할제공하는 것알 필요가 없는 것
연체료 서비스연체일 수와 금액 계산저장소의 내부 자료구조
ILoanRepository도서 번호에 해당하는 대출 기록연체료 계산 공식
IClock계산에 사용할 오늘 날짜도서 번호와 대출 기록
프로그램 시작 부분구현 선택과 객체 연결연체료 계산의 세부 절차

날짜는 DateOnly로 표현한다. 이번 업무는 시각이나 시간대 차이가 아니라 달력상의 날짜 차이를 계산하기 때문이다. 두 날짜의 DayNumber 차이를 구하면 경과한 날짜 수를 얻을 수 있다. 예정일 이전에는 차이가 음수가 되므로, 서비스에서 0과 비교하여 연체일 수가 음수가 되지 않게 한다.

계약에는 실패의 의미도 포함한다. 이 예제의 GetByBookId는 기록이 없으면 KeyNotFoundException을 발생시킨다. 기록이 없는 상태를 연체료 0원과 같게 취급하지 않는다. 0원은 존재하는 대출이 아직 연체되지 않았다는 뜻이다. 호출자는 이 둘을 구별할 수 있어야 한다.

연체료 서비스는 저장소와 시계의 인터페이스에 의존하고 구현은 각각 그 계약을 제공한다

그림에서 서비스가 의존하는 대상은 인터페이스다. 실제 실행 중에는 구현 객체의 메서드와 속성이 호출되지만, 서비스 코드에는 구현 클래스 이름이 나타나지 않는다. 이것이 구현을 바꾸어도 계산 코드를 유지할 수 있는 이유다. 단, 메서드 이름만 같으면 되는 것은 아니다. 기록이 없을 때의 처리처럼 계약의 의미도 일치해야 한다.

생성자 주입으로 객체를 연결하기

의존성 주입(dependency injection)은 객체가 필요한 의존성을 바깥에서 전달받는 방식이다. 이 장에서는 생성자 주입을 사용한다. LoanService를 만들 때 ILoanRepository와 IClock을 전달하며, 서비스는 이 참조를 읽기 전용 필드에 보관한다. 별도의 컨테이너나 외부 패키지가 필요한 것은 아니다.

Dim repository As ILoanRepository = New InMemoryLoanRepository(loans)
Dim clock As IClock = New FixedClock(New DateOnly(2026, 1, 15))
Dim service As New LoanService(repository, clock)

이 세 줄은 어떤 구현을 연결했는지 보여 준다. 인터페이스 형식의 변수에는 해당 인터페이스를 구현한 객체를 담을 수 있다. 서비스는 전달된 객체를 사용하며 자신의 내부에서 다른 저장소나 시계를 생성하지 않는다. 의존성을 생성하는 일과 사용하는 일이 분리된다.

생성자는 Nothing을 검사한다. 저장소 없이 만들어진 서비스는 정상적인 계산을 수행할 수 없으므로, 객체 생성 시점에 원인을 알려 주는 편이 낫다. 나중에 CalculateFee를 호출했을 때 참조 오류가 발생하면 잘못된 객체를 만든 지점과 실패한 지점이 멀어질 수 있다.

읽기 전용 필드는 연결 관계를 안정적으로 유지한다. 실행 도중 서비스의 Clock 속성에 다른 객체를 대입하는 설계라면, 호출 사이에 날짜 정책이 바뀔 수 있다. 생성자에서 필요한 객체를 모두 받고 교체가 필요하면 새 서비스를 만드는 방식은 객체의 사용 조건을 더 분명하게 한다. 읽기 전용 필드라고 해서 참조 대상 객체 자체가 불변이 되는 것은 아니다.

객체를 연결하는 장소를 구성 지점(composition root)이라고 부른다. 이 예제에서는 Sub Main이 그 역할을 맡는다. 계산 클래스 곳곳에서 구현을 선택하지 않고 시작 부분에서 선택하면, 프로그램이 어떤 환경을 사용하도록 구성되었는지 한곳에서 확인할 수 있다.

실제 컴퓨터의 날짜를 사용하려면 같은 위치에서 SystemClock을 전달하면 된다. 이 구현은 DateTime.Today를 DateOnly로 바꾼다. 운영 코드에서 날짜가 필요하다는 사실은 유지하면서, 계산 코드가 컴퓨터 시계를 직접 읽는 일은 피한다. 완성 프로그램에서는 출력이 실행 날짜에 따라 변하지 않도록 FixedClock을 사용한다.

운영에서는 “오늘”을 어느 지역의 날짜로 볼지도 결정해야 한다. 이 장의 SystemClock은 실행 컴퓨터의 지역 날짜를 사용한다. 서버 위치와 도서관의 날짜 기준이 다르다면 IClock 구현에서 그 정책을 처리할 수 있다. 연체료 서비스가 시간대 설정까지 맡을 필요는 없다.

테스트 대역으로 외부 조건 통제하기

테스트 대역(test double)은 확인 작업에서 실제 의존성을 대신하는 객체를 통칭한다. 인터페이스를 구현하면 서비스가 대역을 받아 사용할 수 있다. 대역의 목적은 운영 환경을 그대로 재현하는 것이 아니라, 확인하려는 조건을 명확하게 만드는 데 있다.

FixedClock은 생성자에서 받은 날짜를 계속 반환한다. 1월 15일 기준의 계산을 확인할 때 컴퓨터 날짜는 영향을 주지 않는다. InMemoryLoanRepository는 메모리에 넣어 둔 기록을 조회한다. 파일이나 데이터베이스 연결 없이도 서비스에 필요한 저장소 계약을 제공한다.

RecordingLoanRepository는 다른 저장소를 감싸고 조회 횟수를 기록한다. 기록 조회 자체는 안쪽 저장소에 맡기면서, 호출되었다는 사실을 관찰할 수 있게 한다. 이 장에서는 결과값을 정해 주는 대역과 호출을 기록하는 대역을 함께 사용한다.

이번 예제의 대역과 확인 가능한 조건
객체대역의 역할확인할 수 있는 것
FixedClock정해진 응답 제공특정 날짜 기준의 연체료
InMemoryLoanRepository단순한 저장소 구현준비한 대출 기록을 사용한 계산
RecordingLoanRepository호출 기록저장소에 요청한 조회 횟수

대역을 쓰더라도 업무 규칙을 대역 안에 복사해서는 안 된다. FixedClock은 날짜만 제공하고, 저장소는 기록만 제공한다. 500원을 곱하거나 예정일을 비교하는 일은 LoanService에 남긴다. 계산을 대역에도 넣으면 서비스가 잘못되어도 대역의 계산 때문에 확인이 통과할 수 있다.

같은 대출 기록과 계산 규칙에 서로 다른 고정 시계를 연결하면 기준 날짜에 따른 연체료를 재현할 수 있다

그림의 두 구성은 같은 대출 기록을 사용한다. 시계만 바꾸면 1월 15일에는 1,500원, 1월 16일에는 2,000원이 나온다. 날짜 변경을 계산 코드의 수정으로 처리하지 않고 객체 연결의 차이로 표현한 것이다.

다만 메모리 저장소로 확인한 결과가 실제 저장소의 연결이나 조회 동작까지 보장하지는 않는다. 대역은 서비스의 규칙과 연결 관계를 확인하는 데 사용한다. 실제 저장소 구현이 계약을 지키는지는 그 구현을 대상으로 별도로 확인해야 한다. 다음 장에서는 이러한 확인을 독립적인 단위 테스트로 구성하는 방법을 다룬다.

완성 코드

다음 전체 코드를 Program.vb에 넣는다. 대출 기록은 불변으로 유지하고, 연체료 서비스에는 저장소와 시계를 생성자로 전달한다. SystemClock도 포함하지만 Main에서는 사용하지 않는다. 모든 출력은 고정된 날짜와 준비된 기록으로 결정된다.

Option Strict On
Option Explicit On
Option Infer On

Imports System
Imports System.Collections.Generic
Imports System.Globalization

Public NotInheritable Class Loan
    Public ReadOnly Property BookId As Integer
    Public ReadOnly Property DueDate As DateOnly

    Public Sub New(bookId As Integer, dueDate As DateOnly)
        If bookId <= 0 Then
            Throw New ArgumentOutOfRangeException(NameOf(bookId))
        End If

        Me.BookId = bookId
        Me.DueDate = dueDate
    End Sub
End Class

Public Interface ILoanRepository
    Function GetByBookId(bookId As Integer) As Loan
End Interface

Public Interface IClock
    ReadOnly Property Today As DateOnly
End Interface

Public NotInheritable Class InMemoryLoanRepository
    Implements ILoanRepository

    Private ReadOnly _loans As New Dictionary(Of Integer, Loan)()

    Public Sub New(loans As IEnumerable(Of Loan))
        If loans Is Nothing Then
            Throw New ArgumentNullException(NameOf(loans))
        End If

        For Each loan As Loan In loans
            If loan Is Nothing Then
                Throw New ArgumentException(
                    "대출 기록에 Nothing이 포함되어 있다.", NameOf(loans))
            End If

            _loans.Add(loan.BookId, loan)
        Next
    End Sub

    Public Function GetByBookId(bookId As Integer) As Loan _
        Implements ILoanRepository.GetByBookId

        Dim loan As Loan = Nothing

        If Not _loans.TryGetValue(bookId, loan) Then
            Throw New KeyNotFoundException("대출 기록을 찾을 수 없다.")
        End If

        Return loan
    End Function
End Class

Public NotInheritable Class FixedClock
    Implements IClock

    Private ReadOnly _today As DateOnly

    Public Sub New(today As DateOnly)
        _today = today
    End Sub

    Public ReadOnly Property Today As DateOnly Implements IClock.Today
        Get
            Return _today
        End Get
    End Property
End Class

Public NotInheritable Class SystemClock
    Implements IClock

    Public ReadOnly Property Today As DateOnly Implements IClock.Today
        Get
            Return DateOnly.FromDateTime(DateTime.Today)
        End Get
    End Property
End Class

Public NotInheritable Class RecordingLoanRepository
    Implements ILoanRepository

    Private ReadOnly _inner As ILoanRepository
    Private _getCount As Integer

    Public Sub New(inner As ILoanRepository)
        If inner Is Nothing Then
            Throw New ArgumentNullException(NameOf(inner))
        End If

        _inner = inner
    End Sub

    Public ReadOnly Property GetCount As Integer
        Get
            Return _getCount
        End Get
    End Property

    Public Function GetByBookId(bookId As Integer) As Loan _
        Implements ILoanRepository.GetByBookId

        _getCount += 1
        Return _inner.GetByBookId(bookId)
    End Function
End Class

Public NotInheritable Class LoanService
    Private Const DailyFee As Decimal = 500D

    Private ReadOnly _repository As ILoanRepository
    Private ReadOnly _clock As IClock

    Public Sub New(repository As ILoanRepository, clock As IClock)
        If repository Is Nothing Then
            Throw New ArgumentNullException(NameOf(repository))
        End If

        If clock Is Nothing Then
            Throw New ArgumentNullException(NameOf(clock))
        End If

        _repository = repository
        _clock = clock
    End Sub

    Public Function CalculateFee(bookId As Integer) As Decimal
        Dim loan As Loan = _repository.GetByBookId(bookId)
        Dim today As DateOnly = _clock.Today
        Dim overdueDays As Integer =
            Math.Max(0, today.DayNumber - loan.DueDate.DayNumber)

        Return CDec(overdueDays) * DailyFee
    End Function
End Class

Module Program
    Sub Main()
        Dim loans As Loan() = {
            New Loan(101, New DateOnly(2026, 1, 12)),
            New Loan(102, New DateOnly(2026, 1, 20))
        }

        Dim storage As ILoanRepository =
            New InMemoryLoanRepository(loans)
        Dim recorder As New RecordingLoanRepository(storage)
        Dim clock As IClock =
            New FixedClock(New DateOnly(2026, 1, 15))
        Dim service As New LoanService(recorder, clock)

        Dim overdueFee As Decimal = service.CalculateFee(101)
        Dim futureFee As Decimal = service.CalculateFee(102)

        Dim nextClock As IClock =
            New FixedClock(New DateOnly(2026, 1, 16))
        Dim nextService As New LoanService(recorder, nextClock)
        Dim nextFee As Decimal = nextService.CalculateFee(101)

        Console.WriteLine("도서 101 연체료: " & FormatFee(overdueFee) & "원")
        Console.WriteLine("도서 102 연체료: " & FormatFee(futureFee) & "원")
        Console.WriteLine("다음 날짜의 도서 101 연체료: " & FormatFee(nextFee) & "원")

        Check(overdueFee = 1500D, "연체 3일 계산")
        Check(futureFee = 0D, "예정일 이전 계산")
        Check(nextFee = 2000D, "시계 교체")
        Check(recorder.GetCount = 3, "저장소 조회 횟수")

        Console.WriteLine(
            "저장소 조회 횟수: " &
            recorder.GetCount.ToString(CultureInfo.InvariantCulture))
    End Sub

    Private Function FormatFee(value As Decimal) As String
        Return value.ToString("0", CultureInfo.InvariantCulture)
    End Function

    Private Sub Check(condition As Boolean, name As String)
        If Not condition Then
            Throw New InvalidOperationException("검사 실패: " & name)
        End If

        Console.WriteLine("검사 통과: " & name)
    End Sub
End Module

줄별 해설

대출 기록과 인터페이스

Option Strict On은 암시적인 축소 변환과 늦은 바인딩을 제한한다. 이후 코드에서는 날짜를 DateOnly로, 금액을 Decimal로 명시한다. Option Infer On이 있어도 외부 계약과 주요 지역 변수의 형식은 읽기 쉽게 적었다.

Loan의 BookId와 DueDate는 읽기 전용 자동 구현 속성이다. 생성자에서만 값을 설정하므로 저장소가 반환한 뒤 서비스가 예정일을 변경할 수 없다. BookId가 0 이하이면 생성자에서 거부한다. 날짜가 유효한지는 DateOnly 생성자가 확인한다.

ILoanRepository의 반환 형식은 Loan이다. 서비스는 Dictionary를 직접 받지 않으며, 자료구조의 조회 문법도 알 필요가 없다. IClock.Today에는 설정자가 없다. 날짜를 요청하는 쪽이 시계의 기준 날짜까지 바꾸는 계약으로 만들지 않은 것이다.

저장소 구현과 호출 기록

InMemoryLoanRepository 생성자는 전달받은 열거 가능한 기록을 내부 Dictionary에 넣는다. 원본 배열을 그대로 보관하지 않으므로 이후 배열의 요소를 교체해도 저장소의 대응 관계가 바뀌지 않는다. 같은 도서 번호가 두 번 들어오면 Add가 예외를 발생시킨다. 예제에서 정한 “도서 한 권에 현재 대출 하나”라는 전제를 위반한 입력이다.

GetByBookId는 TryGetValue로 조회한다. 실패하면 계약에 따라 예외를 발생시키고, 성공하면 준비된 Loan을 반환한다. 서비스에서 Nothing 여부를 반복해서 확인하지 않아도 되도록, 정상 반환은 대출 기록이라는 의미를 유지한다.

RecordingLoanRepository는 ILoanRepository를 받아 _inner에 보관한다. 이 객체도 동일한 인터페이스를 구현하므로 LoanService의 저장소 자리에 들어갈 수 있다. _getCount를 먼저 증가시키기 때문에 실패한 조회도 요청 횟수에 포함된다. 완성 프로그램의 세 요청은 모두 성공한다.

GetCount는 확인 작업에서 사용할 수 있는 관찰용 속성이다. 서비스에는 이 속성이 보이지 않는다. 서비스가 받는 형식은 ILoanRepository이므로 대역의 기록 기능을 업무 계산에 사용할 수 없다.

시계와 연체료 계산

FixedClock.Today는 저장된 날짜를 반환한다. SystemClock.Today는 실제 컴퓨터의 날짜를 읽는다. 두 구현의 데이터 출처는 다르지만, 서비스가 읽는 속성의 형식과 의미는 같다.

LoanService 생성자의 Nothing 검사는 의존성 전달 실수를 일찍 드러낸다. _repository와 _clock은 인터페이스 형식이다. 생성자 본문에는 구체적인 저장소나 시계를 만드는 New 식이 없다.

CalculateFee는 먼저 대출을 조회한 뒤 오늘 날짜를 한 번 읽는다. 이어서 두 DayNumber의 차이를 계산하고 Math.Max로 음수를 0으로 바꾼다. 하루 단가의 D 접미사는 Decimal 리터럴을 나타낸다. CDec(overdueDays)는 날짜 수를 Decimal로 변환하여 금액 계산의 형식을 분명히 한다.

하루 500원이라는 정책은 서비스 내부의 상수로 두었다. 지금은 외부 환경에 따라 교체해야 하는 조건이 아니기 때문이다. 정책이 도서 종류나 회원 자격에 따라 달라지는 요구가 생기면 그때 책임을 다시 검토한다. 바뀔 가능성이 있다는 이유만으로 모든 값을 인터페이스로 감싸지는 않는다.

Main의 구성과 검사

Main은 저장소, 기록 대역, 고정 시계, 서비스를 순서대로 연결한다. recorder를 두 서비스가 공유하므로 조회 횟수는 합산된다. 고정 시계 두 개를 사용하여 날짜 조건을 각각 표현하며, 기존 서비스의 내부 상태는 수정하지 않는다.

결과를 먼저 지역 변수에 저장하고 Check에는 그 결과를 전달한다. 검사 과정에서 CalculateFee를 다시 호출하지 않으므로 조회 횟수는 세 번이다. 결과 확인을 위해 업무를 재실행하면 기록 횟수가 늘거나 다른 부수 효과가 생길 수 있다.

Check는 조건이 거짓이면 예외를 발생시켜 실행을 중단한다. 참이면 검사 이름을 출력한다. 이 함수는 테스트 프레임워크를 대신하려는 것이 아니라, 외부 패키지 없이 설계의 효과를 직접 확인하는 작은 장치다. FormatFee와 조회 횟수 출력은 지역 설정과 무관한 숫자 표기를 사용한다.

실행 결과

다음 명령으로 콘솔 프로젝트를 만든 뒤 생성된 Program.vb를 완성 코드로 교체한다. .NET 10 SDK가 설치된 환경에서 실행한다.

dotnet new console -lang VB -f net10.0 -n LibraryDesign
cd LibraryDesign
dotnet run

정상 실행 시 프로그램 출력은 다음과 같다. 복원이나 빌드에 관한 도구 메시지는 아래 프로그램 출력에 포함하지 않았다.

도서 101 연체료: 1500원
도서 102 연체료: 0원
다음 날짜의 도서 101 연체료: 2000원
검사 통과: 연체 3일 계산
검사 통과: 예정일 이전 계산
검사 통과: 시계 교체
검사 통과: 저장소 조회 횟수
저장소 조회 횟수: 3

도서 101의 예정일은 1월 12일이다. 1월 15일 기준으로 3일이 지났으므로 1,500원이다. 도서 102는 예정일이 1월 20일이라 0원이다. 같은 도서 101에 1월 16일 시계를 연결하면 4일분인 2,000원이 된다. 저장소는 각 계산에서 한 번씩, 총 세 번 호출된다.

실무에서 자주 틀리는 것

인터페이스 필드에 구현을 직접 생성하기

필드 형식이 인터페이스라고 해서 의존성이 바깥으로 분리된 것은 아니다. 다음은 LoanService 안에 놓인 잘못된 필드의 예다. 서비스가 시계 구현을 직접 선택하므로 고정 날짜를 전달할 수 없다.

Private ReadOnly _clock As IClock = New SystemClock()

고친 코드에서는 생성자가 시계를 받는다. 아래 조각은 완성 코드의 해당 필드와 생성자 부분에 대응한다. 저장소 검사는 생략하여 시계 전달 부분만 보였다.

Private ReadOnly _clock As IClock

Public Sub New(repository As ILoanRepository, clock As IClock)
    If clock Is Nothing Then
        Throw New ArgumentNullException(NameOf(clock))
    End If

    _repository = repository
    _clock = clock
End Sub

구현 선택은 Main으로 옮긴다. 실제 날짜가 필요하면 SystemClock을, 재현 가능한 조건이 필요하면 FixedClock을 전달한다. 서비스의 계산 메서드를 수정할 필요가 없다.

주입을 나중에 수행하도록 열어 두기

다음처럼 공개 속성으로 의존성을 받으면 객체를 생성한 직후 사용할 수 있는지 알기 어렵다. Clock을 설정하는 것을 잊거나 실행 도중 바꿀 수도 있다.

Public Property Clock As IClock

Public Function ReadToday() As DateOnly
    Return Clock.Today
End Function

고친 형태는 필요한 시계를 생성자에서 받고 읽기 전용 필드에 보관한다. 다음 조각은 날짜 제공 책임만 가진 작은 클래스의 전체 정의다.

Public NotInheritable Class DateReader
    Private ReadOnly _clock As IClock

    Public Sub New(clock As IClock)
        If clock Is Nothing Then
            Throw New ArgumentNullException(NameOf(clock))
        End If

        _clock = clock
    End Sub

    Public Function ReadToday() As DateOnly
        Return _clock.Today
    End Function
End Class

생성자 주입은 객체를 사용하는 데 필요한 조건을 생성자 서명에 드러낸다. 선택적인 설정값과 필수 의존성은 같은 방식으로 취급할 필요가 없다.

저장소의 실패를 연체료 0원으로 바꾸기

다음 조각은 CalculateFee 안의 잘못된 조회 처리다. 기록 없음뿐 아니라 예상하지 못한 실패까지 0원으로 바꾼다. 호출자는 정상적으로 계산된 것처럼 받아들일 수 있다.

Dim loan As Loan
Try
    loan = _repository.GetByBookId(bookId)
Catch ex As Exception
    Return 0D
End Try

고친 코드는 저장소 계약의 실패를 유지한다. 필요한 경우 호출자가 기록 없음 예외를 받아 안내할 수 있다. 계산 서비스에서 실패를 임의의 금액으로 바꾸지 않는다.

Dim loan As Loan = _repository.GetByBookId(bookId)
Dim today As DateOnly = _clock.Today
Dim overdueDays As Integer =
    Math.Max(0, today.DayNumber - loan.DueDate.DayNumber)

Return CDec(overdueDays) * DailyFee

화면에 오류 메시지를 보여 주는 정책과 연체료를 계산하는 규칙은 별개의 책임이다. 실패를 처리하더라도 정상 결과와 구별할 수 있는 방식이어야 한다.

한 계산에서 오늘 날짜를 여러 번 읽기

실제 시계는 호출 사이에 날짜가 바뀔 수 있다. 아래 조각은 예정일 비교와 날짜 차이 계산에서 Today를 따로 읽는다. 자정 부근에서는 두 읽기가 서로 다른 날짜를 반환할 수 있다.

If _clock.Today <= loan.DueDate Then
    Return 0D
End If

Return CDec(_clock.Today.DayNumber - loan.DueDate.DayNumber) * DailyFee

고친 코드는 한 작업의 기준 날짜를 지역 변수로 고정한다. FixedClock으로만 확인하면 드러나지 않는 문제이므로 실제 구현의 성질도 생각해야 한다.

Dim today As DateOnly = _clock.Today
Dim overdueDays As Integer =
    Math.Max(0, today.DayNumber - loan.DueDate.DayNumber)

Return CDec(overdueDays) * DailyFee

한 번 읽은 날짜는 그 계산의 기준이다. 여러 도서 전체를 동일한 날짜로 계산해야 한다는 요구가 추가되면, 개별 호출보다 바깥의 작업 단위에서 기준 날짜를 정하는 설계도 검토할 수 있다.

한눈에 보기

인터페이스 기반 설계에서 기억할 선택 기준
설계 요소이번 장의 선택얻는 효과
외부 의존의 경계저장소와 시계를 인터페이스로 표현계산과 환경의 책임 분리
필수 의존성 전달생성자 주입과 Nothing 검사사용 가능한 객체만 생성
구현 선택 위치Main에서 직접 연결실행 구성을 한곳에서 확인
날짜 재현FixedClock 사용같은 조건과 결과 재현
호출 관찰RecordingLoanRepository 사용저장소 요청 횟수 확인
실패의 의미없는 기록은 예외로 전달정상적인 0원과 구별

인터페이스는 교체 가능한 책임의 약속이고, 생성자 주입은 그 약속을 제공하는 객체를 연결하는 수단이다. 어느 쪽도 업무 규칙의 정확성을 자동으로 보장하지 않는다. 날짜 경계와 실패 처리 같은 규칙을 명확히 정하고, 그 조건을 대역으로 준비하여 확인해야 한다.

별도의 테스트 프로젝트를 사용할 때도 객체 연결 원리는 같다. xUnit 프로젝트에서 대상 프로젝트를 참조하고, 각 검사에서 메모리 저장소와 고정 시계를 생성하여 서비스를 호출한다. 콘솔의 Check를 테스트 프레임워크의 검사 구문으로 옮길 수 있다. 여기서는 프로젝트 구성이나 검사 구문의 상세 사용법까지 확장하지 않는다.

연습 문제

  1. 반납 예정일이 2026년 1월 15일인 도서 103을 추가하라. 같은 날의 연체료가 0원인지 콘솔 검사로 확인하라. 기존 세 계산을 유지할 때 저장소 조회 횟수의 기대값도 수정하라.
  2. 도서 999의 대출 기록이 없을 때 KeyNotFoundException이 발생하는지 확인하라. 예외가 발생하지 않으면 검사가 실패하도록 작성하라. 이 요청을 조회 횟수에 포함하는 이유를 설명하라.
  3. 주말 여부와 관계없이 두 날짜 사이의 달력 날짜 수를 세는 현재 규칙에 따라, 예정일이 2026년 1월 31일이고 기준 날짜가 2월 2일인 대출의 연체료를 확인하라. 별도의 저장소와 서비스를 만들어 기존 조회 횟수 검사에 영향을 주지 않게 하라.
  4. 모든 조회에서 InvalidOperationException을 발생시키는 UnavailableLoanRepository를 구현하라. 이 저장소를 주입한 서비스가 실패를 0원으로 바꾸지 않는지 확인하라.

정답과 해설

예정일 당일의 경계 확인

Main의 loans 배열에 다음 기록을 추가한다. 배열의 기존 마지막 요소 뒤에는 쉼표를 넣는다.

New Loan(103, New DateOnly(2026, 1, 15))

기존 service를 사용하여 한 번 계산하고 결과를 검사한다. 조회 횟수를 확인하기 전에 실행해야 한다.

Dim dueTodayFee As Decimal = service.CalculateFee(103)
Check(dueTodayFee = 0D, "예정일 당일 계산")
Check(recorder.GetCount = 4, "저장소 조회 횟수")

날짜 차이가 0이므로 연체료도 0원이다. 기존의 조회 횟수 검사값 3은 4로 교체한다. 당일과 예정일 이전은 결과가 같지만 경계 조건이 다르므로 각각 확인하는 의미가 있다.

기록 없음과 요청 횟수

다음 코드를 Main에서 실행한다. Check를 Catch 안에 두지 않고, 예외 발생 여부를 Boolean 변수로 기록한다.

Dim missingThrown As Boolean = False

Try
    Dim unusedFee As Decimal = service.CalculateFee(999)
Catch ex As KeyNotFoundException
    missingThrown = True
End Try

Check(missingThrown, "없는 대출 기록")

RecordingLoanRepository는 안쪽 저장소에 요청하기 전에 횟수를 증가시킨다. 따라서 실패한 요청도 한 번으로 센다. 원래 완성 코드에 이 검사만 추가하면 총 네 번이다. 첫 번째 문제의 계산도 함께 추가했다면 총 다섯 번이다. 성공한 조회만 세고 싶다면 기록 대역의 계약과 증가 위치를 함께 바꾸어야 한다.

월 경계를 넘는 날짜 계산

별도 저장소를 만들면 기존 recorder의 횟수에 영향을 주지 않는다. 일자의 숫자만 빼지 않고 DayNumber를 사용하므로 월 경계를 넘어도 계산할 수 있다.

Dim monthLoans As Loan() = {
    New Loan(201, New DateOnly(2026, 1, 31))
}
Dim monthStorage As ILoanRepository =
    New InMemoryLoanRepository(monthLoans)
Dim monthClock As IClock =
    New FixedClock(New DateOnly(2026, 2, 2))
Dim monthService As New LoanService(monthStorage, monthClock)

Check(monthService.CalculateFee(201) = 1000D, "월 경계 계산")

1월 31일부터 2월 2일까지는 2일 차이다. 현재 정책은 휴관일이나 주말을 제외하지 않으므로 1,000원이다. 주말을 제외하는 요구는 달력상의 날짜 차이와 다른 업무 규칙이며 별도로 정의해야 한다.

사용 불가능한 저장소 대역

다음 클래스를 Program.vb의 Module Program 바깥에 추가한다. 이 구현은 저장소 실패 조건을 의도적으로 제공한다.

Public NotInheritable Class UnavailableLoanRepository
    Implements ILoanRepository

    Public Function GetByBookId(bookId As Integer) As Loan _
        Implements ILoanRepository.GetByBookId

        Throw New InvalidOperationException("저장소를 사용할 수 없다.")
    End Function
End Class

Main에서는 이 구현을 새 서비스에 연결하고 실패가 전달되는지 확인한다. 기존 저장소와 recorder는 사용하지 않는다.

Dim unavailable As ILoanRepository = New UnavailableLoanRepository()
Dim failureClock As IClock =
    New FixedClock(New DateOnly(2026, 1, 15))
Dim failureService As New LoanService(unavailable, failureClock)
Dim failureThrown As Boolean = False

Try
    Dim unusedFee As Decimal = failureService.CalculateFee(101)
Catch ex As InvalidOperationException
    failureThrown = True
End Try

Check(failureThrown, "저장소 실패 전달")

이 확인은 서비스가 저장소 실패를 임의의 금액으로 바꾸지 않는다는 점을 살핀다. 실제 저장소가 왜 실패하는지까지 재현할 필요는 없다. 실패 조건을 제공하는 책임과 실패를 전달하는 책임을 나누면 확인하려는 동작이 분명해진다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.