코딩마을방범대

코틀린 기초 (2) - 코틀린에서의 OOP 본문

💡 백엔드/Kotlin

코틀린 기초 (2) - 코틀린에서의 OOP

신짱구 5세 2025. 8. 12. 18:45
728x90

 

코틀린에서의 OOP

 

클래스를 다루는 방법

1. getter/setter 이 자동으로 구현됨

  • get, set 메소드를 따로 사용하지 않고 호출 가능
  • Java 코드를 가져와서 사용할 때도 자동으로 호출 가능
person.age = 10
person.name

 


 

 

2. 생성자에서 프로퍼티를 만들 수 있음

  • before :
class Person(name: String, age: Int){
    val name = name
    var age = age
}
  • after : {} 안에 아무것도 없으니 생략 가능
class Person(val name: String, var age: Int)

 

 


 

 

3. 생성자에서 검증 로직 생성하기

  • 주생성자(init)와 부생성자(constructor) 사용
  • 부생성자는 최종적으로 'this' 를 통해 주생성자를 호출해야함
  • 부생성자를 사용하는 것보다 default parameter 을 사용하는게 더 효율적
// 파라미터가 없는 경우
class Person

// 파라미터가 있는 경우
class Person(val name: String, var age: Int){
    init {
        if(age <= 0){
            throw IllegalArgumentException("..")
        }
        println("주생성자")
    }

    constructor(name: String): this(name, 1){
        println("부생성자")
    }
}

 


 

 

4. 커스텀 getter/setter

  • 프로퍼티의 값을 커스텀할 수 있음
 class Person(val name: String, var age: Int){
    val isAdult: Boolean
        get() = this.age >= 20
}

 


 

 

5. backing field

 

아래 방식으로 하면, name 을 찾기 위해 무한루프가 발생함

val name: String = name
    get() = name.uppercase()


무한루프를 방지 하기 위해 자기 자신을 가르키는 field 키워드를 사용함

val name: String = name
    get() = field.uppercase()




 

 

 

 

상속을 다루는 방법

1. 추상 클래스

  • 코틀린 : 상속 받을 때 :(콜론)을 쓰는데, 타입과의 차이는 띄어쓰기이다. (상속은 띄워써야 함)
// 추상 클래스
abstract class Animal(
    protected val species: String,
    protected val legCount: Int,
){
    abstract fun move()
}

// 상속
class Cat(
    species: String
) : Animal(species, 4){
    override fun move(){
        println("냐옹")
    }
}
프로퍼티를 오버라이드 하는 경우 open 키워드를 붙여줘야 함
// 추상 클래스
abstract class Animal(
    protected val species: String,
    protected open val legCount: Int,
){
    abstract fun move()
}

// 상속
class Penguin(
    species: String
) : Animal(species, 2){
    private val wingCount: Int = 2

    override fun move(){
        println("펭귄")
    }

    override val legCount: Int
        get() = super.legCount + this.wingCount
}​

 


 

 

2. 인터페이스

  • JAVA: 자바 8 이전에는 인터페이스에 추상 메서드(구현부가 없는 메서드)만 정의할 수 있었으나,
    자바 8부터는 default 키워드를 사용하여 메서드 구현 가능
public interface MyInterface {
    void abstractMethod();

    // default 키워드를 사용해야 구현된 메서드를 정의할 수 있다.
    default void defaultMethod() {
        System.out.println("기본 구현 메서드");
    }
}
  • 코틀린 : default 키워드 없이 메서드를 구현 가능.
    이 구현된 메서드는 인터페이스를 구현하는 클래스에서 오버라이드하지 않아도 사용할 수 있음
interface MyInterface {
    fun abstractMethod()

    // 별도의 키워드 없이 바로 메서드 구현 가능
    fun defaultMethod() {
        println("기본 구현 메서드")
    }
}

class MyClass : MyInterface {
    override fun abstractMethod() {
        println("추상 메서드 구현")
    }
}

