Dart (프로그래밍 언어)

내위키
Dennis (토론 | 기여)님의 2021년 4월 10일 (토) 23:17 판
void main() {
  print("You're watching NeWiki.");
}

구글에서 개발하고 관리하는 프로그래밍 언어. 자바스크립트의 단점을 보완해서 웹 개발, 특히 웹 프론트엔드 개발의 편의성을 도모하기 위해서 만들어진 언어다.

자바스크립트는 간단한 문법으로 진입장벽이 낮은 편이지만 코드가 복잡해지면 단점도 많은 언어다. 원래 웹 페이지에 간단한 동적 기능을 부여하기 위해서 만들어졌던 스크립트 언어였지만 AJAX가 대박이 나고 JIT 컴파일 덕택에 처리 속도로 엄청나게 빨라지면서 활용 폭이 엄청나게 넓어졌다. 여기에 jQuery라든가 Angular, 리액트 같은 프레임워크까지 등장하면서 활용 폭이 더더더욱 확대되고, 웬만한 데스크톱이나 모바일 프로그램 뺨칠 정도로 복잡하고 긴 코드들을 개발할 일이 많아졌다. 그러다 보니 전에는 별거 아니었던 자바스크립트의 단점들이 부각되었다. 변수의 유형을 지정하지 않고 서로 다른 유형의 데이터들을 연산하려고 해도 어떻게든 변환해서 이상한 값으로라도 결과를 낸다든지, 자바스크립트 개발자들의 머리를 쥐어뜯게 만드는 콜백 지옥 문제라든가... 물론 자바스크립트도 개량을 거치면서 프로미스(promise)와 같은 방법으로 콜백 지옥을 해소시키는 지원책들이 나오고 있고 async, await 키워드로 비동기 프로그래밍도 한결 편리해졌지만 언어의 근본적인 한계점이 있는지라 이를 넘기 위해 새로운 언어들이 여럿 제안되었다. 이 방면의 원조격이라 할 수 있는 커피스크립트라든가, 요즘 사용자층이 넓어져서 구글에서 내부 공식 개발 언어 중 하나로 지정할 정도까지 이른 타입스크립트 같은 게 그 사례. Dart 역시 그런 목적을 가지고 있었다.

커피스크립트나 타입스크립트가 컴파일을 통해 자바스크립트를 결과물로 내놓아서 호환성을 유지하는데 반해, Dart는 아예 '대체'를 목표로 했다. 즉 웹 브라우저가 Dart 실행 엔진을 가지고 Dart를 바로 실행하는 것. 그러나 이 전략은 실패로 끝났고, 같은 구글에서 만든 크롬 말고는 Dart를 직접 지원하는 웹 브라우저가 없다. 자바스크립트가 단점은 있지만 그렇게 나쁜 언어인 것도 아니며[1], 이미 많은 개발자를 가지고 있으며 jQuery, 리액트를 비롯한 풍부한 프레임워크도 가지고 있다. 여기에 자바스크립트의 단점이 영 마음에 들지 않는다면 타입스크립트 같은 것을 쓰면 단점을 피해갈 수도 있다. 자바스크립트 자체도 개선을 거쳐서 여러 가지 새로운 기능들을 추가했기 때문에 과거의 문제점 중에 여러 가지가 해소되기도 했다. 그러니 크롬을 개발하고 있는 구글을 제외한 경쟁 웹 브라우저 제작자들로서는 자바스크립트 엔진의 속도 경쟁에도 정신 없는데 굳이 Dart 엔진을 또 만들 이유가 없는 것. 게다가 다른 웹 브라우저들로서는 경쟁자 구글한테 좋을 짓을, 대세도 아닌 언어에 굳이 애써서 투자할 이유도 없다. Dart를 컴파일해서 자바스크립트로 변환하는 방법도 있긴 하지만 이쪽은 타입스크립트가 대세를 장악한 상태다. 심지어 구글도 내부 공식 개발 언어로 타입스크립트를 사용할 정도니 웹 프론트엔드 쪽으로는 사실상 Dart를 포기했다고 봐도 과언은 아니다. 다만 Dart를 컴파일해서 자바스크립트를 만들 수 있기 때문에 웹 프론트엔드 개발용 언어로 써먹을 수는 있다. Flutter for 웹이 이런 방식을 채택하고 있다.

