
오늘은 kotlin의 함수와 클래스, 인스턴스에 대해서 배웠다.
함수
함수는 `fun` 키워드를 통해서 만들 수 있다. `fun 함수명(매개변수){}` 형태로 만들 수 있으며, Java와 달리 오버로딩 없이도 함수 선언이 가능하다. 이것을 디폴트 매개변수라고 하는데, 예를 들어 회원가입에서 Java는 다음과 같이 적용되었다.
public class UserService {
// 1. 이름만 아는 경우
public void register(String name) {
register(name, 0, "없음"); // 3번 함수를 호출
}
// 2. 이름과 나이만 아는 경우
public void register(String name, int age) {
register(name, age, "없음"); // 3번 함수를 호출
}
// 3. 다 아는 경우 (메인 로직)
public void register(String name, int age, String email) {
System.out.println(name + "님(" + age + "세, " + email + ") 가입 완료");
}
}
이런 식으로 오버로딩을 포함하여 3가지의 경우 수를 만들어서 시그니처를 다르게 설정해야 작동이 되었는데, Kotlin은 이런 부분을 보완했다.
// 함수 하나로 3가지 경우를 모두 커버할 수 있다.
fun register(name: String, age: Int = 0, email: String = "없음") {
println("${name}님(${age}세, ${email}) 가입 완료")
}
이러한 function 선언은 PHP를 해본 사람 입장에서는 굉장히 친숙하다. PHP 또한 매개변수의 기본값을 지정하고, null이어도 대체할 수 있기 때문이다.
function register($name, $age = 0, $email = "없음") {
echo "$name님($age세, $email) 가입 완료";
}
이 덕분에 kotlin의 함수는 PHP를 다루던 입장에서는 조금 더 편하게 파악할 수 있을 것이다.
Kotlin의 재미있는 다른 점은, 인자를 명명해 넣으면 순서가 좀 달라도 들어갈 수 있다는 점이다. `register(email = "hong@test.com", age = 25, name = "홍길동")` 형태로 넣으면, 함수에서 다르더라도 그대로 적용된다. PHP의 경우도 8.0부터 해당 문법을 지원하기 시작했다.
그리고 Kotlin은 단일 표현식 함수를 지원한다. 이는 중괄호{} 내부의 로직이 단 한 줄의 표현식으로 충분히 표현 가능한 경우, `fun 함수(매개변수): 형 = 표현식` 형태로 `=`를 사용하여 간단하게 축약하는 기능이다. `fun sum(a: Int, b: Int): Int = a + b`가 그 대표적인 형태라고 할 수 있다. 이러한 단일 표현식 함수는 마치 JavaScript나 React에서 사용하는 람다식(화살표 함수)의 축약 방식과 매우 닮아있다는 게 특기할 점이다.
클래스, 인스턴스
Kotlin의 클래스는 Java의 클래스보다 좀 더 간결해진다. 특히 주 생성자(Primary Constructor)가 클래스 이름 바로 옆에(헤더에) 붙는다는 점이 차이점이다.