fun main() {
    val myObject = MyClass()
    myObject.abstractMethod() // 추상 메서드 호출
    myObject.defaultMethod() // 기본 구현 메서드 호출
}

 


 

 

3. 클래스를 상속받을 때 주의할 점

코틀린에서 하위 클래스(자식 클래스)를 인스턴스화하면, 상위 클래스(부모 클래스)의 생성자가 먼저 호출된다.

이 과정에서 상위 클래스의 생성자(init 블록)에서 하위 클래스의 프로퍼티나 메서드를 호출하면 문제가 발생할 수 있다.

 

왜냐하면 상위 클래스의 생성자가 실행되는 시점에는 하위 클래스의 프로퍼티들이 아직 초기화되지 않았기 때문이다.

이때, 초기화되지 않은 Int 타입 프로퍼티는 기본값인 0이 할당된 상태로 호출될 수 있다.

 

생성자 실행 순서:

  1. 하위 클래스 호출: ChildClass()를 호출한다.
  2. 상위 클래스의 init 블록 실행: 이때 parent 생성자가 실행되면서 child의 프로퍼티에 접근할 경우, 아직 초기화되지 않아 문제가 발생할 수 있다.
  3. 하위 클래스의 init 블록 실행: 이 시점에서 child의 프로퍼티가 비로소 초기화된다. 이런 문제를 피하려면, 상위 클래스의 생성자에서는 오버라이드 가능한 멤버(open 또는 abstract 멤버)를 호출하지 않는 것이 좋다.

 


 

 

4. 오버라이딩 관련 키워드 4가지 

코틀린의 클래스와 메서드는 기본적으로 final 상태이다.

이는 상속과 오버라이딩을 명시적으로 허용해야만 사용할 수 있다는 것을 의미한다.

  • final: 상속과 오버라이딩이 불가능하도록 만든다. 코틀린의 모든 클래스와 메서드는 기본적으로 이 상태로 정의된다. 따라서 별도로 final 키워드를 붙이지 않아도 된다.
  • open: 상속과 오버라이딩을 가능하게 해준다. 클래스나 메서드 앞에 open 키워드를 붙여야만 하위 클래스에서 상속받거나 오버라이드할 수 있다.
  • abstract: 클래스나 메서드를 추상화한다. abstract로 선언된 클래스는 인스턴스를 생성할 수 없으며, abstract 메서드는 반드시 하위 클래스에서 override 해야 한다. abstract 메서드에는 구현부가 없으며, open 키워드가 내포되어 있어 별도로 open을 붙일 필요가 없다.
  • override: 상위 클래스의 오버라이드 가능한 메서드(open, abstract)를 재정의할 때 사용한다. 이 키워드를 통해 상위 클래스의 메서드를 재정의하고 있다는 것을 명시적으로 표시한다. override된 메서드는 기본적으로 open 상태이므로, 하위 클래스에서 다시 오버라이드할 수 있다. 만약 더 이상 오버라이드되지 않게 하려면 'override final fun ...' 처럼 final을 함께 사용하면 된다.

 

 


 

 

 

 

접근 제어를 다루는 방법

1. JAVA 와 코틀린의 가시성 제어

  • JAVA 의 기본 접근 지시어는 default / 코틀린의 기본 접근 지시어는 public
  • JAVA :
    • public: 모든 곳에서 접근 가능
    • protected: 같은 패키지 또는 하위 클래스에서만 접근 가능
    • default(기본): 같은 패키지에서만 접근 가능
    • private: 선언된 클래스 내에서만 접근 가능
  • 코틀린 : 패키지를 namespace 를 관리하기 위한 용도로만 사용함
    (파일이 어떤 패키지에 속하는지만 나타냄/접근 제어에는 영향 없음)
    • public(기본): 모든 곳에서 접근 가능
    • protected: 선언된 클래스 또는 하위 클래스에서만 접근 가능
    • internal: 같은 모듈(한 번에 컴파일 되는 코틀린 코드)에서만 접근 가능
    • private: 선언된 클래스 내에서만 접근 가능

 


 

 

