컬렉션 함수를 사용하면 간결하고, 효율적이고, 이해하기 쉬운 코드를 만들 수 있다.

또한, 컬렉션 함수는 아래처럼 연쇄 호출(method chaining)을 할 수 있다.

people.filter { it.name.startsWith("A") }.map { it.name }

하지만 단점이 없는 건 아니다. 연쇄 호출을 하면 매번 중간(임시) 컬렉션을 생성한다.
위 예시에서 filter, map의 결과로 컬렉션 객체가 2개 생성된다. 이것은 원소의 수가 수십만 개 이상일 때 효율을 떨어뜨린다.

이러한 상황을 효율적으로 바꾸고 싶으면 컬렉션을 직접 사용하는 대신 시퀀스(Sequence)를 사용해야 한다.

시퀀스 (Sequence)

people.asSequence()     // 원본 컬렉션을 시퀀스로 변환
    .map(Person::name)    // 시퀀스도 컬렉션과 같은 API를 제공한다.
    .filter { it.startsWith("A") }
    .toList() // 결과 시퀀스를 리스트로 되돌린다

위 코드는 이전 예시의 시퀀스 사용 버전이다.

시퀀스를 사용하면 중간 컬렉션이 생성되지 않아서 원소가 많을 때 성능이 매우 좋아진다.

시퀀스란?

시퀀스(Sequence)는 기본적으로 인터페이스이다.** 시퀀스 자체는 자신이 갖고 있는 데이터를 순서대로 반환할 수 있는 데이터 타입**임을 의미한다.

public interface Sequence<out T> {
    /**
     * Returns an [Iterator] that returns the values from the sequence.
     */
    public operator fun iterator(): Iterator<T>
}

인터페이스에는 iterator 메서드 하나만 정의되어 있는데, 반환되는 Iterator를 통해 원소를 하나씩 리턴받을 수 있다.

시퀀스가 중간 컬렉션을 생성하지 않는 이유는 이 인터페이스를 구현하고 있는 클래스의 연산 수행 방식에 있다.

시퀀스의 연쇄 연산 순서

시퀀스는 원소 단위로 연산을 수행한다.

listOf(1,2,3,4).asSequence() // 컬렉션을 시퀀스로 변경
        .map { print("map($it) "); it * it }
        .filter { print("filter($it) "); it % 2 == 0 }
        .toList() // 결과 시퀀스를 리스트로 되돌린다

컬렉션으로 위 연산을 진행한다면 모든 원소에 대해 map 함수를 수행하고, 그 결과 컬렉션에 filter 함수를 수행한다. 따라서, map에 의한 중간 컬렉션이 생성된다.


반면 시퀀스는 첫 번째 원소를 map 연산하고, 그 결과 값에 filter를 수행한다. 그리고 두 번째 원소에 대해 map -> filter 연산을 수행한다.

위 코드의 결과는 아래와 같다.

map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)

이렇게 원소 단위로 연산을 연쇄 수행하기 때문에 중간 컬렉션이 생기지 않는다.

꼭 시퀀스를 컬렉션으로 되돌려야 하나

예시를 보면 마지막에 시퀀스를 리스트로 되돌린다. 시퀀스가 효율적이면 시퀀스를 계속 쓰는 게 좋을텐데 말이다.

시퀀스의 결과를 차례로 이터레이션 하기만 할거면 시퀀스를 써도 된다. 하지만 인덱스 접근과 같이 컬렉션에서 제공하는 기능을 사용해야 한다면 컬렉션으로 되돌려야 한다.

지연(lazy) 연산

시퀀스의 또 하나의 특징은 지연 연산을 한다는 것이다.

val sequence = List(1, 2, 3, 4).asSequence()
        .map { print("map($it) "); it * it }
        .filter { print("filter($it) "); it % 2 == 0 }

위 코드는 이전 예시에서 마지막 toList() 호출부를 제외한 것이다. 이 코드를 수행하면 아무런 내용도 출력하지 않는다. 시퀀스는 최종 시퀀스를 이터레이션하거나 리스트로 변환해야 비로소 연산이 수행되기 때문이다.

위 코드는 그저 어떤 연산을 수행해서 원소를 반환하는 시퀀스인지를 알려줄 뿐이다.

정리

원소의 개수가 많은 거대한 컬렉션에 연쇄적인 연산을 적용해야 할 경우 시퀀스를 사용하자.

시퀀스의 특징

  • 중간 컬렉션을 사용하지 않아서 연속적인 계산을 효율적으로 할 수 있다.
  • 지연 연산을 한다.

