[번역] Node.js의 이벤트 루프

Sonny·3일 전
10

Article

목록 보기
29/29
post-thumbnail

Node.js의 이벤트 루프

Nodejs 내부 자세히 알아보기 (블로킹, 논 블로킹 IO, event loop, nextTick, promises)

원문: https://medium.com/@manikmudholkar831995/event-loop-in-nodejs-999f6db7eb04

Manik Mudholkar (시니어 소프트웨어 엔지니어) 작성

이 글은 시니어 엔지니어를 위한 고급 Node.js 시리즈의 세 번째 글입니다. 이 글에서는 Node.js의 이벤트 루프가 무엇인지, 작동 방식에 대해 자세히 설명하겠습니다. 시니어 엔지니어를 위한 고급 Node.js 시리즈의 다른 글은 아래에서 확인할 수 있습니다.

시리즈 로드맵

목차

  • Node.js의 이벤트 루프
  • process.nextTick과 promise 콜백
  • I/O 폴링
  • 실습 예제
    • setTimeout
    • setTimeout(0)
    • setTimeout(0)이지만 다른 호출이 블로킹되는 경우
    • setTimeout과 setImmediate
    • fs 콜백 내부의 setTimeout과 setImmediate
    • process.nextTick과 Promise
    • process.nextTick 중첩
    • process.nextTick, promise, setTimeout
    • I/O, process.nextTick, promise, setTimeout, setImmediate

이 글에서는 이벤트 루프에 대해 깊이 있게 설명할 예정입니다. 혹시 여러분이 초보자임에도 이 내용을 이해할 수 있다면, 정말 대단하신 겁니다. 저 또한 시니어 엔지니어로서 이 글을 쓰면서 그간 제가 가지고 있던 여러 오해들을 바로잡는 데 큰 도움을 받았습니다. 자바스크립트의 이벤트 루프는 처음 배울 때부터 매우 추상적으로 다뤄지기 때문에, Node.js로 넘어올 때도 이러한 오해가 그대로 이어지기 쉽습니다. 게다가 인터넷에는 잘못된 다이어그램도 많아서, 잘못된 개념을 익히기가 더욱 쉬운 환경이기도 합니다.

Node.js의 이벤트 루프

이벤트 루프는 처리할 이벤트가 없을 때까지 계속 실행된다는 점에서 흔히 "반 영구 루프(semi-infinite loop)"라고 불립니다. 즉, 이벤트 루프가 살아있는 동안 활성화된 핸들이나 요청이 있는 한 멈추지 않고 계속 실행됩니다.

핸들(Handles): 타이머, 시그널, TCP/UDP 소켓처럼 오랫동안 살아 있는(long-lived) 객체를 나타냅니다. 작업이 완료되면 핸들은 적절한 콜백을 호출합니다. 이벤트 루프는 하나라도 활성화되어 있는 핸들이 있는 한 계속 실행됩니다.

요청(Requests): 파일 읽기/쓰기나 네트워크 연결 수립 등과 같이 비교적 단기간에 이루어지는(short-lived) 작업을 나타냅니다. 핸들과 마찬가지로, 활성화된 요청이 있으면 이벤트 루프는 계속 실행됩니다.

loop process

