인문주의 사피엔스

선언형 UI 프로그래밍을 사용하는 이유 본문

프로그래밍/일반

선언형 UI 프로그래밍을 사용하는 이유

인문주 2021. 9. 16. 15:49
반응형

React Native(2015년)부터 Flutter(2018년), SwiftUI(2019년), Jetpack Compose(2021년)까지 등장하면서 선언형 UI 프로그래밍이 새로운 앱 개발 방식으로 자리잡았습니다. 왜 선언형 UI 프로그래밍을 사용할까요? Flutter, SwiftUI, Jetpack Compose 각각의 방법으로 예제 앱을 구현해서 비교하며 그 이유를 살펴보겠습니다.

 

선언형 UI 프로그래밍과 대비되는 기존의 방식을 명령형 UI 프로그래밍이라고 합니다. 두 방식의 결정적 차이는 ‘앱 상태(state)의 변화를 UI에 반영하는 방식’에 있습니다. 예를 들어 UI 객체에 대응하는 어떤 변수가 있을 때 그 변수의 값이 바뀌면 당연히 UI에도 반영돼야 합니다. 그럴 때 기존의 명령형 UI 프로그래밍 방식은 'UI 객체에 대해 갱신을 명령'하는 것입니다. 따라서 프로그래머는 변수와 UI 객체를 정확히 연결하고 관리해야 합니다. 만약 새로운 변수나 이벤트 또는 UI 객체가 추가되면 그 과정에서 누락되는 것이 없도록 주의해야 합니다.

 

선언형 UI 프로그래밍의 방식은 완전히 다릅니다. 위와 같은 상황에서 ‘UI 객체를 통째로 다시 선언’합니다. 다시 선언한다는 것은 다시 생성한다는 의미입니다. 따라서 변경된 내용의 갱신을 명령하고 말고 할 것이 없습니다. 이 방법의 장점은 안정성과 생산성입니다. 프로그래머가 변수와 UI 객체를 연결하고 관리하는 데 쓰는 시간과 노력이 거의 없습니다. 결과적으로 실수할 가능성이 낮아집니다.

 

‘명령’과 ‘선언’은 UI 프로그래밍에 대해 서로 전혀 다른 시각을 필요로 합니다. 따라서 선언형 UI 프로그래밍의 특징을 제대로 이해하지 못하면 ‘명령’과 ‘선언’의 개념이 뒤섞인 불완전한 프로그램이 될 수 있습니다. 선언형 UI 프로그래밍에서 중요한 것은 앱 상태입니다. 앱 상태를 어떻게 정의하고 구현할 것인지는 선언형 UI 프로그래밍의 핵심 주제 가운데 하나입니다.

 

상태(state)

상태는 ‘시간이 지나면 바뀌는 값’, 간단히 말해 상수가 아닌 변수를 의미합니다. 특히 앱에서는 ‘UI에 연결된 변수’로 말할 수 있습니다. 아래 그림은 이후의 설명에 사용할 예제 앱의 UI입니다.

 

예제 앱 UI

 

위의 화면은 viewA와 viewB로 나누어집니다. viewA는 검은색 바탕의 윗쪽 영역, viewB는 흰색 바탕의 아랫쪽 영역입니다. viewB의 버튼을 누르면 숫자가 1씩 증가합니다. 그리고 그 숫자들의 합계가 viewA의 텍스트에 표시됩니다. 위의 화면에는 ‘UI에 연결되어 시간이 지나면 바뀌는’ 상태 두 개가 존재합니다. 하나는 버튼의 숫자이고 다른 하나는 그 숫자들의 합계입니다. 버튼을 누르면 숫자와 합계가 증가하고 각각이 버튼과 텍스트에 반영됩니다. 상태와 함께 여기서 주목할 것은 버튼을 누르는 '동작'입니다. 그리고 그 동작이 상태의 변화를 일으킨다는 사실입니다. 앱 상태의 변화를 유발하는 동작이나 사건을 이벤트라고 부릅니다.

 

이벤트(event)

이벤트는 앱의 상태를 바꾸는 동작이나 사건을 말합니다. 버튼을 누르거나, 텍스트를 입력하거나, 네트워크를 통해 데이터 받기를 시작하거나, 파일 읽기를 완료하는 것이 모두 이벤트에 해당됩니다. 그리고 대부분의 이벤트는 상태 변수와 연결됩니다. 버튼을 누르면 증가하는 숫자, 입력한 텍스트를 저장하는 변수, 데이터 수신의 시작을 알리는 표시, 파일 읽기의 완료를 알리는 표시 등이 모두 상태 변수입니다. 예제 앱의 경우에 버튼을 누르는 동작이 하나의 이벤트입니다. viewB의 버튼에 이벤트가 발생하면 상태 변수인 버튼의 숫자와 그 합계가 변경됩니다. 변경된 상태 변수의 내용은 viewB의 버튼과 viewA의 텍스트에 반영됩니다.

 

명령형 UI 프로그래밍(imperative style of UI programming)