사정이 이렇다 보니 거의 쓰레기 취급을 받아 왔다. 배울 가치가 없는 언어, 최악의 언어 랭킹을 뽑을 때 최소 상위권이고 정상에 등극하는 일도 종종 있다. 하지만 구글이 누군가. 포기하지 않고 꾸준히 밀어준 끝에 사용자가 상당히 늘어난 편이다. 원래 목적이었던 웹 프론트엔드 쪽에서는 여전히 천덕꾸러기 신세지만 모바일 개발 쪽에서 세를 불려 나가고 있다. 그 이유는 역시 Flutter. 구글에서 들고 나온 크로스플랫폼 개발 프레임워크인 Flutter를 사용하면 안드로이드iOS용 앱을 동시에 개발할 수 있다. 모바일을 위한 크로스플랫폼 프레임워크로는 이미 아파치 코르도바나 리액트 네이티브, 자마린 같은 것들이 있지만 Flutter는 네이티브급 성능을 내면서도 양쪽 OS 모두에서 똑같은 인터페이스를 보장한다는 면에서 주목 받고 있다. 게다가 웹 버전까지 나와서 다시 웹 프론트엔드 개발에 Dart를 밀고 있다.[2] 자세한 내용은 Flutter 참조.

Dart도 최근 프로그래밍 언어의 추세인 널 안전성을 지원하긴 하지만 좀 반쪽짜리인 느낌이 있는데, 1.22부터는 변수를 기본으로 non-nullable로 하는 것을 비롯해서 코틀린 수준의 널 안전성을 제공한다고 예고하고 있다. 2021년 3월 구글이 온라인으로 개최한 Flutter Engaged 행사에서 Flutter 2를 공식 발표했으며 이와 함께 널 안전성을 지원하는 Dart 1.22도 정식으로 발표했다.

기본 문법

변수

Dart는 자바스크립트와 달리 기본은 정적 타이핑 언어다. 따라서 변수를 선언할 때에는 변수 유형을 써 주는 게 원칙이다.

int i = 0;
</syntaxhighlightv>

그러나 유형 추적 기능이 있기 때문에 그냥 변수(variable) 뜻하는 'var' 불여서 다음과 같이 써도 된다.

<syntaxhighlight lang="dart">
var i = 0;

이렇게 하면 i는 정수형(int)으로 결정된다. 따라서 var로 변수를 선언하면서 초깃값을 주지 않으면 컴파일 단계에서 오류가 일어난다. 유형을 써주고 선언한 변수는 초깃값을 주지 않으면 null이 들어가는데, Dart에서는 int도 클래스로 취급하기 때문에 int 변수를 선언할 때 초깃값을 주지 않으면 null이 들어간다는 점에 주의해야 한다.[3] 변수가 일단 선언되고 나면 유형이 고정되므로 다른 유형을 대입할 수 없다.

var i = 0;
i = '메롱' // Error!

정적 타이핑 언어와 동적 타이핑 언어 중 뭐가 더 낫냐는 문제로 많은 논란이 여전하지만 어쨌거나 정적 타이핑 쪽을 선호해서 타입스크립트와 갈은 도구를 쓰는 사람들에게는 Dart가 좀 더 익숙할 것이다.

다만 Dart에는 dynamic이란 유형이 있으며, 어떤 유형이든지 받는다. dynamic 유형을 사용하면 동적 타이핑 언어처럼 사용할 수 있다. 제네릭에도 쓸 수 있으며, 리스트나 맵 같은 컬렉션 개체에 사용하면 어떤 유형이든지 가질 수 있는 컬렉션을 만들 수 있다.

또한 위에서 볼 수 있듯이 C자바스크립트처럼 줄 끝에는 세미콜론(;)을 찍어줘야 한다.

요즘 언어들은 null 값을 쓰지 못하게 하거나, 제한을 많이 두거나, 널 안전성 연산자를 제공하는 경향이 있는데, Dart는 기본으로 null 값을 허용하는 대신 널 안전성 관련 연산자를 몇 가지 제공하다가 버전 1.12부터는 널 안전성 기능을 대폭 강화했다.

변경 불가능한 변수

변수에 초깃값을 대입한 다음 값을 바꾸지 못하도록 하려면 final 또는 const 키워드를 앞에 붙여 준다. 이 키워드를 붙일 경우에는 var는 생략할 수 있다. final와 const의 차이는, final는 초깃값이 실행 시점에서 결정되어도 괜찮지만 const는 컴파일 때 값이 미리 결정되어 있어야 한다.