https://docs.libuv.org/en/v1.x/design.html

  1. 각 루프 반복이 시작될 때마다 이벤트 루프는 현재 시간(now)을 계산하여 해당 반복 전체에서 참조값으로 사용합니다. 이렇게 계산된 시간은 시스템 콜의 빈도를 줄이기 위해 캐싱됩니다.

  2. 만약 루프가 UV_RUN_DEFAULT로 실행되었다면, 타이머들이 실행됩니다. 이 시점에는 setTimeout 또는 setInterval 같은 함수로 예약된 콜백들이 별도의 큐에 쌓여 순서대로 실행됩니다.

  3. 참조된 핸들, 활성화된 요청 또는 종료 중인 핸들이 있는지를 확인하여 루프의 생존 여부를 판단합니다.

  4. 대기 중인 콜백들이 호출됩니다. 대부분의 I/O 콜백은 I/O를 확인한 직후에 바로 호출되지만, 상황에 따라 콜백 호출이 다음 루프 반복으로 연기되는 경우도 있습니다. 이전 반복에서 연기된 I/O 콜백이 있었다면 이 단계에서 실행됩니다.

  5. idle 핸들 콜백들이 호출됩니다. 오해의 소지가 있는 이름과는 달리, idle 핸들은 활성화되어 있다면 이벤트 루프의 각 반복마다 실행됩니다. 이러한 콜백들은 이벤트 루프가 시급한 작업에 묶여 있지 않을 때, 우선순위가 낮은 작업을 처리하기 위해 활용됩니다. idle 핸들은 즉각적인 대응이나 특정 이벤트에 대한 응답이 필요하지 않지만 정기적으로 실행이 필요한 작업에는 유용합니다.

  6. I/O 폴링을 하기 전에, 데이터 구조나 설정을 업데이트하는 것과 같은 필요한 작업을 수행하기 위해 prepare 핸들 콜백들이 실행됩니다.

  7. 루프에서 I/O 블로킹 전에 poll 타임아웃이 계산됩니다. 타임아웃 계산 규칙은 다음과 같습니다.

  • 루프가 UV_RUN_NOWAIT 플래그로 실행되었거나, 루프가 중지될 예정이거나(uv_stop()이 호출됨), 활성화된 핸들이나 요청이 없거나, 활성화된 idle 핸들이 있거나, 종료 대기 중인 핸들이 있는 경우에는 타임아웃이 0으로 설정됩니다.
  • 위의 경우들 중 어느 것도 해당하지 않으면, 타임아웃은 가장 가까운 타이머가 만료될 때까지의 시간으로 설정됩니다. 만약 활성화된 타이머가 없다면 타임아웃은 무한대로 설정됩니다.
  1. 루프는 I/O를 처리하기 위해 블로킹됩니다. 이 시점에서 루프는 이전 단계에서 계산된 시간만큼 I/O를 기다립니다. 특정 파일 디스크립터의 읽기나 쓰기 작업을 감시하던 모든 I/O 관련 핸들의 콜백들은 이때 실행됩니다.

  2. I/O 폴링이 끝난 뒤, 핸들 콜백이 즉시 실행되어 setImmediate 콜백을 처리합니다.

  3. Close 콜백들이 실행됩니다. 이 콜백들은 libuv가 활성 핸들을 정리할 때 실행되도록 예약되어 있습니다.

  4. 루프의 '현재 시간(now)' 값이 갱신됩니다.

  5. 반복이 종료됩니다.

Min heap은 힙에서 최소값에 빠르고 쉽게 접근할 수 있도록 보장하는 자료 구조입니다. 따라서 가장 가까운 만료 시간을 가진 타이머에 더 쉽게 접근할 수 있습니다.

흥미로운 점은 Node.js의 타이머 하나가 libuv 타이머 하나와 일대일로 대응되지 않는다는 것입니다. 그 이유는 타이머가 일대일로 매핑되면 가비지 컬렉터가 과도하게 동작할 수 있기 때문입니다. 따라서 같은 시간에 실행되어야 하는 타이머가 두 개 이상 있다면, 이들을 하나의 libuv 타이머로 처리합니다. 아래 예제와 같이 2개의 Node.js 타이머가 있을 경우, libuv 타이머는 하나로 처리됩니다.

setTimeout(() => {}, 50);
setTimeout(() => {}, 50);

따라서 이러한 단계들은 여러분이 생각하기에 따라 단계나 큐로 좁혀질 수있으며, 각 박스는 이벤트 루프의 "단계(phase)"로 지칭될 것입니다.

phase

https://nodejs.org/en/guides/event-loop-timers-and-nexttick

process.nextTickpromise 콜백

이제까지는 매크로태스크에 대해 이야기했는데, process.nextTick()promise callbacks 같은 마이크로태스크는 어떻게 될까요? 다이어그램에 process.nextTick()이 표시되지 않은 이유는 process.nextTick()이 기술적으로 이벤트 루프의 일부가 아니기 때문입니다. 대신 nextTickQueue는 이벤트 루프의 현재 단계와 관계없이 현재 작업이 완료된 직후에 처리됩니다. 여기서 작업이란 기반이 되는 C/C++ 핸들러에서 자바스크립트 실행까지 마치는 전체 과정을 의미합니다.