그러나 Kotlin에서는 이러한 번거로운 대입 과정 없이, 클래스 헤더에 주 생성자를 적어주는 것만으로도 충분히 기능한다. 헤더 괄호`()` 안에 val이나 var 키워드와 함께 매개변수를 선언하면, 내부적으로 멤버 변수 선언, 생성자 매개변수 수령, `this.id = id` 대입 로직이 한 번에 자동으로 처리된다.
또한 Java에서 필드(field)들이 프로퍼티(property)라는 것으로 불리게 되었는데, 정확히 말해서 프로퍼티는 다음과 같다고 할 수 있다.
프로퍼티(Property) = 필드(Field) + 게터(Getter) + Setter(세터)
덕분에 코틀린 클래스 내부에 변수(`val` 또는 `var`로 불변성을 정한다.)를 선언하면, 컴파일러가 변수뿐만 아니라 Getter와 Setter 함수까지 눈에 안 보이게 자동으로 만들어 주게 되었다. 또한 클래스 내 객체 생성(인스턴스 생성)도 `new` 키워드를 사용하지 않고 객체 생성을 할 수 있게 되어, 다음과 같이 간단하게 만들어지게 되었다.
class User(val name: String, var age: Int)
fun main() {
val user = User("홍길동", 25) // 인스턴스 생성 (new 키워드 없음)
// 1. 값 읽기 (내부적으로 게터인 getName(), getAge()가 호출됨)
println(user.name) // 출력: 홍길동
println(user.age) // 출력: 25
// 2. 값 수정 (내부적으로 세터인 setAge()가 호출됨)
user.age = 26 // var이므로 수정 가능.
// user.name = "이순신" // val로 선언된 프로퍼티는 Setter가 생성되지 않아 수정 불가하다.
}
그리고 Kotlin의 클래스 내에서는 단순 저장을 넘어 프로퍼티의 동작을 정의할 수 있게 되었다. 대표적으로 `get()`과 `set()`과 같은 커스텀 게터와 세터를 구현할 수 있게 된 것이다. 예를 들어, 성(Last Name)과 이름(First Name)을 조합해 전체 이름을 반환하는 프로퍼티를 다음과 같이 구현할 수 있다.
class User(val firstName: String, val lastName: String) {
// 호출될 때마다 성과 이름을 합쳐서 반환하는 커스텀 get() 정의
val fullName: String
get() = "$lastName $firstName"
}
특히 `set()`의 경우 `set(value)`라는 커스텀 세터가 꽤 흥미로운데, 이건 값이 들어갈때(set 할 때) 작동하는 것이기 때문이다. 이것의 제일 대표적인 사용 예로는 유효성 검증이 되겠다. 즉, 본래 Java는 Setter로 받은 후에 유효성 검증을 진행하여 예외를 throw하거나 했는데, Kotlin은 set 단계에서 해당 사항을 미연에 방지하여 불필요한 낭비를 막을 수 있고, 코드의 가독성을 높일 수 있다. 이 `set(value)`의 핵심적인 개념 중 하나는 Backing Field다.
class User {
var age: Int = 0
set(value) {
// 음수 데이터가 들어오는 것을 입구에서 원천 차단
if (value < 0) {
println("에러: 나이는 음수가 될 수 없습니다.")
field = 0
} else {
field = value // field(Backing Field)는 실제 값이 저장되는 메모리 공간
}
}
}
위처럼 유효성 검증을 실행할 때, 맞다면 field에 저장한다고 처리를 하게 되어 있다. 이 Backing Field는 데이터가 저장되는 실제 메모리 공간의 이름으로서, 프로퍼티가 참조하는 실제 값이라고 보면 좋을 것이다. 위 예제에서 `age`의 경우, `set()`의 과정에서 한번 "열렸다"고 할 수 있다. 포인터를 뒀다고도 할 수 있는데, 이때 세터 내부에서 메모리 공간인 `field` 대신 프로퍼티 이름인 `age`를 다시 호출하면 재귀 형태로 무한 호출하게 되는 셈이다. 따라서 이 `field`라는 값은 Backing Field를 임시적으로 내어주는 변수 명이며, `set(value)` 과정에서 이 `field`에 직접 값을 저장해야 무한 루프를 방지하고 안전하게 실제 메모리에 접근할 수 있게 된다.
그리고 `private set`이라는 옵션 키워드도 사용이 가능하다. 이는 클래스 외부에서는 오직 읽기만 가능하고, 값의 수정은 클래스 내부에서만 가능하도록 제어하는 문법이다. 기존 Java의 경우, 특정 멤버 변수를 외부에서 수정하지 못하게 하려면 필드 자체를 `private`으로 꽁꽁 묶은 뒤, Setter 메서드는 아예 만들지 않고 Getter 메서드만 생성할 수 있었다. 반면 Kotlin에서는 프로퍼티를 선언할 때 하단에 `private set`를 덧붙이는 것으로 좀 더 편하게 사용할 수 있게 되었다.
이런 Kotlin의 문법을 보면 너무 Java와 다른 길을 간다고 생각할 수도 있는데, Kotlin은 오버로딩과 비슷한 형태도 가지고 있다. 바로 부생성자(Secondary Constructor)이다. 부생성자는 다음과 같이 `constructor` 키워드를 붙여 만들 수 있다.
class User(val name: String) { // 1. 주 생성자 (이름 필!)
var age: Int = 0
// 2. 부생성자 (이름과 나이를 한 번에 받고 싶을 때 추가)
constructor(name: String, age: Int) : this(name) {
this.age = age
}
}
Kotlin의 부생성자는 한 가지 철저한 규칙이 있는데, 바로 무슨 일이 있어도 대장격인 주 생성자를 호출하여 위임(`:this(name)`)해야 한다는 점이다. 물론 앞서 함수 파트에서 다루었던 '디폴트 매개변수' 기능이 생성자에도 똑같이 적용되므로, 실제 Kotlin 환경에서는 주 생성자에 기본값을 주는 방식을 더 선호하게 된다. 따라서 부생성자는 어디까지나 Java 프로그래밍의 다중 생성자 형태나 기존 레거시 코드와의 호환성을 고려하여 열어둔 선택지에 가깝다.
패키지
마지막으로 패키지인데, Kotlin의 패키지는 Java의 패키지와 재미있는 차이점이 있다.
- 디렉토리 구조와 상관없이 패키지 이름 설정 가능
Java는 파일이 위치한 실제 폴더(디렉토리) 구조와 패키지 명 선언이 반드시 완벽하게 일치해야 했다. 반면 Kotlin은 파일이 물리적으로 어느 폴더에 있든 상관없이, 파일 최상단에 원하는 패키지 이름(`package com.example.mydata`)을 자유롭게 지정할 수 있다. - 별칭 지정 가능
서로 다른 패키지에 이름이 똑같은 클래스가 존재할 경우, Java에서는 하나를 패키지 경로까지 길게 늘여 써야 했다. 하지만 Kotlin은 `import com.foo.User as FooUser` 형태로 별칭을 붙여 이름 충돌을 깔끔하게 해결할 수 있다. - 최상위 함수와 프로퍼티 또한 임포트 가능
Kotlin은 반드시 클래스 내부가 아니더라도, 파일 수준(최상위)에서 함수와 변수를 그냥 선언할 수 있다. 이렇게 만들어진 함수와 프로퍼티는 다른 파일에서 일반 클래스처럼 `import`하여 어디서든 편리하게 가져다 쓸 수 있다.
이처럼 클래스 없이 파일 수준에서 함수와 프로퍼티를 선언할 수 있게 되면서 얻는 장점은 다음과 같다. 첫째, 과거 Java에서 단순 함수 하나를 쓰기 위해 의미 없는 유틸성 클래스를 억지로 만들고 static 함수를 채워 넣던 불필요한 유틸리티 클래스 생성을 회피할 수 있다. 둘째, 클래스 중심이 아닌 기능을 중심으로 관련 기능을 논리적으로 그룹화하기 편해진다. 관련된 클래스와 함수를 한 파일에 모아둠으로써 코드의 응집도를 높일 수 있고, 기능을 수정할 때 이 파일 저 파일 찾아다니는 파일 간 이동을 획기적으로 줄여주는 이점을 가져다준다.
다만 이러한 장점과는 별개로 실무에서 이게 자주 쓰이냐면 그건 아니다. 내가 들은 강의에서도 실제로 잘 쓰이지는 않는다고 하였는데, 왜냐하면 이들 덕분에 문법적인 오류가 나지 않는단 점에 감사하지만 보통의 경우 Java처럼 딱딱하게(Strict) 지키는 편이 알아보기 쉽고 가독성도 좋기 때문이다. 즉, 폴더 구조와 패키지 명을 일치시키고 클래스 중심의 구조를 유지하는 Java식 관례가 코드를 예측 가능하게 만들어 주기 때문에, Kotlin의 패키지 자유도는 어디까지나 특수한 상황을 제외하면 잘 건드리지 않는 편이다.
이로서 Kotlin의 기본적인 함수와 클래스, 인스턴스에 대해 알아보았다. 내일은 무엇을 배울지 궁금하다.
