플러터(Flutter)는 다트(Dart) 언어를 사용합니다. 대부분의 플러터 입문자는 별도의 책으로 다트를 공부하지 않고 플러터 서적에서 1개 장 분량으로 얕게 배웁니다. 플러터로 앱을 원활히 개발하려면 다트를 탄탄하게 아는 것이 중요합니다. 그래서 이 책은 또 다른 자료를 찾아보지 않아도 될 정도로 깊이 있게 다트를 다룹니다.
1장에서 다트 입문하기, 2장에서 객체지향 프로그래밍, 3장에서 비동기 프로그래밍, 4장은 다트 3.0 신규 문법을 학습합니다.
다트 객체지향 프로그래밍 ❶
다트 언어는 높은 완성도로 객체지향 프로그래밍을 지원합니다. 플러터 역시 객체지향 프로그래밍(Object-oriented programming, OOP) 중심으로 설계된 프레임워크입니다. 따라서 객체지향 프로그래밍을 알면 좋은 코드를 작성하는 데 유리합니다. 2장에서는 객체지향 프로그래밍의 기초부터 강력하고 유용한 기능까지 알아보겠습니다.
1편은 객체지향 프로그래밍의 필요성, 클래스, 상속, 오버라이드를 정리했습니다.
1. 객체지향 프로그래밍의 필요성
객체지향 프로그래밍은 왜 필요할까요? 수천 줄에서 수만 줄의 코드를 작성할 때 모든 코드를 main( ) 함수에서 작성하면 코드 정리가 안 돼 유지보수 및 협업에 상당히 큰 장애물이 됩니다. 객체지향 프로그래밍을 하면 변수와 메서드를 특정 클래스에 종속되게 코딩할 수 있습니다. 클래스를 사용해서 서로 밀접한 관계가 있는 함수와 변수를 묶어두면 코드 관리가 용이하기 때문에 객체지향 프로그래밍은 현대 프로그래밍에서 상당히 중요한 부분을 차지합니다.
지금까지 배운 다트 언어의 지식으로는 제니와 로제라는 아이돌 멤버의 나이를 구하려면 Map을 이용해야 합니다. 각각 이름이라는 키를 사용해서 나이를 구할 수 있습니다. 하지만 Map을 사용하면 단순히 값을 저장하는 것 외에 추가적인 편의기능을 구현할 수 없습니다. 클래스를 사용하면 바로 이런 문제를 해결할 수 있습니다. 클래스를 만들어 사용하면 필요한 값들만 입력하도록 제한하고 클래스에 특화된 함수들을 선언할 수 있습니다.
클래스는 일종의 설계도로서 데이터가 보유할 속성과 기능을 정의하는 자료구조입니다. 아파트를 지을 수 있는 설계도가 있다고 해서 아파트가 생겨나지 않습니다. 설계도를 기반으로 실제로 아파트를 지어야 실물 아파트가 생깁니다. 아파트 설계도와 실물 아파트의 관계가 클래스와 인스턴스의 관계입니다. 클래스를 설계도, 인스턴스화를 실물 아파트라고 생각하면 됩니다. 인스턴스화되어야 실제 사용할 수 있는 데이터가 생성됩니다.
인스턴스(Instance)
클래스를 이용해서 객체를 선언하면 해당 객체를 클래스의 인스턴스라고 부릅니다.
인스턴스화(Instantiation)
클래스에서 인스턴스(객체)를 생성하는 과정을 말합니다.
▼ 클래스와 인스턴스의 관계
2. 객체지향 프로그래밍의 시작, 클래스
객체지향 프로그래밍의 기본은 클래스class로부터 시작됩니다. 클래스를 정의하는 예제 코드를 살펴보겠습니다.
// class 키워드를 입력 후 클래스명을 지정해 클래스를 선언합니다.
class Idol {
// ❶ 클래스에 종속되는 변수를 지정할 수 있습니다.
String name = '블랙핑크';
// ❷ 클래스에 종속되는 함수를 지정할 수 있습니다.
// 클래스에 종속되는 함수를 메서드라고 부릅니다.
void sayName() {
// ❸ 클래스 내부의 속성을 지칭하고 싶을 때는 this 키워드를 사용하면 됩니다.
// 결과적으로 this.name은 Idol 클래스의 name 변수를 지칭합니다.
print('저는 ${this.name}입니다.');
// ➍ 스코프 안에 같은 속성 이름이 하나만 존재한다면 this를 생략할 수 있습니다.
print('저는 $name입니다.');
}
}
Idol 클래스를 정의했습니다. Idol 클래스 안에 ❶ 종속된 변수로는 name이, ❷ 함수로는 sayName( )이 있습니다. 클래스에 종속된 변수를 멤버 변수, 종속된 함수를 메서드라고 부릅니다. ❸ 클래스 내부 속성을 지칭하는 데 this 키워드를 사용합니다. 예를 들어 name을 this. name처럼 지칭할 수 있습니다. ❹ this 키워드는 현재 클래스를 의미합니다. 만약에 같은 이름의 속성이 하나만 존재한다면 this를 생략할 수 있습니다. 하지만 만약에 sayName( ) 함수에 name이라는 변수가 존재한다면 ❸ 처럼 this 키워드를 꼭 사용해야 합니다.
this 키워드
클래스에 종속되어 있는 값을 지칭할 때 사용됩니다. 함수 내부에 같은 이름의 변수가 없으면 this 키워드를 생략할 수 있습니다.
💡NOTE: 함수는 메서드를 포함하는 더 큰 개념입니다. 클래스에 정의된 함수인 메서드는 클래스의 기능을 정의한 함수입니다. 이 책은 꼭 메서드로 설명해야 할 때가 아니면 함수로 통칭합니다.
클래스를 만들었으니 사용하겠습니다.
void main() {
// 변수 타입을 Idol로 지정하고
// Idol의 인스턴스를 생성할 수 있습니다.
// 인스턴스를 생성할 때는 함수를 실행하는 것처럼
// 인스턴스화하고 싶은 클래스에 괄호를 열고 닫아줍니다.
Idol blackPink = Idol(); // ➊ Idol 인스턴스 생성
// 메서드를 실행합니다.
blackPink.sayName();
}
▼ 실행 결과
❶ 변수 타입을 Idol로 지정해 Idol의 인스턴스를 생성합니다. 인스턴스를 생성할 때는 함수를 실행하는 것처럼 인스턴스화하고 싶은 클래스명 뒤에 ( )를 붙여주면 됩니다.
2.1 생성자
생성자(Constructor)는 클래스의 인스턴스를 생성하는 메서드입니다. 생성자를 사용해서 앞의 예제의 활용도를 높일 수 있습니다. name 변수의 값을 외부에서 입력할 수 있게 변경하겠습니다.
class Idol {
// ❶ 생성자에서 입력받는 변수들은 일반적으로 final 키워드 사용
final String name;
// ❷ 생성자 선언
// 클래스와 같은 이름이어야 합니다.
// 함수의 매개변수를 선언하는 것처럼 매개변수를 지정해줍니다.
Idol(String name) : this.name = name;
void sayName() {
print('저는 ${this.name}입니다.');
}
}
❶ 생성자에서 입력받을 변수를 일반적으로 final로 선언합니다. 인스턴스화한 다음에 혹시라도 변수의 값을 변경하는 실수를 막기 위함입니다. ❷ 클래스 생성자 코드입니다. 클래스와 같은 이름을 사용해야 합니다. 이름 뒤에 ( )를 붙이고 원하는 매개변수를 지정해줍니다. 후에 배울 네임드 파라미터 및 옵셔널 파라미터도 사용할 수 있습니다. : 기호 뒤에 입력받은 매개변수가 저장될 클래스 변수를 지정해줍니다.
Idol 클래스로 인스턴스를 만들겠습니다.
void main() {
// name에 '블랙핑크' 저장
Idol blackPink = Idol('블랙핑크');
blackPink.sayName();
// name에 'BTS' 저장
Idol bts = Idol('BTS');
bts.sayName();
}
▼ 실행 결과
축하합니다. Idol 클래스 하나로 여러 Idol 인스턴스를 생성해 중복 코딩 없이 활용할 수 있게 되었습니다.
생성자의 매개변수를 변수에 저장하는 과정을 생략하는 방법도 있습니다. 아래 Idol 클래스는 이전 예제의 Idol 클래스와 표현 방식만 다르고 동작은 똑같습니다.
class Idol {
final String name;
// this를 사용할 경우
// 해당되는 변수에 자동으로 매개변수가 저장됩니다.
Idol(this name);
void sayName() {
print('저는 ${this.name}입니다.');
}
}
2.2 네임드 생성자
네임드 생성자(Named Constructor)는 네임드 파라미터와 상당히 비슷한 개념입니다. 일반적으로 클래스를 생성하는 여러 방법을 명시하고 싶을 때 사용합니다.
class Idol {
final String name;
final int membersCount;
// ❶ 생성자
Idol(String name, int membersCount)
// 1개 이상의 변수를 저장하고 싶을 때는 , 기호로 연결해주면 됩니다.
: this.name = name,
this.membersCount = membersCount;
// ❷ 네임드 생성자
// {클래스명.네임드 생성자명} 형식
// 나머지 과정은 기본 생성자와 같습니다.
Idol.fromMap(Map<String, dynamic> map)
: this.name = map['name'],
this.membersCount = map['membersCount'];
void sayName() {
print('저는 ${this.name}입니다. ${this.name} 멤버는 ${this.membersCount}명입니다.');
}
}
❶ 생성자에서 매개변수 2개를 받습니다. , 기호를 사용하면 하나 이상의 매개변수를 처리할 수 있습니다. ❷ 네임드 생성자를 {클래스명.네임드 생성자명} 형식으로 지정하면 됩니다. 나머지 과정은 기본 생성자와 같습니다. 키와 값을 갖는 Map 형식으로 매개변수를 받아봤습니다.
이제 원하는 대로 동작하는지 확인하겠습니다.
void main() {
// 기본 생성자 사용
Idol blackPink = Idol('블랙핑크', 4);
blackPink.sayName();
// fromMap이라는 네임드 생성자 사용
Idol bts = Idol.fromMap({
'name': 'BTS',
'membersCount': 7,
});
bts.sayName();
}
▼ 실행 결과
예상한 결과를 얻었습니다. 이처럼 네임드 생성자는 클래스를 여러 방식으로 인스턴스화할 때 유용하게 사용됩니다.
2.3 프라이빗 변수
다트에서의 프라이빗 변수(Private Variable)는 다른 언어와 정의가 약간 다릅니다. 일반적으로 프라이빗 변수는 클래스 내부에서만 사용하는 변수를 칭하지만 다트 언어에서는 같은 파일에서만 접근 가능한 변수입니다.
class Idol {
// ❶ '_'로 변수명을 시작하면
// 프라이빗 변수를 선언할 수 있습니다.
String _name;
Idol(this._name);
}
void main() {
Idol blackPink = Idol('블랙핑크');
// 같은 파일에서는 _name 변수에 접근할 수 있지만
// 다른 파일에서는 _name 변수에 접근할 수 없습니다.
print(blackPink._name);
}
❶ 프라이빗 변수는 변수명을 _ 기호로 시작해 선언할 수 있습니다. 아직은 코드를 한 파일에 작성하는 환경이라 다른 파일에서 접근을 실패하는 예를 보여드릴 수 없습니다. 일반적으로 클래스 선언과 사용하는 파일이 다릅니다. 다른 파일에서는 _name 변수에 접근할 수 없으니 사용에 유의해주세요.
2.4 게터 / 세터
게터(Getter)는 말 그대로 값을 가져올 때 사용되고 세터(Setter)는 값을 지정할 때 사용됩니다. 가변(Mutable) 변수를 선언해도 직접 값을 가져오거나 지정할 수 있지만 게터와 세터를 사용하면 어떤 값이 노출되고 어떤 형태로 노출될지 그리고 어떤 변수를 변경 가능하게 할지 유연하게 정할 수 있습니다.
최근에는 객체지향 프로그래밍을 할 때 변수의 값에 불변성(Immutable, 인스턴스화 후 변경할 수 없는)을 특성으로 사용하기 때문에 세터는 거의 사용하지 않습니다. 하지만 게터는 종종 사용합니다. ‘이런 게 있다’ 정도로 알고 넘어가면 됩니다.
class Idol {
String _name= '블랙핑크' ;
// ❶ get 키워드를 사용해서 게터임을 명시합니다.
// 게터는 메서드와 다르게 매개변수를 전혀 받지 않는다.
String get name {
return this._name;
}
// ❷ 세터는 set이라는 키워드를 사용해서 선언합니다.
// 세터는 매개변수로 딱 하나의 변수를 받을 수 있습니다.
set name(String name) {
this._name = name;
}
}
❶ 게터는 메서드를 선언하는 문법과 상당히 유사하지만 매개변수는 정의하지 않습니다. 현재 Idol 클래스의 name 변수는 프라이빗으로 선언되어 있기 때문에 다른 파일에서 name 변수에 접근할 수 없습니다. 이럴 때 name 게터를 선언하면 외부에서도 간접적으로 _name 변수를 접근할 수 있습니다. ❷ 세터는 set 키워드를 사용해 지정하며 매개변수를 하나만 받습니다. 이 매개 변수는 멤버 변수에 대입되는 값입니다.
게터와 세터는 모두 변수처럼 사용하면 됩니다. 즉 사용할 때 메서드명 뒤에 ( )를 붙이지 않습니다.
void main() {
Idol blackPink = Idol();
blackPink.name = '에이핑크'; // ❶ 세터
print(blackPink.name); // ❷ 게터
}
▼ 실행 결과
_name의 초깃값이 ‘블랙핑크’입니다. ❶ 세터로 ‘에이핑크’를 대입하고 ❷ 게터로 확인해보니 ‘에이핑크’로 저장되어 있습니다.
3. 상속
extends 키워드를 사용해 상속(Inheritance)할 수 있습니다. 상속은 어떤 클래스의 기능을 다른 클래스가 사용할 수 있게 하는 기법입니다. 기능을 물려주는 클래스를 부모 클래스, 물려받는 클래스를 자식 클래스라고 합니다. 다음과 같은 Idol 클래스가 있다고 가정하겠습니다.
class Idol {
final String name;
final int membersCount;
Idol(this.name, this.membersCount);
void sayName() {
print('저는 ${this.name}입니다.');
}
void sayMembersCount() {
print('${this.name} 멤버는 ${this.membersCount}명입니다.');
}
}
Idol 클래스를 상속하는 BoyGroup 클래스를 만들겠습니다. Idol 클래스는 멤버 변수로 name과 membersCount, 메서드로는 sayName( ), sayMembersCount( )를 가지고 있습니다.
// ❶ extends 키워드를 사용해서 상속받습니다.
// class 자식 클래스 extends 부모 클래스 순서입니다.
class BoyGroup extends Idol {
// ❷ 상속받은 생성자
BoyGroup(
String name,
int membersCount,
) : super( // super는 부모 클래스를 지칭합니다.
name,
membersCount,
);
// ❸ 상속받지 않은 기능
void sayMale() {
print('저는 남자 아이돌입니다.');
}
❶ extends 키워드를 사용해 상속을 받습니다. {class 자식 클래스 extends 부모 클래스} 순서로 지정하면 됩니다. 자식 클래스는 부모 클래스의 모든 기능을 상속받습니다. ❷ 클래스 상속을 하다 보면 super라는 키워드를 자주 사용합니다. 현재 클래스를 지칭하는 this와 달리 super는 상속한 부모 클래스를 지칭합니다. 부모 클래스인 Idol 클래스에 기본 생성자가 있는 만큼 BoyGroup에서는 Idol 클래스의 생성자를 실행해줘야 합니다. 그렇지 않으면 Idol 클래스의 모든 기능을 상속받아도 변숫값들을 설정하지 않아서 기능을 제대로 사용할 수 없으니 당연한 이야기겠죠? ❸ 상속받지 않은 메서드나 변수를 새로 추가할 수도 있습니다. ‘저는 남자 아이돌입니다.’를 출력하는 간단한 메서드를 새로 만들었습니다.
사용법은 부모 클래스와 같습니다.
void main() {
BoyGroup bts = BoyGroup('BTS', 7); // 생성자로 객체 생성
bts.sayName(); // ❶ 부모한테 물려받은 메서드
bts.sayMembersCount(); // ❷ 부모한테 물려받은 메서드
bts.sayMale(); // ❸ 자식이 새로 추가한 메서드
}
▼ 실행 결과
❶ sayName( )과 ❷ sayMembersCount( )는 부모한테 물려받은 메서드입니다. ❸ sayMale( )은 자식이 새로 추가한 메서드입니다. 사용 방식은 둘 다 같습니다.
부모 클래스에 공통으로 사용하는 변수와 메서드를 정의해 상속받으면 결과적으로 자식 코드들은 해당 값들을 사용할 수 있어서 중복 코딩하지 않아도 됩니다. 그렇다면 Idol 클래스를 GirlGroup 클래스가 상속받았다고 가정합시다. 부모가 같다면 GirlGroup 클래스의 객체는 BoyGroup에 새로 추가한 sayMale( ) 메서드를 호출할 수 있을까요? 정답은 그럴 수 없습니다. 같은 방법으로 GirlGroup 클래스를 만들어 직접 확인해보세요.
4. 오버라이드
오버라이드(Override)는 부모 클래스 또는 인터페이스에 정의된 메서드를 재정의할 때 사용됩니다. 다트에서는 override 키워드를 생략할 수 있기 때문에 override 키워드를 사용하지 않고도 메서드를 재정의할 수 있습니다.
3. ‘상속’에서 사용한 Idol 클래스를 상속받아서 메서드 오버라이드를 하겠습니다.
class Idol {
final String name;
final int membersCount;
Idol(this.name, this.membersCount);
void sayName() {
print('저는 ${this.name}입니다.');
}
void sayMembersCount() {
print('${this.name} 멤버는 ${this.membersCount}명입니다.');
}
}
class GirlGroup extends Idol {
// 2.3 상속에서처럼 super 키워드를 사용해도 되고 다음처럼 생성자의 매개변수로 직접 super 키워드를 사용해도 됩니다.
GirlGroup(
super.name,
super.membersCount,
);
// ❶ override 키워드를 사용해 오버라이드 합니다.
@override
void sayName() {
print('저는 여자 아이돌 ${this.name}입니다.');
}
}
❶ 부모 클래스에 이미 존재하는 메서드를 자식 클래스에서 재정의할 경우 override 키워드를 사용해 메서드를 다시 정의합니다. 메서드 재정의라고도 합니다.
Idol 클래스에 메서드가 두 개였습니다. 하나는 오버라이드를 했고 하나는 그러지 않았습니다. 사용할 때 어떻게 적용되는지 확인해봅시다.
void main() {
GirlGroup redVelvet = GirlGroup('블랙핑크', 4);
redVelvet.sayName(); // ❶ 자식 클래스의 오버라이드된 메서드 사용
// sayMembersCount는 오버라이드하지 않았기 때문에
// 그대로 Idol 클래스의 메서드가 실행됩니다.
redVelvet.sayMembersCount(); // ❷ 부모 클래스의 메서드 사용
}
▼ 실행 결과
❶ sayName( ) 메서드는 오버라이드되었으므로 GirlGroup 클래스에 재정의된 sayName( ) 메서드가 실행됩니다. ❷ sayMembersCount( ) 메서드는 오버라이드하지 않았으므로 Idol 클래스에 정의된 sayMembersCount( ) 메서드를 사용합니다.
한 클래스에 이름이 같은 메서드가 존재할 수 없기 때문에 부모 클래스나 인터페이스에 이미 존재하는 메서드명을 입력하면 override 키워드를 생략해도 메서드가 덮어써집니다. 하지만 직접 명시하는 게 협업 및 유지보수에 유리합니다.
다음편에서 계속 됩니다.
다트 입문하기
- 1편 보기: https://bit.ly/47NAtjC
- 2편 보기: https://bit.ly/3Sfl8SX
- 3편 보기: https://bitly.ws/3biHw
객체지향 프로그래밍
- 1편 보기: https://bitly.ws/3cmYi
- 2편 보기: https://bitly.ws/3ctA9
비동기 프로그래밍
- 더 보기: https://bit.ly/49bgMDJ
다트 3.0 신규 문법
- 더 보기: https://bit.ly/3unqMuB
최지호(코드팩토리)
임페리얼 칼리지 런던을 졸업하고 계리 컨설팅 회사 밀리만(Milliman) 한국 지사에서 소프트웨어 엔지니어로 일했습니다. 현재 주식회사 코드팩토리를 창업하여 개발을 하면서 초보자뿐만 아니라 현직 개발자에게도 유용한 개발 강의를 제작합니다. 밀리의서재 플러터 전환 차세대 프로젝트를 리드했습니다.