사실 이게 다른 분들께는 별 의미가 없다는 건 잘 안다. 하지만 정리를 이런식으로 해두지 않으면 손에서 빠져나가는 모래처럼 배운 것이 없어질 것 같아 이 글을 게시하기로 하였다.


사실 집에 이런 책이 있다.

굉장히 깊은 내용들을 다루고 있어서 한번 쯤 읽지 않으면 리액트로 프론트엔드 개발을 못할 것만 같지만 이 책 너무 어렵다.

리액트는 수많은 개발 프로젝트를 자양분 삼아 엄청난 발전이 있었다. 그렇지만 천리길도 한걸음 부터라고 중요한 것만 간단히 다루기로 했다.


Component

React 의 기본 UI 단위이다. 단순히 html div 태그 특별히 하나 더 만든 것은 아니다.

Specification

  • 마치 객체지향의 클래스처럼 재사용이 가능하고 prop 이라는 정의된 값을 전달할 수도 있다.
  • 자신만의 상태값과 렌더링 사이클을 가질 수 있다.
  • import & export 가 가능하다.
  • Built-in 컴포넌트로는 아래의 종류가 있다.
    • Fragment : 여러 개의 JSX 노드들을 하나로 합치는 일종의 바구니이다. Fragment 자체로는 아무런 스타일도 기능도 없다. <></> 로도 나타낼 수 있다.
    • Profiler : React tree 내의 렌더링 퍼포먼스를 측정할 때 사용할 수 있다.
    • Suspense : Child component 들이 로딩 중일 때 보여줄 컴포넌트를 정의한다.
    • StrictMode : React 렌더링을 한번 더 반복한다. 개발 중 발생할 수 있는 오류를 점검할 때 유용하다. (개인적으로는 항상 사용하는 편이다)

Types

Component 를 생성 방법에 따라 종류가 나뉜다.

  • 함수형 컴포넌트 : 함수를 통해 정의하는 컴포넌트이다. 리액트에서는 아래의 클래스보다는 함수형을 더 선호한다. 트리 구조를 갖는 리액트 앱의 특성 상 컴포넌트가 순수(Pure)할수록 명확한 구조를 구성할 수 있기 때문인 것으로 생각된다.
import React from "react";
// Element.jsx 파일
export default function Element(props) {
  return <div>{props.inx}번째 Hello world!</div>
}
// Root.jsx 파일
export default function Root() {
  return <>
    <Element inx="0"/>
    <Element inx="1"/>
    <Element inx="2"/>
  </>
}
// index.js 파일
const root = ReactDOM.createRoot(document.getElementById("root"));

root.render(
  <React.StrictMode>
  	<Root/>
  </React.StrictMode>
)
  • 클래스형 컴포넌트 : 위에서 설명했다시피 최신 리액트 공식문서에서는 해당 내용에 대한 언급이 없다. 예전 문서를 봐야 한다. 클래스형 컴포넌트의 장점은 생명주기를 명확히 정의할 수 있다는 것이다. 생명주기 함수는 어떤 친절한 분이 만들어 놓은 아래 그림 (출처는 https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/)으로 대체할 수 있을 것이다.

import React, { Component } from "react";

class ProgressBar() extends Component {
  constructor(props) {
    super(props); // this.props 에 값 반영
    this.state = { progress: this.props.progress || 0 };
  }
  
  componentDidUpdate(prevProps) { // 갱신 직후 호출
    if (prevProps.progress != this.props.progress) {
      this.setState({ progress: this.props.progress });
    }
  }
  
  render() {
    const { progress } = this.state;
    
    return (
      <div class="w-full h-4 bg-gray-200 rounded">
      	<div
          className="h-full bg-blue-500 rounded"
          style={{ width: `${progress}%` }}
        >
          `${progress}%`
        </div>
      </div>
    )
  }
}

리액트가 클래스 컴포넌트에 대한 언급을 최신 문서에서 제외한 이유는 다음과 같다고 생각된다.

  • 클래스 컴포넌트는 Javascript 의 this 와 깊은 관련이 있지만 this 는 코드베이스를 무겁게 만든다.
  • 컴포넌트를 미리 컴파일해놓고 사용하는 방식의 높은 잠재력이 있다고 판단하였지만, 클래스 컴포넌트는 이런 최적화에 방해가 된다.

