함수형, 선언형 프로그래밍 그리고 안드로이드

김규성
지디지인천,송도 & 플러터송도
12 min readSep 22, 2023

기존에 안드로이드 앱을 개발할 때 XML로 레이아웃을 그리고,
Java에서 해당 xml을 inflate 하는 과정을 통해 해당 View들을 메모리에
올린 후 메모리에 올린 View들을 일일이 어떻게 바꿔줄 것인지에 대해
코드를 작성하는 명령형 프로그래밍 방식으로 개발했습니다.​

하지만 Google I/O 2019에서 안드로이드 개발이 점차 Kotlin-first가 될 것이라고 발표한 이후 대부분 Java 대신 함수형 프로그래밍의 성격을 도입한
Kotlin을 이용하며, XML을 이용해 UI 구조를 정의하는 대신
선언형 프로그래밍 방식인 Jetpack Compose를 이용해 UI 구조를 정의하는 것이 점점 대세가 되고 있습니다.​

그럼 함수형 프로그래밍과 선언형 프로그래밍부터 하나하나 알아보도록 하겠습니다.

함수형 프로그래밍

함수형 프로그래밍은 순수 함수와 불변성, 고차 함수 등의 원칙에 기반한
자료 처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나입니다.

이는 복잡성을 줄이고, 코드의 예측 가능성과 재사용성을 높여주며,
부작용을 최소화하는 데 중점을 둡니다.

<용어 정리>

  • 순수 함수 (Pure Function)
    함수의 출력 값이 입력 값에만 의존하며, 외부의 어떠한 상태도 사용하거나 변경하지 않는 함수입니다.
  • 부작용 (Side Effect)
    함수형 프로그래밍에서 부작용(side effect)은 함수가 외부 세계와 상호작용하는 것(= 영향을 주고받는것)을 말합니다. 부작용은 함수형 프로그래밍에서 지양되며, 부작용이 있는 함수는 순수 함수(pure function)가 아니게 됩니다.
  • 불변성 (Immutability)
    데이터가 생성된 후에 변경될 수 없는 성질을 의미합니다
  • 고차 함수 (Higher-Order Function)
    다른 함수를 인자로 받거나 함수를 결과로 반환하는 함수를 말합니다.

객체 지향과 함수형 프로그래밍 방식 비교 예시

그렇다면, 리스트의 각 요소에 2를 곱하는 작업을
Java에서 객체 지향 프로그래밍 방식으로만 코드를 작성한 것과
함수형 프로그래밍 방식을 적용한 것을 비교​해서 보도록 하겠습니다.

// Java(Java 8 전)
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class DoubleList {
// 클래스 전역 변수로 선언
private static List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
private static List<Integer> doubled = new ArrayList<>();

public static void main(String[] args) {
doubleList(numbers);
System.out.println(doubled);
}

public static List<Integer> doubleList(List<Integer> numbers) {
for (Integer number : numbers) {
doubled.add(number * 2);
}
return doubled;
}
}

위 코드에서 doubleList 메서드는
doubled라는 외부 상태에 의존하기에 순수함수가 아닙니다.

위 방식으로 코드를 작성하게 되면 프로젝트의 규모가 커졌을 때 doubled에 어떤 메서드도 접근할 수 있기에 부작용(side effect)이 생기며 doubled의 상태를 예측하기 어려워집니다.

즉, 같은 입력에 대해 항상 같은 출력을 반환하지 않게됩니다.

// Java(Java 8 이후의 람다와 스트림을 사용)
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class DoubleList {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> doubled = numbers.stream()
.map(n -> n * 2)
.collect(Collectors.toList());
System.out.println(doubled);
}
}

위 코드에서 map 메서드는 람다 함수를 파라미터로 받아(고차 함수의 특징) 외부 상태에 영향을 주고받지 않는(=의존하지 않는) 순수 함수입니다.

따라서 위 코드에서는 numbers라는 입력 값이 같다면 항상 같은 doubled 값이 출력될 것이라고 예측할 수 있습니다.

함수형 프로그래밍의 장점을 도입한 Kotlin

Java에서도 함수형 프로그래밍 방식인 람다와 스트림을 도입했듯이
함수형 프로그래밍의 장점을 도입한 Kotlin에서는 람다 변수, 고차 함수,
순수 함수, 불변성 등의 개념을 사용하여 코드를 작성할 수 있습니다.

이러한 개념을 사용하면 코드가 더 간결하고 유연해지며,
부작용(side effect)을 최소화할 수 있습니다.

아래는 이러한 개념들을 사용한 Kotlin 코드의 예시입니다.

// Kotlin
// 고차함수면서 순수함수인 operate 함수
fun operate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}

fun main() {
/* 람다 표현식을 사용하여 익명 함수를 만들고 multiply 변수에 할당합니다.
또한 val 키워드를 사용하여 값을 재할당 할 수 없게 해
데이터의 불변성을 보장합니다. */
val multiply: (Int, Int) -> Int = { x, y -> x * y }
val result = operate(3, 4, multiply)
println(result) // 출력: 12
}

선언형 프로그래밍

선언형 프로그래밍은 ‘어떻게’가 아니라 ‘무엇을’ 할 것인지를 명시하는 프로그래밍 방식입니다. 사용자가 원하는 결과를 선언적으로 표현하며, 구체적인 실행 방법에 대해서는 신경 쓰지 않습니다.

