250703
Object-Oriented Programming
struct Car {
int speed;
};
void accelerate(struct Car* car) {
car->speed += 10;
}
절차지향 프로그래밍에서는 위와 같이 속성(데이터)과 기능이 분리된다.
-> 데이터가 바뀌면 함수도 따로 수정 필요
class Car {
int speed;
void accelerate() {
speed += 10;
}
}
반면 객체지향 프로그래밍에서 객체
는 속성과 기능을 함께 갖는 단위로, 마치 현실 세계의 사물처럼 작동한다.
-> 상태와 행동을 갖는 객체를 주체로, 객체간의 메시지 전달(메서드 호출)을 통해 상호작용
이때 클래스
는 객체를 설계하기 위한 설계도 역할을 하여, 속성(프로퍼티)과 기능(메서드)을 정의한다.
즉, 객체는 클래스를 기반으로 메모리에 생성되어 정의된 내용대로 실제 동작을 하게 되는 것이다.
그럼 지금부터 이러한 객체지향이 갖는 4개의 주요 특징을 알아보자.
(1) Encapsulation
객체의 속성과 기능을 하나로 묶고, 외부에는 기능만 노출하여 데이터를 보호하고 접근을 제어
- 접근 제어자 사용: 속성은
private
으로, 메서드는public
으로 선언하여 데이터에 대한 접근은 메서드를 통해서만 할 수 있도록 함 - Java에서는
private
으로 선언된 멤버 변수에 접근하기 위해getter
나setter
같은 메서드를 명시적으로 작성하고 호출해야 함 - 반면 Kotlin에서는
get()
/set()
을 프로퍼티 문법의 일부로 제공해, 명시적으로 메서드를 호출하지 않아도 변수처럼 접근 가능함
(2) Inheritance
기존 클래스(부모)의 속성과 메서드를 새로운 클래스(자식)가 물려받음
- 일반적인 것을 상위 클래스에, 구체적인 것을 하위 클래스에 둠
- 오버라이딩시에는 메서드를
override
키워드로 선언해야 메서드의 재정의가 가능함 - Kotlin의 경우에는 부모 클래스에
open
키워드가 있어야 상속이 가능 (closed by defalt) - Java는 기본적으로 상속이 허용되며, 금지시키고 싶을 때만
final
키워드 사용 (open by defalt)
그러나 상속에는 아래와 같은 한계점도 존재한다.
- 부모 클래스의 변경이 자식 클래스에도 직접 영향을 미치므로 ‘강한 결합’임
- 다중 상속은 허용되지 않으므로 공통 기능이 여러 부모에 흩어져 있을 경우 구조화 어려움
- 상속 관계는 코드를 작성하고 컴파일할 때 이미 결정되므로 런타임에 객체를 바꾸거나 동적으로 기능을 바꾸는데 제약이 있음
- 과잉 상속 시 필요없는 기능까지 모두 물려받게 되는 등 재사용 범위에 제약이 있음
이걸 해결하고자 나온 개념이 바로 Composition
이다.
상속이
is-a
관계라면, 구성은has-a
관계다.
즉, 다른 객체를 내부 필드(프로퍼티)로 포함해서 사용하는 방식이다.
이는 코드 상속이 아닌 객체 위임을 통해 기능을 재사용한다는 의미로, 아래와 같은 장점을 갖는다.
- 포함된 객체가 변경되어도 외부에서는 내부 구현이 아닌 기능의 역할에만 의존하기 때문에, 내부적으로 객체만 변경해주면 됨 (느슨한 결합)
- 다중으로 다양한 기능을 조합할 수 있음
- 런타임에 객체를 교체할 수 있음
- 필요한 객체만 골라서 조립할 수 있음
(3) Polymorphism
동일한 이름의 메서드나 인터페이스가 객체에 따라 다른 방식으로 동작함
- 정적 다형성:
오버로딩
을 통해 구현 -> 컴파일 시점에 어떤 메소드가 호출될 지 결정됨 (넓은 의미의 다형성) - 동적 다형성:
오버라이딩
을 통해 구현 -> 런타임에 어떤 메소드가 호출될 지 결정됨
동일한 메시지를 보냈을 때, 내부 상황은 모르지만 객체마다 다르게 반응한다는 점에서 후자가 OOP에서 말하는 다형성의 핵심이다.
(4) Abstraction
복잡한 내부 구현은 숨기고, 외부에는 필요한 정보나 동작만 제공해 단순하게 사용할 수 있도록 함
추상 클래스
: 기본적으로 ‘클래스’이므로, 필드, 메서드, 생성자를 가질 수 있어, 상속을 전제로 하위 클래스의 중복되는 클래스 멤버를 묶어놓은 것 -> 다중 상속 불가인터페이스
: 클래스와는 별도로, 구현 객체가 같은 동작을 한다는 것을 보장하기 위한 규약 -> 다중 구현 가능구상 클래스
: 추상 클래스나 인터페이스에 선언된 추상 메서드를 실제로 구현하고, 둘과는 달리 실제로 인스턴스를 생성하는 클래스
추상 클래스는 계층적으로 구성된 1 : 1의 is-a
(사자는 동물이다) 관계라면, 인터페이스는 계층 상관없이 공통 기능을 공유하는 1 : 多의 can-do
(새는 날 수 있다, 새는 걸을 수 있다) 관계다.
-> 추상 클래스는 강한 결합, 인터페이스는 느슨한 결합이라고 할 수 있다.
SOLID principles
앞서 본 다양한 객체지향의 특징을 바탕으로, 객체지향 설계에는 5대 원칙이 존재한다.
핵심 철학은, ‘변화에 강하고, 유지보수가 쉬운 구조를 만드는 것’이다.
(1) SRP: Single Responsibility Principle
단일 책임 원칙: 클래스는 하나의 책임만 가져야 함
책임별로 클래스를 나눠 변경 영향 범위를 제한하고 유지보수가 쉽게 하는 설계 원칙이다.
ex) MainActivity
가 UI뿐만 아니라 네트워크 통신까지 처리하면 SRP 위반 -> ViewModel
, Repository
등 기능별로 역할을 분리해야 함
(2) OCP: Open Closed Principle
개방 폐쇄 원칙: 확장에는 열려있고, 변경에는 닫혀있어야 함
기존에 잘 돌아가던 코드를 수정하지 않고도 새로운 기능을 추가할 수 있도록 설계하는 원칙이다.
ex) PaymentProcess
인터페이스 구현체로 NaverPay
, KakaoPay
등의 구현체를 만들어 DI로 주입
(3) LSP: Liscov Substitution Principle
자식 클래스는 부모 클래스를 대체할 수 있어야 함
자식 클래스가 부모 클래스의 기능을 제거하거나 변경하면 안되며, 기능을 확장하는 쪽으로 설계하여 상속 구조가 일관된 동작을 보장하도록 하는 예측 가능한 설계 원칙이다.
ex) BaseActivity
가 기대되는 곳에 이를 상속받은 LoginActivity
가 쓰여도 정상 동작해야 함
(4) ISP: Interface Segregation Principle
인터페이스 분리 원칙: 인터페이스는 클라이언트에 맞게 분리해야 함
큰 인터페이스를 분리해서, 불필요한 의존성은 줄이고 사용하지 않는 기능은 강제하지 않도록 맞춤형 인터페이스를 제공하는 설계 원칙이다.
ex) UserActionListener
에 onClick()
, onSwipe()
, onLongPress()
같이 너무 많은 기능을 몰아넣는 대신, 각 동작을 별도 리스너로 분리해서 필요한 것만 구현하도록 해야 함
(5) DIP: Dependency Inversion Principle
의존성 역전 원칙: 고수준 모듈과 저수준 모듈이 모두 추상화에 의존하게 만들어야 함
원래는 추상 모듈이 하위 모듈에 의존하는 것이 기본 의존 구조이나, 이 구조를 뒤집어 둘 다 추상화에만 의존하도록 하는 설계 원칙이다.
- 상위/하위 모듈 모두 추상에만 의존함
- 구체는 런타임에 주입함
모듈이 직접 연결되지 않아 변경에 강하고, 구체적인 구현에 덜 의존해 구체 클래스를 교체하거나 mock 객체로 대체하기 쉬워 테스트하기 좋은 구조가 된다.
JVM
Java(Kotlin) 코드는 내부적으로 아래와 같은 과정을 거쳐 실행된다.
- 작성: 개발자가 소스파일(
.java
) 작성 - 컴파일: 자바 컴파일러가 소스파일을 JVM이 이해할 수 있는 코드인 바이트코드(
.class
)로 컴파일 - 클래스 로딩: 클래스 로더가 바이트코드를 실행 시점에 동적으로 JVM 메모리에 로딩
- 실행: 실행 엔진이 JVM 메모리에 올라온 바이트코드를 해석하여 실행
여기서 1-2는 컴파일 타임 환경, 3-4는 런타임 환경이다.
그럼 여기서 JVM
은 구체적으로 무엇일까?
JVM(Java Virtual Machine): 자바 바이트코드를 해석하고 실행하는 가상 머신
JVM의 가장 중요한 특징은, 자체적으로 논리적 메모리 구조를 정의하고, 이에 맞게 OS에서 할당받은 메모리를 분할하여 사용한다는 것이다.
이처럼 JVM이 OS에 따라 알맞게 구현되기 때문에, 동일한 바이트코드가 어떤 환경에서도 일관되게 실행될 수 있으며, 각 애플리케이션은 OS에 직접 의존하지 않고 JVM이라는 추상 계층 위에서만 동작할 수 있게 된다.
이는 곧 자바가 ‘운영체제에 독립적인 실행 환경을 제공’할 수 있다는 의미이다.
OS위에서 독립적으로 정의되는 JVM의 메모리 구조는 아래와 같이 구성된다.
Method Area
: 바이트 코드를 처음 메모리 공간에 올릴 때 초기화되는, 클래스 수준의 정보를 저장 -> JVM 시작 시 생성되어 프로그램 종료될 때까지 저장됨Heap
: new 연산자로 생성되는 클래스, 인스턴스 변수 등 동적으로 생성된 ‘Reference Type’이 저장됨 -> 런타임 시 동적으로 할당해 사용함Native Method Stack
: 자바 코드가 컴파일되어 생성되는 바이트코드가 아닌, 기계어로 작성된 프로그램(navtive code)을 실행시킴 -> C/C++ 등의 native 라이브러리 호출에 사용되며 플랫폼 의존적임Stack Area
: 메서드에서 쓰이는 임시 변수나 데이터 저장 -> 메서드 호출 시마다 각각의 스택 프레임이 생성되고, 메서드 수행이 끝나면 프레임별로 삭제됨PC Register
: 각 스레드가 실행중인 JVM 명령어의 주소를 저장 -> 스레드가 어떤 부분을 무슨 명령으로 실행해야 할 지에 대한 기록이 있음
여기서 1, 2는 모든 스레드가 공유하는 영역이고, 3-5는 스레드 독립적인 영역이다.
힙 영역에 대한 설명 중에, 참조 타입이 저장된다는 내용이 있다. 이에 대한 참조 주소는 스택 영역의 변수 등이 갖고 있는데, 여기서 한 가지 문제가 발생한다. 만약 참조하는 변수나 필드가 없다면 의미없는 객체가 된다는 것이다.
이를 해결하는 것이 바로 Garbage Collector
다. GC는 힙 영역에서 의미없는 객체를 쓰레기로 취급하여 자동으로 제거한다.
이처럼 힙 영역은 GC의 대상이 되는 공간이다.
Koltin
특징 1) Null Safety
Kotlin은 아래와 같이 언어 차원에서 null safety를 지원한다. 이는 Java와 같은 언어에서 흔히 발생하는 NPE를 방지할 수 있는 아주 중요한 특징 중 하나다.
(1) Nullable Type ?
var name: String? = null // nullable String
기본적으로 Kotlin 변수는 null 값을 가질 수 없으므로 만약 null을 허용하고 싶다면 ?
연산자를 타입 뒤에 붙여야 한다.
(2) Safe Call Operator ?.
val length = name?.length // name이 null이면 length도 null
nullable 변수에 null 체크 없이 안전하게 접근할 수 있도록 해주는 연산자로, null이면 전체 표현식이 null을 반환한다.
(3) Elvis Operator ?
val length = name?.length ?: 0 // name이 null이면 length는 0
값이 null인 경우의 기본값을 설정할 수 있다.
(4) Not-null Assertion Operator !!
val length = name!!.length // name이 null이면 예외 발
개발자가 null이 아님을 확신할 때 사용하는 연산자로, null일 경우 NPE가 발생하므로 주의해서 사용해야 한다.
(5) Safe Casts as?
val obj: Any = "hello"
val str: String? = obj as? String
타입 캐스팅 시 실패하면 예외 대신 null을 반환하도록 한다.
특징 2) High Order Function
우선 Lambda
부터 알아보자.
람다는 [입력 → 출력] 구조가 있어 함수처럼 생겼지만, 따로 이름을 쓰지 않고 쓰는 블록이다. 그래서 ‘이름 없는 함수’라고도 불린다.
이러한 특징은 ‘코드를 마치 값처럼’ 다룰 수 있게 해준다. 람다를 변수에 저장할 수도 있고, 람다 자체가 리턴값이 될 수도 있으며, 함수에 넘길 인자가 될 수 있는 것이다.
이때 ‘함수에 함수를 넘기는’ 방식이 바로 High Order Function
이다.
고차 함수: 함수를 매개변수나 반환값으로 받는 함수
fun operate(x: Int, y: Int, op: (Int, Int) -> Int): Int {
return op(x, y)
}
val sum = operate(3, 5) { a, b -> a + b } // 결과: 8
위 코드에서 operate
함수는 람다 { a, b -> a + b }
를 인자로 전달받는다.
사실 Kotlin에서 고차 함수의 가장 흔한 예시는 바로 collection 함수다. Java에서도 stream 에서 람다를 쓰지만, Kotlin은 고차 함수를 더 편하고, 자연스럽게 쓸 수 있도록 설계되어 있다.