[redux-saga] redux-saga 망라하기

김혜지·2020년 12월 5일
5
post-custom-banner

docs(KO)
docs(EN)
docs/api(EN)
redux-saga로 비동기처리와 분투하다
Redux-saga: 제너레이터와 이펙트

redux-saga

redux-saga는 애플리케이션의 side effect 를 보다 쉽게 관리하고, 실행하기 쉽고, 테스트하기 쉬우 며, 오류를 더 잘 처리하는 것을 목표로 하는 라이브러리입니다.

Mental model은 saga가 side effect에 대한 책임이 있는 애플리케이션의 별도 스레드와 같다는 것입니다. redux-saga는 redux 미들웨어이기 때문에,이 스레드는 정상적인 redux 작업으로 메인 애플리케이션에서 시작, 일시 중지 및 취소 할 수 있으며 전체 redux 애플리케이션 상태에 액세스 할 수 있으며 redux 작업도 전달(dispatch)할 수 있습니다.

Generators라는 ES6 기능을 사용하여 이러한 비동기 흐름을 쉽게 읽고, 쓰고, 테스트 할 수 있습니다. 이렇게하면 비동기 흐름이 표준 동기 JavaScript 코드처럼 보입니다. (async/await와 비슷하지만 Generator는 우리가 필요로하는 몇 가지 멋진 기능을 더 가지고 있습니다)

비동기 작업을 처리하기 위해 이전에 redux-thunk를 사용했을 수 있습니다. redux thunk와는 달리 콜백 지옥으로 끝나지 않고 비동기 흐름을 쉽게 테스트 할 수 있으며 작업은 순수하게 유지됩니다.

Side effect: data fetching와 같은 비동기식 작업 또는 브라우저 캐시 액세스와 같은 순수하지 않은 동작

Quick Start

프로젝트가 이미 React, Redux로 이루어져있고, redux-saga 를 추가한다고 가정하겠습니다.

폴더 구조는 이렇습니다.

...
/store
  /reducer
  /saga
    |index.js
    |exampleSaga.js
  |index.js
App.jsx

/store/saga/exampleSaga.js

import { delay } from 'redux-saga'
import { put, takeEvery } from 'redux-saga/effects'

// worker Saga: 비동기 증가 태스크를 수행할겁니다.
export function* incrementAsync() {
  yield delay(1000)
  yield put({ type: 'INCREMENT' })
}

// watcher Saga: 각각의 INCREMENT_ASYNC 에 incrementAsync 태스크를 생성할겁니다.
export function* watchIncrementAsync() {
  yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}

/store/saga/index.js

// 모든 Saga들을 한번에 시작하기 위한 단일 entry point 입니다.
export default function* rootSaga() {
  yield all([
    helloSaga(),
    watchIncrementAsync()
  ])
}

/store/index.js

// ...
import rootSaga from './sagas'

const sagaMiddleware = createSagaMiddleware()
const store = ...
sagaMiddleware.run(rootSaga)

// ...

들어가며 (Generator and Effect)

reference

들어가기에 앞서서 어려운 개념을 먼저 짚어보자.

Generator

앞서 말했듯이 redux-saga 에서는 Generator 문법을 사용한다. 그리고 redux-saga 에서 말하는 saga는 바로 Generator function이다. (Generator가 아니다) saga를 만들어 redux-saga 미들웨어에 등록하면 이 미들웨어가 generator function 으로부터 만들어진 generator를 계속 실행하여 산출된 모든 effect를 실행한다. 즉, saga는 yield 값만을 반환하기만 하고 미들웨어가 이 값(effect)를 받아서 실행하는 역할을 맡는다. saga는 단순히 반환만 하기 때문에 (직접 비동기 처리를 하지 않음) 테스트가 훨씬 용이해진다.

Effect

그럼 Effect는 무엇인가? saga가 반환하는 값이자 미들웨어가 실행할 명령을 담고 있는 자바스크립트 객체라고 생각하면 된다. 이를 통해 saga가 비동기 처리를 하지 않고 미들웨어에게 실행의 책임을 떠넘길 수 있는 것이다. 정리하자면, Saga는 명령을 담고 있는, 이펙트라 부르는 순수한 객체를 yield 할 것이고, 미들웨어는 이런 명령들을 해석해 처리하고, 그 결과를 다시 Saga에 돌려준다. 바로 위 reference 에서 가져온 내용이며 정리가 훨씬 잘되어있으니 꼭 읽어보길 바란다.