named / default export

자바스크립트의 export 를 간단히 짚고 넘어가자

  • named export
    • 여러 식별자를 한번에 내보낼 수 있다.
    • import 할 땐 export 에서 사용한 식별자를 사용해야 한다.
  • default export
    • 한 파일에 하나의 식별자만 내보낼 수 있다.
    • import 할 때 원하는 식별자를 사용할 수 있다.
SyntaxExport statementImport statement
Defaultexport default function Button() {}import Button from './Button.js';
Namedexport function Button() {}import { Button } from './Button.js';

예제를 끝으로 간단히 마무리 한다. 예제는 MDN 을 그대로 가져왔다.

// named export
// 호이스팅에 의해 미리 export 가능
// import 시 이름은 고정!
export { cube, foo, graph };

function cube(x) {
  return x*x*x;
}
const foo = Math.PI + Math.SQRT2;
var graph = {
  options: {
    color: "white",
    thickness: "2px",
  },
  draw: function() {
    console.log("From graph draw function");
  },
};
// default export
// File A
export default function cube(x) {
  return x*x*x;
}

// File B
import { createContext } from "react"; // 컨텍스트 생성 hook
const AppContext = createContext({ }); // 객체 형태의 컨텍스트 생성

// AppContext 라는 이름으로 export. 다른 파일에선 여러 이름으로 사용 가능.
export default AppContext;

JSX

JSX 는 자바스크립트 파일 내에서 자바스크립트를 이용하여 HTML 를 작성할 수 있도록 도와주는 마크업을 작성할 수 있게 도와주는 자바스크립트 확장 기능이다. 대부분의 리액트 개발에서 UI 를 작성할 때 활용된다.

리액트 안에 JSX 가 포함된 것도 아니고, 반드시 써야되는 건 아니다.

Usability

예를 들어 조건에 따라 3 개의 HTML 을 화면에 출력해야 한다고 가정하자. 그럴 경우에는 약간 복잡한 필요하였다.

<script>
  document.addEventListener("DOMContentLoaded", function() {
  	const isLoggedIn = await isLoggedIn();
  	let id = "";
  	
    if (isLoggedIn === true) {
	  id = "A";
    } else if (isLoggedIn === false) {
      id = "B";
    } else {
      id = "C";
    }
  
  	document.getElementById(id).hidden = false;
  });
</script>

<div id="A" hidden>
  <p>Hello A world</p>
</div>

<div id="B" hidden>
  <p>Hello B world</p>
</div>

<div id="C" hidden>
  <p>Hello C world</p>
</div>

위의 경우 불필요하게 코드가 길어지게 되었다. 원하는 것은 단지 isLoggedIn() 의 결과에 따라 다른 HTML 을 화면에 보여주는 것 뿐인데 말이다. 코드를 읽는 입장에서도 복잡하다.

JSX 는 자바스크립트로 HTML 을 생성할 수 있게 도와준다. 참고로 useState 의 setter 역할의 함수가 set 을 하면 React 의 컴포넌트는 ReRendering 된다.

import React, { useState } from "react";

export default function HelloWorld() {
  const [id, setID] = useState("");
  
  () => {
    const isLoggedIn = await isLoggedIn();
	if (isLoggedIn === true) {
      setID("A");
    } else if (isLoggedIn === false) {
      setID("B");
    } else {
      setID("C");
    }
  }();
  
  return (
    <div>
      <p>Hello {id} world</P>
  	</div>
  );
}

Limitation

JSX 는 확장 기능이자 일종의 문법이기 때문에 아래의 제한사항을 갖는다.

  1. JSX 로 반환되는 HTML Element 는 반드시 하나여야 한다. 여러 개의 Element 를 반환하고 싶다면 Fragment 혹은 다른 컨테이너 역할의 Element 를 사용하자.
// X. It won't work.
export default function ShoppingList() {
  return (
    <h1>Shopping List</h1>
    <ul>
      <li>Tomatoes</li>
      <li>Beef</li>
      <li>Milk</li>
    </ul>
  );
}