예제 앱을 명령형 UI 프로그래밍 방식으로 구현하기 위해서는 일반적으로 UI를 기술하는 별도의 파일을 작성하게 됩니다. 예를 들어 Xcode에서 Swift를 통해 iOS 앱을 만들기 위해서는 스토리보드 파일을 작성해야 합니다. Android Studio에서 Kotlin을 통해 Android 앱을 만들기 위해서는 XML 파일을 작성해야 합니다. 다음은 예제 앱의 UI를 기술한 XML 코드입니다.

 

 

XML 파일로 기술된 UI 객체는 보통 android:id=“@+id/button”과 같이 ID를 할당받습니다. ID는 UI 객체를 소스코드와 연결하는 용도로 사용됩니다. 소스코드에서 UI 객체를 참조하려면 ID를 사용해야 합니다. 다음은 위의 XML 파일에 대응하는 Kotlin 소스코드입니다.

 

 

우선 setContentView를 통해 XML 파일(activity_main.xml)에 접근함으로써 소스코드와 UI를 연결합니다. 그리고 findViewById를 통해 UI 객체를 변수에 연결합니다. 위에서는 text와 button 변수에 각각 viewA의 text와 viewB의 button이 연결되었습니다. setOnClickListener를 통해 버튼의 클릭 이벤트가 전달됩니다. 이벤트가 발생했을 때 변경할 상태 변수는 count와 sum으로서 ++count에 의해 값이 증가한 뒤 sum에 더해집니다. 변경된 count와 sum의 값은 각각 text.text와 button.text에 입력됩니다. 그 입력이 바로 명령입니다. text와 button 객체에 값의 반영을 명령한 것입니다. 그것이 바로 명령형 UI 프로그래밍의 방식입니다. 주목할 부분은 text와 button 객체가 앱의 실행부터 종료까지 소멸되지 않고 존속된다는 사실입니다. 그것은 UI 객체가 수시로 생성과 소멸을 반복하는 선언형 UI 프로그래밍과 완전히 다른 점입니다.

 

만약 sum과 count의 값을 초기화하는 버튼을 추가하려면 어떻게 해야 될까요? 우선 XML 파일을 수정하여 버튼을 추가하고 ID를 할당하여 소스코드와 연결해야 합니다. 그리고 버튼 이벤트가 발생됐을 때 sum과 count의 값을 0으로 초기화해야 합니다. 마지막으로 text.text와 button.text에 초기화된 값을 입력해야 합니다. 그런데 만약 프로그래머의 실수로 마지막 코드가 누락된다면 어떻게 될까요? 그렇게 되면 sum과 count의 값이 실제로는 0으로 초기화 되지만 UI에 반영되지 않는 예상치 못한 상황이 발생합니다. 그것은 UI 객체에 대한 명령의 누락을 의미합니다. 그런 상황은 명령형 UI 프로그래밍에서 피할 수 없는 문제입니다. 왜냐하면 앱 상태를 변경하는 코드와 UI를 갱신하는 코드가 분리되어 있기 때문입니다.

 

선언형 UI 프로그래밍(declarative style of UI programming)

선언형 UI 프로그래밍은 UI를 소스코드에서 구성합니다. UI 객체는 모두 프로그래밍에 의해 선언됩니다. 따라서 XML이나 스토리보드 같은 별도의 파일이 필요없습니다. 당연히 UI 객체와 상태 변수를 연결하는 과정도 없습니다. 결과적으로 상태 변수를 변경하는 과정과 UI를 갱신하는 과정이 통합됩니다. 다음은 ViewA를 구현한 Dart 소스코드입니다.

 

 

build 함수는 UI 객체에 해당하는 위젯(Widget)을 반환합니다. 반환된 위젯은 Flutter 플랫폼에 의해 UI에 표시됩니다. 위젯의 구체적인 내용은 Container입니다. 위에서 Container는 검은색의 사각형을 표시합니다. Container는 Center를 포함하고 있습니다. Center는 자식 위젯인 Text를 화면의 가운데에 위치시킵니다. Text는 앱 상태(AppState)로 정의된 sum의 값을 표시하고 있습니다. 모든 UI 객체가 '선언'되기 때문에 '명령'이 없습니다. 특히 _ViewA가 StatelessWidget을 상속한다는 것은 ViewA에 상태 변수가 없다는 것을 의미합니다. ViewA는 sum을 화면에 표시하는 일만 수행합니다.

 

상태 관리(state management)

StatelessWidget과 달리 StatefulWidget은 상태 변수를 허용하는 위젯입니다. 예제 앱의 ViewB가 StatefulWidget입니다. ViewB의 상태 변수는 버튼을 누를 때 증가하는 숫자입니다. 그 숫자 변수는 ViewB의 내부에서만 표시됩니다. 반면에 합계 변수는 ViewB의 영역을 벗어나 ViewA에 표시되어야 합니다. 따라서 합계 변수는 ViewA와 ViewB를 모두 포함하는 상위에 선언되어야 합니다. 숫자 변수는 지역 상태를 나타내고 합계 변수는 전역 상태(또는 앱 상태)를 나타냅니다.

 

반응형

 

Flutter