var pi = 3.1415;
final i = pi; // Okay
const j = pi; // Error!

final에 다른 변수의 값을 대입하는 것은 괜찮다. 물론 초깃값을 대입하고 나면 i는 더 이상 값을 바꿀 수 없다. 반면 j는 오류를 일으키는데, 소스 코드를 보면 pi 값은 결정되어 있는 것처럼 보이지만 컴파일러 입장에서는 pi가 const가 아닌 한은 값이 결정되어 있지 않다고 보고 오류를 일으킨다. const의 초깃값으로는 상수값을 대입하거나 다른 const 변수 값을 대입해야 한다. 즉, pi가 만약 const로 정의되었다면 j에 대입할 수 있다.

final로 지정된 변수에 컬렉션을 대입할 수 있다. 차이가 있다면, final에 대입한 컬렉션은 다른 컬렉션을 대입할 수 없지만 컬렉션의 내용은 바꿀 수 있는데 반해, const는 내용을 바꿀 수 없는, 즉 변경 불가(immutable) 컬렉션이 된다.

final로 지정된 변수에 클래스 객체를 대입할 수 있다. 초깃값이 정해진 다음에는 다른 객체를 대입할 수 없으나 객체의 속성값을 바꾸는 것은 가능하다. 반면 const에 클래스 객체를 대입하려면 대입하는 객체도 const여야 하는데, 그 클래스의 생성자가 const로 지정되어 있어야만 const 객체를 만들 수 있다. const 생성자는 매개변수로 상수값이나 다른 const 값만 받을 수 있다.

함수

정의 방법은 자바와 비슷하지만 앞에 function 같은 키워드는 필요하지 않다. 또한 자바와는 달리 클래스에 속해 있지 않은 함수를 정의할 수 있다. 오히려 C와 비슷한 모습이다.

int add(int x, int y) {
    return x + y;
}

위 함수는 다음과 같이 줄여 쓸 수도 있다.

int add(int x, int y) => x + y;

C처럼 프로그램 실행은 main() 함수에서 시작한다.

매개변수는 위치와 이름, 두 가지 방식으로 전달할 수 있다. 위치 참조는 기존의 방식처럼 함수에서 선언된 순서대로 매개변수를 전달하며, 이름 참조는 순서 관계 없이 name: value 형식으로 전달한다. 파이썬의 키워드 매개변수와 같은 방식이다.

또한 필수 매개변수와 옵션 매개변수로도 나뉜다. 위치 참조는 필수도 옵션일 수도 있지만 이름 참조는 무조건 옵션 매개변수다. 옵션 매개변수는 모든 필수 매개변수 다음에 오며, 이름 참조는 모든 위치 참조 다음에 온다. 옵션 매개 변수는 기본값을 지정할 수 있다.

void fileOpen(String directory, String fileName,[String mode='read']) {
              print('$directory/$fileName opened with $mode mode.');
}

main() {
    fileOpen('/home/newiki', 'dart.txt', 'write');
}
void fileOpen(String directory, String fileName,
             {String mode='read', String charset='utf-8'}) {
    print('$directory/$fileName opened with $mode mode and $charset charset.');
}

main() {
    fileOpen('/home/newiki', 'dart.txt', charset: 'euc-kr', mode: 'write');
}

위치 참조 옵션 매개변수는 [] 안에, 이름 참조 매개변수는 {} 안에 넣어서 정의하면 된다. 단 옵션 매개변수로 이름 참조와 위치 참조를 동시에 쓸 수는 없고 한 가지만 선택해야 한다. 이름 참조 매개변수를 정의할 때에는 name=value 형식이지만 함수를 호출하면서 매개변수를 전달할 때에는 name: value 형식을 사용한다. 만약 함수 정의 때 옵션 매개변수의 기본값을 정하지 않으면 null 값이 들어간다.

자바스크립트와 마찬가지로 Dart의 함수도 일급 객체다. 함수를 변수에 대입할 수 있도록 Dart는 Function이라는 유형을 제공한다.

클래스

키워드 class로 클래스를 정의할 수 있다. 상속을 받을 때에는 extends [부모 클래스] 형식으로 쓰면 된다. 대체로 자바에서 볼 수 있는 클래스 정의와 비슷하다.