Task

하나의 saga가 실행되는 것을 task라고 부른다.

기본 개념

헬퍼 함수 (effect creators)

redux-saga에서는 Task를 만들기 위해 내부 함수를 감싸는 몇몇 helper 함수를 제공한다. (effect creators라고도 부른다.)

functiondescription
takeEvery(action, sagaFn)action이 발생할 때마다 Task(sagaFn)가 실행되게 한다. 여러 개의 Task를 동시에 시작할 수 있다. redux-thunk와 비슷한 기능이다.
takeLast(action, sagaFn)마지막으로 발생한 하나의 action에만 Task(sagaFn)가 실행되게 한다. 실행중이던 Task는 action이 발생하면 취소되고 새로운 Task가 실행된다.

서술적 이벤트

Saga 로직을 표현하기 위해서 saga는 순수 Javascript object를 yield한다. 이런 object를 effect라고 부른다. effect란 미들웨어에 의해 해석되는 몇몇 정보들을 담고있는 간단한 객체이다. 어떤 기능을 수행하기 위해 미들웨어에 전해지는 명령(스토어에 액션을 dispatch하는 행위나 비동기 함수를 호출하는 등)이라고 볼 수 있다.

effectredux-saga/effects 에서 제공하는 함수(effect creator)로 만들어진다. 대표적인 effect creator는 아래와 같다.

functiondescription
selectstate에서 필요한 데이터를 꺼낸다.
putAction을 dispatch한다.
takeAction/이벤트 발생을 기다린다.
call(fn, ...args)Promise의 완료를 기다린다. apply 함수와 동일하다.
fork다른 Task를 시작한다.
join다른 Tack의 종료를 기다린다.

3. Advanced Concepts

3.1 Pulling future actions (take)

위에서 살펴봤던 takeEvery 함수를 사용하는 것은 redux-thunk와 유사하다. 사실, takeEverytakefork 함수를 사용한 high-level API에 불과하다.

const takeEvery = (patternOrChannel, saga, ...args) => fork(function*() {
  while (true) {
    const action = yield take(patternOrChannel)
    yield fork(saga, ...args.concat(action))
  }
})

take는 특정한 액션이 dispatch될 때까지 기다린다. takeEvery의 경우, 실행된 태스크는 그들이 다시 실행될 때에 대한 관리 방법이 없다. take의 경우 액션이 푸시(push)되는 대신, saga 스스로 액션을 풀링(pulling)하기 때문에 특별한 컨트롤 프로우를 수행할 수 있게 한다. 전통적인 액션의 푸시 접근법을 해결하는 것이다.

이 pulling 접근법은 동기적(synchronous) 스타일로 컨트롤 플로우를 표현할 수 있게 한다. 예를 들어 LOGIN, LOGOUT 액션을 이용하여 로그인 플로우를 만들고 싶을 때 takeEvery를 이용하면 두 개의 테스크(saga)를 작성해야 했을 것이다. 하지만 take를 사용해 하나의 태스크로 만들 수 있다.

function* loginFlow() {
  while (true) {
    yield take('LOGIN')
    // ... perform the login logic
    yield take('LOGOUT')
    // ... perform the logout logic
  }
}

3.2 non-blocking calls (fork)

saga는 effect creators 함수를 통해 blocking 하게 작동한다. 이 때문에 발생하는 몇 가지 문제점이 있어 non-blocking을 지원하기 위한 fork effect creator를 제공한다. Task를 fork한다면 그 테스크는 백그라운드에서 시작되고, 호출자는 fork된 테스크가 종료될 때까지 기다리지 않고 플로우를 계속해서 진행한다. yield fork는 task object 를 반환하기 때문에 후에 테스크 취소가 가능하다.

3.3 Running tasks in parallel (all)

병렬 처리가 필요한 경우 all 을 사용한다.

import { all, call } from 'redux-saga/effects'

// correct, effects will get executed in parallel
const [users, repos]  = yield all([
  call(fetch, '/users'),
  call(fetch, '/repos')
])

위와 같이 effectdml 배열을 yield하면, 제너레이터는 모든 effect들이 resolve되거나, 어느 하나라도 reject될 때까지 봉쇄(blocked)된다.

