Kim-Baek 개발자 이야기

Kotlin 제어 구조와 타입 시스템 완벽 가이드 | when, if expression, Enum, Sealed Class 본문

개발/java basic

Kotlin 제어 구조와 타입 시스템 완벽 가이드 | when, if expression, Enum, Sealed Class

김백개발자 2025. 12. 4. 09:58
반응형

Kotlin의 강력한 제어 구조와 타입 시스템을 마스터하여 더 안전하고 간결한 코드를 작성하는 방법


목차

  1. 제어 구조의 진화: Expression vs Statement
  2. when - 강력한 패턴 매칭
  3. if expression - 더 이상 Statement가 아닙니다
  4. Enum Class - 타입 안전한 상수
  5. Sealed Class - 제한된 클래스 계층
  6. Companion Object - 정적 멤버의 Kotlin 방식
  7. 실전 예제: 네트워크 응답 처리

들어가며

Java에서 Kotlin으로 넘어오면서 가장 놀라웠던 것은 제어 구조가 Expression이라는 점이었습니다. if문에서 값을 반환받고, when문으로 복잡한 분기를 우아하게 처리하는 것을 보고 "이게 가능하다고?"라는 생각이 들었습니다.

특히 프로젝트에서 네트워크 응답이나 상태 관리를 할 때, Sealed Class와 when의 조합은 컴파일 타임에 모든 케이스를 처리했는지 보장해주어 런타임 오류를 크게 줄일 수 있었습니다.

오늘은 Kotlin의 제어 구조와 타입 시스템을 실전 예제와 함께 깊이 있게 다뤄보겠습니다. 각 개념의 동작 원리와 함께 언제, 왜 사용해야 하는지도 함께 알아보겠습니다.


1. 제어 구조의 진화: Expression vs Statement

Statement vs Expression의 차이

Java의 Statement 방식

// Java: if는 statement (값을 반환하지 않음)
int max;
if (a > b) {
    max = a;
} else {
    max = b;
}

Kotlin의 Expression 방식

// Kotlin: if는 expression (값을 반환)
val max = if (a > b) a else b

Expression의 장점

  1. 간결성: 불필요한 변수 선언 제거
  2. 불변성: val 사용 가능 (더 안전)
  3. 함수형 프로그래밍: 체이닝과 조합 가능
  4. 가독성: 의도가 더 명확

핵심 포인트

// ✅ 좋은 예: expression 활용
val result = if (score >= 60) "Pass" else "Fail"

// ❌ 나쁜 예: statement 방식
var result: String
if (score >= 60) {
    result = "Pass"
} else {
    result = "Fail"
}

2. when - 강력한 패턴 매칭

when은 Java의 switch를 훨씬 강력하게 만든 Kotlin의 핵심 기능입니다.

2.1 기본 사용법

단순 값 매칭

fun getGrade(score: Int): String {
    return when (score) {
        100 -> "Perfect!"
        in 90..99 -> "A"
        in 80..89 -> "B"
        in 70..79 -> "C"
        in 60..69 -> "D"
        else -> "F"
    }
}

여러 값 한 번에 처리

fun isWeekend(day: String): Boolean {
    return when (day) {
        "토요일", "일요일" -> true
        else -> false
    }
}

2.2 타입 체크와 스마트 캐스트

fun processData(data: Any): String {
    return when (data) {
        is String -> "문자열 길이: ${data.length}"  // 자동 캐스팅
        is Int -> "정수값: ${data * 2}"
        is List<*> -> "리스트 크기: ${data.size}"
        is Boolean -> if (data) "참" else "거짓"
        else -> "알 수 없는 타입"
    }
}

// 사용 예시
println(processData("Hello"))      // 문자열 길이: 5
println(processData(42))           // 정수값: 84
println(processData(listOf(1,2)))  // 리스트 크기: 2

2.3 조건식 활용

fun checkTemperature(temp: Int): String {
    return when {
        temp < 0 -> "영하입니다"
        temp < 10 -> "춥습니다"
        temp < 20 -> "선선합니다"
        temp < 30 -> "따뜻합니다"
        else -> "덥습니다"
    }
}

