Visual Basic · 심화
LINQ·비동기·설계로 깊어지는 Visual Basic
종합 실습 - 도서 대출·예약 관리 서비스
앞 장의 LINQ·비동기·주입·직렬화를 묶어 대출·반납·예약·연체료 계산과 보고서를 출력
개발자KR · 원고 갱신
이 장에서 배우는 것
동네 도서관 서비스의 기능은 각각 작아 보인다. 책을 빌리고, 반납일을 기록하고, 다음 이용자의 예약을 받으면 된다. 그러나 기능을 연결하면 질문이 늘어난다. 예약자가 있는 책을 다른 사람이 빌려도 되는가. 반납할 때 확정한 연체료와 아직 반납하지 않은 책의 예상 연체료는 어떻게 구분하는가. 저장한 자료를 다시 읽었을 때 예약 순서가 유지되는가.
이 장에서는 이러한 질문에 답하는 콘솔 프로그램을 만든다. 앞 장에서 배운 기능을 새로 나열하기보다 하나의 업무 흐름 안에 배치한다. LINQ는 상태를 조회하고 보고서를 구성하는 데 사용하고, 비동기 처리는 파일을 읽고 쓰는 경계에 둔다. 저장 기능은 인터페이스로 주입하며, JSON 직렬화는 업무 상태를 보관하는 역할을 맡는다. 날짜를 고정한 시나리오와 콘솔 검사로 결과도 확인한다.
- 대출·예약·반납을 연결하고 예약 순서를 업무 규칙으로 적용한다.
- 반납 시 확정 연체료와 보고서 기준일의 예상 연체료를 구분한다.
- 저장소를 주입하고 업무 상태를 JSON 파일로 비동기 저장한다.
- LINQ로 정렬된 보고서를 만들고 저장 후 복원한 결과를 검사한다.
- 고정된 입력으로 실행 결과를 재현하고 서비스의 적용 범위를 설명한다.
문제 상황
도서관에는 관리 번호 B1과 B2인 책이 한 권씩 있다. 이 프로그램에서 도서는 제목별 묶음이 아니라 실제로 빌려주는 한 권을 뜻한다. 같은 제목을 두 권 보유한다면 관리 번호도 두 개여야 한다. 회원 M1이 B1을 빌린 뒤 M2와 M3이 차례로 예약한다. M1이 책을 늦게 반납하면 연체료를 확정하고, 예약 맨 앞에 있는 M2에게 다음 대출 기회를 제공해야 한다.
직원은 별도의 화면 없이 콘솔 보고서로 현재 대출과 남은 예약을 확인한다. 보고서에는 이미 반납한 대출의 확정 연체료와 현재 대출의 예상 연체료가 따로 나타나야 한다. 예상 금액은 보고서 기준일에 따라 달라지지만, 반납 시 확정한 금액은 이후 보고서를 출력해도 바뀌지 않는다.
실습에서는 오늘 날짜를 읽지 않는다. 대출일, 반납일, 보고서 기준일을 모두 코드에 명시한다. 현재 시간을 섞으면 같은 프로그램을 내일 실행했을 때 예상 출력이 달라지고 검사도 흔들린다. 날짜가 외부에서 주어진다는 조건은 실제 서비스에서도 유용하다. 직원이 지난 업무일의 보고서를 다시 조회할 수 있기 때문이다.
| 항목 | 규칙 | 적용 지점 |
|---|---|---|
| 대출 기간 | 대출일에 14일을 더한 날까지 | 대출 생성 |
| 연체료 | 기한 다음 날부터 하루 200원 | 반납과 보고서 |
| 예약 순서 | 접수 순서대로 다음 대출 기회 제공 | 대출 승인 |
| 예약 해소 | 예약자의 대출이 성립할 때 제거 | 대출 상태 변경 |
| 저장 범위 | 도서, 대출 이력, 남은 예약 | JSON 저장과 복원 |
반납 자체는 예약을 제거하지 않는다. 책이 돌아왔다는 사실과 예약자가 실제로 책을 빌렸다는 사실은 다르다. 이 실습에는 예약 보관 기한이나 예약 취소 기능을 넣지 않는다. 따라서 맨 앞 예약자는 대출할 때까지 우선권을 유지한다. 실제 도서관에 적용하려면 이 정책부터 운영 담당자와 정해야 한다.
업무 상태와 변경 규칙을 묶는다
업무 상태는 도서 목록, 대출 이력, 예약 목록으로 구성한다. 대출에는 대출일과 반납 기한을 함께 저장한다. 반납일은 아직 정해지지 않았을 수 있으므로 Nullable(Of DateOnly)를 사용한다. 반납일이 없으면 현재 대출이고, 값이 있으면 종료한 대출이다. 확정 연체료는 반납할 때 기록한다.
DateOnly는 시간을 포함하지 않는 날짜를 표현한다. 이 서비스는 시간대나 시각이 아니라 업무 날짜 단위로 연체를 계산하므로 이 형식이 적합하다. 기한 당일 반납은 연체가 아니다. 두 날짜의 DayNumber 차이가 양수일 때만 요금을 부과한다. 금액 계산은 Decimal로 수행한다.
상태 객체는 저장하기 쉬운 공개 속성을 가진다. 대신 업무 코드에서는 목록을 직접 수정하지 않고 LibraryService의 메서드를 사용한다. 서비스는 변경 전에 조건을 확인한다. 이미 대출된 책인지, 예약 맨 앞의 회원이 맞는지 확인한 뒤에만 새 대출을 추가한다. 실패한 요청은 InvalidOperationException으로 알리고 상태를 바꾸지 않는다.
예약에는 증가하는 순서 번호를 부여한다. 접수 시각을 사용하지 않으므로 동일한 시각이나 컴퓨터 시계 변화에 영향을 받지 않는다. 도서별 예약 조회는 순서 번호로 정렬한다. 새로운 번호는 현재 남아 있는 예약의 최댓값보다 하나 크게 정하며, 목록이 비어 있으면 1부터 시작한다. 모든 예약이 소진된 뒤 번호가 다시 1이 되어도 남은 예약 사이의 순서는 모호하지 않다.
이 구현은 한 프로세스에서 요청을 순차적으로 처리한다. 조건 확인과 목록 변경 사이에 다른 요청이 들어오는 상황은 다루지 않는다. 여러 직원이 동시에 같은 책을 대출하는 서비스를 만들려면 공유 저장소에서 확인과 변경을 하나의 트랜잭션으로 묶어야 한다. 메서드가 Async라는 이유만으로 이러한 업무 일관성이 확보되지는 않는다.
저장과 보고서의 경계를 나눈다
LibraryService는 파일 경로나 JSON 옵션을 알지 않는다. IStateStore에 상태 저장을 요청할 뿐이다. JsonFileStateStore가 직렬화와 파일 입출력을 담당한다. 생성자에서 저장소를 전달하는 방식이 의존성 주입(dependency injection)이다. 서비스는 도서관 규칙에 집중하고 저장소는 상태를 기록하는 방식에 집중한다.
대출과 반납은 메모리 안에서 즉시 처리한다. 파일 저장과 읽기는 운영체제의 입출력을 기다리므로 비동기 메서드를 사용한다. SaveAsync가 끝났다는 것은 이 구현의 파일 쓰기 작업이 완료됐다는 의미다. 대출 메서드가 끝났다는 사실만으로 파일까지 저장됐다고 해석하면 안 된다. 실습에서는 시나리오를 모두 실행한 뒤 명시적으로 저장한다.
보고서는 저장된 상태와 기준일로부터 계산하는 조회 결과다. 현재 대출을 고른 뒤 도서 번호로 정렬하고, 도서 목록과 연결해 제목을 얻는다. 아직 반납하지 않은 대출의 예상 연체료는 조회할 때 계산한다. 보고서를 출력하면서 그 값을 대출의 확정 연체료에 대입하지 않는다.
직렬화할 대상은 업무 상태뿐이다. 서비스 객체나 저장소 객체를 직렬화하려 하면 파일 경로, 실행 중인 처리, 의존성까지 저장하려는 설계가 된다. 복원 후에는 읽어 온 상태와 저장소를 사용해 새 서비스 객체를 만든다. 객체를 다시 조립하는 작업과 업무 자료를 복원하는 작업을 구분하는 편이 유지 보수에 유리하다.
예제의 저장 파일은 운영체제가 제공하는 임시 디렉터리에 만든다. 파일 이름은 실행마다 달라도 화면에 출력하지 않으므로 결과에는 영향을 주지 않는다. 저장과 읽기를 실제로 수행한 뒤 Finally에서 파일을 삭제한다. 운영용으로 바꿀 때는 보관할 경로를 전달하고 삭제 정책을 제거해야 한다. 현재 저장 방식은 기존 파일을 직접 덮어쓰므로 장애 복구용 저장 방식까지 제공하지는 않는다.
고정된 시나리오로 통합 결과를 검사한다
검사는 개별 문법이 아니라 업무 흐름의 약속을 확인한다. B1을 빌렸을 때 기한이 맞는지, 예약 두 건이 접수됐는지, 늦은 반납의 금액이 맞는지 확인한다. 이어서 M3이 먼저 빌리려는 요청이 거절되는지 확인한 뒤 M2의 대출이 성공하도록 한다. 마지막으로 파일에서 복원한 상태의 대출 이력과 보고서 금액을 검사한다.
예상한 거절은 프로그램 오류와 구분한다. 해당 요청 주변에서 InvalidOperationException만 받아 거절 여부를 기록한다. 파일 오류나 직렬화 오류까지 한꺼번에 잡아 정상적인 거절로 취급하지 않는다. 검사 함수는 조건이 거짓이면 예외를 발생시키므로 뒤의 성공 메시지가 출력되지 않는다.
콘솔 검사는 실행 예제와 함께 결과를 확인하기에 알맞다. 별도 테스트 프로젝트로 옮긴다면 같은 시나리오를 예약 우선권, 반납 요금, 저장 복원 등의 사례로 나누면 된다. xUnit을 선택할 경우 테스트 프로젝트에 필요한 패키지를 추가하고 저장소 대역을 주입해 업무 규칙을 검사한다. 이 장의 프로그램은 외부 패키지를 사용하지 않는다.
완성 코드
새 Visual Basic 콘솔 프로젝트의 Program.vb를 다음 코드로 교체한다. 저장 자료의 속성과 서비스가 함께 있지만 책임은 클래스로 나눈다. 실행 진입점은 Module Program의 Sub Main이며, 비동기 작업이 끝날 때까지 기다린다.
Option Strict On
Option Explicit On
Option Infer On
Imports System
Imports System.Collections.Generic
Imports System.Globalization
Imports System.IO
Imports System.Linq
Imports System.Text.Json
Imports System.Threading.Tasks
Public Class Book
Public Property Id As String = ""
Public Property Title As String = ""
End Class
Public Class Loan
Public Property BookId As String = ""
Public Property MemberId As String = ""
Public Property BorrowedOn As DateOnly
Public Property DueOn As DateOnly
Public Property ReturnedOn As Nullable(Of DateOnly)
Public Property FinalFee As Decimal
End Class
Public Class Reservation
Public Property BookId As String = ""
Public Property MemberId As String = ""
Public Property QueueNumber As Integer
End Class
Public Class LibraryState
Public Property Books As List(Of Book) = New List(Of Book)()
Public Property Loans As List(Of Loan) = New List(Of Loan)()
Public Property Reservations As List(Of Reservation) =
New List(Of Reservation)()
End Class
Public Interface IStateStore
Function SaveAsync(state As LibraryState) As Task
Function LoadAsync() As Task(Of LibraryState)
End Interface
Public Class JsonFileStateStore
Implements IStateStore
Private ReadOnly _path As String
Public Sub New(path As String)
_path = path
End Sub
Public Async Function SaveAsync(
state As LibraryState
) As Task Implements IStateStore.SaveAsync
Dim json As String = JsonSerializer.Serialize(state)
Await File.WriteAllTextAsync(_path, json)
End Function
Public Async Function LoadAsync() As Task(Of LibraryState) _
Implements IStateStore.LoadAsync
Dim json As String = Await File.ReadAllTextAsync(_path)
Dim state As LibraryState =
JsonSerializer.Deserialize(Of LibraryState)(json)
If state Is Nothing Then
Throw New InvalidDataException("状態を復元できない。")
End If
Return state
End Function
End Class
Public Class LibraryService
Private ReadOnly _state As LibraryState
Private ReadOnly _store As IStateStore
Private Const DailyFee As Decimal = 200D
Public Sub New(state As LibraryState, store As IStateStore)
_state = state
_store = store
End Sub
Private Sub RequireBook(bookId As String)
If Not _state.Books.Any(Function(b) b.Id = bookId) Then
Throw New InvalidOperationException("등록되지 않은 도서다.")
End If
End Sub
Private Function FindActive(bookId As String) As Loan
Return _state.Loans.SingleOrDefault(
Function(l) l.BookId = bookId AndAlso
Not l.ReturnedOn.HasValue)
End Function
Public Function Borrow(
bookId As String, memberId As String, borrowedOn As DateOnly
) As Loan
RequireBook(bookId)
If String.IsNullOrWhiteSpace(memberId) Then
Throw New InvalidOperationException("회원 번호가 필요하다.")
End If
If FindActive(bookId) IsNot Nothing Then
Throw New InvalidOperationException("이미 대출 중이다.")
End If
Dim first As Reservation = _state.Reservations.
Where(Function(r) r.BookId = bookId).
OrderBy(Function(r) r.QueueNumber).
FirstOrDefault()
If first IsNot Nothing AndAlso first.MemberId <> memberId Then
Throw New InvalidOperationException("앞선 예약자가 있다.")
End If
Dim created As New Loan With {
.BookId = bookId,
.MemberId = memberId,
.BorrowedOn = borrowedOn,
.DueOn = borrowedOn.AddDays(14)
}
_state.Loans.Add(created)
If first IsNot Nothing Then
_state.Reservations.Remove(first)
End If
Return created
End Function
Public Sub Reserve(bookId As String, memberId As String)
RequireBook(bookId)
If String.IsNullOrWhiteSpace(memberId) Then
Throw New InvalidOperationException("회원 번호가 필요하다.")
End If
Dim active As Loan = FindActive(bookId)
If active Is Nothing Then
Throw New InvalidOperationException("대출 중인 책만 예약한다.")
End If
If active.MemberId = memberId Then
Throw New InvalidOperationException("본인의 대출은 예약하지 않는다.")
End If
If _state.Reservations.Any(
Function(r) r.BookId = bookId AndAlso r.MemberId = memberId
) Then
Throw New InvalidOperationException("이미 예약했다.")
End If
Dim nextNumber As Integer = _state.Reservations.
Select(Function(r) r.QueueNumber).
DefaultIfEmpty(0).
Max() + 1
_state.Reservations.Add(New Reservation With {
.BookId = bookId,
.MemberId = memberId,
.QueueNumber = nextNumber
})
End Sub
Public Shared Function CalculateFee(
dueOn As DateOnly, asOf As DateOnly
) As Decimal
Dim days As Integer = Math.Max(0, asOf.DayNumber - dueOn.DayNumber)
Return CDec(days) * DailyFee
End Function
Public Function ReturnBook(
bookId As String, returnedOn As DateOnly
) As Decimal
RequireBook(bookId)
Dim active As Loan = FindActive(bookId)
If active Is Nothing Then
Throw New InvalidOperationException("현재 대출이 없다.")
End If
If returnedOn < active.BorrowedOn Then
Throw New InvalidOperationException("반납일이 대출일보다 이르다.")
End If
Dim fee As Decimal = CalculateFee(active.DueOn, returnedOn)
active.ReturnedOn = returnedOn
active.FinalFee = fee
Return fee
End Function
Public Function SaveAsync() As Task
Return _store.SaveAsync(_state)
End Function
Public Function EstimatedTotal(asOf As DateOnly) As Decimal
Return _state.Loans.
Where(Function(l) Not l.ReturnedOn.HasValue).
Sum(Function(l) CalculateFee(l.DueOn, asOf))
End Function
Private Shared Function DateText(value As DateOnly) As String
Return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
End Function
Private Shared Function MoneyText(value As Decimal) As String
Return value.ToString("0", CultureInfo.InvariantCulture)
End Function
Public Sub PrintReport(asOf As DateOnly)
Console.WriteLine("보고서 기준일: " & DateText(asOf))
Console.WriteLine("[현재 대출]")
Dim rows = From l In _state.Loans
Where Not l.ReturnedOn.HasValue
Join b In _state.Books On l.BookId Equals b.Id
Order By l.BookId
Select BookId = l.BookId, Title = b.Title,
MemberId = l.MemberId, DueOn = l.DueOn,
Fee = CalculateFee(l.DueOn, asOf)
For Each row In rows
Console.WriteLine(
row.BookId & " | " & row.Title & " | " &
row.MemberId & " | 기한 " & DateText(row.DueOn) &
" | 예상 " & MoneyText(row.Fee) & "원")
Next
Console.WriteLine("[남은 예약]")
For Each r In _state.Reservations.
OrderBy(Function(item) item.BookId).
ThenBy(Function(item) item.QueueNumber)
Console.WriteLine(r.BookId & " | " & r.MemberId)
Next
Dim finalized As Decimal = _state.Loans.
Where(Function(l) l.ReturnedOn.HasValue).
Sum(Function(l) l.FinalFee)
Console.WriteLine("반납 확정 연체료: " & MoneyText(finalized) & "원")
Console.WriteLine(
"현재 예상 연체료: " & MoneyText(EstimatedTotal(asOf)) & "원")
End Sub
End Class
Module Program
Private _checks As Integer
Sub Main()
RunAsync().GetAwaiter().GetResult()
End Sub
Private Sub Check(condition As Boolean, name As String)
If Not condition Then
Throw New InvalidOperationException("검사 실패: " & name)
End If
_checks += 1
End Sub
Private Async Function RunAsync() As Task
Dim path As String = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"vb-library-" & Guid.NewGuid().ToString("N") & ".json")
Try
Dim state As New LibraryState()
state.Books.Add(New Book With {
.Id = "B1", .Title = "동네의 나무"})
state.Books.Add(New Book With {
.Id = "B2", .Title = "작은 별 지도"})
Dim store As IStateStore = New JsonFileStateStore(path)
Dim service As New LibraryService(state, store)
Dim first As Loan = service.Borrow(
"B1", "M1", New DateOnly(2026, 10, 1))
Check(first.DueOn = New DateOnly(2026, 10, 15), "대출 기한")
service.Reserve("B1", "M2")
service.Reserve("B1", "M3")
Check(state.Reservations.Count = 2, "예약 접수")
Dim fee As Decimal = service.ReturnBook(
"B1", New DateOnly(2026, 10, 18))
Check(fee = 600D AndAlso first.ReturnedOn.HasValue, "반납 요금")
Dim rejected As Boolean = False
Try
service.Borrow("B1", "M3", New DateOnly(2026, 10, 18))
Catch ex As InvalidOperationException
rejected = True
End Try
Check(rejected, "예약 우선권")
service.Borrow("B1", "M2", New DateOnly(2026, 10, 18))
Check(
state.Reservations.Count = 1 AndAlso
state.Reservations(0).MemberId = "M3",
"예약 해소")
service.Borrow("B2", "M3", New DateOnly(2026, 10, 2))
Await service.SaveAsync()
Dim restored As LibraryState = Await store.LoadAsync()
Check(
restored.Books.Count = 2 AndAlso
restored.Loans.Count = 3 AndAlso
restored.Reservations.Count = 1 AndAlso
restored.Loans(0).FinalFee = 600D AndAlso
restored.Reservations(0).MemberId = "M3",
"저장 복원")
Dim reportDate As New DateOnly(2026, 10, 20)
Dim restoredService As New LibraryService(restored, store)
Check(
restoredService.EstimatedTotal(reportDate) = 800D,
"예상 연체료")
Console.WriteLine("검사 통과: " &
_checks.ToString(CultureInfo.InvariantCulture) & "개")
restoredService.PrintReport(reportDate)
Finally
If File.Exists(path) Then
File.Delete(path)
End If
End Try
End Function
End Module
줄별 해설
Option Strict On부터 Imports까지. 암시적인 축소 변환과 늦은 바인딩을 제한한다. Option Infer On은 LINQ가 만드는 익명 형식의 지역 변수 형식을 추론하게 한다. 추론한 형식도 컴파일 시점에 정해지므로 Option Strict On과 함께 사용할 수 있다. 날짜와 금액의 문자열 변환에는 InvariantCulture를 지정해 실행 환경의 지역 설정을 배제한다.
Book부터 LibraryState까지. 도서는 번호와 제목을 보관하고, 대출은 회원과 날짜를 보관한다. ReturnedOn의 HasValue가 대출 종료 여부를 나타내므로 별도의 종료 플래그를 두지 않는다. 목록은 선언할 때 생성한다. 새 상태를 만들자마자 Add를 호출할 수 있으며, 직렬화 대상 속성도 한곳에서 확인할 수 있다.
IStateStore와 JsonFileStateStore. 인터페이스는 상태를 저장하고 복원한다는 계약만 표현한다. 구현 클래스는 생성자로 받은 경로에 JSON 문자열을 쓰고 다시 읽는다. Await는 파일 입출력 완료를 기다리며, 직렬화 자체는 동기적으로 수행한다. 자료가 커지면 문자열 생성 비용도 측정 대상이 되지만 이 실습의 작은 자료에는 간단한 방식이 적합하다.
LoadAsync의 Nothing 검사. JSON의 최상위 값이 null이면 역직렬화 결과가 Nothing일 수 있다. 그 상태로 서비스를 만들지 않도록 예외를 발생시킨다. 다만 이 검사만으로 파일의 모든 내용이 올바르다고 보장하지는 않는다. 목록에 null이 들어 있거나 도서 번호가 중복되는 등 외부에서 편집한 자료의 검증은 별도로 필요하다.
RequireBook과 FindActive. RequireBook은 없는 도서의 요청을 거절한다. FindActive는 반납일이 없는 대출을 찾는다. SingleOrDefault는 현재 대출이 없으면 Nothing을 반환하고, 같은 도서의 현재 대출이 둘이면 예외를 발생시킨다. 이러한 선택은 자료의 모순을 임의의 첫 번째 대출로 숨기지 않겠다는 뜻이다.
Borrow의 조건 검사. 도서와 회원 번호를 확인한 뒤 현재 대출 여부를 검사한다. 이어서 예약 목록을 정렬해 첫 예약을 얻는다. 예약자가 존재하고 요청 회원과 다르면 새 Loan을 만들기 전에 거절한다. 예약 순서 위반이 발생했을 때 도서가 대출 중으로 바뀌거나 예약이 사라지지 않는 배치다.
Borrow의 상태 변경. 새 대출의 DueOn은 대출일에 14일을 더해 정한다. 대출을 목록에 넣은 뒤 실제로 사용한 첫 예약을 제거한다. 반납한 대출 이력은 그대로 남으므로 같은 도서가 여러 번 대출되어도 Loan 객체들은 서로 다른 기록이다. 도서 번호가 같다는 이유로 과거 대출을 덮어쓰지 않는다.
Reserve의 번호 계산. 빈 목록에서 Max를 직접 호출하지 않도록 DefaultIfEmpty(0)를 넣는다. 예약 번호는 전체 목록에서 증가하지만 조회할 때는 도서별로 먼저 거른다. 따라서 B1의 예약 사이에 B2의 예약이 들어와 번호가 띄엄띄엄이어도 B1의 접수 순서는 유지된다. 이 번호는 회원에게 보여 주는 대기 순위와 다르다.
CalculateFee와 ReturnBook. CalculateFee는 날짜 두 개만 받아 금액을 반환한다. Math.Max로 음수 일수를 0으로 바꾸므로 기한 이전과 기한 당일은 모두 0원이다. ReturnBook은 대출일보다 이른 반납을 거절하고, 계산 결과를 FinalFee에 기록한다. 이 속성은 부과한 금액이며 실제 수납 여부를 의미하지 않는다.
EstimatedTotal과 PrintReport. 현재 대출만 선택해 예상 요금을 합산한다. 보고서의 조인은 도서 제목을 붙이고 정렬은 행 순서를 고정한다. LINQ 쿼리는 열거할 때 실행되므로 For Each가 진행되는 동안 상태를 수정하지 않는다. 이 예제는 순차 실행이라 문제가 없지만 여러 요청과 보고서가 동시에 실행된다면 읽을 자료를 일관되게 확보해야 한다.
Main과 RunAsync. Sub Main은 비동기 시나리오가 끝날 때까지 기다린다. RunAsync는 실제 저장을 Await한 뒤 같은 저장소에서 상태를 복원한다. 보고서는 복원한 객체로 출력하므로 메모리의 원본만 정상인지 확인하는 데 그치지 않는다. Finally는 검사나 입출력이 실패해도 임시 파일 정리를 시도한다.
Check와 예상 거절 처리. Check는 성공 횟수를 늘리고 실패 시 실행을 중단한다. 예약 우선권 검사는 의도적으로 잘못된 순서의 요청을 보낸다. 예외가 발생했다는 사실만 확인하는 검사이므로 더 세밀하게 만들려면 상태가 변경되지 않았는지도 함께 확인해야 한다. 이 확장은 연습 문제에서 다룬다.
실행 결과
.NET 10 SDK가 설치된 macOS 또는 Linux에서 다음 명령을 실행한다. 생성한 Program.vb를 완성 코드로 교체한 뒤 실행한다.
dotnet new console -lang VB -f net10.0 -n NeighborhoodLibrary
cd NeighborhoodLibrary
dotnet run
프로그램이 출력하는 내용은 다음과 같다. 임시 파일 이름, 현재 시각, 처리 시간은 출력하지 않는다.
검사 통과: 7개
보고서 기준일: 2026-10-20
[현재 대출]
B1 | 동네의 나무 | M2 | 기한 2026-11-01 | 예상 0원
B2 | 작은 별 지도 | M3 | 기한 2026-10-16 | 예상 800원
[남은 예약]
B1 | M3
반납 확정 연체료: 600원
현재 예상 연체료: 800원
M1의 B1 대출은 기한이 10월 15일이고 반납일이 10월 18일이므로 3일에 대한 600원이 확정된다. M2의 새 대출은 기한이 11월 1일이므로 보고서 기준일에는 연체가 아니다. B2는 기한 10월 16일에서 기준일 10월 20일까지 4일이 지나 예상 금액이 800원이다. 두 합계는 의미가 다르므로 하나의 미수금으로 합치지 않는다.
실무에서 자주 틀리는 것
보고서를 만들면서 확정 금액을 바꾼다
예상 연체료를 FinalFee에 기록하면 조회가 업무 상태를 변경한다. 기준일을 바꾸어 보고서를 다시 출력했을 때 저장된 금액까지 달라질 수 있다. 다음 코드는 현재 대출의 확정 금액을 잘못 수정한다.
For Each loan In state.Loans.Where(
Function(l) Not l.ReturnedOn.HasValue)
loan.FinalFee = LibraryService.CalculateFee(loan.DueOn, reportDate)
Next
예상 금액은 지역 변수나 조회 결과에 담는다. FinalFee는 반납 처리에서만 확정한다.
Dim estimated As Decimal = state.Loans.
Where(Function(l) Not l.ReturnedOn.HasValue).
Sum(Function(l) LibraryService.CalculateFee(l.DueOn, reportDate))
반납할 때 첫 예약을 제거한다
책이 돌아왔다는 이유로 첫 예약을 제거하면 M2가 아직 빌리지 않았는데도 M3에게 우선권이 넘어간다. 다음 코드는 반납과 예약 사용을 혼동한다.
active.ReturnedOn = returnedOn
Dim first = state.Reservations.
Where(Function(r) r.BookId = active.BookId).
OrderBy(Function(r) r.QueueNumber).
FirstOrDefault()
If first IsNot Nothing Then
state.Reservations.Remove(first)
End If
반납은 대출을 종료하는 데서 끝낸다. 예약 제거는 다음 대출이 승인된 위치에서 수행한다. 다음은 Borrow 안의 변경 부분이다.
_state.Loans.Add(created)
If first IsNot Nothing Then
_state.Reservations.Remove(first)
End If
저장을 시작하고 바로 읽는다
Task를 변수에 담았다는 사실은 완료를 의미하지 않는다. 다음 코드는 쓰기가 끝나기 전에 읽기를 시작할 수 있다. 실행 속도에 따라 실패 양상이 달라져 재현도 어려워진다.
Dim pending As Task = service.SaveAsync()
Dim restored As LibraryState = Await store.LoadAsync()
저장과 복원은 순서에 의존하므로 저장 완료를 먼저 기다린다. 독립 작업이 아니므로 두 작업을 동시에 시작할 이유가 없다.
Await service.SaveAsync()
Dim restored As LibraryState = Await store.LoadAsync()
기한 당일에도 하루 요금을 더한다
날짜 차이에 1을 더하면 기한 당일 반납도 유료가 된다. 음수 일수를 그대로 곱하면 조기 반납에 음수 금액이 생긴다. 다음 계산은 두 문제를 함께 갖는다.
Dim days As Integer = returnedOn.DayNumber - dueOn.DayNumber + 1
Dim fee As Decimal = CDec(days) * 200D
실습 규칙에서는 기한 다음 날이 연체 첫날이다. 날짜 차이를 그대로 사용하고 최솟값만 0으로 제한한다.
Dim days As Integer = Math.Max(0, returnedOn.DayNumber - dueOn.DayNumber)
Dim fee As Decimal = CDec(days) * 200D
한눈에 보기
| 기능 | 담당 위치 | 상태 변화 | 확인 사항 |
|---|---|---|---|
| 대출 | Borrow | 대출 추가, 사용한 예약 제거 | 현재 대출과 예약 우선권 |
| 예약 | Reserve | 순서 번호를 가진 예약 추가 | 중복과 본인 대출 예약 |
| 반납 | ReturnBook | 반납일과 확정 요금 기록 | 대출일 이전 반납 금지 |
| 보고서 | PrintReport | 없음 | 기준일과 정렬 순서 |
| 저장 | JsonFileStateStore | 파일 내용 기록 | 완료 후 읽기 |
| 통합 검사 | RunAsync | シナリオに沿って変更 | 복원한 상태와 금액 |
이번 프로그램의 적용 범위는 한 직원이 순차적으로 실행하는 작은 서비스다. 회원은 번호로만 식별하며 회원 명부, 수납 기록, 외부 입력 파일 검증은 제공하지 않는다. 또한 과거 기준일을 이용한 이력 재구성은 구현하지 않았다. 현재 대출만 조회하므로 보고서 기준일은 시나리오의 마지막 업무일 이후로 사용한다.
앞 장에서 다룬 측정 관점으로 보면, 성능 개선의 출발점은 이처럼 동작과 결과가 명확한 프로그램이다. 자료가 늘었을 때 현재 대출 검색이나 제목 조인이 병목인지 측정하고 필요한 부분을 바꿀 수 있다. 그때도 예약 우선권과 연체료 검사는 유지해야 한다. 저장 방식이나 조회 자료 구조를 교체해도 업무 규칙의 의미는 같아야 한다.
연습 문제
- 예약 우선권 검사에 상태 불변 조건을 추가한다. M3의 요청이 거절된 직후 현재 대출이 없고 예약이 두 건 그대로 남았는지 검사한다.
- CalculateFee의 경계값을 검사한다. 기한이 2026년 10월 15일일 때 10월 14일, 15일, 16일의 결과를 각각 확인한다.
- 보고서에 회원별 현재 대출 건수를 추가한다. 회원 번호로 그룹을 만들고 회원 번호 순서로 출력한다. 완성 시나리오에서 M2와 M3의 대출 건수를 확인한다.
- 도서별 예약 취소 기능 CancelReservation을 추가한다. 해당 회원의 예약만 제거하고 나머지 순서는 유지한다. 존재하지 않는 예약의 취소는 False를 반환하도록 한다.
정답과 해설
거절 후 상태 검사
RunAsync에서 Check(rejected, "예약 우선권") 바로 다음에 아래 검사를 추가한다. 거절 이후 M2를 대출시키기 전에 확인해야 실패한 요청 자체가 상태를 바꾸었는지 판단할 수 있다. 이 검사를 추가하면 성공 횟수는 8개가 된다.
Check(
Not state.Loans.Any(
Function(l) l.BookId = "B1" AndAlso Not l.ReturnedOn.HasValue) AndAlso
state.Reservations.Count = 2,
"거절 후 상태")
예외 발생 여부와 상태 보존 여부는 서로 다른 조건이다. 잘못 구현한 서비스가 대출을 먼저 추가한 뒤 예외를 발생시킬 수도 있다. 변경 결과까지 확인하면 이러한 순서 오류를 잡을 수 있다.
연체료 경계값
RunAsync의 보고서 출력 전에 다음 검사를 추가한다. 세 조건을 한 검사로 묶었으므로 성공 횟수는 하나 증가한다.
Dim due As New DateOnly(2026, 10, 15)
Check(
LibraryService.CalculateFee(due, New DateOnly(2026, 10, 14)) = 0D AndAlso
LibraryService.CalculateFee(due, New DateOnly(2026, 10, 15)) = 0D AndAlso
LibraryService.CalculateFee(due, New DateOnly(2026, 10, 16)) = 200D,
"연체료 경계값")
기한보다 이른 날은 음수 차이를 0으로 바꾸는지 확인하고, 기한 당일은 잘못된 하루 가산을 확인한다. 다음 날은 연체 첫날의 요금을 확인한다. 같은 요금의 먼 날짜만 여러 번 검사하는 것보다 규칙이 바뀌는 경계를 확인하는 편이 목적에 맞다.
회원별 현재 대출 건수
PrintReport의 끝에 다음 코드를 추가한다. 종료한 M1의 대출은 먼저 제외하고 남은 대출을 회원 번호로 묶는다.
Console.WriteLine("[회원별 현재 대출]")
Dim groups = _state.Loans.
Where(Function(l) Not l.ReturnedOn.HasValue).
GroupBy(Function(l) l.MemberId).
OrderBy(Function(g) g.Key)
For Each group In groups
Console.WriteLine(
group.Key & " | " &
group.Count().ToString(CultureInfo.InvariantCulture) & "권")
Next
추가되는 출력은 다음과 같다. 그룹 열거 순서에 맡기지 않고 회원 번호로 정렬했으므로 자료를 추가하는 순서를 바꾸어도 행 순서가 유지된다.
[회원별 현재 대출]
M2 | 1권
M3 | 1권
예약 취소
LibraryService에 다음 메서드를 추가한다. 예약 번호를 다시 매기지 않아도 기존 번호의 대소 관계가 유지되므로 나머지 이용자의 우선권은 바뀌지 않는다.
Public Function CancelReservation(
bookId As String, memberId As String
) As Boolean
Dim target As Reservation = _state.Reservations.SingleOrDefault(
Function(r) r.BookId = bookId AndAlso r.MemberId = memberId)
If target Is Nothing Then
Return False
End If
Return _state.Reservations.Remove(target)
End Function
새 시나리오에서 M2와 M3을 예약한 뒤 M2를 취소하면 M3이 첫 예약자가 된다. 같은 취소를 다시 요청하면 False가 반환된다. 이 메서드는 예약의 존재 여부를 결과로 표현하므로 존재하지 않는 예약을 별도의 오류로 처리하지 않는다. 도서 번호 자체의 유효성을 강제하려면 RequireBook 호출을 추가하고 계약도 함께 바꾸어야 한다.
도서관 서비스의 동작은 여러 기능이 서로 약속을 지킬 때 안정된다. 상태 변경은 서비스에 모으고, 저장 완료는 기다리고, 보고서는 상태를 읽기만 하며, 검사는 실패 경로까지 확인한다. 이 구조를 바탕으로 회원 명부나 예약 보관 기한을 추가할 때도 어떤 규칙을 어디에서 확인할지 구체적으로 결정할 수 있다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.