브라우저에서 데이터 바인딩 만들기

브라우저에서 데이터 바인딩 만들기

데이터 바인딩, 그러니까 애플리케이션 코드에서 뷰(보통 UI)를 갱신하는 일은 브라우저 기반 웹 프레임워크의 성배 같은 존재예요. 프레임워크가 존재하는 이유이자, 웹 표준이 '플랫폼을 그대로 쓰자'는 쪽으로 방향을 틀어온 이유이기도 하죠. 이 글에서는 데이터 바인딩의 네 가지 형태를 하나씩 살펴보면서, 각각을 위한 아주 작은 라이브러리(혹은 일회용 프레임워크)를 직접 만들어 볼게요.

출처: Building data binding in the browser

본문

이 글에서는 네 가지 형태의 데이터 바인딩을 살펴보고, 각각에 대해 간단한 라이브러리, 아니 던져버려도 되는 수준의 프레임워크를 하나씩 만들어 볼 거예요. 주로 학습 목적이에요. 언어로는 Dart를 쓸게요. Dart는 제가 구글에서 일할 때 함께 만들고 있는 고수준 프로그래밍 언어인데, 거기서 저는 AngularDart 팀에서 일하고 있어요. 다만 아래 개념 대부분은 JavaScript, TypeScript, 그리고 다른 'Script' 계열에도 그대로 적용됩니다.

자, 데이터 바인딩 종말의 네 기수라고 부를 만한 것들을 소개할게요.

  • 정적 템플릿(Static templating)
  • 수동 데이터 바인딩(Manual data-binding, 그냥 DOM을 쓰는 방식)
  • MVC 스타일 데이터 바인딩(Angular가 유행시킨 방식)
  • 리액티브 데이터 바인딩(Reactive data-binding, React가 유행시킨 방식)

코드만 궁금하다면 전부 GitHub에서 볼 수 있어요.

참고로 한 가지, 여기 있는 코드는 전혀 최적화되지 않았고, 이 튜토리얼을 한 시간 안에 따라 하기 위해 몇 가지 지름길을 택했어요. 프리프로세서나 코드 제너레이터를 만들지 않고, 대신 생성된 코드가 어떤 모습일지를 손으로 직접 썼는데, 그런 파일들은 .g.dart로 끝나요.

정적 템플릿(Static templating)

주로 PHP, Python, Ruby 같은 서버 사이드 프레임워크에서 유행한 방식이에요. 서버가 정적 HTML을 만들어 전송하면, 브라우저가 그걸 그대로 표시하죠. 백엔드가 있는 비(非)JavaScript 사이트들은 대부분 이 전략을 써요.

핵심은 그냥 순수 HTML을 반환하는 거예요. 최근 웹 프레임워크 중에는 서버 사이드 렌더링(같은 클라이언트 코드를 써서 HTML의 전부나 일부를 서버에서 렌더링)을 지원하는 것도 있는데, 그건 이 글의 범위를 벗어나요.

Dart(그리고 지금 시점의 대부분의 OtherScript)에는 보간(interpolation)이 있어요. 런타임에 문자열을 계산하고 만들 수 있는 기능이죠. 그래서 아주 기초적인 정적 템플릿을 만드는 건 엄청 쉬워요.

void main() {
  var name = 'Matan';
  print('Hello $name!');
}

좀 더 멋지게 만들어서 실제 HTML을 출력해 볼 수도 있어요. 이 예제에서는 월요일에 제가 좋아하는 것을 불릿 리스트로 반환해 볼게요.