2.4 범위와 컬렉션 체크

fun checkNumber(num: Int): String {
    return when (num) {
        in 1..10 -> "1부터 10 사이"
        in listOf(11, 13, 17, 19) -> "소수"
        !in 0..100 -> "범위 밖"
        else -> "기타"
    }
}

2.5 when의 고급 활용

결과를 변수에 저장

val result = when (val code = getResponseCode()) {
    200 -> "성공"
    404 -> "찾을 수 없음"
    500 -> "서버 오류"
    else -> "알 수 없는 오류: $code"
}

여러 줄 처리

when (event) {
    is ClickEvent -> {
        logEvent("Click", event.position)
        handleClick(event)
        updateUI()
    }
    is ScrollEvent -> {
        logEvent("Scroll", event.offset)
        handleScroll(event)
    }
}

실전 팁: when 활용 시나리오

// 1. Enum과 함께 사용 (완전성 보장)
enum class Status { IDLE, LOADING, SUCCESS, ERROR }

fun handleStatus(status: Status) = when (status) {
    Status.IDLE -> showIdle()
    Status.LOADING -> showLoading()
    Status.SUCCESS -> showSuccess()
    Status.ERROR -> showError()
    // else 불필요 (모든 케이스 처리)
}

// 2. Sealed Class와 함께 (완벽한 타입 안전성)
sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val exception: Exception) : Result()
    object Loading : Result()
}

fun handle(result: Result) = when (result) {
    is Result.Success -> println("데이터: ${result.data}")
    is Result.Error -> println("오류: ${result.exception.message}")
    is Result.Loading -> println("로딩 중...")
    // else 불필요, 컴파일러가 완전성 체크
}

3. if expression - 더 이상 Statement가 아닙니다

3.1 기본 expression 사용

// Java 스타일 (비권장)
var status: String
if (isConnected) {
    status = "온라인"
} else {
    status = "오프라인"
}

// Kotlin 스타일 (권장)
val status = if (isConnected) "온라인" else "오프라인"

3.2 복잡한 로직도 expression으로

val price = if (isMember) {
    val discount = originalPrice * 0.1
    originalPrice - discount
} else {
    originalPrice
}

3.3 Elvis 연산자와 조합

val name = user?.name ?: "Guest"

// if expression과 조합
val displayName = if (user != null) {
    user.name ?: "Unknown"
} else {
    "Guest"
}

3.4 중첩 if expression

val result = if (score >= 90) {
    "A"
} else if (score >= 80) {
    "B"
} else if (score >= 70) {
    "C"
} else {
    "F"
}

// when으로 더 깔끔하게
val result = when {
    score >= 90 -> "A"
    score >= 80 -> "B"
    score >= 70 -> "C"
    else -> "F"
}

실전 활용: 뷰 상태 관리

// Android 예시
fun updateUI(isLoading: Boolean, data: List<Item>?) {
    binding.progressBar.visibility = if (isLoading) {
        View.VISIBLE
    } else {
        View.GONE
    }
    
    binding.emptyView.visibility = if (!isLoading && data.isNullOrEmpty()) {
        View.VISIBLE
    } else {
        View.GONE
    }
}

4. Enum Class - 타입 안전한 상수

4.1 기본 Enum

enum class Direction {
    NORTH, SOUTH, EAST, WEST
}

// 사용
val direction = Direction.NORTH

4.2 프로퍼티와 메서드를 가진 Enum

enum class Color(val rgb: Int) {
    RED(0xFF0000),
    GREEN(0x00FF00),
    BLUE(0x0000FF);  // 세미콜론 필수!
    
    fun containsRed(): Boolean {
        return (rgb and 0xFF0000) != 0
    }
}

// 사용
println(Color.RED.rgb)              // 16711680
println(Color.RED.containsRed())    // true

4.3 인터페이스 구현

interface Clickable {
    fun click()
}

enum class Button : Clickable {
    OK {
        override fun click() = println("OK 클릭")
    },
    CANCEL {
        override fun click() = println("Cancel 클릭")
    }
}

// 사용
Button.OK.click()  // OK 클릭

4.4 Enum의 유용한 메서드

