JSON 직렬화와 파일 저장
이 장에서 배우는 것
도서관 서비스를 종료하면 메모리에 있던 대출 기록도 사라진다. 앞 장에서 대출 규칙을 검사할 수 있게 만들었다면, 이제 그 결과를 저장하고 다음 실행에서 복원할 차례다. 저장은 객체를 문자열로 바꾸는 작업에 그치지 않는다. 파일에 어떤 이름과 구조를 남길지, 빠진 값이나 잘못된 값을 어떻게 처리할지, 이전 프로그램이 만든 데이터를 어떻게 읽을지도 정해야 한다.
이 장에서는 JSON 직렬화(serialization)와 역직렬화(deserialization)를 이용해 대출 자료를 파일로 왕복시킨다. 실행할 때마다 같은 예제 자료를 만들고, 이전 형식의 연체료 속성을 새 형식으로 옮긴 뒤 콘솔 검사로 결과를 확인한다. 실행 시각이나 실제 도서관의 운영 자료에 의존하지 않으므로 출력도 일정하다.
- System.Text.Json으로 저장용 객체를 JSON으로 바꾸고 다시 복원한다.
- 속성 이름 정책과 특성을 이용해 파일에 기록할 이름을 정한다.
- UTF-8 파일을 저장하고 다시 읽으면서 파일 오류와 데이터 오류를 구분한다.
- 형식 버전을 확인하고 이전 데이터를 현재 구조로 변환한다.
- 복원한 객체의 업무상 유효성과 저장 결과를 콘솔에서 검사한다.
문제 상황
작은 동네 도서관은 대출 목록을 관리하는 프로그램을 사용한다. 처음 만든 프로그램은 책 제목, 회원 이름, 연체료를 저장했다. 파일에는 연체료가 overdueFee라는 이름으로 들어 있다. 이후 프로그램을 정리하면서 저장용 속성을 FineAmount로 바꾸고, 직원이 남길 수 있는 Notes 속성을 추가했다. 새 파일에서는 fineAmount와 notes라는 이름을 쓰기로 했다.
여기서 이전 파일을 현재 클래스에 곧바로 읽으면 문제가 생긴다. 기본 설정에서는 알 수 없는 JSON 속성을 무시한다. overdueFee가 FineAmount와 연결되지 않으면 이전의 연체료가 복원되지 않는다. 필수 속성 표시도 없다면 Decimal의 기본값인 0이 남아, 실제로 연체료가 없는 기록처럼 보일 수 있다. 구문 오류가 없다는 사실만으로 올바르게 읽었다고 판단할 수 없는 이유다.
이 도서관은 파일의 맨 위에 schemaVersion을 기록하기로 한다. 버전 1의 자료는 이전 저장용 클래스로 읽고, 연체료를 명시적으로 옮겨 버전 2의 객체를 만든다. 버전 2는 현재 저장용 클래스로 읽는다. 지원하지 않는 버전은 추측해서 읽지 않고 오류로 알린다. 이 방식은 저장 구조의 변경을 눈에 보이는 코드로 남긴다.
객체와 JSON 사이의 계약
JSON은 객체의 속성과 배열, 문자열, 숫자 등을 표현하는 텍스트 형식이다. 클래스의 실행 코드나 객체가 제공하는 모든 동작을 저장하는 형식은 아니다. 따라서 서비스 객체 자체를 저장하기보다, 파일에 필요한 값만 가진 저장용 객체를 두는 편이 구조를 이해하기 쉽다. 이 장의 LibraryData와 LoanData는 대출 자료를 옮기는 데이터 전송 객체(Data Transfer Object, DTO)다.
JsonSerializer.Serialize는 객체에서 JSON 문자열을 만든다. JsonSerializer.Deserialize는 JSON 문자열에서 지정한 형식의 객체를 만든다. 이때 지정한 형식은 파일 내용을 해석하는 기준이 된다. 같은 JSON이라도 대상 클래스의 속성이 다르면 복원 결과가 달라질 수 있다. 직렬화와 역직렬화에 같은 설정을 전달하고, 대상 형식을 분명하게 지정하는 것이 출발점이다.
기본 설정은 공개 속성을 중심으로 직렬화한다. 필드는 기본적으로 같은 방식으로 포함되지 않는다. 또한 대상 형식이 클래스이면 JSON의 null을 읽은 결과가 Nothing일 수 있다. 파일을 정상적으로 읽었다는 것과 객체를 정상적으로 얻었다는 것은 별개이므로, 역직렬화 결과를 검사해야 한다.
속성 이름을 일관되게 정하기
Visual Basic의 속성 이름은 BookTitle처럼 단어의 첫 글자를 대문자로 쓰되, JSON에서는 bookTitle처럼 첫 글자를 소문자로 쓰기로 한다. JsonNamingPolicy.CamelCase를 설정하면 이 변환을 공통으로 적용할 수 있다. 정책은 개별 문자열을 직접 수정하는 규칙이 아니라 직렬화기가 속성 이름을 처리할 때 사용하는 설정이다.
예외적으로 FormatVersion은 formatVersion이 아니라 schemaVersion으로 저장한다. 이 속성에는 JsonPropertyName 특성을 붙인다. 특성이 정한 이름은 일반 이름 정책보다 우선한다. 클래스 안의 이름과 외부 파일의 이름을 분리할 수 있으므로, 코드 이름을 다듬을 때 파일 계약까지 우연히 바뀌는 일을 줄인다.
| Visual Basic 속성 | JSON 이름 | 이름 결정 방법 |
|---|---|---|
| FormatVersion | schemaVersion | JsonPropertyName 특성 |
| BookTitle | bookTitle | CamelCase 정책 |
| FineAmount | fineAmount | CamelCase 정책 |
| Notes | notes | CamelCase 정책 |
PropertyNameCaseInsensitive는 JSON 속성 이름의 대소문자를 구분하지 않고 읽을지를 정한다. 이 장에서는 False로 두고 정해진 이름만 사용한다. 이를 True로 바꾸어도 overdueFee가 fineAmount로 연결되지는 않는다. 대소문자의 차이를 허용하는 설정과 속성의 의미가 바뀐 경우를 처리하는 코드는 서로 다른 역할을 한다.
JsonRequired 특성은 해당 속성이 JSON에 존재해야 한다는 뜻이다. 그러나 존재한다는 사실과 값이 적절하다는 사실은 다르다. 필수 문자열에 null이나 빈 문자열이 들어갈 수 있고, 필수 목록에 null이 들어갈 수도 있다. 따라서 특성으로 누락을 확인한 뒤 별도의 검사 함수로 내용까지 확인한다. Notes는 새로 추가한 선택 속성이므로 생략되면 초기값인 빈 문자열을 유지한다.
파일 저장과 읽기의 경계
객체를 파일에 저장하는 과정은 두 단계로 나눌 수 있다. 먼저 JSON 문자열을 만들고, 이어서 문자열을 파일에 기록한다. 읽기도 파일에서 문자열을 얻는 단계와 그 문자열을 객체로 해석하는 단계로 나눈다. 이렇게 분리하면 JSON 변환을 검사할 때 실제 파일을 만들 필요가 없고, 파일 경로 문제를 조사할 때 직렬화 규칙까지 함께 살펴볼 필요도 줄어든다.
완성 코드는 File.WriteAllText와 File.ReadAllText에 UTF-8 인코딩을 명시한다. 파일 경로는 Path.Combine으로 조합한다. 경로 구분자를 문자열에 직접 넣으면 운영체제에 따라 해석이 달라질 수 있다. macOS와 Linux에서도 동일한 코드를 사용할 수 있도록 경로 조합을 라이브러리에 맡긴다.
실습에서는 임시 폴더 안에 실행마다 별도의 디렉터리를 만든다. 그 안에 예제 파일을 기록하고 다시 읽은 뒤 Finally에서 제거한다. 디렉터리 이름에는 고유 값을 사용하지만 경로를 출력하지 않으므로 실행 결과에는 영향을 주지 않는다. 실제 서비스를 만들 때는 저장 위치를 운영 설정으로 정하고 자료를 유지해야 한다. 여기서의 삭제는 실습이 만든 자료를 정리하기 위한 선택이다.
WriteAllText는 대상 파일이 이미 있으면 내용을 덮어쓴다. 이 동작이 성공했다고 해서 서비스의 저장 절차가 모든 중단 상황을 견디는 것은 아니다. 기록 도중 프로세스가 종료되거나 저장 공간이 부족하면 기존 자료를 잃을 수 있다. 실제 자료에는 임시 파일에 먼저 기록하고 검증한 뒤 교체하는 절차와 백업을 검토해야 한다. 교체의 보장 범위는 운영체제와 파일 시스템에 따라 확인해야 한다.
또한 JSON 파일은 일반 텍스트다. 회원 이름처럼 개인과 연결되는 값이 있다면 저장 위치의 접근 권한을 정해야 한다. 직렬화 설정은 파일 접근을 통제하거나 내용을 암호화하지 않는다. 이 장의 예제는 저장 형식과 복원 규칙에 집중하며, 실제 운영 자료의 보호 정책은 서비스 환경에서 따로 정한다.
버전이 다른 자료를 현재 구조로 옮기기
schemaVersion은 파일 형식의 버전이며 .NET의 버전이나 프로그램의 배포 번호와는 다르다. 화면 문구를 고쳤다고 파일 버전을 올릴 필요는 없다. 반대로 저장 값의 의미나 필수 구조가 바뀌었다면 프로그램 배포 번호와 별도로 형식 버전을 관리해야 한다.
완성 코드의 LoadJson은 먼저 JsonDocument로 최상위 구조를 살펴본다. 최상위 값이 객체인지, schemaVersion이 숫자인지, 정수로 해석되는지를 확인한다. 그런 다음 버전에 맞는 클래스를 선택한다. 이 짧은 선행 검사는 이전 자료를 현재 클래스에 억지로 맞추는 일을 막는다.
버전 1의 OldLoanData는 OverdueFee를 가진다. 변환 함수는 이 값을 새 LoanData의 FineAmount에 넣고 Notes를 빈 문자열로 초기화한다. 버전 번호만 2로 바꾸는 것으로는 변환이 끝나지 않는다. 바뀐 속성 이름과 추가된 값의 기본 의미까지 코드에서 정해야 한다.
| 입력 상태 | 처리 | 이유 |
|---|---|---|
| schemaVersion이 1 | 이전 클래스로 읽고 변환 | overdueFee를 fineAmount로 옮긴다. |
| schemaVersion이 2 | 현재 클래스로 읽고 검증 | 현재 저장 계약에 맞는다. |
| 다른 정수 버전 | InvalidDataException 발생 | 알 수 없는 구조를 추측하지 않는다. |
| 버전 누락 또는 잘못된 형식 | InvalidDataException 발생 | 대상 구조를 선택할 근거가 없다. |
이 장은 버전 없는 파일을 자동으로 버전 1로 취급하지 않는다. 과거에 그런 파일을 실제로 사용했다면 누락을 허용하는 별도의 규칙을 만들 수 있다. 다만 그 규칙은 기록된 자료의 구조를 근거로 정해야 한다. 누락된 값을 편의상 오래된 버전이라고 가정하면 손상된 새 파일도 잘못 받아들일 수 있다.
변환 후에는 현재 자료의 규칙을 한곳에서 확인한다. 제목과 회원 이름은 공백만으로 이루어질 수 없고, 연체료는 음수일 수 없다. 이전 형식과 현재 형식 모두 같은 검사로 끝내면 읽기 경로에 따라 자료 품질이 달라지는 일을 줄인다. 여기서는 예약 기록까지 구조를 넓히지 않고 대출 한 건으로 저장 계약의 변경을 확인한다.
완성 코드
새 콘솔 프로젝트의 Program.vb 전체를 다음 코드로 바꾼다. 외부 패키지는 필요하지 않다. JsonRequired는 필수 속성의 누락을 검사하고, Validate는 복원된 값의 업무 규칙을 검사한다. 네 개의 콘솔 검사가 모두 통과하면 정해진 결과를 출력한다.
Option Strict On
Option Explicit On
Option Infer On
Imports System
Imports System.Collections.Generic
Imports System.Globalization
Imports System.IO
Imports System.Text
Imports System.Text.Json
Imports System.Text.Json.Serialization
Public Class LibraryData
<JsonPropertyName("schemaVersion"), JsonRequired>
Public Property FormatVersion As Integer = 2
<JsonRequired>
Public Property Loans As List(Of LoanData) = New List(Of LoanData)()
End Class
Public Class LoanData
<JsonRequired>
Public Property BookTitle As String = ""
<JsonRequired>
Public Property MemberName As String = ""
<JsonRequired>
Public Property FineAmount As Decimal
Public Property Notes As String = ""
End Class
Public Class OldLibraryData
<JsonPropertyName("schemaVersion"), JsonRequired>
Public Property FormatVersion As Integer
<JsonRequired>
Public Property Loans As List(Of OldLoanData) =
New List(Of OldLoanData)()
End Class
Public Class OldLoanData
<JsonRequired>
Public Property BookTitle As String = ""
<JsonRequired>
Public Property MemberName As String = ""
<JsonRequired>
Public Property OverdueFee As Decimal
End Class
Module Program
Private ReadOnly Options As New JsonSerializerOptions With {
.PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
.PropertyNameCaseInsensitive = False,
.WriteIndented = True
}
Sub Main()
Dim oldJson As String =
"{""schemaVersion"":1,""loans"":[{""bookTitle"":""골목의 나무"",""memberName"":""서윤"",""overdueFee"":1500}]}"
Dim folder As String = Path.Combine(
Path.GetTempPath(),
"vb-library-" & Guid.NewGuid().ToString("N"))
Dim filePath As String = Path.Combine(folder, "library.json")
Directory.CreateDirectory(folder)
Try
File.WriteAllText(filePath, oldJson, Encoding.UTF8)
Dim current As LibraryData =
LoadJson(File.ReadAllText(filePath, Encoding.UTF8))
Check(current.FormatVersion = 2 AndAlso
current.Loans.Count = 1 AndAlso
current.Loans(0).FineAmount = 1500D AndAlso
current.Loans(0).Notes = "",
"이전 자료 변환")
Dim savedJson As String = SaveJson(current)
File.WriteAllText(filePath, savedJson, Encoding.UTF8)
Dim restored As LibraryData =
LoadJson(File.ReadAllText(filePath, Encoding.UTF8))
Check(restored.Loans.Count = 1 AndAlso
restored.Loans(0).BookTitle = "골목의 나무" AndAlso
restored.Loans(0).MemberName = "서윤" AndAlso
restored.Loans(0).FineAmount = 1500D AndAlso
restored.Loans(0).Notes = "",
"저장 후 복원")
Using document As JsonDocument = JsonDocument.Parse(savedJson)
Dim root As JsonElement = document.RootElement
Dim loan As JsonElement =
root.GetProperty("loans")(0)
Dim unused As JsonElement
Check(root.GetProperty("schemaVersion").GetInt32() = 2 AndAlso
loan.GetProperty("fineAmount").GetDecimal() = 1500D AndAlso
Not loan.TryGetProperty("overdueFee", unused),
"저장 속성 이름")
End Using
Dim rejected As Boolean = False
Try
LoadJson("{""schemaVersion"":99,""loans"":[]}")
Catch ex As InvalidDataException
rejected = True
End Try
Check(rejected, "지원하지 않는 버전 거부")
Console.WriteLine("읽은 파일 버전: 1")
Console.WriteLine("저장한 파일 버전: " &
restored.FormatVersion.ToString(
CultureInfo.InvariantCulture))
Console.WriteLine("대출: " & restored.Loans(0).BookTitle &
" / " & restored.Loans(0).MemberName)
Console.WriteLine("연체료: " &
restored.Loans(0).FineAmount.ToString(
"0.00", CultureInfo.InvariantCulture))
Console.WriteLine("검사: 4개 통과")
Finally
Directory.Delete(folder, recursive:=True)
End Try
End Sub
Private Function SaveJson(data As LibraryData) As String
Validate(data)
Return JsonSerializer.Serialize(data, Options)
End Function
Private Function LoadJson(json As String) As LibraryData
Using document As JsonDocument = JsonDocument.Parse(json)
Dim root As JsonElement = document.RootElement
Dim versionElement As JsonElement
If root.ValueKind <> JsonValueKind.Object Then
Throw New InvalidDataException(
"최상위 값은 객체여야 한다.")
End If
If Not root.TryGetProperty("schemaVersion", versionElement) Then
Throw New InvalidDataException(
"schemaVersion이 필요하다.")
End If
Dim version As Integer
If versionElement.ValueKind <> JsonValueKind.Number OrElse
Not versionElement.TryGetInt32(version) Then
Throw New InvalidDataException(
"schemaVersion은 정수여야 한다.")
End If
Dim data As LibraryData
Select Case version
Case 1
Dim oldData As OldLibraryData =
JsonSerializer.Deserialize(Of OldLibraryData)(
json, Options)
If oldData Is Nothing OrElse oldData.Loans Is Nothing Then
Throw New InvalidDataException(
"이전 대출 목록이 필요하다.")
End If
data = ConvertOld(oldData)
Case 2
data = JsonSerializer.Deserialize(Of LibraryData)(
json, Options)
Case Else
Throw New InvalidDataException(
"지원하지 않는 파일 버전이다.")
End Select
Validate(data)
Return data
End Using
End Function
Private Function ConvertOld(oldData As OldLibraryData) As LibraryData
Dim data As New LibraryData()
For Each oldLoan As OldLoanData In oldData.Loans
If oldLoan Is Nothing Then
Throw New InvalidDataException(
"이전 대출 항목은 null일 수 없다.")
End If
data.Loans.Add(New LoanData With {
.BookTitle = oldLoan.BookTitle,
.MemberName = oldLoan.MemberName,
.FineAmount = oldLoan.OverdueFee,
.Notes = ""
})
Next
Return data
End Function
Private Sub Validate(data As LibraryData)
If data Is Nothing Then
Throw New InvalidDataException("저장 자료가 필요하다.")
End If
If data.FormatVersion <> 2 Then
Throw New InvalidDataException(
"현재 자료의 버전은 2여야 한다.")
End If
If data.Loans Is Nothing Then
Throw New InvalidDataException("대출 목록이 필요하다.")
End If
For Each loan As LoanData In data.Loans
If loan Is Nothing Then
Throw New InvalidDataException(
"대출 항목은 null일 수 없다.")
End If
If String.IsNullOrWhiteSpace(loan.BookTitle) OrElse
String.IsNullOrWhiteSpace(loan.MemberName) Then
Throw New InvalidDataException(
"책 제목과 회원 이름이 필요하다.")
End If
If loan.FineAmount < 0D Then
Throw New InvalidDataException(
"연체료는 음수일 수 없다.")
End If
If loan.Notes Is Nothing Then
Throw New InvalidDataException(
"메모는 null일 수 없다.")
End If
Next
End Sub
Private Sub Check(condition As Boolean, name As String)
If Not condition Then
Throw New InvalidOperationException(
"검사 실패: " & name)
End If
End Sub
End Module
줄별 해설
Option Strict On은 암시적 축소 변환과 늦은 바인딩을 제한한다. 파일 내용은 외부에서 들어오는 값이지만, 이를 처리하는 코드의 자료형까지 느슨하게 만들 필요는 없다. 버전은 Integer, 연체료는 Decimal, 대출 목록은 List(Of LoanData)로 선언한다. Imports는 이 프로그램에서 사용하는 기본 라이브러리의 이름을 짧게 쓰기 위한 선언이다.
LibraryData의 FormatVersion은 2로 초기화한다. 새로 만들어 저장하는 객체는 현재 형식이어야 하기 때문이다. JsonPropertyName은 파일에 쓸 이름을 schemaVersion으로 고정한다. JsonRequired를 함께 붙였으므로 읽을 때 해당 속성이 빠지면 기본값 2가 있다는 이유만으로 받아들이지 않는다.
LoanData의 세 필수 속성에는 JsonRequired를 붙인다. 특히 FineAmount가 빠진 자료와 연체료가 실제로 0인 자료를 구분할 수 있다. Notes에는 이 특성을 붙이지 않는다. 현재 형식에서 메모가 생략되면 빈 문자열로 해석한다는 계약이다. 명시적인 null은 Validate에서 거부하므로 생략과 null의 처리도 구분한다.
OldLibraryData와 OldLoanData는 이전 파일을 읽기 위한 클래스다. 현재 서비스에서 사용하는 속성을 오래된 구조에 맞춰 계속 늘리는 대신, 읽기 경계에서 이전 구조를 따로 표현한다. 이전 클래스의 OverdueFee도 이름 정책의 적용을 받아 overdueFee와 대응한다.
Options는 한 번 만들고 계속 사용한다. WriteIndented는 사람이 읽기 쉽도록 줄바꿈과 들여쓰기를 넣는다. 값의 의미를 바꾸는 설정은 아니다. 설정 객체를 직렬화에 사용한 뒤에는 속성을 수정하지 않고, 처음 정한 계약을 유지한다.
Main의 oldJson은 버전 1 자료를 재현하기 위한 고정 입력이다. Visual Basic 문자열 안에서 큰따옴표 하나를 나타내려면 큰따옴표를 두 번 쓴다. UTF-8 파일에 이 문자열을 쓴 뒤 LoadJson으로 읽으므로, 첫 번째 검사는 문자열 변환뿐 아니라 실제 파일 읽기를 거친 결과를 확인한다.
첫 번째 Check는 현재 버전, 대출 수, 옮겨진 연체료, 추가된 메모의 기본값을 확인한다. AndAlso는 앞 조건이 False이면 뒤 조건을 평가하지 않는다. 따라서 대출 수가 1이 아닌 경우에는 곧바로 실패하며, 없는 첫 항목을 읽는 평가도 피한다.
SaveJson은 먼저 Validate를 호출한다. 복원할 때만 검사하면 프로그램 안에서 잘못 만든 자료가 파일에 저장될 수 있기 때문이다. 저장 직전과 복원 직후에 같은 검사를 적용한다. 파일 기록은 Main에 있으므로 이 함수 자체는 문자열을 만드는 책임만 가진다.
두 번째 검사는 새 파일에서 제목, 회원 이름, 연체료, 메모가 복원되는지 확인한다. 세 번째 검사는 JsonDocument로 저장된 JSON을 직접 살펴본다. 객체 값만 비교하면 저장 속성 이름이 달라졌는지 놓칠 수 있으므로, schemaVersion과 fineAmount가 있고 overdueFee는 없는지도 확인한다.
Using은 JsonDocument의 사용 범위를 정하고 범위를 벗어날 때 해제한다. JsonElement는 문서 내부 자료를 참조할 수 있으므로, 이 예제에서는 문서가 살아 있는 범위 안에서만 사용한다. 문서 밖에 요소를 보관해야 한다면 Clone을 이용해 수명 관계를 분리해야 한다.
LoadJson은 구문을 분석한 뒤 구조와 버전을 확인한다. 숫자가 아닌 값에는 TryGetInt32를 호출하지 않도록 OrElse를 사용한다. 버전 1은 이전 클래스로 읽고 ConvertOld로 옮기며, 버전 2는 현재 클래스로 읽는다. 그 밖의 버전은 InvalidDataException을 던진다.
ConvertOld는 기존 항목마다 새 객체를 만든다. 핵심은 .FineAmount = oldLoan.OverdueFee다. 이름이 바뀐 값을 명시적으로 연결한다. 이 변환에는 단위 변경이 없으므로 숫자를 그대로 옮기지만, 원에서 다른 단위로 바뀌는 설계라면 단위 변환도 이곳에서 처리해야 한다.
마지막 검사는 지원하지 않는 버전만 의도적으로 입력한다. InvalidDataException을 받았을 때에만 거부가 확인된다. JSON 구문 오류는 JsonException, 파일 접근 문제는 IOException이나 UnauthorizedAccessException 등으로 나타날 수 있다. 예제는 예상하지 않은 오류를 성공으로 바꾸지 않고 그대로 드러낸다.
출력의 숫자는 InvariantCulture로 형식을 정한다. 운영체제의 지역 설정에 따라 소수점 표현이 바뀌지 않게 하기 위해서다. Finally는 검사나 출력 도중 예외가 나더라도 실습 디렉터리의 정리를 시도한다. 정리 자체의 실패까지 숨기는 코드는 넣지 않았으므로, 정리 오류가 발생하면 실행도 오류로 끝난다.
실행 결과
.NET 10 SDK가 설치된 macOS 또는 Linux에서 다음 명령으로 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 바꾼 뒤 실행한다. 첫 명령의 안내 문구는 SDK 환경에 따라 달라질 수 있으므로, 아래 예상 출력은 dotnet run으로 실행한 프로그램의 출력만 나타낸다.
dotnet new console -lang VB -n LibraryJsonDemo
cd LibraryJsonDemo
dotnet run
읽은 파일 버전: 1
저장한 파일 버전: 2
대출: 골목의 나무 / 서윤
연체료: 1500.00
검사: 4개 통과
실행이 끝나면 실습 파일은 제거된다. 저장된 JSON의 모습은 다음과 같다. 문자열의 한국어는 직렬화기의 기본 인코더에 따라 유니코드 이스케이프 형태로 기록될 수 있다. 아래는 구조를 읽기 쉽게 한국어로 표시한 같은 내용이다. 파일의 바이트나 문자열 표현을 비교하는 검사와 복원한 값의 검사는 구분해야 한다.
{
"schemaVersion": 2,
"loans": [
{
"bookTitle": "골목의 나무",
"memberName": "서윤",
"fineAmount": 1500,
"notes": ""
}
]
}
실무에서 자주 틀리는 것
설정 없이 현재 클래스로 바로 읽기
저장할 때 쓴 이름 정책을 읽을 때 빼면 속성 이름이 대응하지 않을 수 있다. 또한 버전 1을 현재 클래스로 바로 읽으면 연체료 이름 변경을 처리할 수 없다. 완성 코드에서는 아래의 LoadJson이 설정과 버전 선택을 함께 적용한다.
틀린 코드다.
Dim data As LibraryData =
JsonSerializer.Deserialize(Of LibraryData)(json)
고친 코드다.
Dim data As LibraryData = LoadJson(json)
현재 버전만 받는 별도의 입력 경로라면 같은 Options를 전달하고 검증하는 것으로 충분할 수 있다. 그러나 여러 버전이 섞여 있는 저장소라면 버전 선택을 생략하지 않는다.
필수 속성 표시를 값 검증으로 생각하기
JsonRequired는 누락을 확인한다. 제목이 빈 문자열이거나 연체료가 음수인 경우까지 판단하지 않는다. 속성이 존재하면 올바른 자료라는 가정은 업무 규칙을 빠뜨린다.
틀린 코드다.
Dim data As LibraryData =
JsonSerializer.Deserialize(Of LibraryData)(json, Options)
Return data
고친 코드다.
Dim data As LibraryData =
JsonSerializer.Deserialize(Of LibraryData)(json, Options)
Validate(data)
Return data
이 조각은 현재 버전의 JSON을 읽는 함수 안에 있다고 가정한다. 전체 프로그램에서는 먼저 버전을 확인한다. Validate는 Nothing, 목록의 null 항목, 빈 제목, 음수 연체료를 각각 거부한다.
모든 오류를 빈 자료로 바꾸기
파일을 읽지 못한 이유가 파일 부재인지, 권한 문제인지, 손상된 JSON인지에 따라 대응은 달라야 한다. 모든 예외를 잡아 빈 목록을 반환하면 기존 자료를 잃어버린 것처럼 보이고, 다음 저장에서 빈 자료로 덮어쓸 수도 있다.
틀린 코드다.
Try
Return LoadJson(File.ReadAllText(path, Encoding.UTF8))
Catch ex As Exception
Return New LibraryData()
End Try
고친 코드다.
Try
Return LoadJson(File.ReadAllText(path, Encoding.UTF8))
Catch ex As FileNotFoundException
Return New LibraryData()
End Try
이 수정은 파일이 없으면 첫 실행으로 취급한다는 정책이 있을 때 사용한다. 경로의 디렉터리까지 없을 수 있다면 그 상황도 별도로 설계해야 한다. 그 외 오류는 호출자에게 전달해 사용자에게 알리거나 복구 절차로 연결한다.
버전 번호만 바꾸고 변환했다고 생각하기
버전 번호는 구조를 설명하는 표지다. 표지를 바꾸어도 오래된 속성 이름이나 값의 단위는 바뀌지 않는다. 문자열 일부를 치환하는 방식은 같은 문자열이 다른 위치에 나타날 때도 잘못 적용될 수 있다.
틀린 코드다.
Dim changed As String =
json.Replace("""schemaVersion"":1", """schemaVersion"":2")
고친 코드다.
Dim current As LibraryData = LoadJson(json)
Dim changed As String = SaveJson(current)
기본 설정에서는 알 수 없는 속성이 무시되므로 다시 저장할 때 그 속성이 사라질 수 있다. 예제는 알려진 필드만 유지한다는 계약이다. 다른 프로그램이 추가한 필드를 보존해야 한다면 JsonExtensionData 같은 별도 수단을 검토해야 한다. 지원하지 않는 버전을 거부하는 규칙도 이런 정보 손실을 줄이는 경계가 된다.
한눈에 보기
| 기능 | 맡는 일 | 별도로 확인할 일 |
|---|---|---|
| Serialize와 Deserialize | 객체와 JSON 사이를 변환한다. | 대상 형식과 Nothing 결과 |
| PropertyNamingPolicy | 공통 속성 이름 규칙을 적용한다. | 읽기와 쓰기의 설정 일치 |
| JsonPropertyName | 개별 속성의 외부 이름을 정한다. | 파일 계약의 이름 유지 |
| JsonRequired | JSON 속성의 누락을 거부한다. | null과 업무상 부적절한 값 |
| 파일 읽기와 쓰기 | 텍스트를 저장 매체와 주고받는다. | 경로, 권한, 덮어쓰기, 백업 |
| 버전 선택과 변환 | 이전 자료를 현재 구조로 옮긴다. | 값의 의미와 지원 범위 |
직렬화는 형식을 맞추고, 검증은 값의 의미를 확인하며, 파일 저장은 자료를 유지한다. 이 세 책임을 구분하면 변경과 오류를 찾기 쉬워진다. 성능과 메모리를 다룰 때도 이 경계가 있어야 파일 작업과 JSON 변환 중 어느 부분을 측정하는지 분명하게 정할 수 있다.
기본 동작을 확인할 공식 자료로는 System.Text.Json 직렬화와 역직렬화 안내, 속성 이름 사용자 지정 안내, 필수 속성 안내가 있다. 이 장의 저장 구조와 변환 코드는 도서관 예제를 위해 별도로 구성한 것이다.
연습 문제
- 현재 버전의 JSON에서 notes를 생략한 경우와 null로 지정한 경우를 각각 입력하라. 완성 코드가 어느 입력을 받아들이는지 설명하고, 콘솔 검사로 확인하라.
- 연체료가 0인 버전 2 자료와 fineAmount 자체가 빠진 버전 2 자료를 비교하라. 각각의 처리 결과와 발생하는 예외 형식을 설명하라.
- 버전 1 자료에 대출 두 건을 넣어라. 두 번째 연체료를 300D로 하고, 변환 후 건수와 연체료 합계가 1800D인지 검사하라. LINQ 없이 반복문으로 합계를 구하라.
- 저장 전 FineAmount를 -1D로 바꿔 보라. SaveJson이 실패할 때 기존 파일을 덮어쓰지 않도록 호출 순서를 구성하라. 이 순서가 기록 도중의 파일 손상도 해결하는지 설명하라.
정답과 해설
첫 번째 문제에서 notes를 생략하면 LoanData의 초기값인 빈 문자열이 유지되어 읽기에 성공한다. null을 명시하면 속성 값이 Nothing이 되고 Validate가 InvalidDataException을 던진다. 다음 조각은 Main의 검사 구간에 넣을 수 있다. 검사를 추가하면 마지막 검사 개수 출력도 그에 맞게 바꿔야 한다.
Dim omitted As LibraryData = LoadJson(
"{""schemaVersion"":2,""loans"":[{""bookTitle"":""골목의 나무"",""memberName"":""서윤"",""fineAmount"":0}]}")
Check(omitted.Loans(0).Notes = "", "메모 생략")
Dim nullRejected As Boolean = False
Try
LoadJson(
"{""schemaVersion"":2,""loans"":[{""bookTitle"":""골목의 나무"",""memberName"":""서윤"",""fineAmount"":0,""notes"":null}]}")
Catch ex As InvalidDataException
nullRejected = True
End Try
Check(nullRejected, "메모 null 거부")
두 번째 문제에서 fineAmount가 0이면 필수 속성이 존재하고 음수도 아니므로 읽기에 성공한다. 속성이 빠지면 JsonRequired의 조건을 만족하지 않아 역직렬화 중 JsonException이 발생한다. 이때 Validate까지 도달하지 않는다. 누락과 업무상 잘못된 값이 서로 다른 단계에서 걸러진다는 점을 확인한다.
Dim missingRejected As Boolean = False
Try
LoadJson(
"{""schemaVersion"":2,""loans"":[{""bookTitle"":""골목의 나무"",""memberName"":""서윤""}]}")
Catch ex As JsonException
missingRejected = True
End Try
Check(missingRejected, "연체료 누락 거부")
세 번째 문제는 기존 oldJson을 다음 입력으로 바꾸고, 변환 직후 합계를 계산하면 된다. 기존 완성 코드에는 대출 수가 1이라는 검사도 있으므로 이 실습에서는 해당 검사와 단일 항목 출력 가정을 함께 수정한다.
Dim twoLoans As LibraryData = LoadJson(
"{""schemaVersion"":1,""loans"":[{""bookTitle"":""골목의 나무"",""memberName"":""서윤"",""overdueFee"":1500},{""bookTitle"":""작은 지도"",""memberName"":""민재"",""overdueFee"":300}]}")
Dim total As Decimal = 0D
For Each loan As LoanData In twoLoans.Loans
total += loan.FineAmount
Next
Check(twoLoans.Loans.Count = 2 AndAlso total = 1800D,
"두 대출 변환과 합계")
네 번째 문제에서는 JSON 문자열을 만드는 작업을 파일 기록보다 먼저 둔다. SaveJson 안에서 음수 연체료를 거부하므로 WriteAllText에 도달하지 않는다. 완성 코드도 이미 이 순서를 사용한다.
current.Loans(0).FineAmount = -1D
Dim jsonToSave As String = SaveJson(current)
File.WriteAllText(filePath, jsonToSave, Encoding.UTF8)
이 순서는 유효하지 않은 객체 때문에 기존 파일이 바뀌는 일을 막는다. 그러나 유효한 JSON을 얻은 뒤 실제 기록 도중에 발생하는 저장 공간 부족이나 프로세스 종료까지 해결하지는 않는다. 그런 상황에는 별도 임시 파일과 교체, 백업 정책이 필요하다. 검사를 정식 테스트 프로젝트로 옮길 때는 JSON 변환 검사와 임시 디렉터리를 사용하는 파일 왕복 검사를 나누고, 지원하지 않는 버전과 필수 속성 누락도 각각 독립된 사례로 유지한다.