Java 프로젝트에서 NPE(NullPointerException)가 전체 런타임 에러의 40% 이상을 차지한다는 통계가 있다. Tony Hoare는 null을 설계한 것을 “10억 달러짜리 실수”라고 불렀다. Kotlin은 그 실수를 언어 레벨에서 바로잡은 언어다. Kotlin은 Java의 단축 버전이 아니라, Java가 설계 단계에서 못 고친 결함들을 고친 언어다.

비유 — 영어 계약서 vs 한국어 계약서

Java와 Kotlin은 같은 JVM 위에서 돌아간다. 영어 계약서와 한국어 계약서가 같은 법원에서 효력을 갖는 것처럼, 두 언어는 동일한 바이트코드로 컴파일된다. 그런데 한국어 계약서에는 “이 조항은 null일 수 없다”고 명시할 수 있고, 컴파일러가 그걸 강제 검토한다. 영어 계약서(Java)는 “아마 null이 아닐 거야”라고 믿고 서명한다.


변수 선언 — val과 var의 의미

val name: String = "홍길동"      // 불변 — Java의 final String
var mutableName: String = "홍길동" // 가변
val age = 30                      // 타입 추론 — Int로 자동 결정

val이 중요한 이유: 불변 변수는 멀티스레드 환경에서 동기화 없이 안전하게 공유된다. var로 선언한 변수가 여러 스레드에서 수정되면? 레이스 컨디션이 생긴다. Kotlin은 기본값을 불변(val)으로 유도하는 방식으로 동시성 버그를 설계 단계에서 줄인다.


data class — 보일러플레이트 300줄의 종말

Java에서 단순한 데이터 객체 하나를 만들려면 생성자, getter/setter, equals, hashCode, toString을 직접 작성하거나 Lombok에 의존해야 한다.

// Java — 실제로 이게 필요한 코드 전부
public class Person {
    private final String name;
    private final int age;
    private String email;

    public Person(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person p = (Person) o;
        return age == p.age && Objects.equals(name, p.name) && Objects.equals(email, p.email);
    }

    @Override
    public int hashCode() { return Objects.hash(name, age, email); }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + ", email='" + email + "'}";
    }
}
// Kotlin — 완전히 동일한 기능
data class Person(
    val name: String,
    val age: Int,
    var email: String
)
// equals, hashCode, toString, copy() 자동 생성

data class가 없으면? equals를 손으로 잘못 구현해서 Set에서 중복 제거가 안 되거나, hashCode가 equals와 일치하지 않아서 HashMap에서 조회가 안 되는 버그가 생긴다. Kotlin은 이걸 컴파일러가 보장한다.

copy()의 가치는 불변 객체를 다룰 때 특히 크다:

val original = Person("홍길동", 30, "hong@example.com")
// 나이만 바꾼 새 객체 — original은 변경 없음
val older = original.copy(age = 31)

// Order 상태 변경 패턴
val pendingOrder = Order(1L, 100L, emptyList(), OrderStatus.PENDING)
val confirmedOrder = pendingOrder.copy(status = OrderStatus.CONFIRMED)
// pendingOrder는 그대로 — 이벤트 소싱 패턴에서 핵심

Null Safety — 컴파일러가 NPE를 막는 원리

graph TD
    Java["Java\n모든 참조 타입이 null 가능\n→ 런타임에야 NPE 발생"] --> Problem["NullPointerException\n스택 트레이스로 추적\n새벽 3시 장애"]

    Kotlin["Kotlin\nnon-null: String\nnullable: String?"] --> Compile["컴파일 타임 체크\n?. 연산자 강제"]

    Compile --> Safe["런타임 NPE 발생 불가\n(!! 연산자 남용 제외)"]

Kotlin의 타입 시스템은 null 가능 여부를 타입에 인코딩한다. String은 null이 될 수 없고, String?는 될 수 있다. 컴파일러가 이걸 강제한다.

// Non-null 타입 — null 대입 자체가 컴파일 에러
var name: String = "홍길동"
// name = null  → 컴파일 에러: Null can not be a value of a non-null type String

// Nullable 타입 — 사용하려면 반드시 null 체크
var nullableName: String? = "홍길동"
nullableName = null  // OK