2. 코틀린 파일의 접근 제어

  • 코틀린은 하나의 .kt 파일에 변수, 클래스, 함수 여러개를 만들 수 있음
    • public: 기본값, 어디서든 접근 가능
    • protected: 파일(최상단)에는 사용 불가능
      • protected 멤버에 접근하려면 상위 클래스에 open 키워드를 붙여 상속을 허용해야 한다.
    • internal: 같은 모듈에서만 접근 가능
    • private: 같은 파일 내에서만 접근 가능
  • 생성자에 접근 지시어를 붙이려면, constructor 을 입력해야 함
    아래 예시처럼 constructor를 통해 private 으로 명시하면, 외부에서 직접 객체를 생성하지 못하게 할 수 있음
class PrivateClass private constructor() {
    companion object {
        fun create(): PrivateClass {
            return PrivateClass()
        }
    }
}

// 사용 예시:
// val myObject = PrivateClass() // 컴파일 오류 발생!
val myObject = PrivateClass.create() // 가능
프로퍼티에서 setter 만 접근 제어 조건을 다르게 설정하고 싶은 경우
class Car(
    internal val name: String,
    _price: Int
){
    var price = _price
        private set
}​

 

 


 

 

3. JAVA 와 코틀린을 함께 사용할 때 주의할 점

  • Internal 은 바이트 코드 상 public 이 됨(= 자바 코드에서 코틀린 모듈의 Internal 코드를 가져올 수 있음)
  • 코틀린과 자바의 protected 접근 가능 범위는 다르지만, 자바에서 코틀린 코드를 가져다 쓸 때 자바의 접근 범위를 이용한다.

 

 


 

 

 

 

Object 키워드를 다루는 방법

1. static 함수와 변수

  • JAVA :
private static final int MIN_AGE = 1;
public static JavaPerson newBaby(String name){
    return new JavaPerson(name, MIN_AGE);
}
private String name;
private int age;
private JavaPerson(String name, int age){
    this.name = name;
    this.age = age;
}
  • 코틀린 : static이 없음. => companion object 라는 키워드 사용
 class Person private constructor(
    private var name: String,
    private var age: Int
){
    companion object {
        private val MIN_AGE = 1
        fun newBaby(name: String): Person{
            return Person(name, MIN_AGE)
        }
    }
}
* static: 클래스가 인스턴스화 될 때 새로운 값이 복제되는게 아니라 정적으로 인스턴스끼리의 값을 공유
* companion object: 클래스와 동행하는 유일한 오브젝트
     - 클래스와 관련된 여러 유용한 멤버들(상수, 팩토리 메서드 등)을 한 곳에 모아두고, 인스턴스 생성 없이 쉽게 접근할 수 있도록 도와줌
* const(상수) : 기본(원시) 타입과 String에만 붙일 수 있음
// 런타임 시 할당
val MIN_AGE = 0
// 컴파일 시 할당
const val MIN_AGE = 0​

 




companion object 특징

동반객체(companion object)도 하나의 객체로 간주된다.

때문에 이름을 붙일 수도 있고, interface 를 구현할 수도 있다.

// 인터페이스 Log 구현
companion object Factory : Log {
    private val MIN_AGE = 1
    fun newBaby(name: String): Person{
        return Person(name, MIN_AGE)
    }

    override fun log(){
        TODO("Not Yet implemented")
    }
}
* TODO: 코틀린에서 아직 구현되지 않은 코드를 표시하는 특별한 함수
     - '여기에 나중에 코드를 작성할 거야'라고 스스로에게 약속하는 일종의 마커

 




💡 JAVA 에서 코틀린의 static 함수 활용하기

 

코틀린 : 이름을 붙이지 않을 경우 Companion 이라는 이름이 숨겨져 있는 것이다.

class Person private constructor(
    private var name: String,
    private var age: Int
){
    companion object {
        private val MIN_AGE = 1
        fun newBaby(name: String): Person{
            return Person(name, MIN_AGE)
        }
    }
}

 