인터페이스 정의를 위해서는 자바처럼 interface 키워드는 따로 없고 그냥 class를 사용한다. class 앞에 abstract 키워드를 주면 자바처럼 이 클래스로는 인스턴스를 만들 수 없고 상속해서 써야 한다. 같은 abstract 클래스를 가지고 extends로 받아서 쓸 수도 있고 implements로 받아서 쓸 수도 있는데, 둘의 차이점은 extends는 구현되지 않은 메서드만 오버라이드해서 구현해 주면 되지만 implements로 받아서 쓰면 해당 클래스의 메서드 구현은 모조리 무시되고, 받아다 쓰는 클래스에서 메서드를 모두 구현해야 한다.

변수나 메서드의 이름 앞에 밑줄(_) 기호를 붙이면 private으로 정의되고, 그렇지 않으면 public이다.

생성자는 클래스와 같은 이름의 함수를 정의하면 되는데, 생성자를 다중으로 만들려면 C++처럼 매개변수만 차이나게 하면 되는 게 아니라 이름을 가진 생성자를 만들어야 한다. 또한 매개변수의 이름 앞에 this.를 붙이면 해당 매개변수는 자동으로 같은 이름을 가진 클래스 변수에 대입된다. 이렇게 하려면 변수를 public으로 해야 하는데, 값이 쉽게 변형될 수 있는 문제는 변수를 const 또는 final로 지정함으로써 해소해야 한다.

class Point {
    double x, y;

    Point(this.x, this.y);
    Point.origin() {
        x = 0;
        y = 0;
    }
    Point.copyFrom(Point point) {
        x = point.x;
        y = point.y;
    }
}

한편 factory 생성자라는 것도 있다. 예를 들어 싱글턴 객체라면 생성자를 처음 불렀을 때에는 객체가 만들어지지만 그 다음부터는 처음에 만들었던 객체를 재활용해야 한다. 생성자 앞에 factory 키워드를 주면 이럴 경우를 위한 생성자를 만들 수 있다.

Dart의 특징으로는 mixin이 있다. mixin은 단순 상속이나 인터페이스와는 약간 다른 클래스로 어떤 클래스에서 여러 개의 mixin을 가져다 쓸 수 있으며, 메서드 호출 형식만 지정해 놓는 인터페이스와는 달리 실제 메서드도 사용할 수 있어서 약간 변형된 다중 상속으로 볼 수 있다. 클래스 이름 뒤에 with 키워드를 쓰고, mixin으로 사용할 클래스의 이름을 써 주면 된다. 여러 개를 쓰려면 쉼표로 구분해서 쓴다.

기존 클래스를 확장하는 기능도 제공한다 extension [확장 이름] on [확장할 클래스]와 같은 형식으로 정의한다. 제네릭을 사용한 클래스도 확장할 수 있다.

extension on List<int> {
    int getSum() {
       int sum = 0;
       this.forEach((value) => sum += value);

       return sum;
    }
}

main() {
    List<int> testArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    print(testArray.getSum());
}

실행해 보면 55가 출력된다.

캐스케이드 표기법

클래스나 객체에 속한 메서드와 필드를 사용할 때에는 대다수 다른 언어와 마찬가지로 점(.)이 쓰인다. 만약 메서드가 객체를 돌려준다면 다시 점(.)을 사용해서 돌려 받은 객체의 메서드나 필드를 부를 수 있다. Dart에서는 점을 두 개 찍으면(..) 객체의 메서드는 물론 필드에도 접근할 수 있다. 이를 캐스케이드 표기법이라고 한다. Dart 공식 문서에서는 이는 연산자가 아니라 Dart의 문법 가운데 일부라고 밝히고 있다.

var button = querySelector('#confirm');
button.text = 'Confirm';
button.classes.add('important');
button.onClick.listen((e) => window.alert('Confirmed!'));

이 소스코드를 캐스케이드 표기법으로 다시 쓰면,

querySelector('#confirm') // Get an object.
  ..text = 'Confirm' // Use its members.
  ..classes.add('important')
  ..onClick.listen((e) => window.alert('Confirmed!'));

좀 더 간결하고 들여쓰기를 사용하면 보기도 편리하다. 특히 객체를 만든 후 여러 필드에 값을 대입해야 할 경우에는 정말로 편리하다. 한 가지 주의할 점은, 앞서 언급했던 것처럼 ..는 연산자가 아니라 문법의 일부이기 때문에 만약 어떤 메서드가 반환값이 있다고 해도 이를 무시한다. 따라서 반환값을 받아서 저장하거나 사용하려면,

final anObject = SomeClass();
anOjbect
  ..text = 'Okay'
  ..number = 50;