​이번에는 안드로이드에서 어떻게 선언형 프로그래밍을 도입하고 있는지알아보도록 하겠습니다.

선언형 UI 도구 모음 Jetpack Compose

Jetpack Compose는 선언형 UI 도구 모음으로,
UI를 간결하고 직관적으로 구성할 수 있습니다.
기존의 XML + Java or Kotlin 기반의 UI 정의와 달리,
Compose는 선언형으로 UI 컴포넌트를 구성하고 상태를 변경합니다.

아래의 코드는 드로이드나이츠 앱의 코드 중 Jetpack Compose를 이용해
선언적으로 UI 관련 코드를 작성한 예시입니다.

// HomeScreen.kt 일부 코드
@Composable
private fun HomeScreen(
padding: PaddingValues,
sponsorsUiState: SponsorsUiState, /* UI 상태 */
onSessionClick: () -> Unit, /* UI 동작 */
onContributorClick: () -> Unit, /* UI 동작 */
) {
val scrollState = rememberScrollState()
Column( /* UI 구조 */
Modifier
.padding(padding)
.padding(horizontal = 8.dp)
.verticalScroll(scrollState)
.padding(bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SessionCard(onClick = onSessionClick)
ContributorCard(onClick = onContributorClick)
SponsorCard(uiState = sponsorsUiState)
}
}

위 방식으로 UI를 작성하게 되면 이전의 방식과 달리 UI 상태, 구조, 동작 모두를 Kotlin 코드 내에서 선언적으로 정의합니다. UI 컴포넌트의 상태, 구조, 동작이 같은 곳에서 관리되므로 코드가 더 간결하고 일관적입니다.

선언형 방식의 Coroutine Flow Chaining​

Coroutine Flow는 Kotlin의 Coroutine 라이브러리에 포함된 반응형 스트림을 위한 도구입니다. Flow는 선언적으로 데이터 스트림을 표현하고,
함수형 연산자(operator)를 통해 이를 처리합니다.

여기서 Coroutine Flow Chaining은 여러 Flow 연산자를 함께 연결하여
복잡한 비동기 작업 흐름을 선언적으로 구성하는 방식을 의미합니다.

아래의 코드는 드로이드나이츠 앱의 코드중
Coroutine Flow Chaining을 이용한 예시입니다.

// HomeViewModel.kt

@HiltViewModel
class HomeViewModel @Inject constructor(
getSponsorsUseCase: GetSponsorsUseCase,
) : ViewModel() {

private val _errorFlow = MutableSharedFlow<Throwable>()
val errorFlow: SharedFlow<Throwable> get() = _errorFlow

val sponsorsUiState: StateFlow<SponsorsUiState> = flow { emit(getSponsorsUseCase()) }
.map { sponsors -> /* SponsorsUiState 생성 */
if (sponsors.isNotEmpty()) {
SponsorsUiState.Sponsors(sponsors)
} else {
SponsorsUiState.Empty
}
}
.catch { throwable -> /* Error Handling */
_errorFlow.emit(throwable)
}
.stateIn( /* StateFlow 초기화 Setting */
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SponsorsUiState.Loading,
)
}

HomeViewModel은 GetSponsorsUseCase를 이용하여 Sponsor들의 정보를 가져와 UI 상태를 관리합니다. 가져온 Sponsor들의 정보는 StateFlow를 통해 외부에 노출되며, 에러 발생 시에는 SharedFlow를 통해 에러를 전달합니다.

위와 같이 선언형 방식인 Coroutine Flow Chaining 방식을 통해
어떻게 StateFlow<SponsorsUiState>가 초기화되는지
한 눈에 알아볼 수 있게됩니다.

함수형 프로그래밍과 선언형 프로그래밍의 차이

함수형 프로그래밍과 선언형 프로그래밍은 함께 묶여 사용되는 경우가 많기에 같은 의미라는 생각이 들 순 있지만 두 용어는 다른 의미입니다.​

선언형 언어인 SQL을 예로 들어보겠습니다.

SELECT * FROM users WHERE age >= 19;

위 쿼리는 19세 이상의 사용자를 가져오라는 결과를 선언적으로 나타냅니다.여기서 ‘어떻게’ 데이터를 가져올지에 대한 세부적인 과정은 명시되어 있지 않습니다.

그러나 SQL은 함수의 개념이나 순수 함수, 불변성 등 함수형 프로그래밍의 특징을 포함하고 있지 않습니다.​

따라서, 함수형 프로그래밍은 선언형 프로그래밍의 하위 카테고리 중 하나로 볼 수 있습니다. 선언형 프로그래밍은 그 방법론의 범주가 더 넓으며,
함수형 프로그래밍은 그 중 특정한 스타일을 나타냅니다.

마무리하며…

선언형 프로그래밍과 함수형 프로그래밍은 코드의 가독성과 유지 보수성이 증가하도록 도와주는 현대적인 프로그래밍 패러다임입니다.

안드로이드는 Kotlin, Coroutine Flow, Jetpack Compose와 같은 도구를 통해이러한 패러다임을 효과적으로 적용하고 있습니다.

이를 통해 안드로이드 개발자들은 유지 보수성이 높은 앱을 만들 수 있었고,
더욱 직관적이고 유연한 코드를 작성할 수 있게 되었습니다.

감사합니다 :)

--

--