enum class Priority {
    LOW, MEDIUM, HIGH, URGENT
}

fun main() {
    // 모든 값 가져오기
    Priority.values().forEach { println(it) }
    
    // 문자열로 변환
    val priority = Priority.valueOf("HIGH")
    println(priority)  // HIGH
    
    // 이름과 순서
    println(Priority.HIGH.name)      // HIGH
    println(Priority.HIGH.ordinal)   // 2
    
    // 안전한 변환
    val maybePriority = Priority.values()
        .find { it.name == "MEDIUM" }
}

4.5 when과 함께 사용

enum class HttpStatus(val code: Int) {
    OK(200),
    NOT_FOUND(404),
    INTERNAL_ERROR(500)
}

fun handleResponse(status: HttpStatus) = when (status) {
    HttpStatus.OK -> "성공"
    HttpStatus.NOT_FOUND -> "찾을 수 없음"
    HttpStatus.INTERNAL_ERROR -> "서버 오류"
    // else 불필요 (모든 케이스 처리)
}

실전 예제: 네트워크 상태 관리

enum class NetworkState(val message: String) {
    CONNECTED("인터넷 연결됨"),
    DISCONNECTED("인터넷 연결 끊김"),
    CONNECTING("연결 중..."),
    ERROR("연결 오류");
    
    fun isAvailable(): Boolean {
        return this == CONNECTED
    }
    
    companion object {
        fun fromBoolean(isConnected: Boolean): NetworkState {
            return if (isConnected) CONNECTED else DISCONNECTED
        }
    }
}

// 사용
val state = NetworkState.CONNECTED
println(state.message)        // 인터넷 연결됨
println(state.isAvailable())  // true

5. Sealed Class - 제한된 클래스 계층

Sealed Class는 Enum의 확장판이라고 생각할 수 있습니다. 각 하위 클래스가 서로 다른 데이터를 가질 수 있다는 점이 핵심입니다.

5.1 기본 구조

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String, val code: Int) : Result()
    object Loading : Result()
}

5.2 when과의 완벽한 조합

fun handleResult(result: Result) = when (result) {
    is Result.Success -> println("성공: ${result.data}")
    is Result.Error -> println("오류 ${result.code}: ${result.message}")
    is Result.Loading -> println("로딩 중...")
    // else 불필요! 컴파일러가 완전성 체크
}

5.3 Enum vs Sealed Class

Enum의 한계

// ❌ Enum은 모든 인스턴스가 같은 구조
enum class Result {
    SUCCESS,  // 추가 데이터를 가질 수 없음
    ERROR     // 각기 다른 데이터 불가
}

Sealed Class의 유연성

// ✅ Sealed Class는 각 하위 타입이 다른 데이터 보유
sealed class Result {
    data class Success(val data: String, val timestamp: Long) : Result()
    data class Error(val exception: Exception, val retryable: Boolean) : Result()
    object Empty : Result()
}

5.4 실전 예제: API 응답 처리

sealed class ApiResponse<out T> {
    data class Success<T>(val data: T) : ApiResponse<T>()
    data class Error(
        val message: String,
        val code: Int,
        val exception: Throwable? = null
    ) : ApiResponse<Nothing>()
    object Loading : ApiResponse<Nothing>()
}

// 사용
fun <T> handleApiResponse(response: ApiResponse<T>) = when (response) {
    is ApiResponse.Success -> {
        println("데이터 수신: ${response.data}")
        // 데이터 처리
    }
    is ApiResponse.Error -> {
        println("오류 발생: ${response.message} (코드: ${response.code})")
        response.exception?.printStackTrace()
    }
    is ApiResponse.Loading -> {
        println("데이터 로딩 중...")
    }
}

// 실제 사용 예시
data class User(val name: String, val age: Int)

val response: ApiResponse<User> = ApiResponse.Success(
    User("홍길동", 30)
)
handleApiResponse(response)

5.5 계층 구조 활용

sealed class UIEvent {
    // 클릭 이벤트 그룹
    sealed class Click : UIEvent() {
        object ButtonClick : Click()
        data class ItemClick(val position: Int) : Click()
    }
    