3.4 Starting a race between multiple Effects (race)

여러 task를 병렬로 시작하지만, 그 task를 전부 기다리고 싶지 않을 때가 있다. 이 경우는 race 를 사용한다. race는 첫 번째로 resolve(or reject)된 task가 나오면 나머지 task를 자동으로 취소시킨다.

3.11 Connecting Sagas to external Input/Output

take 는 스토어에 dispatch될 액션이 들어오면 resolve 되었다. 그리고 put은 액션을 인자로 dispatch함으로써 resolve된다. saga가 시작될 때 미들웨어는 자동으로 take/put을 스토어와 연결한다. 이 두 이펙트는 saga의 입력/출력처럼 보일 수 있다.

redux-saga는 리덕스 미들웨어 환경 바깥에서 사가를 실행하고 커스텀 입/출력에 연결할 수 있는 방법을 제공한다.

import { runSaga } from 'redux-saga'

function* saga() { ... }

const myIO = {
  subscribe: ..., // this will be used to resolve take Effects
  dispatch: ...,  // this will be used to resolve put Effects
  getState: ...,  // this will be used to resolve select Effects
}

runSaga(
  saga(),
  myIO
)

3.12 Using Channels

채널은 외부의 이벤트 소스 또는 사가 간의 통신을 위해 해당 이펙트를 일반화한다. 또한 스토어에서 특정 작업을 대기열에 넣을 때도 사용할 수 있다.

이 장에서, 다음 내용을 살펴볼 것이다.

  • yield actionChannel 이펙트를 이용해 스토어의 특정 액션을 버퍼링하는 방법
  • eventChannel 팩토리함수를 사용하여 take 이펙트를 외부 이벤트 소스에 연결하는 방법
  • 일반 channel 팩토리 함수를 이용하여 채널을 만드는 방법과 사가 간의 통신을 위해 take/put 이펙트에 이를 사용하는 방법

actionChannel effect 사용하기

import { take, fork, ... } from 'redux-saga/effects'

function* watchRequests() {
  while (true) {
    const {payload} = yield take('REQUEST')
    yield fork(handleRequest, payload)
  }
}

function* handleRequest(payload) { ... }

위 예제는 전형적인 watch와 fork 패턴이다. 짧은 시간에 많은 액션이 들어온다면 동시에 많은 haneldRequest 테스크가 실행될 것이다. 근데 우리는 이 테스크를 순차적으로 처리하고 싶다. 그래서 우리는 아직 처리되지 않은 액션을 대기열에 집어넣고, 현재 요청을 모두 처리했다면 대기열에서 다음 것을 가져올 것이다. 이를 actionChannel을 사용해 구현할 수 있다.

import { take, actionChannel, call, ... } from 'redux-saga/effects'

function* watchRequests() {
  // 1- Create a channel for request actions
  const requestChan = yield actionChannel('REQUEST')
  while (true) {
    // 2- take from the channel
    const {payload} = yield take(requestChan)
    // 3- Note that we're using a blocking call
    yield call(handleRequest, payload)
  }
}

function* handleRequest(payload) { ... }
  1. actionChannel을 생성한다. take와의 차이점은, saga가 아직 그 액션을 처리할 준비가 되지 않았다면 actionChannel은 들어오는 메시지(액션)을 버퍼링할 수 있다는 것이다.
  2. 스토어에서 특정 액션을 받기 위해 take(pattern) 에 넣을 패턴을 사용한 것처럼, take(channel) 도 가능하다. take는 메시지를 받을 수 있을 때만 saga를 봉쇄할 것이다. 채널 버퍼에 메시지가 저장되어 있을 경우에만 봉쇄되지 않고 진행할 것이다.
  3. saga는 call(handleRequest)가 반환될 때까지 봉쇄를 유지할 것이다. 하지만 봉쇄되어 있는 중에 다른 REQUEST 액션이 dispatch된다면, 그것은 채널의 버퍼에 저장될 것이다. saga가 call(handleRequest)에 의해 재개되고 다음 yield take(requestChan)이 실행될 때, take는 대기열에 저장된 메시지를 resolve할 것이다.

기본적으로 actionChannel은 제한 없이 버퍼링이 가능하나 버퍼 인자를 주어 제한할 수도 있다.