참조

책: Kotlin In Action

람다는 함수에 인자로 전달하거나 변수에 저장할 수 있는 작은 코드 블록을 말한다. 간략한 형태의 함수라고 볼 수 있다. 매개 변수부와 코드 본문으로 구성되어 있으며, '->'로 둘이 구분된다

val sumLambda = { p1: Type1, p2: Type2 -> p1 + p2 }

 

개발을 하다 보면 이벤트에 대한 핸들러를 등록하거나, 리스트의 모든 원소에 동일한 연산을 적용하기 위해 동작을 함수에 전달하거나 변수에 저장하는 과정이 요구되는데, 이럴 때 람다를 사용한다. 

 

그렇다면 람다는 어떤 점이 특별한 걸까?

 

자바에서는 함수에 코드 블록을 전달하기 위해 익명 객체를 사용한다.

button.setOnClickListener(new OnClickListener() {
	@Override
	public void onClick(View view) {
    	/* 버튼 클릭시 수행할 동작 */
	}
});

하지만 onClick에 정의된 동작을 전달하기 위해 익명 객체를 선언하고 함수를 오버라이딩 해야 하는 등 과정이 번거롭다는 것이 단점이다.

 

코틀린은 이런 번거로움을 함수형 언어처럼 함수를 값으로 다룸으로써 개선했다. 함수 자체를 값처럼 인자로 전달하거나 변수에 저장하는 것이 가능해진 것이다. 여기에 람다를 사용하면, 함수를 정의하지 않고 코드 블록만 넘기는 것이 가능하여 코드가 더욱 간결해진다.

button.setOnClickListener { /* 버튼 클릭 시 수행할 동작 */ }

첫 번째 코드와 같은 동작을 수행하지만 코드가 훨씬 짧아졌다. 참고로, 오버라이딩 해야하는 메서드가 하나인 익명 객체는 모두 람다로 대체할 수 있다.

 

 

그리고 람다는 코틀린의 Collections 라이브러리에서 많이 활용되고 있다. 람다 덕분에 라이브러리에서 컬렉션에 자주 사용되는 기능을 쉽게 제공하는 것이 가능해졌다. 모든 원소에 동일하게 적용할 동작을 람다로 손쉽게 전달할 수 있기 때문이다.

 

예를 들어, 리스트에서 최대 값을 찾고 싶을 때, 자바에서 람다가 나오기 전에는 직접 max 변수를 선언하고 for문을 돌며 찾아야 했다. 하지만 지금은 함수 타입을 인자로 받는 컬렉션 라이브러리 함수를 호출하여 간단하게 해결할 수 있다.

println(people.maxBy { it.age }) // 가장 age가 큰 Person 객체를 출력한다
// 무엇을 기준으로 최대 값을 찾을지를 리턴하는 람다를 전달한다.

코틀린 컬렉션 라이브러리에서는 이런 편한 기능을 정말 많이 제공하고 있다. 덕분에 시간을 아끼면서 간결하고 가독성 좋은 코딩을 할 수 있다.

 

결론: 람다를 사용하면 코드 블록을 함수의 인자로 전달하거나 변수에 저장하는 일을 쉽고 간결하게 할 수 있다.

 

참조

도서 - Kotlin In Action (5장)

Context란

Context는 안드로이드 시스템에 대한 인터페이스 역할을 하는 객체이다.

흠…?

 

좀 더 쉽게 풀어보면, Context는 어플리케이션 리소스, 파일, 시스템 서비스 등에 접근할 수 있는 메소드들을 갖고 있다. 그래서 앱의 resources나 File과 같은 시스템 자원에 접근하거나 새로운 Activity 실행, Intent 수신과 같이 애플리케이션 레벨의 작업을 요청할 수 있다.

또한, Context는 추상 클래스이며 구현체는 안드로이드 시스템에 의해 제공된다

Context 쓰임 정리

  • 앱의 리소스에 접근: res, assets, internal storage
  • 컴포넌트 통신: 액티비티 시작, Intent 수신 등
  • 시스템 서비스에 접근: SystemServices
  • 앱 정보에 접근: ApplicationInfo
  • GUI 작업: ex: AlertDialog.Builder(requireContext())

실제로는 훨씬 다양한 상황에서 사용된다!


Context의 종류 (feat. 계층 구조)

Context의 클래스 계층 구조를 보면 재밌는 사실을 확인할 수 있다.