    // 스크롤 이벤트 그룹
    sealed class Scroll : UIEvent() {
        data class VerticalScroll(val offset: Int) : Scroll()
        data class HorizontalScroll(val offset: Int) : Scroll()
    }
    
    // 입력 이벤트
    data class TextInput(val text: String) : UIEvent()
}

fun handleEvent(event: UIEvent) = when (event) {
    is UIEvent.Click.ButtonClick -> println("버튼 클릭")
    is UIEvent.Click.ItemClick -> println("아이템 ${event.position} 클릭")
    is UIEvent.Scroll.VerticalScroll -> println("세로 스크롤: ${event.offset}")
    is UIEvent.Scroll.HorizontalScroll -> println("가로 스크롤: ${event.offset}")
    is UIEvent.TextInput -> println("입력: ${event.text}")
}

5.6 상태 관리 패턴

sealed class ScreenState<out T> {
    object Initial : ScreenState<Nothing>()
    object Loading : ScreenState<Nothing>()
    data class Success<T>(val data: T) : ScreenState<T>()
    data class Error(val message: String) : ScreenState<Nothing>()
    object Empty : ScreenState<Nothing>()
}

// ViewModel에서 사용
class UserViewModel : ViewModel() {
    private val _state = MutableStateFlow<ScreenState<List<User>>>(
        ScreenState.Initial
    )
    val state: StateFlow<ScreenState<List<User>>> = _state
    
    fun loadUsers() {
        viewModelScope.launch {
            _state.value = ScreenState.Loading
            
            try {
                val users = userRepository.getUsers()
                _state.value = if (users.isEmpty()) {
                    ScreenState.Empty
                } else {
                    ScreenState.Success(users)
                }
            } catch (e: Exception) {
                _state.value = ScreenState.Error(e.message ?: "알 수 없는 오류")
            }
        }
    }
}

// UI에서 상태 처리
fun renderState(state: ScreenState<List<User>>) = when (state) {
    is ScreenState.Initial -> showInitial()
    is ScreenState.Loading -> showLoading()
    is ScreenState.Success -> showUsers(state.data)
    is ScreenState.Error -> showError(state.message)
    is ScreenState.Empty -> showEmpty()
}

6. Companion Object - 정적 멤버의 Kotlin 방식

Java의 static을 대체하는 Kotlin의 우아한 방법입니다.

6.1 기본 사용법

class MyClass {
    companion object {
        const val TAG = "MyClass"
        
        fun create(): MyClass {
            return MyClass()
        }
    }
}

// 사용
val tag = MyClass.TAG
val instance = MyClass.create()

6.2 팩토리 패턴

class User private constructor(
    val name: String,
    val email: String
) {
    companion object {
        fun fromJson(json: String): User {
            // JSON 파싱 로직
            return User("John", "john@example.com")
        }
        
        fun create(name: String, email: String): User {
            require(email.contains("@")) { "올바른 이메일이 아닙니다" }
            return User(name, email)
        }
    }
}

// 사용
val user1 = User.create("홍길동", "hong@example.com")
val user2 = User.fromJson("""{"name":"김철수","email":"kim@example.com"}""")

6.3 이름이 있는 Companion Object

class Database {
    companion object Factory {
        fun getInstance(): Database {
            return Database()
        }
    }
}

// 두 가지 방법으로 접근 가능
val db1 = Database.getInstance()
val db2 = Database.Factory.getInstance()

6.4 인터페이스 구현

interface Factory<T> {
    fun create(): T
}

class User(val name: String) {
    companion object : Factory<User> {
        override fun create(): User {
            return User("Default User")
        }
    }
}

// 사용
val user = User.create()

6.5 확장 함수

class Person(val name: String) {
    companion object
}

// Companion Object에 확장 함수 추가
fun Person.Companion.create(name: String): Person {
    return Person(name)
}

// 사용
val person = Person.create("홍길동")

6.6 실전 예제: 싱글톤 패턴

class DatabaseManager private constructor() {
    companion object {
        @Volatile
        private var INSTANCE: DatabaseManager? = null
        
        fun getInstance(): DatabaseManager {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: DatabaseManager().also {
                    INSTANCE = it
                }
            }
        }
    }
    
    fun query(sql: String) {
        println("쿼리 실행: $sql")
    }
}