process.nextTick()은 현재 작업이 완료된 직후, 이벤트 루프가 다음 단계로 넘어가기 전에 콜백 함수를 즉시 실행할 수 있게 해주는 함수입니다. 하지만 재귀적으로 process.nextTick()을 호출하면 이로 인해 이벤트 루프가 poll 단계에 도달하는 것을 방해하여 I/O를 "굶기는" 좋지 않은 상황을 만들 수 있습니다.

process.nextTick() 사용 시기는 다음과 같습니다.

  1. process.nextTick()의 주요 용도는 다른 대기 중인 작업들을 기다리지 않고 즉시 실행해야 하는 시급하거나 우선순위가 높은 작업을 빠르게 처리하기 위함입니다.
  2. 이벤트 루프가 계속되기 전에 사용자가 오류를 처리하고 불필요한 자원을 정리하거나, 필요한 경우 요청을 다시 시도할 수 있도록 해줍니다.
  3. 때로는 콜 스택이 모두 해제된 뒤, 이벤트 루프가 다음 단계로 넘어가기 전에 콜백을 실행해야 할 필요가 있습니다.

출력 순서 측면에서, process.nextTick()의 콜백들은 항상 Promise 콜백들보다 먼저 실행됩니다.

  • process.nextTick()은 같은 단계에서 즉시 실행됩니다.
  • setImmediate()는 이벤트 루프의 다음 반복 또는 '틱'에서 실행됩니다.

개념 다이어그램은 다음과 같이 보일 것입니다.

event loop

I/O 폴링

몇 가지 예제를 살펴보겠습니다.

예제 1)

const fs = require('fs')

setTimeout(() => {
  console.log('hello');
}, 0);
fs.readFile('./AWS Migration.txt', () => {
  console.log('world');
});
setImmediate(() => {
  console.log('immediate');
});

for (let index = 0; index > 2000000000; index++) {}
hello
immediate
world

'world'가 먼저 출력될 것이라고 예상하셨을 텐데요. 단계별로 살펴보겠습니다.

  1. 먼저 for 루프와 같은 동기적인 사용자 코드가 실행됩니다.
  2. 이벤트 루프는 타이머 콜백을 실행하면서 해당 타이머가 완료되어 실행할 준비가 되었음을 발견합니다. 그 결과, setTimeout이 실행되어 콘솔에 "hello"가 출력됩니다.
  3. 그 후 이벤트 루프는 I/O 콜백 단계로 넘어갑니다. 이 시점에서 파일 읽기 작업은 이미 완료되었지만, I/O 콜백은 오직 I/O 폴링 단계에서만 큐에 추가되기 때문에 아직 실행 대기 상태로 표시되지 않은 상태입니다. 즉, 파일 읽기가 완료되었더라도 이벤트 루프가 I/O 폴링 단계에 도달하기 전까지는 콜백이 I/O 큐에 들어가지 않기 때문에 I/O 큐는 여전히 비어있습니다. 이 시점에서 readFile() 콜백 이벤트가 수집되어 I/O 큐에 추가되지만, 아직 실행되지는 않습니다. 실행 준비는 되어있지만, 이벤트 루프는 다음 사이클에 이를 실행합니다.
  4. 다음 단계로 넘어가면서 이벤트 루프는 setImmediate() 콜백을 실행합니다. 콘솔에는 "immediate"가 출력됩니다.
  5. 이벤트 루프는 다시 처음부터 시작합니다. 더 이상 실행할 타이머가 없으므로 "대기 중인 콜백 호출 단계"로 이동하여 마침내 readFile() 콜백을 찾아 실행합니다. 콘솔에는 "world"가 출력됩니다.

예제 2)

const fs = require('fs')
const now = Date.now();
setTimeout(() => {
  console.log('hello');
}, 50);
fs.readFile(__filename, () => {
  console.log('world');
});
setImmediate(() => {
  console.log('immediate');
});
while(Date.now() - now < 2000) {} // 2초 동안 차단

setTimeout, readFile와 setImmediate, 세 가지 작업을 실행합니다. 그리고 스레드를 2초 동안 블로킹하는 while 루프가 있습니다. 이 시간 동안 세 가지 이벤트 모두 각자의 큐에 추가됩니다. 따라서 while 루프가 끝나면 이벤트 루프는 같은 사이클에서 이 세 가지 이벤트를 모두 처리하고, 다이어그램에 표시된 순서대로 콜백들을 실행합니다.