https://medium.com/swlh/context-and-memory-leaks-in-android-82a39ed33002

(1) Application, Service, Activity 모두 Context를 상속받고 있다. Activity가 Context 자리를 this로 대신할 수 있었던 이유가 이것이었다.

 

(2) Context는 Activity, Service, Application 등 종류가 나뉘어 있다.

앱 안에는 여러 Activity와 Service가 존재할 수 있으므로 Activity Context는 여러 객체가 있을 수 있다. 반면, Application은 하나라서 Singleton으로 앱 내에서 하나만 존재한다.

즉, Context는 종류가 나뉘며 각각이 다른 생명주기를 갖고 있다. 따라서, 올바르지 않은 Context를 사용할 경우 메모리 누수가 발생할 수 있다.


Context와 Memory Leak

Context는 크게 ApplicationContext와 나머지 Context, 그중에서도 Activity Context로 나눌 수 있다.

Application Context

ApplicationContext는 Applicaton 생명주기를 따르는 싱글턴 객체이다. 따라서, 앱 전역에서 사용될Context가 필요하다면 ApplicationContext를 사용하자. (ex: RoomDatabase 객체 생성, 싱글턴 객체 생성)

 

싱글턴(or 액티비티 생명주기를 넘어가는) 작업에 Activity Context를 사용할 경우 메모리 누수가 발생하므로 주의해야 한다! Activity가 종료되더라도 다른 객체에서 Activity를 Context로 참조하고 있다면 garbage collector가 메모리를 해제할 수 없기 때문이다.

Activity Context

Activity Context는 Activity의 생명주기에 종속되어 있다. 따라서, Activity 생명주기 내에서 사용될 Context가 필요하다면 Activity Context를 사용하는 것이 좋다.

 

특히, GUI 작업을 한다면 반드시 Application Context가 아닌 Acitivty Context를 사용해야 한다. (ex: 다이얼로그 생성, Toast 출력) 왜냐면 Acitivty의 Theme과 같은 고유한 정보는 해당 Context만이 갖고 있지 때문이다. 다른 Context를 사용할 경우 예상과 다른 결과를 보게 되거나 crash로 앱이 종료될 수도 있다.

정리

  • 앱 전역에서 사용될 Context가 필요하다 → Application Context
  • 액티비티 생명주기 내에서 or GUI 작업에 사용할 Context가 필요하다 → Activity Context

부록) Context를 가져오는 방법

1. Activity

액티비티가 Context 그 자체이므로, this로 Activity Context에 접근할 수 있다.

2. Fragment

context or requireContext 를 통해 Fragment를 hosting하는 Activity Context에 접근할 수 있다.

3. View

view.context 를 통해 View가 포함되어 있는 Activity의 Context에 접근할 수 있다.

4. ContentProvider

ContentProvider 클래스 안에서 context 를 통해 Application Context에 접근할 수 있다.

5. Application Context

각 Context에서 applicationContext을 통해 Application Context에 접근할 수 있다. (ex: context?.applicationContext)


정리

Context는 안드로이드 시스템과 통신할 수 있는 인터페이스로, 앱 리소스, 시스템 작업, 컴포넌트 통신 등을 요청할 수 있다.

Context는 크게 Applicaton Context와 Activity Context로 나눌 수 있는데, 상황에 적절한 것을 사용하지 않으면 메모리 누수나 앱 크래시가 발생한다. 앱 전역에서 사용된다면 Applicaton Context를, 액티비티 내에서 유효하거나 GUI 작업에 필요한 것이라면 Activity Context를 사용하자.


참조

(1) 의존성 주입의 개념과 장점


SOLID 원칙이란?

SOLID 원칙이란 로버트 마틴이 소개한 객체 지향 프로그래밍 및 설계의 5가지 기본 원칙을 마이클 페더스가 약어를 따서 정리한 것이다. SOLID 원칙은 소스 코드의 가독성을 높이고 확장하기 쉬운 구조를 만들기 위한 지침이다.

SOLID

  • SRP(Single Responsibility Principle, 단일 책임 원칙)
  • OCP(Open-Closed Principle, 개방-폐쇄 원칙)
  • LSP(Liskov Substitution Principle, 리스코프 치환 원칙)
  • ISP(Interface Segregation Principle, 인터페이스 분리 원칙)
  • DIP(Dependency Inversion Principle, 의존성 역전 원칙)

Single Responsibility Principle (단일 책임 원칙)

“클래스를 변경하는 이유(책임)는 오직 하나이어야 한다.”

 