JAVA : 숨겨져있는 이름인 Companion 로 호출

Person.Companion.newBaby("ABC")

 

------------------------------

 

@JvmStatic 어노테이션을 붙일 경우 바로 접근 가능

 

코틀린 :

...
@JvmStatic
fun newBaby(name: String): Person{
...

 

JAVA :

Person.newBaby("ABC")
함수명이 있다면?
- 코틀린 :
...
companion object Factory {
    private val MIN_AGE = 1
    fun newBaby(name: String): Person{
    	...​

- JAVA :
Person.Factory.newBaby("ABC")​

 


 

 

2. 싱글톤

  • JAVA :
public class JavaSingleton {
    private static final JavaSingleton INSTANCE = new JavaSingleton();
    private JavaSingleton(){}
    public static JavaSingleton getInstance(){
        return INSTANCE;
    }
}
  • 코틀린 : object 키워드 사용
object Singleton {}

 


 

 

* 익명 클래스: 특정 인터페이스나 클래스를 상속받은 구현체를 일회성으로 사용할 때 쓰는 클래스

3. 익명 클래스

  • JAVA : 'new 클래스명()' 으로 선언
moveSomething(new Movable(){ ...
  • 코틀린 : object에 상속 시킴
moveSomething(object : Movable { ...

 

 


 

 

 

 

중첩 클래스를 다루는 방법

1. 중첩 클래스의 종류

  • static 을 사용하는 중첩 클래스: 클래스 안에 static을 붙인 클래스는 밖에 클래스를 직접적으로 참조할 수 없는 클래스이다.
  • static 을 사용하지 않는 중첩 클래스:
    • 내부 클래스(Inner Class): 밖에 클래스 참조 가능
    • 지역 클래스(Local Class): 메소드 내부에 클래스 정의
    • 익명 클래스(Anonymous Class): 일회성 클래스
* Effective JAVA 3rd Edition - Item24, Item86
1. 내부 클래스는 숨겨진 외부 클래스 정보를 가지고 있어, 참조를 해지하지 못하는 경우 메모리 누수가 생길 수 있고, 이를 디버깅하기 어렵다.
2. 내부 클래스의 직렬화 형태가 명확하게 정의되지 않아 직렬화에 있어 제한이 있다.

 




💡 권장되는 클래스 안의 클래스

  • JAVA : static 키워드를 사용
public class JavaHouse{
    private String address;
    private LivingRoom livingRoom;
    public JavaHouse(String address){
        this.address = address;
        this.livingRoom = new LivingRoom(10);
    }
    public LivingRoom getLivingRoom(){
        return livingRoom;
    }

    public static class LivingRoom {
        private double area;
        public LivingRoom(double area){
            this.area = area;
        }
    }
}
  • 코틀린 : 기본적으로 바깥 클래스에 연결이 없는 중첩 클래스가 만들어짐
class House(
    var address: String,
    var livingRoom: LivingRoom = LivingRoom(10.0)
){
    class LivingRoom(
        private var area: Double
    )
}

 

--------------------------------------------------------------

 

💡 권장되지 않는 클래스 안의 클래스(바깥 클래스에 참조를 가지고 있는)

  • JAVA : '상위 클래스.this.변수명'으로 사용
public class JavaHouse{
    private String address;
    private LivingRoom livingRoom;
    public JavaHouse(String address){
        this.address = address;
        this.livingRoom = new LivingRoom(10);
    }
    public LivingRoom getLivingRoom(){
        return livingRoom;
    }

    public class LivingRoom {
        private double area;
        public LivingRoom(double area){
            this.area = area;
        }
        public String getAddress(){
            return JavaHouse.this.address;
        }
    }
}
  • 코틀린 : inner 라는 키워드를 붙여줘야함 / 바깥 클래스 참조를 위해 'this@바깥클래스'를 사용한다
class House(
    var address: String,
    var livingRoom: LivingRoom = LivingRoom(10.0)
){
    inner class LivingRoom(
        private var area: Double
    ){
        val address: String
            get() = this@House.address
    }
}
inner 클래스: 바깥쪽 클래스의 인스턴스에 종속되며, 바깥쪽 클래스의 멤버에 접근할 수 있다.
일반 중첩 클래스 (키워드 없음): 바깥쪽 클래스의 인스턴스와 독립적이며, 바깥쪽 클래스의 멤버에 접근할 수 없다.

 

 


 

 

 

 

다양한 클래스를 다루는 방법

1. Data Class(DTO; Data Transfer Object)

  • JAVA :
@ToString
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode
public class PersonDto{
    private final String name;
    private final int age;
}
  • 코틀린 : data 키워드 추가 시 equals, hashCode, toString 이 자동으로 구현됨
data class PersonDto(
    val name: String,
    val age: Int
)

 


 

 

2. Enum Class

  • JAVA
@AllArgsConstructor
public enum JavaCountry {
    KOREA("KO"),
    AMERICA("US");
    private final String code;
    public String getCode() {
        return code;
    }
}
  • 코틀린 :
enum class Country(
    private val code: String
) {
    KOREA("KO"),
    AMERICA("US")
}
- 자바와 코틀린의 enum은 내부적으로 Enum 클래스를 상속받기 때문에, 다른 클래스를 상속받을 수 없다.
     이는 다중 상속을 허용하지 않는 규칙 때문이다.
- 자바와 코틀린 모두 다중 상속은 허용하지 않지만, 다중 인터페이스 구현은 허용한다.
- 자바와 코틀린 둘 다 각 요소가 public static final 필드이며, 해당 enum 타입의 유일한 인스턴스(즉, 싱글톤)이다.

 

* Enum 을 이용한 조건문 처리

- JAVA:
private static void handleCountry(JavaCounty country){
    if (country == JavaCountry.KOREA){
        // 로직 처리
    }
    if (country == JavaCountry.AMERICA){
        // 로직 처리
    }
}​

- 코틀린: 컴파일러가 enum 의 모든 타입을 알고 있기 때문에 다른 타입에 대한 로직(else)를 작성하지 않아도 되고, enum 에 변화가 있으면 알 수 있다.
fun handleCountry(country: Country){
    when(country){
        Country.KOREA -> TODO()
        Country.AMERICA -> TODO()
    }
}​

 



        
3. Sealed Class, Sealed Interface

Sealed나 Enum은 컴파일 시점에 모든 하위 타입(또는 상수)이 결정된다.
따라서 when 표현식 사용 시 컴파일러가 모든 경우를 체크해서 else 분기 누락 시 경고 또는 에러를 발생시켜 안전하다.

Sealed Class
: 상속이 가능하도록 추상클래스를 만들었으나, 외부에서는 해당 클래스를 상속받지 못하게 지정한 클래스만 하위 클래스로 설정될 수 있게끔 봉인해둔 클래스
  • 컴파일 타임 때 하위 클래스의 타입을 모두 기억한다. 즉, 런타임 때 클래스 타입이 추가될 수 없다.
  • 하위 클래스는 같은 패키지에 있어야 한다.

Enum과 다른 점

  • 클래스를 상속받을 수 있다.
  • 하위 클래스는 멀티 인스턴스가 가능하다.
sealed class HyundaiCar(
    val name: String,
    val price: Long
)
class Avante : HyundaiCar("아반떼", 1_000L)
class Sonata : HyundaiCar("소나타", 2_000L)
class Grandeur : HyundaiCar("그랜저", 3_000L)

// 런타임에 타입이 추가될 수 없고, 하위 구현체가 추가되거나 제거되었을 때 확인이 가능하기 때문에 when에서 else를 추가해주지 않아도됨
private fun handleCar(car: HyundaiCar){
    when(car){
        is Avante -> TODO()
        is Grandeur -> TODO()
        is Sonata -> TODO()
    }
}





 

728x90