hello
world
immediate

하지만 실제 결과는 다음과 같습니다.

hello
immediate
world

그 이유는 I/O 폴링이라는 추가적인 프로세스가 있기 때문입니다.

I/O 이벤트는 다른 유형의 이벤트들과 달리 사이클의 특정 시점에서만 이벤트 큐에 추가됩니다. 그래서 while 루프가 종료될 때 이미 두 콜백 모두 준비된 상태임에도 불구하고, setImmediate() 콜백이 readFile() 콜백보다 먼저 실행되는 것입니다.

문제는 이벤트 루프의 I/O 큐 확인 단계에서는 이미 이벤트 큐에 있는 콜백만 실행한다는 점입니다. 작업이 완료되었다고 해서 이벤트 큐에 자동으로 추가되는 것이 아니라, I/O 폴링 단계가 되어야 비로소 이벤트 큐에 추가됩니다.

다음은 while 루프가 완료되고 2초 뒤에 일어나는 일입니다.

  1. 이벤트 루프는 타이머 콜백들을 실행하면서 타이머가 완료되어 실행 준비가 되었음을 발견합니다. 그 결과 타이머가 실행되고, 콘솔에 "hello" 메시지가 출력됩니다.
  2. 그 후 이벤트 루프는 I/O 콜백 단계로 넘어갑니다. 이 시점에서 파일 읽기 작업은 이미 끝났지만, 해당 콜백은 아직 실행 대상으로 표시되지 않습니다. 이는 현재 사이클의 후반부에 표시될 예정입니다. 이벤트 루프는 여러 단계를 거쳐 결국 I/O 폴링 단계에 도달합니다. 이 시점에서 readFile() 콜백 이벤트가 수집되어 I/O 큐에 추가되지만, 여전히 바로 실행되지는 않습니다. 실행할 준비는 되었지만, 이벤트 루프는 다음 사이클에 이 콜백을 실행할 것입니다.
  3. 다음 단계로 넘어가면서 이벤트 루프는 setImmediate() 콜백을 실행합니다. 콘솔에는 "immediate"가 출력됩니다.
  4. 이벤트 루프가 다시 처음부터 시작됩니다. 실행할 타이머가 없으므로 I/O 콜백 단계로 넘어가서 마침내 readFile() 콜백을 찾아 실행합니다. 콘솔에는 "world"가 출력됩니다.

이 예제는 이해하기 다소 어려울 수 있지만, I/O 폴링 과정에 대한 중요한 통찰을 제공합니다. 만약 2초짜리 while 루프를 제거한다면, 다른 결과를 보게 될 것입니다.

immediate
world
hello

setImmediate()는 setTimeout이나 파일 시스템 프로세스가 완료되지 않았을 때, 이벤트 루프의 첫 번째 사이클에서 동작합니다. 일정 시간이 지나면 타임아웃이 완료되고 이벤트 루프는 해당 콜백을 실행합니다. 그리고 나중에 파일 읽기가 완료되면 이벤트 루프가 readFile 콜백을 실행합니다.

이 모든 것은 타임아웃의 지연 시간과 파일의 크기에 따라 달라집니다. 파일이 크다면 읽기 프로세스가 완료되는 데 더 오랜 시간이 걸립니다. 마찬가지로 타임아웃 지연 시간이 길다면 파일 읽기 프로세스가 타임아웃보다 먼저 완료될 수 있습니다. 하지만 setImmediate() 콜백은 고정되어 있어서 V8이 이를 실행하는 즉시 이벤트 큐에 등록됩니다.

실습 예제

예제 1) setTimeout

console.log('first');
setTimeout(() => { console.log('second') }, 10);
console.log('third');
first
third
second

이는 꽤 단순한데, 첫 번째 줄과 세 번째 줄의 코드가 동기식 코드이므로 바로 실행되고, 타이머는 10밀리초가 설정되어 있어서 나중에 실행되는 것입니다.

예제 2) setTimeout(0)

console.log('first');
setTimeout(() => { console.log('second') }, 0);
console.log('third');
first
third
second