// 안전 호출 연산자 ?. — null이면 실행 자체를 건너뜀
println(nullableName?.length)       // null이면 null 출력, 아니면 길이 출력
nullableName?.uppercase()?.trim()   // 체이닝 가능

// Elvis 연산자 ?: — null일 때 기본값 지정
val length = nullableName?.length ?: 0      // null이면 0
val display = nullableName ?: "이름 없음"   // null이면 "이름 없음"

// 스마트 캐스트 — if 체크 후 자동으로 non-null로 인식
if (nullableName != null) {
    println(nullableName.length)  // null 체크 통과 → String으로 자동 캐스트
}

// let — null일 때 블록 자체를 건너뜀
nullableName?.let { name ->
    println("이름: $name, 길이: ${name.length}")
    emailService.send(name)  // null이면 이 블록 전체 실행 안 됨
}

!! 연산자는 “내가 null이 아님을 보장한다”는 선언이다. 틀리면 NPE가 발생한다. !!가 코드베이스에 많다면 Kotlin의 null safety를 Java처럼 쓰고 있다는 신호다.

// !! 남용 — Kotlin의 이점을 버리는 패턴
val name = user!!.profile!!.name!!  // user, profile, name 중 하나라도 null이면 NPE

// 올바른 패턴
val name = user?.profile?.name ?: "익명"

Java 코드와 상호운용 시 주의점

// Java 메서드의 반환값은 타입을 알 수 없음 (플랫폼 타입 String!)
val javaResult = JavaService.findUser()  // String! — null 여부 모름

// 안전하게 처리하려면 명시적으로 nullable로 받아야 함
val safe: String? = JavaService.findUser()   // nullable로 처리 (권장)
val unsafe: String = JavaService.findUser()  // non-null 주장 (NPE 위험)

sealed class — 컴파일러가 모든 경우를 강제

Java의 상속 구조는 “어떤 하위 클래스가 있는지” 컴파일러가 알 수 없다. 새 하위 클래스가 추가되면 switch 문에서 처리 누락이 발생해도 컴파일 에러가 안 난다.

// 모든 가능한 상태를 타입으로 정의
sealed class ApiResponse<out T> {
    data class Success<T>(val data: T, val statusCode: Int = 200) : ApiResponse<T>()
    data class Error(val message: String, val statusCode: Int) : ApiResponse<Nothing>()
    object NetworkError : ApiResponse<Nothing>()
    object Loading : ApiResponse<Nothing>()
}

// when이 else 없이 컴파일됨 — 새 하위 클래스 추가 시 컴파일 에러로 누락 방지
fun handleResponse(response: ApiResponse<User>) {
    when (response) {
        is ApiResponse.Success -> showUser(response.data)
        is ApiResponse.Error -> showError("${response.statusCode}: ${response.message}")
        ApiResponse.NetworkError -> showRetryButton()
        ApiResponse.Loading -> showSpinner()
        // else 불필요 — 컴파일러가 모든 경우를 알고 있음
    }
}

sealed class 없이 Java로 같은 걸 구현하면? 새 상태를 추가할 때 모든 when/switch 문을 찾아 수동으로 업데이트해야 한다. 하나라도 빠뜨리면 런타임에야 발견한다.


확장 함수 — 기존 클래스를 수정하지 않고 기능 추가

라이브러리 클래스나 Java 클래스를 상속할 수 없는 상황에서, 유틸리티 클래스(StringUtils, DateUtils)를 만들지 않고 기능을 추가할 수 있다.

// String 클래스에 이메일 검증 추가 — String 소스코드 건드리지 않고
fun String.isValidEmail(): Boolean {
    return this.contains("@") && this.contains(".")
}

fun String.toKoreanWon(): String {
    return NumberFormat.getCurrencyInstance(Locale.KOREA).format(this.toLong())
}

// 사용 — 마치 String의 원래 메서드처럼
"user@example.com".isValidEmail()  // true
"hello".isValidEmail()             // false
"10000".toKoreanWon()              // ₩10,000

// null-safe 확장 함수
fun String?.orEmpty(): String = this ?: ""

확장 함수 없이 Java 스타일로 작성하면:

// Java
StringUtils.isValidEmail("user@example.com")  // 메서드가 앞에 와서 가독성 나쁨

