Dart 의 기초

Clean Code Big Poo·2022년 5월 4일
0

Flutter

목록 보기
2/38
post-thumbnail

Overview

flutter는 dart기반의 크로스플랫폼 프레임워크이다. 고로 flutter를 사용하기에 앞서 dart에 대해 공부하고자 한다.
이 정도 길이면 포스트를 나누는데... 솔직히 귀찮다...
dart에 관련된 지식은 여기에 우겨넣거나 나중에 정리하도록 하자!
(본 포스팅에서 소개하는 글보다 자세한 설명이 필요하다면 아래의 사이트를 이용하자! dart 공문서 사이트이다.)

https://dart.dev/samples

간단한 dart 예제를 별도의 환경세팅 없이 학습해보고 싶다면 아래의 사이트를 이용하자!
이 포스트에서 제공하는 코드도 dartpad에서 동작함.

https://dartpad.dev/?

개발환경

MacBook pro(M1, 2020)
Monterey v.12.3.1
Visual Studio Code 버전: 1.66.2
Flutter 2.10.4
Dart 2.16.2
DevTools 2.9.2

기본문법

기본적으로 ;(세미콜론)으로 문장이 끝난다.

print

void main() {
  print('test');//print 기본
  
  int a = 1;
  int b = 1;
  print('a + b = ${a+b}');//${exp} print
  
  List<int> list=[1,2,3,4,5,6];
  print(list.toString()); //list print
  
  
  var car = Car();
  print(car._enginNo);//class변수 print
}

class Car{
  String model = 'toyota';
  String _enginNo = 'asdasdasdasda';
}

주석

//이것은 인라인 주석
int a=1; //이렇게도 사용가능하다.

/*블록주석
*/

///문서 주석
///클래스 문서화에 사용한다.
view raw

변수, 상수

int, double, String, bool 을 기본적으로 제공한다.

final과 const차이
final은 컴파일 이후 한번만 값을 할당 가능.
const는 컴파일 이후 항상 같은 값을 가짐.
static은 클래스에서만 사용되며, 스코프를 글로벌로 넓혀준다. 클래스로 직접 접근해 호출이 가능하다.

void main() {
  String name;
  name = "홍길동";
  name = '홍길동';
//따옴표 종류 상관없이 가능

  int a1 = 1;
  double b1 = 2.0;
  num c1 = a1;
  c1 = b1;
// int, double은 num type의 하위 집합이라 int, double 대신 num으로 선언 가능.
// num 타입에는 int, double 대입 가능.

  var i2 = 10; //int
  var d2 = 10.0; //double
  var s2 = "hello"; //String
  var b2 = true; //boolean

// 위와 같이 type을 직접 명시하지 않고, var로 대체할 수 있음. (JS같이)
// 일반적으로 많이 사용.

  final String name2 = "홍길동";
  final name3 = "홍길동";
//값이 변하지 않는 경우는 상수 사용.a
//선언시 final을 제일 앞에 붙이면 값이 수정되지 않음. 타입 생략 가능.
//final 와 const는 효과가 비슷하다.
//변수의 값을 바꿀수 없게 하려면 두 키워드를 사용해야 한다.
//final변수는 한번만 할당할 수 있으며, 클래스 수준에서 변수를 할당하기 전에 선언한다.
//클래스 생성자에서 할당하는 모든 변수에 final을 사용한다.
  
  const String name = 'nahyun';
  const String name = 'nahyun $kim'; //error!
  //const변수는 할당하기 전에 선언하지 않는다. 컴파일 이후 항상 같은 값을 같는 변수를 상수라 한다.
}

enum

상수를 정의하는 특수한 형태의 클래스. 상수처럼 사용이 가능하다.

void main() {
  var authStatus = Status.logout;
  
  switch (authStatus) {
    case Status.login:
      print('login');
      break;
    case Status.logout:
      print('logout');
      break;
  }
}

enum Status { login, logout } //열겨형. 상수처럼 사용이 가능하다.

연산자