// 더 간단한 방법: object 사용
object SimpleDatabaseManager {
    fun query(sql: String) {
        println("쿼리 실행: $sql")
    }
}

6.7 상수와 정적 메서드

class Constants {
    companion object {
        // const: 컴파일 타임 상수
        const val MAX_COUNT = 100
        const val API_KEY = "your_api_key"
        
        // 일반 프로퍼티: 런타임에 계산
        val TAG = this::class.java.simpleName
        
        @JvmStatic  // Java에서 정적 메서드처럼 접근 가능
        fun log(message: String) {
            println("[$TAG] $message")
        }
    }
}

7. 실전 예제: 네트워크 응답 처리

모든 개념을 종합한 실전 예제입니다.

7.1 응답 타입 정의

// Sealed Class로 모든 가능한 상태 표현
sealed class NetworkResult<out T> {
    data class Success<T>(
        val data: T,
        val code: Int = 200
    ) : NetworkResult<T>()
    
    data class Error(
        val message: String,
        val code: Int,
        val exception: Throwable? = null
    ) : NetworkResult<Nothing>()
    
    object Loading : NetworkResult<Nothing>()
    
    // Companion Object로 팩토리 메서드 제공
    companion object {
        fun <T> success(data: T, code: Int = 200): NetworkResult<T> {
            return Success(data, code)
        }
        
        fun error(message: String, code: Int, exception: Throwable? = null): NetworkResult<Nothing> {
            return Error(message, code, exception)
        }
        
        fun loading(): NetworkResult<Nothing> {
            return Loading
        }
    }
}

// HTTP 상태 코드를 Enum으로 관리
enum class HttpStatus(val code: Int, val message: String) {
    OK(200, "성공"),
    CREATED(201, "생성됨"),
    BAD_REQUEST(400, "잘못된 요청"),
    UNAUTHORIZED(401, "인증 필요"),
    FORBIDDEN(403, "접근 거부"),
    NOT_FOUND(404, "찾을 수 없음"),
    INTERNAL_ERROR(500, "서버 오류");
    
    companion object {
        fun fromCode(code: Int): HttpStatus? {
            return values().find { it.code == code }
        }
    }
}

7.2 Repository 구현

class UserRepository {
    suspend fun getUser(userId: String): NetworkResult<User> {
        return try {
            // 로딩 상태 반환
            NetworkResult.loading()
            
            // API 호출 시뮬레이션
            delay(1000)
            
            // when expression으로 응답 코드 처리
            val responseCode = 200  // API에서 받은 코드
            
            when (val status = HttpStatus.fromCode(responseCode)) {
                HttpStatus.OK -> {
                    val user = User("홍길동", 30)
                    NetworkResult.success(user)
                }
                HttpStatus.NOT_FOUND -> {
                    NetworkResult.error(
                        message = "사용자를 찾을 수 없습니다",
                        code = status.code
                    )
                }
                HttpStatus.UNAUTHORIZED -> {
                    NetworkResult.error(
                        message = "인증이 필요합니다",
                        code = status.code
                    )
                }
                else -> {
                    NetworkResult.error(
                        message = status?.message ?: "알 수 없는 오류",
                        code = responseCode
                    )
                }
            }
        } catch (e: Exception) {
            NetworkResult.error(
                message = "네트워크 오류가 발생했습니다",
                code = 0,
                exception = e
            )
        }
    }
}

7.3 ViewModel에서 사용

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    
    private val _userState = MutableStateFlow<NetworkResult<User>>(
        NetworkResult.Loading
    )
    val userState: StateFlow<NetworkResult<User>> = _userState
    
    fun loadUser(userId: String) {
        viewModelScope.launch {
            _userState.value = NetworkResult.Loading
            
            val result = repository.getUser(userId)
            
            // when으로 결과 처리
            _userState.value = when (result) {
                is NetworkResult.Success -> {
                    logSuccess(result.data)
                    result
                }
                is NetworkResult.Error -> {
                    logError(result.message, result.code)
                    result
                }
                is NetworkResult.Loading -> result
            }
        }
    }
    
    private fun logSuccess(user: User) {
        println("사용자 로드 성공: ${user.name}")
    }
    
    private fun logError(message: String, code: Int) {
        println("사용자 로드 실패: $message (코드: $code)")
    }
}