// Correct. Used Fragment
export default function ShoppingList() {
  return (<>
    <h1>Shopping List</h1>
    <ul>
      <li>Tomatoes</li>
      <li>Beef</li>
      <li>Milk</li>
    </ul>
  </>);
}

여러 개의 Element 를 바로 반환하는 것을 허용하지 않는 이유는 JSX 의 반환값이 자바스크립트 객체이기 때문이다. 하나의 함수에서 두 개의 객체를 동시에 반환하지는 못한다.

  1. 태그는 전부 닫혀있어야 한다.
  2. JSX 속성은 대부분 camelCase 를 사용한다. 자바스크립트의 변수명은 '-'와 키워드로 선언되는 것을 금지한다. JSX 의 리턴 값은 객체이기 때문에 속성 또한 변수명의 규칙을 따른다.
<img 
  src="https://i.imgur.com/yXOvdOSs.jpg" 
  alt="Hedy Lamarr" 
  className="photo" // CamelCase
/>

Extension using Curly Braces

JSX 는 HTML 을 쉽게 작성하기 위한 마크업 확장이기 때문에 동적으로 리턴값인 HTML(자바스크립트 객체) 을 정의할 수 있다.

export default function toDollar(number) {
  function formatNumber(number) {
    return number.toLocaleString();
  }
  return <p>Current price is `${formatNumber(number)}`</p>
}

특이하게도 style 처럼 다수의 속성을 정의하는 경우에는 다음과 같이 사용한다. 하지만 이것도 자세히 보면 한 개의 객체를 전달하는 것 뿐이라는 것을 알 수 있다.

export default function TodoList() {
  return (
    <ul style={{
        backgroundColor: 'black',
        color: 'pink'
    }}>
      <li>Study</li>
      <li>Workout</li>
      <li>Sleep well</li>
    </ul>
  );
}

Props

리액트의 컴포넌트들은 props 라는 변수를 이용해 서로 소통한다. 소통방향은 부모 컴포넌트에서 자식 컴포넌트가 일반적이며, props 는 값, 객체, 함수/메소드 심지어 컴포넌트도 가능하다.

pass data, object, function/method

컴포넌트는 미리 지정된 다음의 프로퍼티 혹은 파라미터를 사용할 수 있다. 클래스 컴포넌트인 경우는 this.props (이는 constructur 에서 super(props) 를 호출하였기 때문) 으로 사용 가능하며, 함수형 컴포넌트인 경우는 그냥 파라미터로 받아서 사용하면 된다.

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}
class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>
  }
}

주로 함수형 컴포넌트를 사용하는 현재는 자바스크립트의 객체 destructuring 문법을 혼합하여 사용하고 있다.

export default function MyProfile({name, job, age}) {
  return (<>
    <ul>
      <li>이름은 {name}</li>
      <li>직업은 {job}</li>
      <li>나이는 {age}</li>
    </ul>
  </>);
}

set default value

간단히 예제로 언급하도록 한다.

export default function MyProfile({ name, job = 'searching', age }) {
  // ...
}

children

props 내에는 children 이라는 키워드가 있다. 이는 사용중인 컴포넌트를 리턴값으로 선언할 때 태그와 태그 사이의 위치한 컴포넌트이다.

function Card({ children }) {
  return (
    <div className="card">
      {children}
    </div>
  );
}

export default function Profile() {
  return (
    <Card>
      <Avatar
        size={100}
        person={{ 
          name: 'Katsuko Saruhashi',
          imageId: 'YfeOqp2'
        }}
      />
    </Card>
  );
}

props that changing

부모 컴포넌트에 의해 전달된 props 는 부모 컴포넌트의 라이프사이클에 따라 바뀔 수 있다.

export default function Clock({ color, time }) {
  return (
    <h1 style={{color: color}}>
      {time}
    </h1>
  );
}

부모에 의해 전달된 color 값, time 값은 state 일 것이다. useState 훅에 의해 정의된 위의 두 값은 바뀔 때마다 부모 컴포넌트를 rendering 할 것이며, 그로 인해 자식 컴포넌트도 redering 될 것이다.

React UI as Tree & and Pure function

Pure function

순수 함수란 외부의 영향 없이 정해진 값에 정해진 결과만을 반환하는 함수를 말한다.