void main() {
  int num1 = 10;
  int num2 = 30;
  print('[num1 = $num1, num2 = $num2]\n');
//1. 산술 연산자
  print('1. 산술연산자');
  print('num1 + num2 = ${num1+num2}');
  print('num1 - num2 = ${num1-num2}');
  print('num1 * num2 = ${num1*num2}');
  print('num1 / num2 = ${num1/num2}');
  print('num1 ~/ num2 = ${num1~/num2}');
  print('num1 % num2 = ${num1%num2}');
  print('------------------');
//+, -, *, /(나누기 - double), ~/(몫 - int), %(나머지 - int) 사용 가능.
// +의 경우 string concat에서도 사용.
  
//2. 증감 연산자
  print('2. 증감연산자');
  print('num1 = ${num1++}');//다음 라인으로 넘어갈때 값이 증가
  print('num1 = $num1');
  print('num1 = ${--num1}');//다음 라인으로 넘어가기 전 값 증가
  print('------------------');
//1씩 증가(++) 또는 1씩 감소(--). 후위(식++)/전위(++식) 연산 모두 가능.
  
  
//3. 비교 연산자
  print('3. 비교연산자');
  print('exp(num1 == num) => ${num1==num2}');
  print('exp(num1 != num) => ${num1!=num2}');
  print('exp(num1 > num)  => ${num1>num2}');
  print('exp(num1 < num)  => ${num1<num2}');
  print('exp(num1 >= num) => ${num1>=num2}');
  print('exp(num1 <= num) => ${num1<=num2}');
  print('------------------');
//==, !=, >, <, >=, <= 사용 가능.
   
//4. 논리 연산자
  bool exp1 = false;
  bool exp2 = true;
  
  print('4. 논리연산자');
  print('[exp1 = $exp1, exp2 = $exp2]\n');
  print('exp1 && exp2 => ${exp1 && exp2}');
  print('exp1 || exp2 => ${exp1 || exp2}');
  print('exp1 == exp2 => ${exp1 == exp2}');
  print('!exp1        => ${!exp1}');
  print('exp1 != exp2 => ${exp1 != exp2}');
  print('------------------');
// boolean 타입으로 결과 반환.
// &&, ||, ==, !, != 사용 가능.
  
  
  bool exp1 = true;
  bool exp2 = false;
  
  String expTrue = 'true';
  String expFalse = 'false';
  print('5. 삼항연산자');
  print('exp ? if true return this : if false return this => ${exp1?expTrue:expFalse}');
  print('------------------');
//조건이 참이면 첫번쨰 옵션을, 거짓이면 두번째 옵션을 리턴하는 연산자이다.
}

널인지 연산자

어떤 객체든 null이 될수 있는 문제가 발생한다. (예를 들어 비동기 호출함수의 리턴이 null인 케이스)
이때 if(response == null) return 과 같은 예외처리 코드를 추가해야 한다.
dart에서는 이를 널인지 연산자를 통해 해결한다.

?. ?? ?? 의 차이
?. null이 아니면 값을 할당하고, null이면 오류 발생없이 null을 할당하시오!
?? 정보가 있는지 알수 없는 상황에서 '값이 존재하지 않는 상황'에 할당할 백업값을 저장할수 있다.
??= 이전 연산자(??)과 비슷하지만 반대의 작업을 수행한다. 객체가 null이면 백업값을 할당하고 아니면 객체를 그대로 반환한다.

  • 이 곳에서 더 자세히 알아보자

형변환

void main() {
  //메소드 활용
  int width = 140;
  print(width.toString());
  //변수명 뒤에 .을 찍고 나오는 메소드들을 활용하는 방식이다.
  //일반적으로 숫자 데이터를 문자열로 바꾸고 싶을때 많이 사용한다.
  //toString(), toList() 등의 방식이 있다.
  //위의 예제에서 width의 데이터타입은 int이다.
  //width.toString()의 데이터타입은 String이 된다.

  //parse 메소드 활용
  String height = '180';
  print('width + height = ${width + int.parse(height)}');
  //위의 코드는 height를 int형으로 바꾸는 코드이다.
  //toString때와는 반대로 변환하고자하는 타입을 적고, parse메소드의 아규먼트로 height를 넣어준다.
  //double.parse(), Uri.parse() 등 여러 형변환을 지원한다.

  //as 연산자
  var c = 10.0;
  num n = c as num;
  //num n = c; //as num 생략 가능
  int d = c as int; //errpr
  //type casting. as를 사용. 다른 타입끼리는 변환 불가. 상위 개념으로만 변환 가능.
  //특히, int, double은 num으로 묶여있지만 각자는 관계가 없어 형변화 불가.
}