“클래스가 오직 하나의 책임을 갖도록 하라”라고도 한다. 그러면, 마치 함수처럼 클래스에 하나의 기능만 구현하라는 걸까? 그렇지 않다. 여기서 하나의 의미는 관련성이 적은 다른 기능의 코드까지 함께 변경하도록 만들지 말라는 것이다.

 

예를 들어, 요구사항A를 반영하여 클래스를 수정했다. 그 다음에 요구사항B가 새로 들어와서 그것도 반영했다. 그랬더니 요구사항A와 관련된 코드에 문제가 발생했다. 다시 수정하자니, 요구사항B를 반영할 수 없다. 서로 다른 책임의 코드가 결합된 상황이 발생한 것이다.

 

따라서, 이러한 충돌을 해결하고 앞으로의 변경에도 유연하게 만들기 위해 두 책임의 결합을 분리(ex: 별도의 클래스)하라는 뜻으로 이해할 수 있다.


Open-Closed Principle (개방-폐쇄 원칙)

“기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 설계하라.”

의의

이 원칙을 지킬 수록 프로젝트는 탄탄해진다. 왜냐면,

  1. 기존 코드의 변경은 항상 버그의 가능성을 의미한다.
  2. 변경은 테스트를 필요로 한다.
  3. 코드의 수정이 또 다른 수정을 불러오는 현상은 개발자의 개발 의지를 꺾는다.

OCP의 구현은 클라이언트 클래스와 의존성 클래스 사이에 추상화를 둠으로써 가능하다. (⇒ 의존성 역전 원칙)

예시)

  1. Repository 클래스가 LocalDataSource 클래스에 의존하고 있다. LocalDataSource는 File의 데이터를 읽어오는 기능만 구현되어 있다.
  2. Room에서 데이터를 저장,불러오는 기능을 추가하고 싶다.
  3. LocalDataSource를 인터페이스로 변경한다.그리고 구현을 FileDataSource 클래스로 옮기고 LocalDataSource를 상속한다.
  4. Room 기능을 추가하기 위해 RoomDataSource를 정의하고 똑같이 LocalDataSource를 상속한다. 그러면 기존의 Repository와 FileDataSource의 코드를 변경하지 않으면서 Room 기능을 확장할 수 있게 된다. 또한, 앞으로 다른 종류의 로컬 데이터 기능을 확장하더라도 기존의 클래스를 변경하지 않는 것이 가능해진다.

정리)

  • 원칙: 기존 코드를 수정하지 않고 기능을 확장할 수 있도록 하자.
  • 이유: 기존 코드의 변경은 버그의 가능성을 의미하며, 연쇄적인 수정은 개발의 의지를 꺾는다.
  • 방법: 하위 기능들을 대표하는 추상 클래스(인터페이스)를 정의하고, 새 기능을 추가할 때 구현 클래스가 추상 클래스를 상속하도록 한다. (DIP)

Liskov Substitution Principle (리스코프 치환 원칙)

“서브타입(subtype)은 그것의 기반 타입(base type)으로 완벽하게 치환 가능해야 한다.”

 

즉, 상위 타입 자리에 하위 타입 객체를 사용할 때, 하위 타입 객체가 상위 타입의 동작을 완전히 대체할 수 있어야 한다.

위반 사례)

직사각형(Rectangle)-정사각형(Square) 문제

open class Rectangle {
	open var width = 0
	open var height = 0
	val area: Int
		get() = width * height
} 

class Square : Rectangle() {
	override var width: Int
		get() = super.width
		set(width) {
			super.width = width
			super.height = width  
		}

	override var height: Int
		get() = super.height 
		set(height) {
			super.width = height 
			super.height = height 
		}
}
fun calculateArea(rect: Rectangle): Int {
	rect.width = 2
	rect.height = 3
	return rect.area
}

fun main() {
	val rect = Rectangle()
	val square = Square()
	println(calculateArea(rect)) // 6
	println(calculateArea(square)) // 9
}
  • 문제: 같은 상황에서 같은 메서드를 호출했을 떄 Rectangle과 Square의 결과가 다르다. 이것은 클라이언트 객체 입장에서 에러 상황이다.
  • 원인: 개념적으로 상속 관계처럼 보이지만 실제 동작은 상속 관계가 아닌데 직사각형을 상속했기 때문이다.
  • 해결: 새로운 추상 클래스를 선언하여 둘 다 그것을 상속하게 한다.