그런데 왜 이것도 비슷한 결과가 나왔을까요? 네, 정확히 이해하셨습니다. 0밀리초로 설정되어 있더라도 이는 비동기 함수이기 때문에 타이머 큐에 추가된 후 실행되므로 그만큼의 시간이 소요됩니다.

예제 3) setTimeout(0)이지만 다른 호출이 블로킹되는 경우

만약 세 번째 호출이 루프를 3초 동안 블로킹한다면, 0밀리초로 지정했기 때문에 두 번째 호출이 먼저 실행될까요?

console.log('first');
setTimeout(() => { console.log('second') }, 0);
const startTime = new Date()
const endTime = new Date(startTime.getTime() + 3000)
while (new Date() < endTime) {
}
console.log('third');
first
third
second

두 번째 호출은 여전히 세 번째 호출 이후에 출력됩니다. 타임아웃을 0밀리초로 지정했다 하더라도 0초가 보장되지는 않습니다. 이는 사용자 코드가 우선순위를 가지기 때문입니다. 사용자의 동기 코드가 이벤트 루프를 블로킹하면 타이머들은 '굶주리게' 됩니다. 이것이 바로 이벤트 루프를 블로킹하지 말라고 하는 이유입니다.

예제 4) setTimeout과 setImmediate

setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

terminal

때때로 프로세스가 실행되는 데 더 오래 걸릴 수 있으며(밀리초 단위의 차이), 이로 인해 이벤트 루프가 타이머 큐가 비어있을 때 지나쳐 버릴 수 있습니다. 또는 이벤트 루프가 너무 빠르게 동작하여 디멀티플렉서가 이벤트를 이벤트 큐에 제때 등록하지 못할 수도 있습니다. 따라서 이 예제를 여러 번 실행하면 매번 다른 결과가 나올 수 있습니다.

예제 4) fs 콜백 내부의 setTimeout과 setImmediate

const fs  = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);

  setImmediate(() => {
    console.log('setImmediate');
  });
});
setImmediate
setTimeout

setTimeoutsetImmediatereadFile 함수 내부에 작성되어 있기 때문에, 콜백이 실행될 때 이벤트 루프가 I/O 단계에 있다는 것을 알 수 있습니다. 따라서 이벤트 루프 진행 방향에서 다음 큐는 setImmediate 큐입니다. 그리고 setImmediate는 즉시 큐에 등록되기 때문에, 로그가 항상 이 순서대로 출력되는 것은 당연합니다.

예제 5) process.nextTick과 Promise

console.log('first');

process.nextTick(() => {
  console.log('nextTick');
});

Promise.resolve()
  .then(() => {
    console.log('Promise');
  });

console.log('second');
first
second
nextTick
Promise

예제 5) process.nextTick 중첩

process.nextTick(() => {
  console.log('nextTick 1');

  process.nextTick(() => {
    console.log('nextTick 2');

    process.nextTick(() => console.log('nextTick 3'));
    process.nextTick(() => console.log('nextTick 4'));
  });

  process.nextTick(() => {
    console.log('nextTick 5');

    process.nextTick(() => console.log('nextTick 6'));
    process.nextTick(() => console.log('nextTick 7'));
  });

});
nextTick 1
nextTick 2
nextTick 5
nextTick 3
nextTick 4
nextTick 6
nextTick 7

설명은 다음과 같습니다.

이 코드가 실행되면 중첩된 process.nextTick 콜백들이 예약됩니다.

  1. 첫 번째 process.nextTick 콜백이 먼저 실행되어 콘솔에 'nextTick 1'을 출력합니다.
  2. 이 콜백 내부에서 두 개의 process.nextTick 콜백이 추가로 예약됩니다. 하나는 'nextTick 2'를 출력하고 다른 하나는 'nextTick 5'를 출력합니다.
  3. 'nextTick 2'를 출력하는 콜백이 다음으로 실행되어 콘솔에 'nextTick 2'를 출력합니다.
  4. 이 콜백 내부에서 두 개의 process.nextTick 콜백이 추가로 예약됩니다. 하나는 'nextTick 3'을 출력하고 다른 하나는 'nextTick 4'를 출력합니다.
  5. 'nextTick 5'를 출력하는 콜백이 'nextTick 2' 다음에 실행되어 콘솔에 'nextTick 5'를 출력합니다.
  6. 이 콜백 내부에서 두 개의 process.nextTick 콜백이 추가로 예약됩니다. 하나는 'nextTick 6'을 출력하고 다른 하나는 'nextTick 7'을 출력합니다.
  7. 마지막으로, 남은 process.nextTick 콜백들이 예약된 순서대로 실행되어 콘솔에 'nextTick 3', 'nextTick 4', 'nextTick 6', 'nextTick 7'을 출력합니다.