eventChannel 팩토리를 사용해 외부 이벤트에 연결하기

eventChannel(effect creator가 아닌 팩토리 함수)는 리덕스 스토어가 아닌 외부 이벤트를 위한 채널을 생성한다.

이 예제는 일정한 간격마다 채널을 생성한다.

import { eventChannel, END } from 'redux-saga'

function countdown(secs) {
  return eventChannel(emitter => {
      const iv = setInterval(() => {
        secs -= 1
        if (secs > 0) {
          emitter(secs)
        } else {
          // this causes the channel to close
          emitter(END)
        }
      }, 1000);
      // The subscriber must return an unsubscribe function
      return () => {
        clearInterval(iv)
      }
    }
  )
}

eventChannel의 첫 번째 인자는 구독자(subscriber) 함수이다. 구독자의 역할은 외부의 이벤트 소스를 초기화하고 (위의 setInterval 사용), 제공된 emitter를 실행하여 소스에서 채널로 들어오는 모든 이벤트를 라우팅한다. 위의 예제에서 우리는 매 초마다 emitter를 호출한다.

주의: 이벤트 채널을 통해 null 또는 undefined를 전달하지 않도록 해야한다. 숫자를 전달하는 것이 좋지만, 이벤트 채널 데이터를 리덕스 액션처럼 구조화하는 것을 추천한다. number를 { number }로 바꾸는 것과 같이.

emitter(END) 호출에도 주의해야 한다. 우리는 채널 소비자에게 채널이 폐쇄되었다는 것을 알리기 위해 사용한다. 이는 더 이상 다른 메시지가 이 채널을 통해 올 수 없다는 것을 의미한다.

우리의 사가에서 이 채널을 어떻게 쓰는지 보자 (이 예제는 저장소(repository)의 cancellable-counter 예제에서 가져왔습니다.)

import { take, put, call } from 'redux-saga/effects'
import { eventChannel, END } from 'redux-saga'

// creates an event Channel from an interval of seconds
function countdown(seconds) { ... }

export function* saga() {
  const chan = yield call(countdown, value)
  try {    
    while (true) {
      // take(END) will cause the saga to terminate by jumping to the finally block
      let seconds = yield take(chan)
      console.log(`countdown: ${seconds}`)
    }
  } finally {
    console.log('countdown terminated')
  }
}

사가는 take(chan)를 yield하고 있다. 메시지가 채널에 들어가기 전까지 사가는 봉쇄된다. 위의 예제에서, 이는 emitter(secs)를 호출할 때와 일치한다. 우리가 try/finally 구역 내에서 전체 while(true) {...}를 실행하고 있는 것에 주목해보자. countdown의 interval이 종료되면, countdown 함수는 emitter(END)를 호출함으로써 이벤트 채널을 폐쇄한다. 채널을 닫으면 그 채널에서 take에 봉쇄된 모든 사가들을 종료시키는 효과가 있다. 예제에서, 사가를 종료하면 finally 구간으로 점프하게 된다 (finally 구간이 없으면 그냥 종료된다).

구독자는 unsubscribe 함수를 반환한다. 이것은 이벤트 소스가 완료되기 전에 채널 구독을 취소하는 데에 사용된다. 이벤트 채널의 메시지를 소비하는 사가 내에서 이벤트 소스가 완료되기 전에 일찍 나가기를 원한다면 (예로, 사가가 취소됨) chan.close()를 호출해 채널을 폐쇄하고 구독을 취소할 수 있다.

여기 웹 소켓 이벤트를 사가에 전달하여 이벤트 채널을 사용하는 또 다른 예제가 있다. ping이라는 서버 메시지를 기다리고 있고, 조금 뒤에 pong이라는 메시지로 답한다고 가정해보자.

import { take, put, call, apply } from 'redux-saga/effects'
import { eventChannel, delay } from 'redux-saga'
import { createWebSocketConnection } from './socketConnection'

// this function creates an event channel from a given socket
// Setup subscription to incoming `ping` events
function createSocketChannel(socket) {
  // `eventChannel` takes a subscriber function
  // the subscriber function takes an `emit` argument to put messages onto the channel
  return eventChannel(emit => {

    const pingHandler = (event) => {
      // puts event payload into the channel
      // this allows a Saga to take this payload from the returned channel
      emit(event.payload)
    }

    // setup the subscription
    socket.on('ping', pingHandler)

    // the subscriber must return an unsubscribe function
    // this will be invoked when the saga calls `channel.close` method
    const unsubscribe = () => {
      socket.off('ping', pingHandler)
    }

    return unsubscribe
  })
}