조건문

void main() {
  //if else
  String name = 'name';

  print('1. if else');
  if (name == '') {
    //이 조건이 맞으면
    print('plz, enter your name'); //이것을 프린트 한다.
  } else if (name == 'admin') {
    //조건을 추가하고 싶은면 else if를 사용한다.
    print('this name is not available');
  } else {
    //마지막으로, 아무 조건도 충족하지 않은면
    print(name); //이것을 프린트 한다... 기본적으로 else 를 생활화하는 것이 좋다! 개발자도 인간이기에..
  }

  //switch case
  print('2. switch case');
  int num = 1;
  switch (num) {
    //대상이 될 변수를 넣는다.
    case 1:
    case 2:
    case 3:
      print('case 3 $num');
      break; //case 를 탈출하기 위헤서 필요한 키워드
    //각각 case에 넣지 않고도 이렇게 사용가능하다
    //1~3까지 '3'을 프린트한다.
    //break; 없이 사용할수 없다! 분기때 무언가 하고 break하던가.. 아무것도 안하던가!
    default:
      print('nothing');
  }
  print('3. switch case-continue');
  String animal = 'tiger';
  switch (animal) {
    //String도 가능하다
    case 'tiger':
      print('this is tiger');
      continue alsoCat;
    case 'lion':
      print('this is lion');
      break;
    alsoCat:
    case 'cat':
      print('and this is also cat');
      break;
    default:
      print('just animal');
      break;
  }
  
  print('4. is 타입검사');
  String whatIsMyType = '';
  print('whatIsMyType 의 타입은 int 인가? ${whatIsMyType is int}');
}

반복문

void main() {
  //for
  print('1. for 증가');
  for(int i=0;i<5;i++){
    //기본적으로 모든 언어는 zero-based이므로.. 0부터 시작하시오!
    print(i);
  }
  print('2. for 감소');
  for(int i=5;i>0;i--){//이렇게도 가능하다
    print(i);
  }
  
  //fot-in
  print('3. for in');
  List<String> pets = ['dofu','danchu','choco'];
  for(var pet in pets){
    print(pet);
  }
  
  //fotEach
  print('4. forEach');
  pets.forEach((pet){print(pet);});
  //List에서 호출하는 함수로서의 forEach이다.
  //forEach로 접근한 값은 이 블록 밖에서 접근할 수 없다.
  
  //중요!! 또한 forEach함수는 무명함수(anonymous function)이다.
  //고로 forEach가 종료되면 이 무명함수는 사라진다.
  
  //while
  print('5. while');
  int i = 0;//int를 써주어야 한다! 위에서 i를 사용하였지만 for문 안에서 사용하였지 때문에..
  while(i<5){
    print(i);
    i++;//while을 사용할때에는 무한루프가 돌지 않도록 조심해야 함!
  }
  
  //do while
  print('6. do while');
  i=2;
  do{
    print('really?');//최소 한번은 이 블록을 실행한다.
  }while(i < 0);//그쳐? 조건이 전혀 맞지 않는데 really?가 프린트됨.
}

Function

반환형식 함수명(인수형식 arg) 의 패턴으로 구성된다. 다트는 함수도 객체이며 fuction이라는 형식을 갖는다.
함수를 인수로 전달하거나 함수에서 함수를 반환하는 언어의 기능을 가진다.(고차 함수)

함수와 메소드 차이
함수(function) : class 밖에서 작성하는 함수. 어디에서나 호출 가능.
메소드(method) : class 내부에 작성하는 함수. 정의된 class에 관계된 기능을 수행. Static이 붙은 method는 정적 메서드가 되어 최상위 함수처럼 사용 가능.

익명함수와 람다식

(인수형식){동작} 의 패턴으로 구성된다. 변수에 할당하여 사용할 수 있다.

void main() {
  //1.
  var hello = (){//Function, var, dynamic Type이 가능하다.
    print('heelo');
  };
  
  hello();
  
  //2.
  Function str1 = (){
    return 'abc';
  };
  
  Function add = (){
  return 1 + 1;
};
  
 
  print(str1);//괄호를 안쓰면 원하는 값이 안나온다!
  print(str1());
  print(add());
  
  //3. 화살표 함수 활용
  int add2(int a,int b) => a+b;
  //int add2(int a,int b) {return a+b}; 와 동일하다.
  print('1 + 2 = ${add2(1,2)}');
}

