단일 API 엔드포인트에서의 다양한 응답 kotlin serialization의 다형성으로 Type-Safe한 구조 만들기

2025. 10. 17.

안녕하세요.

최근 개발 중인 프로젝트에서 단일 API 엔드포인트임에도 불구하고 요청값(Request Parameter)에 따라 서로 다른 JSON 구조를 반환받아야 하는 문제를 마주했습니다.

보통 API는 하나의 URL에 하나의 응답 구조를 갖는 것이 일반적이지만 비즈니스 특성상 불가피한 상황이었는데요. 이번 포스팅에서 해당 문제를 kotlin serialization polymorphism(직렬화 다형성)을 활용해 해결한 경험을 공유하고자 합니다.

 

문제 배경

개발 중인 프로젝트(모아: MoA)는 "퀴즈 기반의 치매 예방 서비스"로, 사용자 인지 능력 측정을 위해 다양한 유형(기억력, 주의력/계산 등)의 퀴즈를 제공하고 있습니다.

제공중인 퀴즈 유형

문제는 퀴즈 유형에 따라 필요한 데이터의 형태가 서로 다르다는 점이었는데요. 클라이언트 입장에서는 GET /api/quiz라는 동일한 API 엔드포인트를 호출하지만, 요청 파라미터(퀴즈 유형)에 따라 서버가 반환하는 JSON 구조(Schema)는 가변적이었습니다.

예를 들어, "주의력/계산 퀴즈"는 단순한 텍스트 정답만 있으면 되지만, "기억력 퀴즈"는 문제 풀이를 위한 이미지 URL목록과 객관식 보기 목록이 추가로 필요했습니다.

// CASE A: 기억력 퀴즈(이미지, 보기 리스트 필요)
{
	"quizType": "MEMORY",
	"questionId": 101,
	"questionContent": "문제 안내",
	"answer": ["정답1", "정답2"],
	"imageUrls": ["링크1", "링크2"] // 고유 필드
}

// CASE B: 주의력/계산 퀴즈 (수식, 단일 정답 필요)
{
	"quizType": "ATTENTION",
	"questionId": 102,
	"questionContent": "문제 안내",
	"answer": "정답",
	"expression": "계산식" //고유 필드
}

비록 클라이언트는 자신이 어떤 퀴즈를 요청했는지 알고 있지만, 코드 레벨(Retrofit Interface)에서는 이런 다양한 응답 구조를 하나의 공통된 반환 타입으로 받아내야 하는 구조적 난제에 직면하게 되었습니다.

kotlin 환경에서 이런 가변적인 데이터를 어떻게 처리할 수 있을까요?

 

문제 해결

1) Nullable 필드를 사용한 해결방법?

먼저 쉽고, 빠른 해결 방법은 Nullable 필드(프로퍼티)를 활용해 클래스를 정의하는 것이라 생각합니다.

@Serializable
data class QuizResponse(
    val quizType: String,
    val questionId: Long,
    val questionContent: String,

    // 기억력 퀴즈에만 필요한 필드 (나머지는 null)
    val imageUrls: List<String>? = null, 

    // 주의력 퀴즈에만 필요한 필드 (나머지는 null)
    val expression: String? = null,
)

BUT 해당 방법은 아래와 같은 명확한 단점이 존재하기에 가능한 사용을 피하고자 했습니다.

  • 타입 안정성 x
    • quizType이 “기억력 퀴즈”라고 해서 imageUrlsnull이 아니라는 보장이 없는데요. 즉, 컴파일러는 이를 모르기 때문에 개발자가 일일이 기억하거나 주석에 의존해야 합니다.
  • 확장성 저하
    • 새로운 유형의 퀴즈가 추가될 때마다 클래스가 비대해지고, 어떤 필드가 어떤 퀴즈에 쓰이는지 파악하기 어렵다는 문제가 있습니다.
  • 불필요한 널 체크
    • 사용하는 곳마다 !! or ?. 와 같은 처리를 반복해야 합니다.

 

2) Kotlin Serialization Polymorphism을 활용해 해결하기

앞서 본 Nullable 방식의 한계를 극복하기 위해 kotlin serialization polymorphism(직렬화 다형성)을 활용했는데요. 이를 통해 타입(특정 퀴즈)에 따라 필요한 필드만 존재하는 안전한 객체를 만들 수 있습니다.

 

직렬화 다형성은 JSON 내부에 타입을 구분하는 식별자를 심어 두고 파싱 할 때 미리 준비된 테이블에서 해당 식별자에 맞는 클래스를 찾아 매핑하는 방식인데요. 즉, 식별자(Discriminator)Lookup Table을 활용해 단순 텍스트인 JSON을 어떤 타입인지("기억력 퀴즈"인지 "주의력/계산"퀴즈인지) 구분 가능하도록 합니다.

 

kotlin serialization은 다형성(Polymorphism)을 구현하기 위한 두 가지 방법을 제공합니다.

  • 열린 다형성 (Open Polymorphism):
    • 부모와 자식 클래스가 서로 다른 모듈에 있어도 됩니다. (느슨한 결합)
    • 주로 라이브러리, 플러그인처럼 확장성이 중요한 경우 사용한다고 하는데요, 런타임에 SerializersModule을 통해 타입을 연결해야 하므로 설정이 복잡할 수 있다고 합니다.
  • 닫힌 다형성 (Closed Polymorphism):
    • 부모와 자식 클래스가 같은 파일(또는 모듈)내에 있어야 합니다. (강한 결합)
    • sealed class를 기반으로 구현하며, 컴파일러가 모든 자식 타입을 알고 있습니다. 즉, 컴파일 타임에 타입 검사가 이뤄지므로 새로운 타입 추가 시 처리를 누락하면 컴파일 에러가 발생하기에 보다 안전합니다.

 