// reply with a `pong` message by invoking `socket.emit('pong')`
function* pong(socket) {
  yield call(delay, 5000)
  yield apply(socket, socket.emit, ['pong']) // call `emit` as a method with `socket` as context
}

export function* watchOnPings() {
  const socket = yield call(createWebSocketConnection)
  const socketChannel = yield call(createSocketChannel, socket)

  while (true) {
    const payload = yield take(socketChannel)
    yield put({ type: INCOMING_PONG_PAYLOAD, payload })
    yield fork(pong, socket)
  }
}

주의: eventChannel의 메시지는 기본적으로 버퍼링되지 않는다. 채널의 버퍼링 전략을 지정하려면 eventChannel 팩토리에 버퍼를 인수로 넣어줘야 한다 (예: eventChannel(subscriber, buffer)).

saga간 통신에 채널 사용하기

기본적으로 어떤 소스에도 연결되지 않은 채널을 직접 생성할 수 있다. 그런 다음 채널에 수동으로 put 할 수 있다. 이는 사가 간에 통신을 하기 위해 채널을 사용할 때 유용하다.

3.13 Root Saga Patterns

root Saga는 sagaMiddleware가 실행될 수 있도록 여러 Sagas를 단일 entry point으로 합친다. 초급 튜토리얼에서 루트 사가는 다음과 같이 보일 것입니다.

export default function* rootSaga() {
  yield all([
    helloSaga(),
    watchIncrementAsync()
  ])
  // code after all-effect
}

이것은 루트를 구현하는 몇 가지 방법 중 하나입니다. 여기서 all effect는 배열과 함께 사용되며 sagas는 병렬로 실행됩니다. 다른 루트 구현은 오류 및 더 복잡한 데이터 흐름을 더 잘 처리하는 데 도움이 될 수 있습니다.

Non-blocking fork effects

contributor @slorber가 issue #760에서 몇 가지 다른 일반적인 루트 구현을 언급했습니다. 시작하려면 위 예제와 유사하게 작동하는 인기있는 구현이 하나 있습니다.

export default function* rootSaga() {
  yield fork(saga1)
  yield fork(saga2)
  yield fork(saga3)
  // code after fork-effect
}

세 개의 고유한 yield fork를 사용하면 task descriptor가 세 번 반환됩니다. 앱의 결과 동작은 모든 하위 사가가 동일한 순서로 시작되고 실행된다는 것입니다. 포크가 차단되지 않기 때문에 rootSaga는 자식 무용담이 계속 실행되고 내부 효과에 의해 차단되는 동안 완료 될 수 있습니다.

하나의 큰 모든 effect와 여러 개의 포크 effect의 차이점은 all effect가 blocking이라는 것이다. 따라서 all-effect 이후의 코드는 모든 자식 sagas가 완료 될 때 실행되는 반면, 포크 효과는 차단되지 않으므로 이후 코드 포크 효과는 포크 효과를 산출 한 직후에 실행됩니다. 또 다른 차이점은 포크 효과를 사용할 때 작업 설명자를 얻을 수 있다는 것입니다. 따라서 후속 코드에서 작업 설명자를 통해 분기 된 작업을 취소/결합 할 수 있습니다.

Nesting fork effects in all effect

const [task1, task2, task3] = yield all([ fork(saga1), fork(saga2), fork(saga3) ])

루트 사가를 디자인 할 때 또 다른 인기있는 패턴이 있습니다. 모든 효과의 중첩 포크 효과입니다. 이렇게하면 작업 설명 자의 배열을 얻을 수 있으며 각 포크 효과가 non-blocking이고 동기적으로 작업 설명자를 반환하기 때문에 모든 effect 이후의 코드가 즉시 실행됩니다.

fork 효과는 all 효과에 중첩되지만 항상 기본 forkQueue를 통해 상위 작업에 연결됩니다. 분기 된 작업에서 포착되지 않은 오류는 상위 작업으로 버블링되므로 중단(및 모든 하위 작업)-상위 작업에서 포착 할 수 없습니다.