매개변수

다트 함수는 위치 지정, 이름 지정, 선택형 위치 지정, 선택형 이름 지정 파라미터와 이 모두를 조합한 파라미터 등 다양한 파라미터를 지원한다.

///1.  매개변수가 한 개
int f(int x){
  return x + 10;
}

///2.  매개변수가 두 개
int f2(int x, int z){
  return x + z + 10;
}

///3. 매개변수가 없는 경우
String s(){
  return '안녕하세요?';
}

///4. return이 없는 경우 : 즉 반환값이 없는 경우!
void s2(){
  print('Return이 없어도 괜찮아요.');
}

//5. 이름 지정
void a1({String str ='', int num=0}){
  print('str = $str ,  num = $num');
}

//6. 선택형 이름 지정
void a2({required int num}){
  print(num.toString());
}

//7. 선택형 위치 지정
void a3(String str , [int? num]){//[]로 묶인 인수는 선택적으로 사용한다.
  if(num == null)
    print('str = $str');
  else
    print('str = $str ,  num = $num');
}

void main(){
  print(f(12)); // 22
  print(f2(10,10)); // 30
  print(s()); // 안녕하세요?
  s2(); // Return이 없어도 괜찮아요.
  a1(str : '12', num : 12); //이름을 지정하여 사용
  a1();//파라미터 없으면 기본값
  
  a2(num : 123);//그냥 숫자 넣는 것도 안돼고 반드시 이름을 대동함!
  
  a3('13', 12);
  a3('13');
}

Class

객체 object : 저장 공간에 할당되어 값을 가지거나 식별자에 의해 참조되는 공간. (변수, 함수, 메서드)
인스턴스 instance : 객체를 메모리에 작성하는 것.
클래스 class : 인스턴스의 설계도.
속성 property : 클래스 안에 표현되는 속성.

클래스는 일종의 사용자 정의 타입이라 볼 수 있다.

new 키워드를 사용하지 않음!
다른 객체지향 언어에서는 new라는 키워드로 클래스의 인스턴스를 만든다. 다트에서도 new 키워드를 사용할 수 있지만, 선택사항이다.
컴파일러가 자동으로 알맞은 키워드를 추론하기 때문이다. 다만, dart에서는 new사용을 권장하지 않는다.

생성자, getter, setter

void main() {
  var person  = Person();
  person.setAge(10);//setter로 값 입력하기
  person.Print();
  print('${person.age}'); //getter로 print하기.
  
  var person2 = Person(name: '홍길동');//constructor로 name 입력 및 인스턴스 생성
  person2.Print();
}

class Person {
  String name; //클래스의 property선언, 처음에는 null이 할당된다.
  int _age = -1; //_로 시작하는 변수는 private타입이다.(접근지정자)
  
  Person({this.name=''}); //constructor
  //Person({this.name='', this._age=-1}); //this will throw an Error.
  
  void setAge(int age){
    this._age = age;
  }//setter
  //private 변수 세팅을 이렇게 한다.
  
  //set setAge(num value) => _age = value; 
  //위와 동일한 코드이다.
  //더 간단하쥬???
  
  int get age => _age; //getter

  void Print(){
    print('name= ${this.name}, age= ${this._age}');
  }
}

상속, 추상클래스, 믹스인, 팩토리

extends, implements, mixins 의 차이.

extends : 클래스의 멤버변수, 함수, 생성자 등을 구현없이 사용할때 사용
implements : 클래스의 멤버변수, 함수, 생성자 등의 구현이 필수적
mixins : 다양한 계층의 클래스에서 클래스의 코드를 재사용

  • ex) 동물 클래스의 까마귀 물고기 호랑이 클래스가 있다고 하면 각각 날고 걷고 수영하는 행동(메소드)를 가지는데 이러한 동일한 메소드들을 재사용 하기 위해 사용한다.

factory : 미리 정해진 프로퍼티를 포함하는 클래스의 특별한 메서드이다.

void main() {
  Monster mon = Monster();

  print(mon is Bird); // true
  print(mon is Dog); // true
  print(mon is Animal); // true

  Dove dove = Dove();

  print(dove is Bird); // true
  print(dove is Dog); // false
  print(dove is Animal); // true
  print(dove is Walker2); // true
}