function pureSum(a, b) {
  return a+b;
}

let externalNumber = 0;
function notPureSum(a) {
  return externalNumber + a;
}

순수 함수의 장점은 개발자가 의도한 결과를 반환하는데 더 유리하다는 것이다. 해당 함수에 대해서는 정확한 값만 전달 되었다면 예상한 값만 반환할 것이기 때문에, 전달된 값만 검증하면 된다.

UI as Tree

React 뿐만 아닌 최근 프론트엔드의 트렌드는 트리 구조의 UI 인가보다.

여기서 중요한 것은 부모 컴포넌트가 렌더링되면 자식 컴포넌트도 같이 렌더링 된다는 것이다. 위의 그림에서 A가 렌더링되면 B,C 도 렌더링되지만, B가 렌더링 된다고 A가 렌더링 되지는 않는다.

그렇기 때문에 자식 컴포넌트를 정의함에 있어 순수 함수를 사용하는 것이 중요하다
만약 자식 컴포넌트를 렌더링하는 함수가 다른 함수 혹은 컴포넌트의 영향을 받게 되면 코드 베이스는 혼잡해지고, 유지보수하기 어려워진다.

리액트 공식문서에서는 렌더링과 함께 순수함수에 대해 아래와 같이 명시하고 있다.

렌더링은 반드시 순수 연산으로 이뤄져야 한다.
- 같은 입력은 같은 출력을 동반해야 한다.
  컴포넌트는 같은 입력에 대해 언제나 같은 JSX 를 반환해야 한다.
- 독립성을 고려해야 한다.
  특정 컴포넌트 변경이 다른 컴포넌트에 영향을 끼쳐서는 안된다.
코드베이스가 복잡 해질수록 혼란스러운 버그와 예상치 못한 상황을 여러 번 맞이할 것이다.
"Strict Mode" 로 개발을 진행하면 각 컴포넌트 함수가 두 번 호출 되는데
표면 상 잘 드러나지 않는 실수를 검증할 수 있다.

React Triggering & Rendering & Committing

렌더링은 다음의 과정에 따라 진행된다. 내 머릿 속에 박아놓고 튀어 나와야 할 지식들을 적어 놓는다.

Triggering

Triggering은 렌더링을 유도하는 과정이고 다음의 두 이유로 발생한다.

  • 컴포넌트로서 최초 렌더링
  • 컴포넌트의 상태값이 변경될 경우

여기서 말하는 최초 렌더링은 createRoot 에 의해 발생하는 최초 렌더링을 말한다. 주로 리액트 프로젝트를 생성하면 index.js 가 생성되는데 이 안에 createRoot 가 들어있는 것을 확인할 수 있다.

(화면 이동은 react-router-dom 같은 외부 라이브러리를 이용하거나, a 태그를 이용하는 것으로 리액트와는 거리가 먼 이야기다)

상태값이 변경되는 것은 useState 훅을 이용하는 경우를 말한다. useState 훅의 리턴값은 배열인데, 0번째 인덱스는 상태값 자체, 1번째 인덱스는 상태값을 변경할 수 있는 set 함수이다. 즉, 화면 상의 변화가 발생하였다면 컴포넌트의 set 함수가 작동했을 가능성이 가장 높다.

Redering

트리거가 되면 리액트는 렌더링해야 할 컴포넌트를 호출한다.

  • 최초 렌더링(createRoot) 시에는 리액트가 루트 컴포넌트를 호출한다.
  • 이후의 렌더링은 리액트가 컴포넌트 상태값 업데이트에 컴포넌트 함수가 호출된다.

이 과정은 연속적인데, 한 부모 컴포넌트를 런더링하면 자식 컴포넌트도 렌더링되는 방식이다.

렌더링의 작동방식을 이해한다면, UI 트리 구조상 높은 위치의 컴포넌트 렌더링에는 신중을 기해야 한다는 것도 이해할 수 있을 것이다. 성능 이슈가 발생한다면 Performance 를 참고하자.

Committing

렌더링과 동시에 리액트는 DOM 을 변경한다.

  • 최초 렌더링 시에 리액트는 appendChild() DOM API 를 이용하여 루트 DOM 노드와 그 아래의 노드를 새로 생성한다.
  • 이후의 렌더링은 최소한의 작업만을 이용해 DOM 이 렌더링 되도록 한다.