Avoid nesting fork effects in race effect

// DO NOT DO THIS. The fork effect always win the race immediately.
yield race([
  fork(someSaga),
  take('SOME-ACTION'),
  somePromise,
])

반면에 레이스 효과의 포크 효과는 버그 일 가능성이 높습니다. 위의 코드에서 포크 효과는 차단되지 않기 때문에 항상 즉시 레이스에서 승리합니다.

Keeping the root alive

실제로 rootSaga가 개별 하위 효과 또는 saga의 첫 번째 오류에서 종료되고 전체 앱이 충돌하므로 이러한 구현은 그다지 실용적이지 않습니다! 특히 Ajax 요청은 앱이 요청하는 모든 엔드 포인트의 상태에 따라 앱을 결정합니다.

spawn은 부모와 자식 사가의 연결을 끊는 효과로 부모와 충돌하지 않고 실패 할 수 있습니다. 분명히 이것은 오류가 발생할 때에도 여전히 처리해야하는 개발자로서의 우리의 책임에서 벗어나지 않습니다. 실제로 이로 인해 개발자의 관점에서 특정 실패가 가려지고 향후 문제가 발생할 수 있습니다.

스폰 효과는 React의 Error Boundaries와 유사한 것으로 간주 될 수 있는데, 이는 saga 트리의 특정 수준에서 추가 안전 조치로 사용되어 실패한 기능을 차단하고 전체 앱이 충돌하지 않도록 할 수 있다는 점입니다. 차이점은 React Error Boundaries에 대해 존재하는 componentDidCatch와 같은 특별한 구문이 없다는 것입니다. 여전히 자체 오류 처리 및 복구 코드를 작성해야합니다.

export default function* rootSaga() {
  yield spawn(saga1)
  yield spawn(saga2)
  yield spawn(saga3)
}

이 구현에서는 한 사가가 실패하더라도 rootSaga와 다른 사가가 죽지 않습니다. 그러나 실패한 사가는 앱의 수명 동안 사용할 수 없기 때문에 문제가 될 수도 있습니다.

Keeping everything alive

어떤 경우에는 실패시 sagas를 다시 시작할 수있는 것이 바람직 할 수 있습니다. 장점은 앱과 sagas가 실패한 후에도 계속 작동 할 수 있다는 것입니다 (예 : takeEvery (myActionType)을 생성하는 saga). 그러나 우리는 이것을 모든 사가를 살리기위한 포괄적 인 해결책으로 권장하지 않습니다. 무용담이 정확하고 예측 가능하게 실패하고 오류를 처리 / 기록하는 것이 더 합리적 일 가능성이 높습니다.

예를 들어 @ajwhite는이 시나리오를 사가를 살아있게 유지하면 해결하는 것보다 더 많은 문제가 발생하는 경우를 제공했습니다.

function* sagaThatMayCrash () {
  // wait for something that happens _during app startup_
  yield take(APP_INITIALIZED)

  // assume it dies here
  yield call(doSomethingThatMayCrash)
}

sagaThatMayCrash가 다시 시작되면 다시 시작되고 응용 프로그램이 시작될 때 한 번만 발생하는 작업을 기다립니다. 이 시나리오에서는 다시 시작되지만 복구되지 않습니다.

그러나 시작으로 이익을 얻을 수있는 특정 상황에 대해 user @granmoe는 issue #570에서 다음과 같은 구현을 제안했습니다.

function* rootSaga () {
  const sagas = [
    saga1,
    saga2,
    saga3,
  ];

  yield all(sagas.map(saga =>
    spawn(function* () {
      while (true) {
        try {
          yield call(saga)
          break
        } catch (e) {
          console.log(e)
        }
      }
    }))
  );
}

이 전략은 우리의 sagas를 try 블록의 하위 작업으로 시작하는 생성 된 생성기 (루트 부모에서 분리)에 자식 sagas를 매핑합니다. 우리의 saga는 종료 될 때까지 실행 된 다음 자동으로 다시 시작됩니다. catch 블록은 우리의 사가에서 던지고 종료되었을 수있는 모든 오류를 무해하게 처리합니다.

profile
Developer ( Migrating from https://hyex.github.io/ )
post-custom-banner

0개의 댓글