Flutter에서 setState를 호출하면 해당 위젯이 통째로 다시 선언됩니다. 위젯의 재선언이 필요한 경우는 위젯의 지역 상태가 변경되어 UI를 갱신이 필요할 때입니다. ViewB의 버튼을 누르면 숫자를 증가시키고 setState를 호출해야 바뀐 숫자가 버튼에 표시됩니다. 다음은 ViewB를 구현한 Dart 소스코드입니다.

 

 

_ViewA에 비해 조금 복잡해 보이지만 StatefulWidget과 State를 구분하면 딱히 복잡할 것은 없습니다. _ViewBState에 상태 변수로 선언된 localCount의 값은 onPressed로 버튼 이벤트가 전달될 때 ++localCount에 의해 증가합니다. 그리고 setState를 호출하면 build 함수가 다시 실행되면서 UI에 localCount의 변경된 값이 반영됩니다. 

 

앱 상태(전역 상태)를 다루는 방식은 지역 상태의 경우와 다릅니다. 다음은 Flutter에서 앱 상태를 정의한 Dart 소스코드입니다.

 

 

Flutter에서 앱 상태를 다루는 방법은 다양합니다. 다양한 방법들의 핵심은 앱 상태가 변할 때 원하는 곳으로 알려주는 것입니다. 위의 소스코드에서 사용한 방법은 provider라는 Flutter 패키지입니다. AppState의 sum에서 notifyListeners를 실행하면 sum을 참조하는 위젯의 build 함수가 다시 호출됩니다. 다음의 소스코드는 ViewB의 버튼을 누를 때마다 sum을 변경하는 내용입니다.

 

 

SwiftUI

SwiftUI는 setState 같은 함수를 사용하지 않습니다. 대신 상태 변수를 선언할 때 @State를 붙이는 방법을 사용니다. 상태 변수의 값이 변경되면 그 변수가 선언된 UI 객체가 플랫폼에 의해 자동으로 갱신됩니다. 다음은 ViewB를 SwiftUI로 구현한 Swift 소스코드입니다.

 

 

상태 변수인 localCount 앞에 @State가 선언되었습니다. 그 점을 제외하면 소스코드의 구조는 Flutter의 경우와 유사합니다. 다른 점이라면 localCount += 1 코드가 실행되는 곳에 setState 같은 함수가 없다는 것입니다. localCount의 값이 변경될 때마다 ViewB가 자동으로 갱신됩니다.

 

SwiftUI에서 앱 상태를 클래스 형식으로 선언할 때는 다음과 같이 방식이 조금 달라집니다.

 

 

클래스를 상태 변수로 선언할 때는 반드시 ObservableObject, @Published, @StateObject, @ObservedObject 같은 표시자를 사용해야 합니다. state.sum += count가 실행되면 state가 선언된 ContentView가 자동으로 갱신됩니다. 다음은 ViewA를 구현한 SwiftUI 소스코드입니다.

 

 

SwiftUI에서 상태를 관리하는 방식은 다른 플랫폼에 비해 복잡합니다. 클래스 변수에 대해서는 @State 대신 ObservableObject, @Published, @StateObject, @Binding, @EnvironmentObject 등의 표시자들을 사용해야 합니다. 간단하게 만들지 않은 나름의 이유가 있겠지만 애플 개발자 문서의 설명은 언제나 도움이 안 됩니다.

 

Jetpack Compose

SwiftUI에 비해 Jetpack Compose의 상태 관리 방식은 훨씬 간단합니다. 상태 변수를 선언하는 데 필요한 것은 mutableStateOf와 remember 뿐입니다. 다음은 ViewB를 Jetpack Compose로 구현한 Kotlin 소스코드입니다.

 

 

상태 변수를 선언하려면 mutableStateOf를 사용해야 합니다. 그렇게 선언된 변수의 값을 변경하면 UI가 자동으로 갱신됩니다. 위의 소스코드에서 localCount++가 실행되면 ViewB가 자동으로 갱신됩니다. 여기서 주의해야 할 내용이 있습니다. localCount는 함수 안에서 선언된 지역 변수입니다. 따라서 ViewB가 다시 호출되면 localCount의 값이 초기화됩니다. 그래서 Jetpack Compose에서는 remember라는 특별한 함수가 사용됩니다. remember를 사용하면 지역 변수의 값이 기억됩니다. 다음은 예제 앱의 나머지 내용을 구현한 Kotlin 소스코드입니다.

 

 

맨 앞에서 언급된 프로그래머의 실수를 다시 생각해보겠습니다. 그 실수에 의해 sum과 localCount의 값이 실제로는 바뀌지만 UI에 반영되지 못하는 상황이 예상됐습니다. 그런 상황이 선언형 UI 프로그래밍에서는 발생하기 어렵습니다. 상태 변수의 변경이 바로 UI의 갱신으로 연결되기 때문입니다. 특히 SwiftUI와 Jetpack Compose의 경우에는 상태 변수를 변경하면 UI가 자동으로 갱신됩니다. Flutter의 경우는 setState와 notifyListeners를 호출해야 하지만 언제나 상태 변수의 변경과 함께 하기 때문에 실수할 가능성은 거의 없습니다.

 

반응형
Comments