2024. 10. 13. 19:00ㆍDart
Dart는 객체 지향 프로그래밍 언어이며, 데이터를 구조화하고 관련 기능을 묶어주는 역할을 하는 클래스Class로 필요한 객체Object들을 만들 수 있습니다. 클래스는 일종의 설계도나 레시피라고 생각하시면 될 것 같습니다.
만약에 커피를 만든다고 생각해 봅시다. 커피를 만들기 위해서는 최소한 몇 가지를 정해야 합니다.
- 어떤 원두를 사용할 것인지
- 따뜻하게 마실 것인지 차게 마실 것인지
- 얼마 만큼의 물을 사용할 것인지
이걸 클래스로 만들어 본다면 이렇게 될 겁니다.
1~11번 라인에서 Coffee라는 클래스를 정의합니다.
2~4번 라인에서 Coffee 클래스는 beanType, waterAmount, hot 데이터를 가집니다. 이를 클래스의 인스턴스 변수instance variable라고 합니다. 각각 자유롭게 타입을 정의할 수 있습니다.
6번 라인에서 Coffee 클래스의 생성자를 정의합니다.
8~10번 라인에서는 다시 함수를 정의하고 있는데, method라고도 지칭합니다. 예제에서는 50ml이하면 에스프레소로 간주하고 있습니다.
14~16번 라인에서 Coffee 클래스의 생성자를 호출해서 서로 다른 객체를 생성하고 있습니다. 14번 라인처럼 new 연산자를 사용해도 되지만 Dart에서는 생성자 호출 때 new를 생략해도 되기 때문에, 앞으로 생략한 버전을 계속 사용하도록 하겠습니다.
18~20번 라인에서는 생성한 객체의 method나 인스턴스 변수를 '.'를 통해 접근하는 것을 볼 수 있습니다.
인스턴스 변수
인스턴스 변수를 선언하는 방법은 여러 가지가 있습니다. 먼저 요약하자면 다음 물음에 따라 케이스가 나뉜다고 보시면 됩니다.
- 생성될 때 null을 허용하는가?
- 기본값을 가지는가?
- 생성된 이후에 값이 수정될 수 있는가?
- 객체를 먼저 생성하고 나중에 값을 초기화할 수 있는가?
1. 생성될 때 null을 허용하는가?
전통적인 Java 같은 언어에서는 인스턴스 변수를 선언할 때, 기본적으로 null을 항상 허용했습니다. 하지만 Dart는 null 안정성을 중요시하는 언어이기 때문에 null을 허용하지 않는 것이 기본값입니다. null을 허용하기 위해서는 '?'가 항상 따라 붙어야 합니다.
생성자를 호출할 때 인스턴스 변수에 값을 따로 할당하지 않고, 기본값도 따로 가지지 않는다면 해당 인스턴스 변수는 null이 됩니다.
사실 앞의 예제에서 생성자를 주석 처리 한다면, 아래처럼 컴파일 에러가 발생하게 됩니다.