Hooks

React 16.8 부터 지원되어오던 리액트의 기본 기능 중 하나이다. 버전만 보면 상당히 늦게 지원이 시작된 것으로 보인다.

https://youtu.be/dpw9EHDh2bM

훅의 사용은 선택적이면서 클래스로 관리되었던 컴포넌트를 함수형으로 관리할 때의 장점을 극대화 한다.

훅의 특이한 점은 반드시 컴포넌트의 최상위 부분에 선언해야 한다는 것이다. 이는 훅이 컴포넌트의 상태값을 관리하는데 깊이 관여하기 때문인데, 이에 관하여서는 바로 아래 useState 와 함께 정리한다.

useState

컴포넌트는 자신의 상태를 따로 저장하고 있다. 이를 useState 를 통해 정의하고 가져올 수 있다.

import { useState } from 'react';
import { images } from './images.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  
  function handleClick() {
    setIndex((prev) => prev % images.length);
  }
  
  return (
    <>
      <img src={images[index].url} alt={images[index].alt}/>
      <button onClick={handleClick}>Next</button>  
    </>
  );
}

다음은 Hook 없이 useState 를 구현한 코드이다.


사실 굉장히 간단하지만 약간 마법같은 일이다. 값을 바꾸니 컴포넌트가 렌더링 된다는 것은 어떻게 발생하는가? 그건 다음 링크에 자세히 나와있지만 따로 아래에 정리하도록 한다. How does React know which state to return

리액트는 같은 함수로 생성된 상태값임에도 불구하고 특정 setter 를 통해 정확한 상태값 업데이트가 가능하다. 그것이 가능한 이유는 컴포넌트 당 최상위에 각자의 상태값을 갖기 때문이다.

리액트 내부에는 각 컴포넌트마다 상태값 쌍을 위한 배열과 상태값을 가져올 현재 인덱스를 갖도록 한다.

아래 useState 를 직접 구현한 것을 보면 더 자세히 이해할 수 있다.

let componentHooks = [];
let currentHookIndex = 0;

function useState(initialState) {
  let pair = componentHook[currentHookIndex];
  if (pair) { // 현재 인덱스로 저장된 상태값이 있다면 그 상태값 반환
    
    currentHookIndex++;
    return pair;
  }
  
  pair = [initialState, setState]; // 없다면 새로 만들어줌
  
  function setState(nextState) {
    
    pair[0] = nextState;
    updateDOM(); // setter 에서 DOM 을 업데이트 하도록 함
  }
  
  componentHooks[currentHookIndex] = pair;
  currentHookIndex++;
  return pair;
}

useState 는 특히 다른 Hook 도 마찬가지지만, 컴포넌트 최상위에서만 선언이 가능함을 다시 강조한다. 특정 컴포넌트만 렌더링 하는 것으로 퍼포먼스를 상승시키고, 상태값을 분리하고 순수함수 형태로 컴포넌트를 관리할 수 있도록 하려면 훅은 컴포넌트 내 최상위에서 선언되어야 알맞다.

useReducer

요즘 대세는 단방향 아키텍처인 것 같다. 예를 들어 나는 본업이 iOS 개발자이니 단방향 아키텍처이면서 redux 의 영향을 받은 2 개의 구조를 나타내는 그림만 가져와 보았다.

