기타

Sealed Interface로 깔끔하고 안전한 API 응답 구조 만들기

Stitchhhh 2026. 2. 26. 15:10

백엔드 개발을 하다 보면 서비스 레이어의 처리 결과를 컨트롤러에 어떻게 전달할지 고민하게 됩니다. 데이터가 없으면 null을 보낼지, 아니면 Exception을 던져야 할지 모호한 경우가 많죠. 이럴 때 Sealed Interface를 활용하면 응답의 상태를 명확하게 강제할 수 있고, 코드의 안정성도 획기적으로 높아집니다.

1. Sealed Interface 응답 구조 설계

성공, 데이터 없음, 에러 상황을 하나의 인터페이스로 묶어 관리하는 방식입니다. 여기서 핵심은 상황에 따라 class와 object를 적절히 섞어 쓰는 것입니다.

sealed interface AnalyticsResult {
    // 성공: 결과 데이터라는 '본문'이 있으므로 data class
    data class Success(val data: AnalyticsDashboard) : AnalyticsResult
    
    // 데이터 없음: 추가 정보 없이 '상태'만 전달하므로 data object (싱글톤)
    data object NoData : AnalyticsResult
    
    // 에러: 에러 메시지와 코드라는 '본문'이 있으므로 data class
    data class Error(val message: String, val code: String) : AnalyticsResult
}

왜 어떤 건 class고 어떤 건 object인가?

객체가 **개별적인 데이터(상태)**를 들고 있어야 하는지를 보면 됩니다.

  • data class (Success, Error): 성공 데이터나 에러 메시지는 매번 내용이 달라집니다. 객체마다 서로 다른 정보를 담아야 하므로 매번 새로 생성할 수 있는 class가 적합합니다.
  • data object (NoData): "데이터 없음"은 그 자체로 의미가 고정되어 있습니다. 굳이 여러 개를 만들 필요 없이 메모리에 하나만 올려두고 돌려쓰는 게 효율적이라 싱글톤인 object를 사용합니다.

2. 실전 활용 시나리오

Service: 명확한 결과 반환

단순히 null을 리턴하거나 예외를 던져 흐름을 끊는 대신, 비즈니스 로직의 결과를 타입에 담아 넘겨줍니다.

@Service
class AnalyticsService(private val clickLogRepository: ClickLogRepository) {

    fun getDashboardData(): AnalyticsResult {
        val totalClicks = clickLogRepository.countTotalClicks()
        
        // 1. 데이터가 없는 케이스
        if (totalClicks == 0L) return AnalyticsResult.NoData

        return try {
            // 2. 정상 조회 성공 케이스
            val topPosts = clickLogRepository.findTopPosts()
            AnalyticsResult.Success(AnalyticsDashboard(totalClicks, topPosts))
        } catch (e: Exception) {
            // 3. 예외 상황을 '데이터'로 변환
            AnalyticsResult.Error(message = "데이터 분석 중 오류 발생", code = "ANALYTICS_500")
        }
    }
}

Controller: 분기 처리를 통한 응답 생성

컨트롤러에서는 when 식을 사용해 결과를 소비합니다. 이때 모든 케이스를 처리하지 않으면 컴파일 에러가 나기 때문에 실수를 방지할 수 있습니다.

@RestController
@RequestMapping("/api/analytics")
class AnalyticsController(private val analyticsService: AnalyticsService) {

    @GetMapping("/dashboard")
    fun getDashboard(): ResponseEntity<*> {
        return when (val result = analyticsService.getDashboardData()) {
            is AnalyticsResult.Success -> 
                ResponseEntity.ok(result.data)
                
            is AnalyticsResult.NoData -> 
                ResponseEntity.noContent().build()
                
            is AnalyticsResult.Error -> 
                ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(ErrorResponse(result.message, result.code))
        }
    }
}

3. 이 패턴을 썼을 때의 확실한 장점

  1. 예외조차 '데이터'로 취급: 에러가 났을 때 흐름을 툭 끊는 게 아니라, 에러 정보를 봉투에 담아 넘겨주는 식입니다. 비즈니스 흐름을 깨지 않고 결과값으로 핸들링할 수 있어 관리가 훨씬 편해집니다.
  2. 런타임 에러를 컴파일 타임으로: when 식에서 특정 케이스를 누락하면 컴파일러가 바로 경고를 줍니다. "에러 처리를 깜빡했다"는 실수를 코딩 시점에 잡을 수 있습니다.
  3. 가독성과 스마트 캐스트: if (result == null) 같은 모호한 코드 대신 is NoData라고 쓰면 의도가 명확해집니다. 또한 is Success 체크만으로 내부 데이터에 즉시 접근할 수 있는 스마트 캐스트 기능 덕분에 코드가 간결해집니다.

4. 주의할 점 (Redis & JSON)

Sealed Interface는 다형성을 가지므로, Redis 캐시를 쓰거나 Jackson으로 직렬화할 때 주의가 필요합니다. 역직렬화 시 어떤 하위 클래스인지 구분할 수 있도록 @JsonTypeInfo 같은 설정을 통해 타입 정보를 JSON에 포함시켜야 에러가 발생하지 않습니다.