void main() {
  var sundays = [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  print(
    'On Sunday: \n'
    '<ul>\n'
    '${sundays.map((i) => '  <li>$i</li>\n').join('')}'
    '</ul>\n'
  );
}

이 코드의 출력은 이렇게 나와요.

On Sunday: <ul> <li>sleep in</li> <li>eat brunch</li> <li>wash car</li> </ul>

템플릿 해법으로서는 딱히 최선은 아니지만, 이런 식으로도 많은 사이트가 잘 동작하곤 해요. 저는 점점 더 일에 맞는 가장 단순한 도구를 쓰고, 정말 필요할 때만 키워 나가는 방식을 좋아하게 됐어요. 그래도 정말 마이크로 라이브러리가 필요하다면, 한번 만들어 볼게요.

기본적인 정적 템플릿 라이브러리에 바라는 게 뭘까요? 아마 HTML 문법 생성을 대신 처리해서 오류를 줄여 주고, 흔한 태그를 만드는 헬퍼 함수를 제공해 주는 정도일 거예요. 먼저 단일 HTML 요소를 캡슐화하는 Tag 클래스와, Node, Text를 설계해 볼게요.

abstract class Node {}

class Tag implements Node {
  final String name;
  final List<Node> children;

  const Tag(this.name, {this.children: const []});

  @override
  String toString() => '<$name>${children.join('')}</$name>';
}

class Text implements Node {
  final String text;

  const Text(this.text);

  @override
  String toString() => text;
}

void main() {
  var sundays = [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  var output = new Tag(
    'ul',
    children: sundays.map(
      (i) => new Tag('li', children: [new Text(i)]),
    ).toList(),
  );
  print(output);
}

이 코드는 대략 위에서 본 것과 같은 일을 해요. 다만 몇 가지 추상화를 만들었다는 차이가 있죠. Tag는 HTML 요소이고 어떤 수의 자식도 가질 수 있으며, Text는 말 그대로 텍스트예요. 전보다 코드를 더 많이 썼지만, 어떤 사람에게는 이 방식이 더 마음에 들 수도 있어요. HTML 속성 같은 걸 넣는 것도 아주 쉬워서, 그건 지금은 건너뛸게요.

그런데 저는 한 가지가 걸려요. 프로덕션 코드를 출력하는 데 toString을 쓰는 게 영 마음에 안 들어요. 저는 또 비지터 패턴의 팬인데, 비지터 패턴이면 이 로직을 DSL에서 깔끔하게 분리할 수 있어요.

abstract class Visitor<T> {
  T visitNode(Node node);
  T visitTag(Tag node);
  T visitText(Text node);
}

class HtmlEmitter implements Visitor<String> {
  @override
  String visitNode(Node node) => node.accept(this);

  @override
  String visitTag(Tag node) {
    final buffer = new StringBuffer('<${node.name}>');
    buffer
      ..writeAll(node.children.map(visitNode))
      ..write('</${node.name}>');
    return buffer.toString();
  }

  @override
  String visitText(Text node) => node.text;
}

abstract class Node {
  R accept<R>(Visitor<R> visitor);
}

class Tag implements Node {
  final String name;
  final List<Node> children;

  const Tag(this.name, {this.children: const []});

  @override
  R accept<R>(Visitor<R> visitor) => visitor.visitTag(this);
}

class Text implements Node {
  final String text;

  const Text(this.text);

  @override
  R accept<R>(Visitor<R> visitor) => visitor.visitText(this);
}

void main() {
  var sundays = [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  var output = new Tag(
    'ul',
    children: sundays.map(
      (i) => new Tag('li', children: [new Text(i)]),
    ).toList(),
  );
  print(new HtmlEmitter().visitNode(output));
}

역시 코드는 더 늘었어요. 하지만 이제 들여쓰기된 HTML 문법이나 검증 로직 같은 걸 추가하고 싶다면, 새 Visitor 하나만 구현하면 돼요. 다른 클래스는 전혀 건드릴 필요가 없죠. 멋지지 않나요?

마지막으로, 새로 만든 '라이브러리'로 아주 기초적인 HTTP 서버를 하나 연결해 볼게요. 예제에서는 8080 포트에서 자동으로 리슨하면서 제 TODO 리스트를 출력하도록 만들 거예요.

import 'dart:io';

import 'package:static_templating/static_templating.dart';

main() async {
  final sundays = const [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  final server = await HttpServer.bind(InternetAddress.LOOPBACK_IP_V4, 8080);
  print('Listening to localhost:8080.');
  await for (final request in server) {
    print('Connection received. Writing TODO list...');
    final list = _createTodoList(sundays);
    final output = list.accept(new HtmlEmitter());
    request.response
      ..headers.contentType = ContentType.HTML
      ..write(output)
      ..close();
    print('Done!');
  }
}

Node _createTodoList(Iterable<String> tasks) {
  return new Tag(
    'ul',
    children: tasks.map((i) => new Tag('li', children: [new Text(i)])).toList(),
  );
}

(이 작업을 하면서 특히 좋았던 Dart의 점이 있어요. 서드파티 라이브러리를 하나도 임포트하지 않았고, 특별한 도구를 설정할 필요도 없었으며, 구글링 없이 ContentType 헤더를 어떻게 설정해야 하는지 정적 분석이 알려줬다는 점이죠.)

좀 심심하지만, 동작은 해요! 이런 서버를 직접 만들어 보고 싶다면, 제가 추천하는 순서대로 먼저 이쪽을 확인해 보세요. (어차피 저는 주어진 일에 되도록 적은 라이브러리를 쓰는 걸 매우 좋아하니까요.)

  • dart:io — 표준 VM 라이브러리(브라우저에서는 쓸 수 없어요)
  • shelf — NodeJS의 Express 같은 미들웨어 기반 서버 기술
  • aqueductangel 같은 더 풍부한 기능을 갖춘 프레임워크

이 정도면 끝이에요. 물론 비슷한 기법을 브라우저에서 HTML을 렌더링하는 데도 쓸 수 있어요. 다만 그건 좀 심심하죠. 아마 사용자가 페이지와 상호작용할 때 HTML이 바뀌길 원할 테니까요.

수동 데이터 바인딩(Manual data-binding)

JavaScript 웹 프레임워크 시대가 오기 전, 그러니까 모든 게 더 '라이브러리'였던 시절에는 코드를 직접 DOM에 맞춰 쓰거나('바닐라' JavaScript), jQuery 같은 라이브러리를 써서 브라우저 간 차이를 없애고 흔한 작업을 쉽게 만드는 게 일반적이었어요.

다행히 dart:html은 Dart SDK가 제공하는 고수준 라이브러리이고, JavaScript에서도 요즘 브라우저는 단순한 데이터 바인딩에 라이브러리가 필요 없을 만큼 기능이 충분해요. 그럼 제 새로운 '일요일 TODO 리스트'를 가져와서, 항목을 끝내면 체크(삭제)할 수 있게 만들고 싶다면 어떨까요?

여기서 수동 데이터 바인딩이 등장해요. 어떤 프레임워크나 특정 라이브러리에 기대지 않고, HTML 요소를 직접 만들고, DOM을 갱신해야 하는 시점을 알기 위해 이벤트를 직접 듣는 거죠.

<html>
  <body>
    <div id="todo-list"></div>
  </body>
  <script type="application/dart" src="main.dart"></script>
  <script src="packages/browser/dart.js"></script>
</html>
import 'dart:html';

main() {
  final sundays = const [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  document.querySelector('#todo-list')
    ..append(new UListElement()
      ..children.addAll(
        sundays.map(_createListItem),
      ));
}

Element _createListItem(String item) {
  final li = new LIElement();
  final button = new ButtonElement()
    ..text = 'Done'
    ..onClick.listen((_) => li.remove());
  li
    ..appendText(item)
    ..append(button);
  return li;
}

역시 심심하지만, 이렇게 간단한 TODO 앱을 만드는 데는 나쁘지 않아요. Dart의 .. 메서드 캐스케이드 덕분에 코드가 비교적 간결하고, 다른 기본 동작도 아주 쉽게 추가할 수 있어요.

대략 말하면, 다른 모든 데이터/템플릿 바인딩 방식은 위에서 한 일 — DOM을 만들고 무언가 일어나면 DOM을 갱신하는 것 — 을 하려는 거나 마찬가지예요. 다만 (a) 더 성능 좋게, (b) 더 우아하게, (c) 더 쓰기 편하게, 혹은 (d) 더 모듈화해서 하려는 것이죠.

자동 데이터 바인딩(Automatic data-binding)

현재 브라우저 표준 기준으로는 이걸 더 쉽게 만들어 주는 게 아무것도 없어요. 어떤 사람들은 그게 WebComponents V1이 기존 프레임워크만큼 안착하지 못한 이유라고 하기도 해요. 프레임워크들이 DOM을 만들고 갱신하는 걸 너무 쉽고 편하게 만들어서, 아무도 되돌아가고 싶어 하지 않거든요.

저는 AngularDart 팀에서 일하니까, 다음으로 MVC 스타일 데이터 바인딩 예시를 보여드릴게요. (Angular가 유행시킨 방식이에요. Ember도 훌륭한 예라고 들었고, 다른 사례도 아주 많을 거예요.)

MVC 스타일 데이터 바인딩

MVC의 핵심은 (브라우저에서는 보통 DOM)와 컨트롤러(브라우저에서는 보통 여러분의 JavaScript) 사이의 로직 분리예요. m, 즉 모델은 일단 건너뛸게요.

Angular는 (시도했다고 말하는 게 맞겠지만) 평범한 오래된 객체(PODO)를 쓰고, 원하는 로직을 얼마든지 넣으면 HTML 템플릿에 '그냥 바인딩'되어야 한다는 생각을 유행시켰어요. 우리 일요일 TODO 리스트를 MVC 스타일로 캡슐화한 코드를 볼게요.

<ul>
  <!--
    Invented syntax. *bind="<TYPE> <TO>".

    In this case, a for-loop bound to "items", with every individual "item".
  -->
  <li *bind="for items:item">
    <!-- Pretty much the gold-standard for string interpolation. -->

    <!--
      Invented syntax. *event="<NAME>:<TO>".

      In this case, a "click" event that invokes the "onClick" method.
    -->
    <button *event="click:onClick">Done</button>
  </li>
</ul>

어떻게 느껴지나요? 여기에는 장단점이 있어요.

장점:

  • 뷰 로직의 대부분(혹은 전부)을 단일 파일에 캡슐화할 수 있어요.
  • 표준은 아니지만, 코드가 아닌 사람도 '치트 시트'만 있으면 꽤 읽을 수 있어요. 템플릿 언어를 위한 HTML 표준 확장을 설명하는 치트 시트만 있으면 되죠.
  • 실제 코드를 하나도 작성하지 않았으니, 좋은 AOT(사전 처리) 프리프로세서를 가진 프레임워크가 이걸 잠재적으로 효율적으로 JavaScript/DOM 호출로 '컴파일'할 수 있어요.

단점:

  • MVC 종교에 동의하지 않는다면 이 파일이 마음에 안 들 수도 있어요.
  • HTML/CSS와 Dart 혹은 JavaScript를 잘 다루더라도, 여러분과 개발자들이 그 둘 어디에도 속하지 않는 또 다른 무언가를 새로 배워야 해요.
  • 실제 코드를 작성하지 않았으니, 사실상 프레임워크 저자가 이걸 효율적으로 프리프로세스/컴파일할 거라고 기대하고, 출력 결과가 수동으로 데이터 바인딩을 작성했을 때만큼 좋을 거라고 믿는 셈이에요. (힌트: 거의 항상 그렇지 않아요.)

아직 '컨트롤러' 코드를 보여드리지 않았으니, 간단한 클래스를 하나 작성해 볼게요.

class ListController {
  final List<String> items;

  ListController(this.items);

  void onClick(String item) {
    items.remove(item);
  }
}

이걸 '그냥 동작'하게 만들려면 프레임워크를 작성해야 해요. 블로그 글이니 템플릿 언어를 위한 파서나 프리프로세서/컴파일러를 직접 만들기는 어렵고, 제너레이터가 만들었을 코드를 손으로 그대로 써 볼게요.

먼저 우리 '프레임워크'를 만들어요. 간단하게 갈게요.

import 'dart:html';

abstract class Controller {
  View get view;

  /// Invoke when something in the DOM should be changed.
  ///
  /// In a "real framework" this would collate and be more intelligent.
  void markDirty() => view.detectChanges(this);
}

abstract class View<C extends Controller> {
  /// Create the initial representation of the DOM.
  Element build();

  /// Applies data-binding for a given [controller].
  void detectChanges(C controller);
}

두 가지 추상화를 만들었어요. 하나는 Controller로, 비즈니스 로직을 만들기 위해 확장하는 클래스(Angular에서는 보통 컴포넌트)이고, 다른 하나는 View로, HTML 파일을 순수 Dart(나중엔 JavaScript)로 표현한 거예요.

먼저 생성된 코드, 즉 '뷰'를 만들어 볼게요.

// A dart "partial library", that is, in the namespace of another library.
part of list;

class _ViewListController extends View<ListController> {
  UListElement _ul;
  List<String> _itemsLastCheck = const [];

  @override
  Element build() {
    return _ul = new UListElement();
  }

  @override
  void detectChanges(ListController controller) {
    // Not an exhaustive check. In a real framework you'd need to verify that
    // the contents of the list are the same, not just the lists are of
    // different lengths.
    if (_itemsLastCheck.length != controller.items.length) {
      _updateItems(controller);
    }
  }

  void _updateItems(ListController controller) {
    // Intentionally not efficient. In a real framework you'd want to track
    // every <li> individually to avoid blowing away big parts of the DOM and
    // rebuilding.
    _ul.children
      ..clear()
      ..addAll(controller.items.map((i) => _createLi(controller, i)));
    _itemsLastCheck = controller.items.toList();
  }

  Element _createLi(ListController controller, String item) {
    final li = new LIElement()..appendText(item);
    li.append(new ButtonElement()
      ..text = 'Done'
      ..onClick.listen((_) => controller.onClick(item)));
    return li;
  }
}

위에서 몇 가지 지름길을 썼어요. 예를 들어 항목은 리스트의 길이가 바뀔 때만 갱신되고, 상태를 전혀 유지하지 않아서 리스트가 갱신되면 기존 DOM을 날려버리고 새 DOM을 만들어요. 전형적인 프레임워크(Angular 포함)는 이걸 훨씬 더 영리하게 처리하겠죠. 그러나 이 글의 목적 중 하나는, 아무리 단순한 데이터 바인딩이라도 그 뒤에서 얼마나 많은 작업이 벌어지고 있는지를 보여주는 거예요.

마지막으로, 컨트롤러가 우리 프레임워크를 쓰도록 갱신할게요.

library list;

import 'dart:html';

import 'package:mvc_data_binding/framework.dart';

// Would be generated in a typical framework.
part 'list.g.dart';

class ListController extends Controller {
  final List<String> items;

  ListController(this.items);

  void onClick(String item) {
    items.remove(item);
    markDirty();
  }

  @override
  final view = new _ViewListController();
}

그리고 main.dart에서 전부 묶어볼게요.

<html>
  <body>
    <div id="todo-list"></div>
  </body>
  <script type="application/dart" src="main.dart"></script>
  <script src="packages/browser/dart.js"></script>
</html>
import 'dart:html';

import 'package:mvc_data_binding/list.dart';

main() {
  final sundays = [
    'sleep in',
    'eat brunch',
    'wash car',
  ];
  final container = document.querySelector('#todo-list');
  final component = new ListController(sundays);
  container.append(component.view.build());

  // Start change detection. This should be automatic in most frameworks.
  component.view.detectChanges(component);
}

이제 Angular 스타일의 MVC 프레임워크가 하나 완성됐어요. .g.dart 파일을 자동으로 만들어 주는 코드 제너레이터를 만드는 일은, 얼마나 단순하게 혹은 복잡하게 하느냐에 따라 수 시간에서 수일(혹은 수 주, 수 개월)이 걸릴 거예요. 그건 이 글의 범위를 벗어나니 넘어갈게요.

리액티브 스타일 데이터 바인딩(Reactive-style data-binding)

FP, 즉 함수형 혹은 리액티브 방식의 데이터 바인딩은 조금 달라요. 뷰와 컨트롤러 로직을 분리하는 대신, 애플리케이션을 입력으로 값(데이터)을 받고 출력으로 '뷰'(여기서는 HTML)를 내는 함수(혹은 클래스)로 모델링해요.

React, Vue, 그리고 Flutter까지 이 방식을 택하고 있어요.

아래 코드를 볼게요.

Node todoList(List<String> todos) => new Tag(
  'ul',
  children: todos.map(
    (i) => new Tag('li', children: [
      new Text(i),
      new Tag('button', children: [
        'Done',
      ], events: {
        'click': () => throw new UnimplementedError(),
      }),
    ]),
  ).toList(),
);

사실 이건 앞서 정적 템플릿 바인딩에서 본 것과 크게 다르지 않아요. 차이는 현대 라이브러리와 프레임워크가 DOM에 미치는 영향을 최소화하기 위해 일련의 알고리즘을 사용한다는 점이죠. 예를 들어 <li> 하나만 지워도 React 같은 전형적인 프레임워크는 아무것도 다시 빌드하지 않아요.

장점:

  • '컴포넌트'의 로직 전부를 한곳에 캡슐화할 수 있어요.
  • 특별한 프리프로세싱이나 컴파일러가 필요 없어요.
  • 가령 Flutter SDK로 작성한 프로그램에서는 IDE가 곧 신이 돼요. 모든 걸 자동 완성해 주니, 어쩌면 타이핑을 배울 필요조차 없어질지도요.
  • JSX 같은 쓰기 쉽고 선택적인 도구를 쓰면 코드를 꽤 간결하게 만들 수 있어요.

단점:

  • 앞서와 마찬가지로, MVC 순수주의자들(그리고 여러 회사 웹 플랫폼 DevRel 팀의 일부)이 좀 불만을 가질 수 있어요. 이제 여러분은 HTML과 JavaScript, 그리고 잠재적으로 CSS까지 JavaScript 함수(혹은 클래스) 안에 넣고 있으니까요.
  • Angular 같은 것(물론 그것도 나름 문제가 있지만)과 달리, 이 함수가 만들어 내는 코드를 '살펴볼' 수는 없어요. 즉 수천 개 항목의 리스트를 최적화하거나, 레이지 스크롤 같은 걸 다룰 때는 프레임워크만의 특이한 점들을 아주 잘 알아야 해요.

리액티브 스타일 데이터 바인딩 프레임워크를 위한 간단한 라이브러리를 쓰겠다고 약속했는데, 이 글을 쓰는 동안 제 동료 예고르(Yegor)가 만든 'Butterfly' 프레임워크가 이미 그걸 더 잘 해냈다는 결론에 이르렀어요.

// Copyright 2016 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:butterfly/butterfly.dart';

class App extends StatelessWidget {
  Node build() {
    return div(attrs: {'id': 'greeting'})([
      text('Hello, World!'),
      button(eventListeners: {
        EventType.click: (_) {
          print('Woohoo!');
        }
      })([
        text('Update'),
      ]),
    ]);
  }
}

결론은 무엇일까요?

결론은 없어요. 혹시 벤치마크 표라도 찾아서 프로젝트를 최신 프레임워크로 옮기는 데 쓴 200시간을 정당화하려고 여기까지 읽으셨다면 미안해요. 아마도 각 접근 방식마다 장단점이 있고, 하이브리드 모델을 쓰는 프레임워크도 점점 늘고 있는 걸 목격했어요.

집에 가져갈 일반적인 경험 법칙 몇 가지를 정리하면:

  • 데이터 바인딩은 어려워요. 'X와 Y를 그냥 해 주는 가볍고 단순한 데이터 바인딩 라이브러리'가 왜 없는지 묻고 싶을 때, 이 사실을 떠올려 보세요.
  • 템플릿 시스템은 트레이드오프 위에 세워져 있어요. 쓰기 편함, 속도, 복잡성 중에서 하나를 맞바꾸는 거죠. 저는 모든 면에서 완벽하게 성공하는 프레임워크를 하나도 본 적이 없어요. 그리고 존재하는 프레임워크와 모델의 수를 생각하면, 다른 누구도 그런 걸 써 본 적 없을 거라고 짐작해요.
  • 직접 사이트를 구축하면서 '마이크로' 프레임워크를 쓰고 싶은 마음이 든다면, 단순하게 시작하세요. 아주 작은 것만 하고, 큰 문제를 해결할 때만 복잡성을 더하세요. 보일러플레이트 자체가 나쁜 게 아니에요. 오류가 생기기 쉽고 제대로 쓰기 어려운 보일러플레이트가 문제인 거죠.

독자분들이 어떻게 생각하시는지, 어떤 주제를 더 자세히 다뤘으면 하는지 궁금해요. 즐거운 7월 4일 보내세요!

더 알아보기