예제

← JSON 코덱 | API | 예제


개요

이 문서는 설정 옵션, 커스텀 인코더/디코더, 실제 시나리오를 포함한 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())
//   )
// )

← JSON 코덱 | API | 예제