Deep: 왜 하위 타입이 상위 타입을 완전히 대체해야만 하는가?

하위 타입은 상위 타입으로 언제나 업캐스팅 되어 사용될 수 있기 때문이다. 따라서, 하위 타입마다 상위 타입의 메서드 호출에 대한 동작이 다르다면 이것을 사용(의존)하는 클라이언트 객체 입장에선 참으로 난감할 것이다.

이것을 해결하는 방법으로, 객체를 사용할 때 if문으로 하위 클래스의 타입을 검사해서 다르게 처리할 수 있는데, 이럴 경우 하위 클래스를 추가할 때마다 사용하는 쪽에 if문을 새로 추가해야 한다. 이것은 기능을 확장할 때마다 기존의 코드를 수정하게 하여 변경을 어렵게 만든다. (OCP 원칙 위반)


Interface Segregation Principle (인터페이스 분리 원칙)

“클라이언트가 사용하지 않는 메소드를 갖지 않도록 인터페이스를 분리하라”

 

범용적인 인터페이스가 아니라 클라이언트가 사용하는 기능만 갖도록 인터페이스를 분리하라는 의미로 볼 수 있다.

왜 인터페이스를 분리하는 것이 좋을까?

범용적인 인터페이스를 구현(상속)하는 클래스 입장에서 아래와 같은 문제가 발생한다.

  1. 사용되지 않을 메서드를 억지로 구현해야 한다.
  2. 사용되지 않는 메서드의 시그니쳐가 변경됐을 때 불필요하게 수정해야 한다.

위반 예시

interface Animal {
	fun eat()
	fun run(from: Where, to: Where)
	fun fly(from: Where, to: Where)
}

class Lion : Animal {
	override fun eat() = println("Lion eat")
	override fun run(from: Where, to: Where) = println("Lion run")
	override fun fly(from: Where, to: Where) {} // 구현 필요 없음
}

Animal의 하위에 Bird와 Mammal로 인터페이스를 분리하고 Lion이 Mammal을 상속한다면 Lion은 필요한 메서드만 구현할 수 있다.


Dependency Inversion Principle (의존성 역전 원칙)

“상위 레벨 모듈은 하위 레벨 모듈에 직접 의존하지 않고 추상화에 의존해야 한다.”

 

클라이언트 클래스가 의존성 대상인 구체 클래스에 의존하지 않고 추상 클래스(인터페이스)에 의존하도록 하라는 것으로 해석할 수 있다.

장점)

DIP를 지키면 하위 모듈을 변경할 때 상위 모듈에 미치는 영향을 최소화 할 수 있다. 즉, 의존 대상에 변경이 생겼을 때 클라이언트에게 미치는 영향을 최소화 할 수 있다.

예시) Car → Engine

Car의 내부에서 가솔린 엔진을 쓰다가 디젤 엔진으로 변경한다.

class GasolineEngine() {
	private val fuel = "Gasoline"
}
class DieselEngine() {
	private val fuel = "diesel"
}

class Car {
	private val engine = GasolineEngine()
}
// 디젤 엔진으로 변경
class Car {
	private val engine = DieselEngine()
}

이 방식의 단점은 Engine 을 변경하는데 Car의 코드도 함께 변경해야 한다는 것이다.

하지만 가운데에 Engine 인터페이스(추상화)를 두면, 의존성을 갖지 않던 하위 레벨 모듈인 가솔린 엔진이 상위 레벨로 도입된 엔진에 의존하는 의존성 역전이 일어난다.

class Car(private val engine: Engine)

interface Engine
class GasolineEngine : Engine
class DieselEngine : Engine
class TestEngine : Engine

fun main() {
    val gasolineCar = Car(GasolineEngine())
    val dieselCar = Car(DieselEngine())
    val testCar = Car(TestEngine())
}

이제 어떤 엔진으로 갈아 끼우더라도 Car엔 변경이 일어나지 않는다. (Engine 자체에 혁신이 일어나서 인터페이스 명세서가 변경되지 않는 한..)

구체적인 장점)

  • 구조가 유연해진다. 즉, 하나의 클래스가 여러 구체 클래스에 의존 할 수 있다.
  • 기존 코드를 변경하지 않으면서 상황에 따라 의존 대상을 변경할 수 있다. ⇒ DIP는 OCP를 지키기 위한 방법이 된다는 것을 알 수 있다!
  • 구조가 유연해진 덕분에 테스트용 가짜 객체를 사용할 수 있어서 단위 테스트가 편해진다.

참조

+ Recent posts