abstract class Animal {} //1. 추상 클래스

abstract class Dog extends Animal {} //1-1. 추상클래스를 상속(extend) 받는 추상클래스

abstract class Bird extends Animal {
  int count(); //1-2. 추상 클래스 내의 추상 메소드는 구현 클래스에서 구현한다.

  void fly() {
    print('난다!!'); //1-3.혹은 아싸리 구현하등등
  }
}

class Flyer {
  void fly() {
    print('flying');
  }
}

class Walker {
  Walker._();
  //다트에서 선행 문자가 밑줄이면 함수or생성자는 라이브러리에 private하다.
  //여기에서도 마찬가지이며 당 생성자를 전혀 호출할 계획이 없었다고 볼수 있다.
  //즉, 클래스를 인스턴스화할 수 없도록 만드는 것.
  // 이것은 싱글톤이라 불리겠죠?
  
  static final Walker _instance = Walker._();

  factory Walker() {
    return _instance;
  }//2. factory 는 미리 정해진 프로퍼티를 포함하는 클래스의 특별한 메서드이다.
  //factory 메서드는 캐시된 인스턴스 또는 서브 형식의 인스턴스를 반화한다.
  //즉, factory는 기존 인스턴스를 반환하거나 새 인스턴스를 만들어 반환할수 있다.
  //지정 생성자보다 더 유연하다!!
  

  void walk() {
    print('Im walking');
  }
}

abstract class Walker2 {
  void walk() {
    print('Im walking');
  }
}

//class A extends Walker{} //this will throw an error

//class Cat extends Dog with Walker {} //this will throw an error

class Cat extends Dog with Walker2 {}

class Dove extends Bird with Walker2, Flyer {
  //3. 믹스인 : with를 사용하면 구현하지 않고 상속받아 사용할수 있다.(다형성)
  //즉, 오버라이드가 필요없다!

  //@override//이 어노테이션은 생략할수 있다!
  int count() {
    //Bird를 상속 받으므로 구현해주어야 한다.
    return 1;
  }
}

class Monster implements Bird, Dog {//4. 구현 클래스
  //여러 추상 클래스를 구현할수 있다!
  
  int count() {
    //Bird를 상속 받으므로 구현해주어야 한다.
    return 1;
  }

  void fly() {
    print('flying');
  }
}
view raw

Collection

List, Spread, Map, Set

void main() {
  //1. List
  //순서가 있는 자료를 담음. Dart는 배열 (Array)를 별도로 제공하지 않는다.
  
  List<String> itemsList = ['a', 'b', 'c'];
  //var itemsList = ['a', 'b', 'c'];//위와 같음

  itemsList[0] = 'd'; //0부터 시작하는 index.
  print(itemsList.length); //3

  //2. Spread 연산자
  // spread. '...' 연산자. 컬렉션을 펼쳐준다. 다른 컬렉션 안에 컬렉션을 삽입할 때 사용.
  
  var items = ['a', 'b', 'c'];
  var items2 = ['d', ...items, 'e']; //d, a, b, c, e

  //3. Map
  //순서 없음. 탐색 빠른 자료구조. key-value의 쌍.
  
  Map<String, String> cityMap = {
    'korea': 'busan',
    'japan': 'tokyo',
    'china': 'Beijing'
  };
//   var cityMap = {
//     'korea': 'busan',
//     'japan': 'tokyo',
//     'china': 'Beijing'
//     };//위와 같음
  cityMap['korea'] = 'seoul';
  print(cityMap.length); //3
  cityMap['America'] = 'Washington'; //새 값 추가

  //4. Set
  // 집합 표현. => 중복 불허용
  // add(), remove()로 추가/삭제 가능.
  // contains() : 찾는 자료가 집합에 있는지 없는지 bool로 반환.
  
  Set<String> citySet = {'서울', '부산', '광주', '대전', '울산'};
  //var citySet = {'서울', '부산', '광주', '대전', '울산'};//위와 같음
  citySet.add('대구');
  citySet.remove('서울');
  print('${citySet.contains('울산')}'); // true

  //4-1 비어있는 Set이나 map을 작성할 때는 주의! 그냥 {}만 쓰면 Map으로 인식해버림.

  var mySet = <String>{}; //set으로 인식
  var mySet2 = {}; //dynamic, dynamic인 map으로 인식
}