Non-nullable instance field 'beanType' must be initialized. Try adding an initializer expression, or a generative constructor that initializes it, or mark it 'late'
beanType이 Non-nullable, 즉 null이 될 수 없는 형태로 선언되었기 때문에 생성할 때 항상 null이 아닌 값이 되게끔 해주어야 합니다.
Coffee(this.beanType, this.waterAmount, this.hot); // generative constructor
위 생성자는 generative constructor로서 항상 생성을 할 때 null이 아닌 값을 강제하도록 하는 코드가 됩니다. 생김새를 조금 더 관찰해보면, 첫 번째 parameter로 들어오는 값이 this.beanType에 할당된다는 것을 축약해서 표현한다고 보시면 됩니다.
Coffee(this.beanType, this.waterAmount, this.hot); 라는 코드를 주석 처리하면 default constructor를 가지게 되는데, 아무런 파라미터를 가지지 않는 생성자입니다. 그래서 위처럼 에러 메시지가 발생하는 것입니다.
Coffee(); // default constructor
이번에는 클래스 내에 null을 허용할 수 있는 인스턴스 변수를 추가해보겠습니다. 커피를 달달하게 마시는 것을 좋아하는 사람도 있으니 Syrup 클래스를 만들어서 인스턴스 변수로 추가해봅시다.
1~6번 라인에서 Syrup 클래스를 정의했습니다. 시럽의 이름과 양을 인스턴스 변수로 갖고, 두 인스턴스 변수를 항상 갖는 generative constructor를 정의했습니다.
12번 라인에서 Syrup? 로 null을 허용하는 인스턴스 변수를 추가했습니다.
22번 라인의 coffee1 변수는 바닐라 시럽을 5만큼 넣은 시럽을 추가했네요!
23~24번 라인에서는 Syrup?이 들어가는 자리에 null을 명시적으로 추가해줘야 했습니다. 코드가 명확하긴 하지만 뭔가 못생겨지고 효율적이지 않은 것 같네요. 이를 해결하기 위해서 optional positional parameter를 정의할 때와 비슷한 방식을 사용할 수 있습니다.
[this.syrup] 처럼 syrup을 대괄호로 감쌌습니다. 이럴 경우, 대괄호 안에 있는 파라미터는 선택적으로 됩니다. (다만 여러개가 있을 때는 앞에서부터 순서대로 넣어야 하니 우선순위를 생각해야 하긴 합니다.)
사실, 대괄호에 기본값을 추가한다면 null을 허용하지 않는 앞의 인스턴스 변수에도 적용할 수 있습니다. 만약 '에스프레소가 진짜 커피지!'라고 생각하는 바리스타가 생성한 클래스라면 이렇게 변경할 수 있을 것 같습니다.
15번 라인에 waterAmount와 hot에 기본값을 추가했습니다.
24번 라인을 보시면 Coffee 생성자와 원두 이름만으로도 Coffee의 객체를 생성할 수 있게 됩니다!
25~26번 라인에서 coffee2 객체의 waterAmount와 hot이 각각 기본값을 나타내고 있는 것을 볼 수 있습니다.
28번 라인의 coffee3 객체는 물을 200ml 넣은 따뜻한 아메리카노라고 볼 수 있겠네요.
이처럼 생성자를 어떻게 정의하느냐에 따라 다양한 방식으로 객체를 생성할 수 있습니다.
2. 기본값을 가지는가?
이번에는 바리스타가 차 종류도 서빙하려고 합니다. 그래서 Tea라는 클래스를 생성했습니다.
1~12번 라인에서 Tea라는 클래스를 정의했습니다.
2번 라인에서 teaBag은 기본값이 "얼그레이"로 정해져있습니다. 이렇게 클래스를 정의하면서 기본값을 지정할 수 있습니다.
3번 라인에 Milk는 null을 허용하기 때문에, Tea는 default constructor로도 객체를 생성할 수 있게 됩니다.
20번 라인에서 Tea()로 생성한 tea 객체는 기본값인 얼그레이와 우유가 들어가지 않은 상태라고 보시면 됩니다.
23~24번 라인에서는 tea 객체의 정보를 바꾸고 있는데요, 캐모마일 티백과 우유를 넣어서 tea를 완전히 다른 음료로 바꾸었습니다.
24번 라인에서 Milk()도 정의되어있는 기본값을 사용하기 때문에 100ml의 서울우유를 사용하고 있다고 보시면 될 것 같습니다.
3. 생성된 이후에 값이 수정될 수 있는가? - final
위 예제의 23~24번 라인처럼 생성된 객체가 이후 코드 진행에서 정보가 수정되기도 하지만, 생성 초기의 의도가 수정되지 않기를 원할 때도 있습니다.
2번 라인에서 teaBag을 일단 하나 정하고 나면, 절대 바꿀 수 없게 하기 위해서 final 키워드를 추가했습니다. 원래처럼 얼그레이 같은 기본값을 설정해두면, 모든 Tea의 객체는 얼그레이 티만 팔아야 하기 때문에 기본값을 제거했습니다.
5번 라인에 생성자를 두었고, teaBag은 필수값이며 milk는 선택적으로 넣을 수 있는 파라미터입니다.
final 키워드를 두지 않은 인스턴스 변수는 항상 setter가 같이 선언된다고 보시면 됩니다. final 키워드를 붙이게 되면 이러한 setter가 없기 때문에 25번 라인과 같은 에러가 발생하게 됩니다.
Error: The setter 'teaBag' isn't defined for the class 'Tea'.
4. 객체를 먼저 생성하고 나중에 값을 초기화할 수 있는가? - late
객체를 생성하다 보면, 생성시점에서 알 수 없는 정보가 있을 수 있습니다. 외부 시스템에 있는 정보를 API를 통해서 가져와야 한다든지, 계산을 하는데 시간이 많이 소요된다든지, 아니면 다른 이유로 인해서 클래스에서 바로 초기화하기 어려운 인스턴스 변수는 late라는 키워드를 사용해서 초기화를 지연시킬 수 있습니다.
만약에 알레르기 정보를 외부에서 불러와서 객체에 넣어주고 화면에서 같이 보여주는 로직이라고 생각해봅시다. DB에 저장되어 있는 값을 불러오거나, API에서 정보를 가져올 수 있을 것 같습니다.
4번 라인에 allergyInfo를 late 키워드와 함께 추가했습니다. 생성자에서 allergyInfo에 대한 정보를 두지 않더라도 객체 생성이 가능합니다.
14~18번 라인에서 외부 API를 호출하는 코드는 String을 리턴한다고 가정해보겠습니다.
23번 라인을 주석해제 한다면, 아직 초기화되지 않은 인스턴스 변수에 접근하려고 하기 때문에 아래 에러를 마주하게 될 수 있습니다.
LateInitializationError: Field 'allergyInfo' has not been initialized.
28번 라인에서 값을 가져와 allergyInfo에 전달해 준 후에, 비로소 allergyInfo에 접근을 할 수 있게 됩니다.
객체를 생성하는 여러가지 방법
현재까지는 default constructor와 generative constructor를 위주로 다루었는데요, 더 다양한 방법으로 객체를 생성할 수 있는 방법이 있습니다.
Named constructor
아이스 아메리카노가 가장 많이 팔린다면, 빠르게 원두만 골라서 아아를 뽑고 싶을 수도 있을 것 같습니다.
11~12번 라인에서 named constructor의 예시를 볼 수 있는데요, className.namedConstructor()와 같은 방식으로 선언을 할 수 있습니다. generative constructor처럼 parameter를 설정할 수 있습니다.
12번 라인에서 처럼, iceAmericano 생성자가 자체적으로 인스턴스 변수를 초기화하는 로직을 추가할 수 있습니다. iceAmericano는 차가우니 당연히 hot = false;가 되겠습니다
20번 라인에서 Coffee.iceAmericano()를 호출하고 있는데요, 이렇게 named constructor를 사용하면 생성 로직에서부터 명시적으로 어떠한 특징을 갖는 객체인지를 나타낼 수 있게 됩니다.
Redirecting constructor
이미 선언되어 있는 생성자를 활용하는 것도 할 수 있습니다.
12~13번 라인에서도 named constructor를 생성했는데요, `: this()`를 통해 이미 선언되어 있는 생성자를 호출했습니다.
Factory constructor
Dart에는 생성자에 사용할 수 있는 factory라는 특별한 키워드가 있는데요, 두 가지 use case에 따라서 사용할 수 있습니다.
- 이미 만들어져 있는 객체를 반환하는 경우
- 조건에 따라 다른 인스턴스를 반환하는 경우
이미 만들어져 있는 객체 반환
싱글톤이나 캐싱되어 있는 객체를 반환할 때 factory constructor를 사용할 수 있습니다. 싱글톤 패턴은 클래스의 인스턴스를 오직 하나만 유지하도록 보장하는 디자인 패턴입니다. application의 모든 코드에서 사용하는 Logger를 싱글톤으로 만든다고 가정해보겠습니다.
6번 라인에서 Logger의 named constructor를 생성했습니다.
2번 라인에서 static final 키워드로 정의한 _instance 객체를 생성했습니다. final은 수정할 수 없는 객체를 만들고, static 인스턴스 변수는 처음 사용될 때 초기화되며, 프로그램이 종료될 때까지 유지됩니다.
8~10번 라인에서는 Logger의 factory constructor를 정의하고 _instance를 리턴하고 있습니다.
18번 라인에서는 Logger() 객체를 생성하고 있는데, 이미 생성된 객체를 받았기 때문에 Logger()를 몇 번을 호출하더라도 같은 객체를 받게 됩니다.
조건에 따라 다른 인스턴스를 반환
상속에 대해서는 다음 장에서 다루겠지만, factory 생성자를 사용하면 입력 조건이나 타입에 따라 다른 클래스의 인스턴스를 반환할 수 있기 때문에 상황에 맞게 다양한 형태의 객체를 생성하고 반환하는 기능을 제공합니다.
1~11번 라인은 Language라는 클래스를 정의했습니다.
2~8번 라인은 factory constructor로 languageCode에 따라 다른 언어를 리턴 받게 되어있습니다. 리턴 타입이 Language이기 때문에 Language를 구현한 타입만 가능합니다.
10번 라인에서 sayHello()는 구현되지 않은 채로 정의되어있습니다.
13~25번 라인에서 Korean, English 클래스는 각각 sayHello()를 구현한 간단한 객체로 정의되어 있습니다.
28~29번 라인에서 Language factory 생성자를 실제 호출하게 되면 각각 Korean, English 객체를 리턴받고, 각 객체의 sayHello() 메소드를 실행하게 됩니다.(겉으로 표현되는 타입은 Language입니다)
지금까지 Dart의 클래스 개념에 대해서 다뤄보았는데요, 아직 끝난게 아닙니다! 상속에 대한 이야기를 다음 편에서 다루게 될거예요. 클래스를 잘 이해하면 Flutter나 다른 Dart 프로젝트에서도 객체지향적으로 데이터를 구조화하고 필요한 기능을 추가하는 데 유용하게 사용할 수 있기 때문에 이해가 안 되는 부분은 꼭 짚고 넘어갔으면 좋겠습니다!
참고
https://news.dartlang.org/2012/02/static-variables-no-longer-have-to-be.html?m=1
'Dart' 카테고리의 다른 글
Dart 프로그래밍 기초 - #8. 클래스 상속, 추상 클래스, 인터페이스 (0) | 2024.10.20 |
---|---|
Dart 프로그래밍 기초 - #6. Generics (0) | 2024.10.09 |
Dart 프로그래밍 기초 - #5. 컬렉션 Collection (1) | 2024.10.07 |
Dart 프로그래밍 기초 - #4. 제어 흐름 (2) | 2024.09.25 |
Dart 프로그래밍 기초 - #3. 함수 (0) | 2024.09.19 |