현재 모아는 MVP 단계이기 때문에 퀴즈의 종류가 정해져 있고, DTO가 외부 모듈로 확장될 가능성이 낮다고 판단했는데요. 이러한 이유로 복잡한 런타임 설정이 필요 없고, 컴파일 타임 안정성을 보장하는 닫힌 다형성 방식을 통해 다음과 같이 구현했습니다.

// 부모 클래스: 공통 필드 정의 (추상 프로퍼티)
@Serializable
@JsonClassDiscriminator("quizType")
sealed class QuizResponse(
	@SerialName("questionId") abstract val questionId: Long,
	@SerialName("questionContent") abstract val questionContent: String,
)

// 자식 클래스 1: 주의력/계산 퀴즈
@Serializable
@SerialName("ATTENTION")
data class AttentionQuizResponse(
    override val questionId: Long,
    override val questionContent: String,
    @SerialName("answer") val answer: String,
    @SerialName("expression") val expression: String,    
) : QuizResponse()

// 자식 클래스 2: 기억력 퀴즈
@Serializable
@SerialName("MEMORY")
data class MemoryQuizResponse(
    override val questionId: Long,
    override val questionContent: String,
    @SerialName("answer") val answer: List<String>,
    @SerialName("imageUrls") val imageUrls: List<String>,
) : QuizResponse()

생각보다 간단하게 구현이 가능했는데요. 내부에선 어떤 처리가 이뤄지고 있을까요?

 

sealed class의 사용이유: 왜 부모가 자식을 다 알아야 할까?

sealed class@Serializable 어노테이션을 붙이면, 빌드 시점에 컴파일러는 SealedClassSerializer 란 구현체를 생성합니다. 이 SealedClassSerializer 객체는 생성 시점에 Lookup Table을 구축하는데요. JSON에서 읽어올 식별자 값(예: "MEMORY")을 Key로, 해당 자식 클래스의 전담 Serializer를 Value로 하는 1:1 매핑 테이블을 메모리에 올립니다. 이를 활용해 JSON이 들어오면 식별자 값을 키로 사용하여 적절한 파서(자식 클래스의 Serializer)를 찾아내는 것입니다.

즉, 이러한 매핑 과정을 위해 부모(SealedClassSerializer)는 컴파일 시점에 모든 자식의 목록을 알고 있어야 하는데요. 이것이 닫힌 다형성에서 sealed class를 사용하는 이유입니다.

 

식별자(Discriminator)의 Key, Value 제어하기

직렬화 다형성이 동작하려면 JSON 내부에 “이 객체가 어떤 타입인지”를 알려주는 식별값이 필요한데요. kotlin serialization은 이를 위해 key-value 쌍을 JSON 내부에 포함합니다.

  • 식별자 키(Key) 제어: @JsonClassDiscriminator
    • 기본 식별자 키 이름은 "type"입니다.
    • @JsonClassDiscriminator를 통해 제어가 가능한데요. 모아의 경우 서버 API의 명세가 "quizType"란 이름을 사용 중이기에 부모 클래스에 @JsonClassDiscriminator("quizType")를 붙여 키 이름을 변경했습니다.
  • 식별자 값(Value) 제어: @SeriaName
    • 기본 식별자 값은 클래스의 전체 경로입니다. ex) com.moa.data.MemoryResponse
    • @SeriaName을 통해 제어가 가능한데요. 기본값 사용 시 패키지 구조 변경 시 호환성이 깨질 위험이 존재하기에 자식 클래스에 @SeriaName("MEMORY")를 붙여 변하지 않는 고정된 이름을 지정했습니다.

 

왜 추상 프로퍼티(abstract val)를 썼을까?

kotlin serialization에는 “생성자 프로퍼티 요구사항”이라는 제약이 있는데요. @Serializable 클래스의 주 생성자의 파라미터(프로퍼티)는 반드시 val/var 프로퍼티여야 한다는 점입니다. 만약 부모 클래스가 open class로서 프로퍼티를 직접 가지게 되면 자식 클래스도 값을 받기 위해 동일한 이름의 프로퍼티를 선언해야 합니다.

@Serializable
open class QuizResponse(
    val questionId: Long
)

@Serializable
class AttentionQuizResponse(
    val questionId: Long,    // <- 문제 발생 지점: 이름이 겹침
    val answer: String
) : QuizResponse(questionId)

이 경우 Backing Field 충돌이 발생하게 되는데요. 이를 방지하기 위해 부모는 abstract로 선언하여 형태만 정의하고, 실제 데이터 저장은 자식 클래스의 생성자(override val)에 위임하는 방식으로 충돌 방지 + 제약 사항을 준수할 수 있습니다.

 

마무리

지금까지 모아(MoA) 프로젝트에서 마주했던 단일 엔드포인트의 가변적인 응답 구조 문제를 해결했던 경험을 공유해 봤습니다.

유지보수가 어려운 Nullable 필드 나열 방식 대신, Kotlin Serialization의 닫힌 다형성(Closed Polymorphism)을 도입함으로써 타입 안전성확장성을 확보할 수 있었는데요. 새로운 퀴즈 유형이 추가되더라도 DTO를 정의하고 when절 처리만 하면 간편하게 대응 가능할 거라 생각이 듭니다.

비슷한 데이터 구조 문제로 고민하고 계신다면 직렬화 다형성을 활용해 보시길 추천합니다.

 

 

 

[ 참고자료 ]

 

kotlinx.serialization/docs at master · Kotlin/kotlinx.serialization

Kotlin multiplatform / multi-format serialization - Kotlin/kotlinx.serialization

github.com