다음은 실행 과정에서 큐가 어떻게 구성되는지 보여주는 개요입니다.

Proess started: [ nT1 ]
nT1 executed: [ nT2, nT5 ]
nT2 executed: [ nT5, nT3, nT4 ]
nT5 executed: [ nT3, nT4, nT6, nT7 ]
// ...

예제 6) process.nextTick, promise, setTimeout

process.nextTick(() => {
  console.log('nextTick');
});

Promise.resolve()
  .then(() => {
    console.log('Promise');
  });

setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});
nextTick
Promise
setTimeout
setImmediate

예제 7) I/O, process.nextTick, promise, setTimeout, setImmediate

const fs  = require('fs');

fs.readFile(__filename, () => {
  process.nextTick(() => {
    console.log('nextTick in fs');
  });

  setTimeout(() => {
    console.log('setTimeout');

    process.nextTick(() => {
      console.log('nextTick in setTimeout');
    });
  }, 0);

  setImmediate(() => {
    console.log('setImmediate');

    process.nextTick(() => {
      console.log('nextTick in setImmediate');

      Promise.resolve()
        .then(() => {
          console.log('Promise in setImmediate');
        });
    });
  });
});
nextTick in fs
setImmediate
nextTick in setImmediate
Promise in setImmediate
setTimeout
nextTick in setTimeout

V8이 코드를 실행할 때, 처음에는 fs.readFile() 하나의 작업만 있습니다. 이 작업이 처리되는 동안 이벤트 루프는 각 큐를 확인하며 작업을 시작합니다. 카운터(기억하시죠?)가 0이 될 때까지 큐를 계속 확인하다가, 0이 되면 이벤트 루프는 프로세스를 종료합니다.

결국 파일 시스템 읽기 작업이 완료되면 이벤트 루프는 I/O 큐를 확인하면서 이를 감지합니다. 콜백 함수 내부에는 nextTick, setTimeout, setImmediate라는 세 가지 새로운 작업이 있습니다.

이제 우선순위에 대해 생각해봅시다.

각 매크로태스크 큐 이후에는 마이크로태스크가 실행됩니다. 따라서 "nextTick in fs"가 로그에 출력됩니다. 마이크로태스크 큐가 비어있으므로 이벤트 루프는 계속 진행됩니다. 다음 단계는 immediate 큐입니다. 따라서 "setImmediate"가 로그에 출력됩니다. 또한 nextTick 큐에 이벤트도 하나 등록합니다.

이제 immediate 이벤트가 남아있지 않으면 자바스크립트는 마이크로태스크 큐를 확인하기 시작합니다. 결과적으로 "nextTick in setImmediate"가 로그에 출력되고, 동시에 Promise 큐에 이벤트가 추가됩니다. nextTick 큐가 이제 비었으므로 자바스크립트는 Promise 큐를 확인하고, 여기서 새로 등록된 이벤트로 인해 "Promise in setImmediate"가 로그에 출력됩니다.

이 시점에서 모든 마이크로태스크 큐가 비어있으므로 이벤트 루프는 계속 진행되어 다음 타이머 큐에서 이벤트를 발견합니다.
이제 마지막으로 앞서 설명한 것과 동일한 로직으로 "setTimeout"과 "nextTick in setTimeout"이 로그에 출력됩니다.

예제 8) IO, process.nextTick promises, setTimeouts, setImmediate

setTimeout(() => console.log('Timeout 1'));
setTimeout(() => {
    console.log('Timeout 2');
    Promise.resolve().then(() => console.log('promise resolve'));
});
setTimeout(() => console.log('Timeout 3'));
Timeout 1
Timeout 2
promise resolve
Timeout 3

참조

profile
FrontEnd Developer

0개의 댓글