예제
개요
이 문서는 설정 옵션, 커스텀 인코더/디코더, 실제 시나리오를 포함한 JSON 코덱의 실전 사용 패턴을 보여줍니다.
기본 Product 인코딩
import org.sigilaris.core.codec.json.*
case class Person(name: String, age: Int) derives JsonCodec
val person = Person("Alice", 30)
// 인코딩
val json = JsonEncoder[Person].encode(person)
// json: JsonValue = JObject(
// fields = Map("name" -> JString(value = "Alice"), "age" -> JNumber(value = 30))
// )
// 디코딩
JsonDecoder[Person].decode(json)
// res0: Either[DecodeFailure, Person] = Right(
// value = Person(name = "Alice", age = 30)
// )
중첩 구조
import org.sigilaris.core.codec.json.*
case class Address(street: String, city: String, zipCode: String)
derives JsonCodec
case class Company(name: String, address: Address)
derives JsonCodec
case class Employee(
id: String,
name: String,
company: Company,
salary: BigDecimal
) derives JsonCodec
val employee = Employee(
id = "emp-001",
name = "Bob Smith",
company = Company("Acme Inc", Address("123 Main St", "NYC", "10001")),
salary = BigDecimal("75000.00")
)
// employee: Employee = Employee(
// id = "emp-001",
// name = "Bob Smith",
// company = Company(
// name = "Acme Inc",
// address = Address(street = "123 Main St", city = "NYC", zipCode = "10001")
// ),
// salary = 75000.00
// )
JsonEncoder[Employee].encode(employee)
// res2: JsonValue = JObject(
// fields = Map(
// "id" -> JString(value = "emp-001"),
// "name" -> JString(value = "Bob Smith"),
// "company" -> JObject(
// fields = Map(
// "name" -> JString(value = "Acme Inc"),
// "address" -> JObject(
// fields = Map(
// "street" -> JString(value = "123 Main St"),
// "city" -> JString(value = "NYC"),
// "zipCode" -> JString(value = "10001")
// )
// )
// )
// ),
// "salary" -> JString(value = "75000.00")
// )
// )
Coproduct (Sealed Trait) 인코딩
Sealed trait는 wrapped-by-type-key discriminator를 사용합니다:
import org.sigilaris.core.codec.json.*
sealed trait PaymentMethod derives JsonCodec
case class CreditCard(number: String, expiry: String) extends PaymentMethod
case class BankTransfer(accountNumber: String, routingNumber: String)
extends PaymentMethod
case object Cash extends PaymentMethod
val payment1: PaymentMethod = CreditCard("1234-5678", "12/25")
// payment1: PaymentMethod = CreditCard(number = "1234-5678", expiry = "12/25")
val payment2: PaymentMethod = Cash
// payment2: PaymentMethod = Cash
// Wrapped discriminator 포맷
JsonEncoder[PaymentMethod].encode(payment1)
// res4: JsonValue = JObject(
// fields = Map(
// "CreditCard" -> JObject(
// fields = Map(
// "number" -> JString(value = "1234-5678"),
// "expiry" -> JString(value = "12/25")
// )
// )
// )
// )
JsonEncoder[PaymentMethod].encode(payment2)
// res5: JsonValue = JObject(fields = Map("Cash" -> JObject(fields = Map())))
필드 네이밍 정책
Snake Case
import org.sigilaris.core.codec.json.*
case class UserProfile(firstName: String, lastName: String, emailAddress: String)
derives JsonCodec
val profile = UserProfile("Alice", "Smith", "alice@example.com")
// 기본: Identity
JsonEncoder[UserProfile].encode(profile)
// res7: JsonValue = JObject(
// fields = Map(
// "firstName" -> JString(value = "Alice"),
// "lastName" -> JString(value = "Smith"),
// "emailAddress" -> JString(value = "alice@example.com")
// )
// )
// SnakeCase를 사용하려면 config로 encoder 생성
// (실전에서는 전역으로 설정하거나 given 인스턴스로 제공)
val snakeConfig = JsonConfig.default.copy(
fieldNaming = FieldNamingPolicy.SnakeCase
)
// snakeConfig: JsonConfig = JsonConfig(
// fieldNaming = SnakeCase,
// dropNullValues = true,
// treatAbsentAsNull = true,
// writeBigIntAsString = true,
// writeBigDecimalAsString = true,
// discriminator = DiscriminatorConfig(typeNameStrategy = SimpleName)
// )
// snakeConfig 사용 시: { "first_name": "Alice", "last_name": "Smith", ... }
Kebab Case
import org.sigilaris.core.codec.json.*
val kebabConfig = JsonConfig.default.copy(
fieldNaming = FieldNamingPolicy.KebabCase
)
// firstName → first-name
Camel Case
import org.sigilaris.core.codec.json.*
case class DataClass(FirstName: String, LastName: String)
derives JsonCodec
val camelConfig = JsonConfig.default.copy(
fieldNaming = FieldNamingPolicy.CamelCase
)
// FirstName → firstName
Optional 필드와 Null 처리
import org.sigilaris.core.codec.json.*
case class UserData(
name: String,
email: Option[String],
phone: Option[String]
) derives JsonCodec
val user1 = UserData("Alice", Some("alice@example.com"), None)
// user1: UserData = UserData(
// name = "Alice",
// email = Some(value = "alice@example.com"),
// phone = None
// )
val user2 = UserData("Bob", None, Some("555-1234"))
// user2: UserData = UserData(
// name = "Bob",
// email = None,
// phone = Some(value = "555-1234")
// )
JsonEncoder[UserData].encode(user1)
// res11: JsonValue = JObject(
// fields = Map(
// "name" -> JString(value = "Alice"),
// "email" -> JString(value = "alice@example.com")
// )
// )
JsonEncoder[UserData].encode(user2)
// res12: JsonValue = JObject(
// fields = Map(
// "name" -> JString(value = "Bob"),
// "phone" -> JString(value = "555-1234")
// )
// )
Drop Null Values
import org.sigilaris.core.codec.json.*
val dropNullConfig = JsonConfig.default.copy(dropNullValues = true)
// 이 설정으로 None 필드는 출력에서 생략됩니다
Treat Absent as Null
import org.sigilaris.core.codec.json.*
case class PartialData(name: String, age: Option[Int]) derives JsonCodec
// "age" 필드가 누락됨
val json = JsonValue.obj("name" -> JsonValue.JString("Alice"))
// json: JsonValue = JObject(fields = Map("name" -> JString(value = "Alice")))
val treatAbsentConfig = JsonConfig.default.copy(treatAbsentAsNull = true)
// treatAbsentConfig: JsonConfig = JsonConfig(
// fieldNaming = Identity,
// dropNullValues = true,
// treatAbsentAsNull = true,
// writeBigIntAsString = true,
// writeBigDecimalAsString = true,
// discriminator = DiscriminatorConfig(typeNameStrategy = SimpleName)
// )
// 이 설정으로 누락된 "age"는 None으로 디코딩됩니다
val decs = JsonDecoder.configured(treatAbsentConfig)
// decs: Decoders = org.sigilaris.core.codec.json.JsonDecoder$configured$Decoders@5bafa683
import decs.given
JsonDecoder[PartialData].decode(json)
// res15: Either[DecodeFailure, PartialData] = Right(
// value = PartialData(name = "Alice", age = None)
// )
큰 숫자 포맷팅
BigInt를 문자열로
import org.sigilaris.core.codec.json.*
case class Transaction(
id: String,
amount: BigInt,
nonce: BigInt
) derives JsonCodec
val tx = Transaction(
"tx-001",
BigInt("123456789012345678901234567890"),
BigInt("42")
)
// tx: Transaction = Transaction(
// id = "tx-001",
// amount = 123456789012345678901234567890,
// nonce = 42
// )
// 기본: writeBigIntAsString = true
JsonEncoder[Transaction].encode(tx)
// res17: JsonValue = JObject(
// fields = Map(
// "id" -> JString(value = "tx-001"),
// "amount" -> JString(value = "123456789012345678901234567890"),
// "nonce" -> JString(value = "42")
// )
// )
// 인코딩 결과: { ..., "amount": "123456789012345678901234567890", ... }
BigDecimal을 숫자로
import org.sigilaris.core.codec.json.*
case class Price(currency: String, amount: BigDecimal) derives JsonCodec
val price = Price("USD", BigDecimal("29.99"))
// price: Price = Price(currency = "USD", amount = 29.99)
// 기본: writeBigDecimalAsString = true
JsonEncoder[Price].encode(price)
// res19: JsonValue = JObject(
// fields = Map(
// "currency" -> JString(value = "USD"),
// "amount" -> JString(value = "29.99")
// )
// )
// JSON 숫자로 인코딩하려면: writeBigDecimalAsString = false
디코더는 견고성을 위해 문자열과 숫자 표현을 모두 수용합니다.
컬렉션
List
import org.sigilaris.core.codec.json.*
case class Playlist(name: String, songs: List[String]) derives JsonCodec
val playlist = Playlist("Favorites", List("Song A", "Song B", "Song C"))
// playlist: Playlist = Playlist(
// name = "Favorites",
// songs = List("Song A", "Song B", "Song C")
// )
JsonEncoder[Playlist].encode(playlist)
// res21: JsonValue = JObject(
// fields = Map(
// "name" -> JString(value = "Favorites"),
// "songs" -> JArray(
// values = Vector(
// JString(value = "Song A"),
// JString(value = "Song B"),
// JString(value = "Song C")
// )
// )
// )
// )
Vector
import org.sigilaris.core.codec.json.*
case class Tags(values: Vector[String]) derives JsonCodec
val tags = Tags(Vector("scala", "json", "codec"))
// tags: Tags = Tags(values = Vector("scala", "json", "codec"))
JsonEncoder[Tags].encode(tags)
// res23: JsonValue = JObject(
// fields = Map(
// "values" -> JArray(
// values = Vector(
// JString(value = "scala"),
// JString(value = "json"),
// JString(value = "codec")
// )
// )
// )
// )
String 키를 가진 Map
import org.sigilaris.core.codec.json.*
case class Config(settings: Map[String, String]) derives JsonCodec
val config = Config(Map("theme" -> "dark", "language" -> "en"))
// config: Config = Config(
// settings = Map("theme" -> "dark", "language" -> "en")
// )
JsonEncoder[Config].encode(config)
// res25: JsonValue = JObject(
// fields = Map(
// "settings" -> JObject(
// fields = Map(
// "theme" -> JString(value = "dark"),
// "language" -> JString(value = "en")
// )
// )
// )
// )
String이 아닌 키를 가진 Map
JsonKeyCodec[K]
인스턴스가 필요합니다:
import org.sigilaris.core.codec.json.*
import java.util.UUID
case class UserPermissions(permissions: Map[UUID, String])
derives JsonCodec
val permissions = UserPermissions(Map(
UUID.fromString("550e8400-e29b-41d4-a716-446655440000") -> "admin",
UUID.fromString("6ba7b810-9dad-11d1-80b4-00c04fd430c8") -> "user"
))
// permissions: UserPermissions = UserPermissions(
// permissions = Map(
// 550e8400-e29b-41d4-a716-446655440000 -> "admin",
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 -> "user"
// )
// )
JsonEncoder[UserPermissions].encode(permissions)
// res27: JsonValue = JObject(
// fields = Map(
// "permissions" -> JObject(
// fields = Map(
// "550e8400-e29b-41d4-a716-446655440000" -> JString(value = "admin"),
// "6ba7b810-9dad-11d1-80b4-00c04fd430c8" -> JString(value = "user")
// )
// )
// )
// )
Map 키는 JSON object의 필드명(문자열)으로 인코딩됩니다.
커스텀 Encoder와 Decoder
contramap 사용 (Encoder)
import org.sigilaris.core.codec.json.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
case class CustomDate(value: LocalDate)
given JsonEncoder[CustomDate] =
JsonEncoder[String].contramap { cd =>
cd.value.format(DateTimeFormatter.ISO_LOCAL_DATE)
}
JsonEncoder[CustomDate].encode(CustomDate(LocalDate.of(2025, 1, 15)))
// res29: JsonValue = JString(value = "2025-01-15")
map과 emap 사용 (Decoder)
import org.sigilaris.core.codec.json.*
import org.sigilaris.core.failure.DecodeFailure
import cats.syntax.either.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import scala.util.Try
case class CustomDate(value: LocalDate)
given JsonDecoder[CustomDate] =
JsonDecoder[String].emap { s =>
Try(LocalDate.parse(s, DateTimeFormatter.ISO_LOCAL_DATE))
.toEither
.bimap(
ex => DecodeFailure(s"Invalid date format: ${ex.getMessage}"),
CustomDate(_)
)
}
val validDate = JsonValue.JString("2025-01-15")
// validDate: JsonValue = JString(value = "2025-01-15")
val invalidDate = JsonValue.JString("not-a-date")
// invalidDate: JsonValue = JString(value = "not-a-date")
JsonDecoder[CustomDate].decode(validDate)
// res31: Either[DecodeFailure, CustomDate] = Right(
// value = CustomDate(value = 2025-01-15)
// )
JsonDecoder[CustomDate].decode(invalidDate).isLeft
// res32: Boolean = true
양방향 커스텀 Codec
import org.sigilaris.core.codec.json.*
import org.sigilaris.core.failure.DecodeFailure
import cats.syntax.either.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import scala.util.Try
case class CustomDate(value: LocalDate)
object CustomDate:
given JsonEncoder[CustomDate] with
def encode(cd: CustomDate): JsonValue =
JsonValue.JString(cd.value.format(DateTimeFormatter.ISO_LOCAL_DATE))
given JsonDecoder[CustomDate] = new JsonDecoder[CustomDate]:
def decode(json: JsonValue): Either[DecodeFailure, CustomDate] =
json match
case JsonValue.JString(s) =>
Try(LocalDate.parse(s, DateTimeFormatter.ISO_LOCAL_DATE))
.toEither
.bimap(
ex => DecodeFailure(s"Invalid date: ${ex.getMessage}"),
CustomDate(_)
)
case _ =>
DecodeFailure(s"Expected JString for CustomDate, got $json").asLeft
given JsonCodec[CustomDate] = JsonCodec(summon[JsonEncoder[CustomDate]], summon[JsonDecoder[CustomDate]])
val date = CustomDate(LocalDate.of(2025, 1, 15))
// date: CustomDate = CustomDate(value = 2025-01-15)
val encoded = JsonEncoder[CustomDate].encode(date)
// encoded: JsonValue = JString(value = "2025-01-15")
JsonDecoder[CustomDate].decode(encoded)
// res34: Either[DecodeFailure, CustomDate] = Right(
// value = CustomDate(value = 2025-01-15)
// )
커스텀 Discriminator 매핑
import org.sigilaris.core.codec.json.*
sealed trait Event derives JsonCodec
case class UserCreated(userId: String, username: String) extends Event
case class UserDeleted(userId: String) extends Event
case class UserUpdated(userId: String, fields: Map[String, String]) extends Event
val event1: Event = UserCreated("user-1", "alice")
// event1: Event = UserCreated(userId = "user-1", username = "alice")
val event2: Event = UserDeleted("user-2")
// event2: Event = UserDeleted(userId = "user-2")
// 기본: SimpleName discriminator
JsonEncoder[Event].encode(event1)
// res36: JsonValue = JObject(
// fields = Map(
// "UserCreated" -> JObject(
// fields = Map(
// "userId" -> JString(value = "user-1"),
// "username" -> JString(value = "alice")
// )
// )
// )
// )
JsonEncoder[Event].encode(event2)
// res37: JsonValue = JObject(
// fields = Map(
// "UserDeleted" -> JObject(fields = Map("userId" -> JString(value = "user-2")))
// )
// )
// 커스텀 매핑
val customConfig = JsonConfig.default.copy(
discriminator = DiscriminatorConfig(
TypeNameStrategy.Custom(Map(
"UserCreated" -> "user.created",
"UserDeleted" -> "user.deleted",
"UserUpdated" -> "user.updated"
))
)
)
// customConfig: JsonConfig = JsonConfig(
// fieldNaming = Identity,
// dropNullValues = true,
// treatAbsentAsNull = true,
// writeBigIntAsString = true,
// writeBigDecimalAsString = true,
// discriminator = DiscriminatorConfig(
// typeNameStrategy = Custom(
// mapping = Map(
// "UserCreated" -> "user.created",
// "UserDeleted" -> "user.deleted",
// "UserUpdated" -> "user.updated"
// )
// )
// )
// )
// customConfig 사용 시: { "user.created": { "userId": "user-1", ... } }
JsonParser와 JsonPrinter 사용하기
JSON 문자열 파싱
import org.sigilaris.core.codec.json.*
import org.sigilaris.core.codec.json.backend.circe.CirceJsonOps
val jsonString = """{"name":"Alice","age":30}"""
// JsonValue로 파싱
val parsed = CirceJsonOps.parse(jsonString)
// parsed: Either[ParseFailure, JsonValue] = Right(
// value = JObject(
// fields = Map(
// "name" -> JString(value = "Alice"),
// "age" -> JNumber(value = 30)
// )
// )
// )
JsonValue를 문자열로 출력
import org.sigilaris.core.codec.json.*
import org.sigilaris.core.codec.json.backend.circe.CirceJsonOps
val json = JsonValue.obj(
"name" -> JsonValue.JString("Bob"),
"age" -> JsonValue.JNumber(25)
)
// JSON 문자열로 출력
CirceJsonOps.print(json)
// res40: String = "{\"name\":\"Bob\",\"age\":25}"
전체 라운드트립
import org.sigilaris.core.codec.json.*
import org.sigilaris.core.codec.json.backend.circe.CirceJsonOps
case class Product(id: String, name: String, price: BigDecimal)
derives JsonCodec
val product = Product("p-1", "Widget", BigDecimal("19.99"))
// Domain → JsonValue
val jsonValue = JsonEncoder[Product].encode(product)
// jsonValue: JsonValue = JObject(
// fields = Map(
// "id" -> JString(value = "p-1"),
// "name" -> JString(value = "Widget"),
// "price" -> JString(value = "19.99")
// )
// )
// JsonValue → String (Circe 경유)
val jsonString = CirceJsonOps.print(jsonValue)
// jsonString: String = "{\"id\":\"p-1\",\"name\":\"Widget\",\"price\":\"19.99\"}"
// String → JsonValue (Circe 경유)
val reparsed = CirceJsonOps.parse(jsonString)
// reparsed: Either[ParseFailure, JsonValue] = Right(
// value = JObject(
// fields = Map(
// "id" -> JString(value = "p-1"),
// "name" -> JString(value = "Widget"),
// "price" -> JString(value = "19.99")
// )
// )
// )
// JsonValue → Domain
reparsed.flatMap(JsonDecoder[Product].decode(_))
// res42: Either[SigilarisFailure, Product] = Right(
// value = Product(id = "p-1", name = "Widget", price = 19.99)
// )
emap을 이용한 검증
import org.sigilaris.core.codec.json.*
import org.sigilaris.core.failure.DecodeFailure
import cats.syntax.either.*
case class Email(value: String)
object Email:
given JsonEncoder[Email] with
def encode(email: Email): JsonValue =
JsonValue.JString(email.value)
given JsonDecoder[Email] = new JsonDecoder[Email]:
def decode(json: JsonValue): Either[DecodeFailure, Email] =
json match
case JsonValue.JString(s) =>
if s.contains("@") && s.contains(".") then
Email(s).asRight
else
DecodeFailure(s"Invalid email format: $s").asLeft
case _ =>
DecodeFailure(s"Expected string for Email, got $json").asLeft
given JsonCodec[Email] = JsonCodec(summon[JsonEncoder[Email]], summon[JsonDecoder[Email]])
val validEmail = JsonValue.JString("alice@example.com")
// validEmail: JsonValue = JString(value = "alice@example.com")
val invalidEmail = JsonValue.JString("not-an-email")
// invalidEmail: JsonValue = JString(value = "not-an-email")
JsonDecoder[Email].decode(validEmail)
// res44: Either[DecodeFailure, Email] = Right(
// value = Email(value = "alice@example.com")
// )
JsonDecoder[Email].decode(invalidEmail).isLeft
// res45: Boolean = true
복잡한 예제: API 응답
import org.sigilaris.core.codec.json.*
import java.time.Instant
sealed trait ApiStatus derives JsonCodec
case object Success extends ApiStatus
case object Error extends ApiStatus
case class Metadata(
requestId: String,
timestamp: Instant,
status: ApiStatus
) derives JsonCodec
case class UserInfo(
id: String,
username: String,
email: String
) derives JsonCodec
case class ApiResponse(
data: Option[UserInfo],
metadata: Metadata,
errors: List[String]
) derives JsonCodec
val response = ApiResponse(
data = Some(UserInfo("u-1", "alice", "alice@example.com")),
metadata = Metadata(
requestId = "req-123",
timestamp = Instant.parse("2025-01-15T10:30:00Z"),
status = Success
),
errors = List()
)
JsonEncoder[ApiResponse].encode(response)
// res47: JsonValue = JObject(
// fields = Map(
// "data" -> JObject(
// fields = Map(
// "id" -> JString(value = "u-1"),
// "username" -> JString(value = "alice"),
// "email" -> JString(value = "alice@example.com")
// )
// ),
// "metadata" -> JObject(
// fields = Map(
// "requestId" -> JString(value = "req-123"),
// "timestamp" -> JString(value = "2025-01-15T10:30:00Z"),
// "status" -> JObject(fields = Map("Success" -> JObject(fields = Map())))
// )
// ),
// "errors" -> JArray(values = Vector())
// )
// )