Spring Data Repository 확장 — 실무에서 자주 쓰는 패턴:

// findById가 Optional<T>를 반환하는 게 불편할 때
fun <T, ID> JpaRepository<T, ID>.findByIdOrThrow(id: ID): T =
    findById(id).orElseThrow { EntityNotFoundException("Entity with id $id not found") }

// 사용
val member = memberRepository.findByIdOrThrow(1L)  // null이면 즉시 예외
// Optional.get(), orElseThrow() 보일러플레이트 없음

스코프 함수 — null 처리와 초기화 패턴

스코프 함수(let, run, apply, also, with)는 처음 보면 “이게 왜 필요하지?” 싶지만, 각각 명확한 용도가 있다.

graph TD
    ScopeFn["스코프 함수"] --> LambdaResult["반환값: 람다 결과"]
    ScopeFn --> ReceiverResult["반환값: 수신 객체 자신"]

    LambdaResult --> Let["let\n수신 객체를 it으로\nnull 체크 + 변환에 사용"]
    LambdaResult --> Run["run\n수신 객체를 this로\n여러 연산 후 결과 반환"]
    LambdaResult --> With["with\n수신 객체를 this로\n인자로 전달하는 형태"]

    ReceiverResult --> Apply["apply\n수신 객체를 this로\n초기화 빌더 패턴"]
    ReceiverResult --> Also["also\n수신 객체를 it으로\n부수 효과 (로깅 등)"]
// let — null 체크 후 변환할 때
val email: String? = getEmail()
val upperEmail = email?.let { it.trim().uppercase() } ?: "NO_EMAIL"
// email이 null이면 블록 실행 안 됨

// apply — 객체 초기화 (Builder 패턴 대체)
val user = User().apply {
    name = "홍길동"          // this.name = "홍길동"과 동일
    email = "hong@example.com"
    age = 30
}  // User 객체 반환

// also — 부수 효과 추가 (메서드 체이닝 중간에 로깅)
val processedUser = createUser()
    .also { log.info("사용자 생성됨: {}", it.name) }
    .also { auditService.record(it) }
// createUser()의 결과가 그대로 반환됨

// run — 여러 작업 후 결과값이 필요할 때
val orderSummary = run {
    val orders = orderRepository.findAll()
    val total = orders.sumOf { it.price }
    OrderSummary(count = orders.size, total = total)  // 마지막 표현식이 반환값
}

Spring에서 Kotlin 실전 사용

컨트롤러 — Java 대비 차이

@RestController
@RequestMapping("/api/orders")
class OrderController(
    private val orderService: OrderService  // 생성자 주입 — @Autowired 불필요
) {

    @GetMapping("/{id}")
    fun getOrder(@PathVariable id: Long): ResponseEntity<OrderResponse> {
        val order = orderService.findById(id)
        return ResponseEntity.ok(OrderResponse.from(order))
    }

    @PostMapping
    fun createOrder(
        @RequestBody request: CreateOrderRequest,
        @AuthenticationPrincipal user: CustomUserDetails
    ): ResponseEntity<CreateOrderResponse> {
        val orderId = orderService.createOrder(
            memberId = user.id,      // named argument — 파라미터 순서 실수 방지
            itemId = request.itemId,
            quantity = request.quantity
        )
        return ResponseEntity
            .created(URI.create("/api/orders/$orderId"))
            .body(CreateOrderResponse(orderId))
    }
}

// DTO — data class로 한 줄
data class CreateOrderRequest(
    val itemId: Long,
    @field:Min(1) val quantity: Int  // Bean Validation — @field: 접두어 필요
)

data class CreateOrderResponse(val orderId: Long)

JPA 엔티티 — 주의점

@Entity
@Table(name = "member")
class Member(
    @Column(nullable = false)
    var name: String,

    @Column(unique = true, nullable = false)
    val email: String,

    @Enumerated(EnumType.STRING)
    var status: MemberStatus = MemberStatus.ACTIVE  // 기본값
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L  // JPA가 관리 — 0L은 영속화 전 임시값

    @OneToMany(mappedBy = "member", cascade = [CascadeType.ALL])
    val orders: MutableList<Order> = mutableListOf()

    // 비즈니스 메서드
    fun deactivate() {
        status = MemberStatus.INACTIVE
    }
}