(왼쪽은 ReactorKit Github, 오른쪽은 이 블로그의 TCA Deep-Dive[1] 에서 가져왔다.

2 개의 구조에 Reactor, Reducer 라는 요소를 발견할 수 있다. 이 2 요소는 공통적으로 Action 을 받는데 (Store 내부에 Action 이 정의되어 있음) Action 은 여러 개 정의될 수 있으며, 다수의 액션에 대한 동작을 Reactor, Reducer 가 정의하고 있다.

Reducer 연산자를 컴퓨터 과학에서는 동시 프로그래밍에 사용한다고 한다. 여러 개의 element 들을 하나로 만들어주는 연산자를 뜻한다. 위키에서 행렬로 이론을 설명하는 걸 보고 여기까지만 알고 싶어졌다.


리액트에서도 마찬가지이다. 미리 정의한 액션들을 토대로 똑같은 작업을 수행하는 Pure 한 reducer 함수와 reducer 의 초기 상태값을 정의한 뒤 reducer 에 명령을 보낼 수 있는 dispatch 함수를 반환한다.

내가 쓰고도 무슨 말인지 잘 모르겠으니 코드로 표현하고자 한다.

import { useReducer } from 'react';
import { data } from './cards.js';

export default function Root() {
  // dispatch 로 실행되는 reducer.
  // 현재 상태값인 state, 전달된 action 을 파라미터로 받는다. 둘 다 객체.
  function reducer(state, action) {
    switch(action.type) {
      case "setCard":
        let newCards = state.cards;
        newCards.push(action.card);
        return { ...state, newCards };
      case "removeCard:
        let newCards = state.cards;
        newCards.splice(action.index, 1);
        return { ...state, newCards };
      case "printCard":
        console.log(state.status());
        return state;
      default:
        return state;
    }
  }
  
  // dispatch 에는 액션 객체를 전달해서 작업을 수행하게 할 수 있다.
  // dispatch({ type: 'printCard' });
  // dispatch({ type: 'removeCard', index: 0 });
  const [state, dispatch] = useReducer(reducer, {
    cards: data.cards,
    status: () => {
      return `Card count is ${state.cards.length}`
    }
  });
  
  return <CardGame state={state} dispatch={dispatch}/>
}

개인적으로 이 훅은 상당히 즐겨쓴다. 위에서 정한 reducer 만 잘 테스트 된다면 이를 통해 정의된 컴포넌트도 testable 해지게 되는 것 같다고 생각한다.

useEffect

이 훅은 굉장히 의존성과 관련이 많다. 만약 이 훅이 선언된 컴포넌트가 추가되면 이 훅의 셋업 함수가 실행되는데, 이외에도 훅의 동작방식이 굉장히 특이하다.

useEffect(setup, dependencies?);
  1. 최초 컴포넌트가 추가되면 setup 함수가 실행된다.
  2. dependencies 는 옵셔널 형태의 의존성으로 전달된 상태값이 바뀌게 되면(useState 의 setter) setup 함수부터 다시 실행하게 된다.
  3. setup 함수는 cleanup 함수를 반환할 수 있다. cleanup 함수는 의존성 변경으로 인해 다시 실행되는 setup 함수 실행 전에 정리 작업을 할 수 있도록 해준다.

리액트 공식문서의 예제 코드가 아주 좋다. 사실 나도 네트워크 연결보다는 컴포넌트 트리를 완전 리프레쉬할 때 말고는 써본 적이 없다.

import { useEffect, useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');
  
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    conntion.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);
  // ...
}

만약 setServerUrl 이 호출되어 URL 이 바뀐다면 connection.disconnect() 를 호출 후에 다시 connection.connect() 하게 된다.

createContext, useContext

리액트에서 컨텍스트란 컴포넌트와 완전히 분리된 위치에서 선언되는 객체이며, 컴포넌트는 이 컨텍스트의 Provider (Consumer 도 있지만 잘 사용하지 않음) 내에 있다면 이를 읽고 수정할 수 있다.

컴포넌트들을 분리한 뒤 특정 컴포넌트들끼리 공유하는 값을 관리하고 싶을 때 유용하다.

import ThemeContext from './context.js';

function App() {
  const [theme, setTheme] = useState('light');
  // ...
  return (
    <ThemeContext.Provier value={ theme, setTheme }>
      <Page/>
    </ThemeContext.Provider>
  );
}

function Page() {
  let { theme, setTheme } = useContext(ThemeContext);
  function toggleTheme() {
    setTheme((prev) => prev === 'light' ? 'dark' : 'light');
  }
  return (
    <div>
      <Contents theme={theme}/>
      <button onClick={toggleTheme}>toggle theme</button>
    </div>
  )
}

Page 는 ThemeContext 의 Provider 의 트리 내에 속해있기 때문에 useContext 를 통해서 상태를 읽을 수도 있고, 수정할수도 있다.

출처

profile
plug-compatible programming unit

0개의 댓글