7.4 UI에서 상태 처리

@Composable
fun UserScreen(viewModel: UserViewModel) {
    val userState by viewModel.userState.collectAsState()
    
    // when expression으로 UI 상태 처리
    when (val state = userState) {
        is NetworkResult.Loading -> {
            LoadingIndicator()
        }
        is NetworkResult.Success -> {
            UserContent(user = state.data)
        }
        is NetworkResult.Error -> {
            ErrorView(
                message = state.message,
                onRetry = { viewModel.loadUser("userId") }
            )
        }
    }
}

@Composable
fun UserContent(user: User) {
    Column {
        Text("이름: ${user.name}")
        Text("나이: ${user.age}")
    }
}

@Composable
fun ErrorView(message: String, onRetry: () -> Unit) {
    Column {
        Text(text = message, color = Color.Red)
        Button(onClick = onRetry) {
            Text("다시 시도")
        }
    }
}

@Composable
fun LoadingIndicator() {
    CircularProgressIndicator()
}

7.5 확장 함수로 편의성 추가

// NetworkResult 확장 함수
fun <T> NetworkResult<T>.getOrNull(): T? = when (this) {
    is NetworkResult.Success -> data
    else -> null
}

fun <T> NetworkResult<T>.isSuccess(): Boolean = this is NetworkResult.Success

fun <T> NetworkResult<T>.isError(): Boolean = this is NetworkResult.Error

fun <T> NetworkResult<T>.isLoading(): Boolean = this is NetworkResult.Loading

// 사용 예시
val result: NetworkResult<User> = repository.getUser("123")

if (result.isSuccess()) {
    val user = result.getOrNull()
    println("사용자: ${user?.name}")
}

마치며

핵심 요약

  1. when: Java의 switch보다 훨씬 강력한 패턴 매칭
    • 범위, 타입 체크, 조건식 모두 가능
    • Sealed Class와 함께 사용하면 완전성 보장
  2. if expression: 값을 반환하는 if문
    • 간결하고 불변성 유지
    • 삼항 연산자 불필요
  3. Enum: 타입 안전한 상수 집합
    • 프로퍼티와 메서드 포함 가능
    • when과 함께 사용하면 else 불필요
  4. Sealed Class: 제한된 클래스 계층
    • 각 하위 클래스가 다른 데이터 보유 가능
    • 상태 관리에 완벽
  5. Companion Object: static의 Kotlin 방식
    • 팩토리 패턴에 이상적
    • 인터페이스 구현 가능

다음 단계

이제 여러분은 Kotlin의 제어 구조와 타입 시스템을 자유자유롭게 다룰 수 있습니다. 이 개념들을 조합하면:

  • 더 안전한 코드: 컴파일 타임에 오류 잡기
  • 더 간결한 코드: 불필요한 보일러플레이트 제거
  • 더 읽기 쉬운 코드: 의도가 명확한 표현

다음 포스팅에서는 Kotlin의 컬렉션 API와 함수형 프로그래밍 기법을 다룰 예정입니다. 더욱 강력한 Kotlin 코드를 작성하는 방법을 배워보세요!


함께 보면 좋은 글

▶ Kotlin 기초 완벽 가이드 | 변수, 함수, 클래스
▶ Kotlin Null Safety 마스터하기 | ?, !!, let, run
▶ Kotlin Coroutine 입문 | 비동기 프로그래밍의 새로운 패러다임


참고 자료

  • Kotlin 공식 문서: kotlinlang.org
  • Effective Kotlin by Marcin Moskała
  • Kotlin in Action by Dmitry Jemerov & Svetlana Isakova

작성일: 2025년 1월
카테고리: Kotlin, 프로그래밍, 안드로이드 개발
태그: #Kotlin #when #SealedClass #Enum #CompanionObject #제어구조 #타입시스템

반응형
Comments