final newObject = anObject.getNewObject();
newObject
  ..printText()
  ..printNumber()
  ..number += 10;

[4]

이런 식으로 반환값을 받기 전에 캐스케이드 표기법을 끝내야 한다.

비동기 프로그래밍

Dart는 비동기 프로그래밍을 잘 지원한다. 이를 위해 Dart는 async, await, yield와 같은 키워드를 지원하며 Future와 Stream 객체를 제공한다.

Future

Future는 일회성 비동기 작업을 처리한다. 예를 들어 REST 서버에서 데이터를 받아온다면 Future가 적절하다. 어떤 함수가 async라면 반환값은 Future 객체여야 한다.[5] 이 함수를 호출한 코드가 Future 객체를 받았다면 실제로 값이 들어왔을 때 이를 처리할 코드를 then 메서드에 함수 형식으로 넘겨주면 된다. C#처럼 async와 await 키워드를 써서 처리할 수도 있다. Flutter를 쓸 경우에는 비동기 데이터를 사용하는 위젯이 있다면 FutureBuilder 클래스를 써서 처리할 수 있다.

Future<String> createOrderMessage() async {
    var order = await fetchUserOrder();
    return 'Your order is: $order';
}

Future<String> fetchUserOrder() =>
    // Imagine that this function is
    // more complex and slow.
    Future.delayed(
        Duration(seconds: 2),
        () => 'Large Latte',
    );

void main() async {
    print('Fetching user order...');
    print(await createOrderMessage());
}

Stream

Stream은 연속되는 비동기 작업을 처리한다. 예를 들어 인터넷으로 용량이 큰 파일을 받는다면, Future는 다운로드가 다 끝나야 후속 작업을 하겠지만 Stream은 중간 중간에 필요한 작업, 예를 들어 얼마나 다운로드를 받았는지 표시하는 것과 같은 작업을 처리할 수 있다. Stream 객체를 돌려주는 함수는 async에 *를 붙인 async*를 사용하며, return 대신 yield로 값을 돌려준다. return은 돌려줘야 할 값을 다 모은 다음 마지막에 호출하지만 yield는 개별 값이 만들어질 때마다 그때 그때 돌려준다.

Stream<int> count(int to) async* {
    for (int i = 0; i <= to; i++) {
        yield i;
    }
}

void main() async {
    var stream = count(10); 
    var sum = 0;
  
    await for (int i in stream) {
        sum += i;
        print(sum);
    }
}

Flutter를 쓴다면 Future가 FutureBuilder를 가지고 있는 것처럼 Stream도 StreamBuilder를 가지고 있다.

널 안전성

Dart는 처음에는 기본으로 변수의 null 값을 허용하는 대신 널 안전성 관련 연산자를 몇 가지 지원했다가 Dart 1.12부터는 'sound null safety'라는 이름으로 널 안전성을 대폭 강화했다. 기법을 살펴보면 안드로이드 개발 언어로 구글이 적극 밀어주고 있는 코틀린과 비슷한 면이 많이 보인다. 기본 개념은 null 값을 쓸 수 있다고 명시하지 않은 변수에는 null 값을 허용하지 않는다.

int a = 0; // Okay
int b = null; // Error!
int? c = null; // Okay

null 값을 넣을 수 있는, 즉 nullable한 변수를 만들려면 데이터 유형 뒤에 ? 기호를 붙여줘야 하며, 그렇지 않은 변수에는 null 값을 넣을 수 없다. nullable하지 않은 변수에 null을 대입하려는 시도는 컴파일 단계에서 잡아낸다. nullable 하지 않은 변수라면 if (a == null) 같은 테스트도 불가능하며 컴파일 단계에서 잡아낸다.

각주

  1. 사실 어떤 프로그래밍 언어든 단점이 없을 리 없다.
  2. 다만 이전과는 달리 HTML과 CSS까지 포함해서 아예 통으로 개발하는 방식이다.
  3. 변수 정의 때 명시적으로 null 값으로 초기화 하면 Dart 컴파일러가 그렇게 하지 말라고 권고한다.
  4. final 키워드를 써도 객체 안의 필드 값이 final이 아니면 값을 바꿀 수 있다.
  5. 돌려줄 값이 전혀 없다면 반환값을 Future 없이 void로 해도 되긴 하는데, 호출한 쪽에서 await를 쓸 필요가 있다면 Future<void>로 해야 한다.