기타

void main() {
  List<int> items = [1, 2, 3, 4, 5];

// 1. 계단식 표기법 .. 연산자
// cascade notation .. 연산자. 동일 객체에서 일련의 작업을 수행 가능.
// .. 연산자를 사용하면 메서드를 수행한 객체의 참조를 반환.

  print(items
    ..add(6) //6 추가하고
    ..remove(2)); //2 빼고
  //결과 : 1, 3, 4, 5, 6

// 2. 컬렉션 if
// 조건에 의해 컬렉션의 값을 조정하거나 다르게 사용하고 싶을 때 사용.

  bool promoActive = true;
  print([1, 2, 3, 4, 5, if (promoActive) 6]); // true일 때만 6이 추가됨

// 3. 컬렉션 for
// 컬렉션 문법 안에서 for 문을 사용 가능.

  var listOfInts = [1, 2, 3];
  var listOfStrings = ['#0', for (var i in listOfInts) '#$i'];
  print(listOfStrings);
//결과 : #0, #1, #2, #3
}

함수형 프로그래밍

Dart는 객체지향 프로그래밍과 함수형 프로그래밍의 특징을 모두 제공한다.
함수를 값으로 취급하여 다른 변수에 함수를 대입할 수 있다.
다른 함수의 인수로 함수 자체를 전달하거나 함수를 반환받을 수도 있다.

함수를 매개변수로 전달, 수정, 변수에 대입하기가 가능한 객체를 '일급 객체', first-class object라고 함.

루프에서 살펴본 forEach가 함수형 프로그래밍이다.
이터레이션 프로토콜을 지원하는 자료구조하면 모두 forEach를 사용할 수 있다.

forEach외의 고차함수를 살펴보자.

void main() {
  List<int> items = [1,2,3,4,5];
  
  //1. forEach
  String resultStr = '';
  print('1-1');
  items.forEach(print); // 1, 2, 3, 4, 5
  items.forEach((e) {
    resultStr += ' $e'; 
  });
  print('1-2 forEach '+resultStr);
  
  resultStr = '';
  items.forEach((e) => resultStr += ' $e');
  print('1-3 forEach '+resultStr);
  print('1-4');
  items.forEach(print);
  
  // 2.where
  // 조건을 필터링 할 때 사용. 
  // 함수형 프로그래밍을 지원하는 함수들은 결과를 반복 가능한 타입으로 반환하여 메서드 체인으로 연결해서 사용 가능.
  print('2. where');
  items.where((e) => e % 2 == 0).forEach(print); //2, 4
  
  // 3. map
  // 반복되는 값을 다른 형태로 변환하는 방법을 제공.

  items.where((e) => e % 2 == 0).map((e) => '숫자 $e').forEach(print);

  // 4. toList
  // 함수형 프로그래밍을 지원하는 함수 대부분은 Iterable<T> 인터페이스 타입 인스턴스를 반환.
  // 하지만 실제 사용할 때는 대부분 리스트 형태로 변환해야 하는 경우가 많음. => 결과를 리스트로 바꿔야함.

  final result1 = items.where((e) => e % 2 == 0).toList();
  print('toList : $result1}');

  // 5. toSet
  // 리스트에 중복된 데이터가 있을 경우, 중복을 제거한 리스트를 얻고 싶을 때 집합인 set을 사용.

  final result2= items.where((e) => e % 2 == 0).toSet().toList();
  print('toSet : $result2}');
  
  // 6. any
  // 리스트에 특정 조건을 충족하는 요소가 있는지 없는지 검사할 때 사용하는 함수.

  print(items.any((e) => e % 2 == 0));

  // 7. reduce
  // 반복 요소를 줄여가면서 결과를 만들 때 사용하는 함수.
  // Ex. 합산하는 로직

  int resultSum = items.reduce((total, e){return total+e;}); 
  print('resut = $resultSum');
  
  //8. fold
  //reduce와 크게 차이나지 않는다..
  //다만 fold에서는 시작 지점(index)를 지정해준다.
  resultSum = items.fold(0,(total, e){return total + e;});
  //0은 index이다.
  //tip : List 타입지정하지 않아 dynamic 이면 e에서 에러나 나네..
  //꼭 타입 지정을 해줍시다.

}

0개의 댓글