JPA 엔티티는 data class로 만들면 안 된다. equalshashCode가 id 기반으로 동작해야 하는데, data class는 모든 필드를 기준으로 동작하기 때문이다. 또한 프록시 객체 생성을 위해 no-arg 생성자가 필요하므로 kotlin("plugin.jpa") 플러그인이 필수다.


점진적 마이그레이션 전략

flowchart TD
    A["Java 프로젝트"] --> B["1단계: 새 파일은 Kotlin으로 작성\nJava-Kotlin 공존 가능"]
    B --> C["2단계: DTO, Value Object를 data class로\n가장 안전하고 효과 큰 변환"]
    C --> D["3단계: 서비스 계층 변환\nnull safety + scope function 도입"]
    D --> E["4단계: 컨트롤러 변환\n생성자 주입 간결화"]
    E --> F["완전한 Kotlin 프로젝트\nbuild.gradle.kts로 전환"]

    B -->|"양방향 호출 가능"| G["@JvmStatic, @JvmField\nJava에서 Kotlin 호출 지원"]

Java에서 Kotlin 코드를 호출할 때 추가 어노테이션이 필요한 경우:

// Kotlin 유틸리티 — Java에서 호출 가능하게
object StringUtils {
    @JvmStatic  // Java에서 StringUtils.capitalizeWords() 형태로 호출 가능
    fun capitalizeWords(input: String): String =
        input.split(" ").joinToString(" ") { word ->
            word.replaceFirstChar { it.uppercase() }
        }
}

data class ApiResult<T>(val data: T?, val error: String?) {
    companion object {
        @JvmStatic
        fun <T> success(data: T) = ApiResult(data, null)
        @JvmStatic
        fun <T> failure(error: String) = ApiResult<T>(null, error)
    }
}
// Java에서 호출
String result = StringUtils.capitalizeWords("hello world");
ApiResult<User> ok = ApiResult.success(user);

함수형 스타일로 비즈니스 로직

// 주문 목록에서 완료된 주문의 회원별 통계 계산
fun processOrders(orders: List<Order>): OrderSummary {
    return orders
        .filter { it.status == OrderStatus.COMPLETED }    // 완료된 주문만
        .groupBy { it.memberId }                           // 회원별로 그룹화
        .mapValues { (_, memberOrders) ->
            MemberOrderStats(
                count = memberOrders.size,
                totalAmount = memberOrders.sumOf { it.totalPrice },
                lastOrderDate = memberOrders.maxOf { it.createdAt }
            )
        }
        .let { stats ->
            OrderSummary(
                totalOrders = orders.size,
                completedOrders = orders.count { it.status == OrderStatus.COMPLETED },
                memberStats = stats
            )
        }
}

// runCatching — try-catch 대신 Result 타입으로
suspend fun fetchUserSafely(id: Long): Result<User> =
    runCatching { userService.findById(id) }

// 체이닝으로 에러 처리
val displayName = fetchUserSafely(userId)
    .map { it.name.uppercase() }
    .recover { error ->
        log.warn("사용자 조회 실패: ${error.message}")
        "Unknown"
    }
    .getOrThrow()

Java vs Kotlin 핵심 비교

기능 Java Kotlin 이유
null 안전 X (런타임 NPE) O (컴파일 타임) 타입 시스템에 nullable 인코딩
데이터 클래스 수동 또는 Lombok data class 컴파일러가 보일러플레이트 생성
불변 선언 final val 기본값을 불변으로 유도
확장 함수 없음 있음 유틸리티 클래스 없이 기능 추가
스마트 캐스트 없음 (캐스트 필요) 있음 null/타입 체크 후 자동 캐스트
문자열 템플릿 + 연산 "$variable" 가독성
Default 파라미터 오버로딩 fun f(x: Int = 0) 오버로딩 없이 선택적 파라미터
싱글톤 수동 구현 object 컴파일러 보장 싱글톤
sealed class 없음 sealed class 컴파일러가 하위 타입 완전성 검사
Coroutine CompletableFuture suspend fun 동기 코드처럼 보이는 비동기

카테고리:

업데이트:

댓글