Devin.KR

배압(backpressure) - 느린 쪽에 속도를 맞추기

개발자KR 조회 14

이 장에서 배우는 것

앞 장에서 스트림을 활용해 거대한 파일을 조각내어 처리하는 방법을 알아보았습니다. 큰 파일을 한 번에 메모리에 올리지 않고 작은 청크(chunk) 단위로 나누어 처리함으로써 메모리 사용량을 크게 줄일 수 있었습니다. 하지만 스트림을 단순히 연결하는 것만으로는 시스템의 안정성을 보장할 수 없습니다. 시스템을 구성하는 각 요소들은 데이터를 처리하는 속도가 제각각이기 때문입니다.

네트워크를 통해 데이터를 수신하는 속도와 이를 디스크에 기록하는 속도가 다르다면 문제가 발생합니다. 이 속도 차이를 방치하면 앞 장에서 아꼈던 메모리가 다시 가득 차는 현상을 겪게 됩니다. 이 장에서는 이런 속도 불균형 문제를 해결하는 핵심 메커니즘을 배웁니다.

  • 배압(backpressure)의 개념과 시스템 안정성에 미치는 영향을 깊이 있게 이해한다.
  • 스트림의 내부 버퍼 크기를 결정하는 highWaterMark 옵션의 역할을 파악한다.
  • write 메서드의 반환값과 drain 이벤트를 활용해 데이터를 보내는 쪽과 받는 쪽의 속도를 동기화한다.
  • 비동기 이터레이터와 수동 제어 방식을 결합하여 세밀한 흐름 제어를 구현한다.
  • 최신 Node.js 환경에서 권장되는 pipeline 함수로 배압과 자원 누수 문제를 한 번에 해결한다.

문제 상황

우리가 만들고 있는 로그 수집 서버를 떠올려 봅시다. 이 서버는 여러 대의 웹 애플리케이션 서버나 클라이언트 기기로부터 발생하는 접속 로그를 실시간으로 수신합니다. 트래픽이 몰리는 시간대나 일시적인 트래픽 급증 현상이 발생하면, 초당 수십 메가바이트의 로그 데이터가 네트워크 소켓을 통해 밀려들어 옵니다.

서버는 이 데이터를 받아 안전하게 디스크의 파일로 기록하거나, 후처리를 위해 다른 데이터베이스 서버로 전송해야 합니다. 여기서 문제가 발생합니다. 메모리에 있는 데이터를 읽어들이는 네트워크 소켓의 수신 속도는 매우 빠르지만, 물리적인 장치에 기록해야 하는 디스크 쓰기 속도는 상대적으로 훨씬 느립니다. 외부 데이터베이스로 네트워크 전송을 한다고 해도 대상 서버의 부하 상태에 따라 쓰기 속도는 언제든지 느려질 수 있습니다.

데이터가 들어오는 속도를 디스크나 대상 서버가 따라가지 못하면, 쓰기 스트림(Writable Stream)은 아직 처리하지 못한 로그 데이터를 자신의 내부 버퍼(메모리 영역)에 잠시 보관해 둡니다. 평상시라면 이 버퍼는 금방 비워지겠지만, 속도 차이가 장시간 지속되면 버퍼에 보관해야 할 데이터가 기하급수적으로 늘어납니다.

Node.js는 이 상황에서도 데이터를 유실하지 않기 위해 버퍼의 크기를 끊임없이 확장합니다. 그 결과 Node.js 프로세스가 차지하는 메모리 점유율(RSS, Resident Set Size)이 비정상적으로 치솟게 됩니다. 메모리 사용량이 늘어나면 가비지 컬렉터(Garbage Collector)가 더 자주, 더 오래 실행되면서 CPU 자원까지 낭비하게 되고, 최종적으로는 시스템에서 할당받을 수 있는 메모리 한계를 초과하여 'Out Of Memory' 오류와 함께 서버 프로세스가 강제로 종료됩니다. 이를 막기 위해서는 데이터를 소화할 수 있는 속도에 맞춰서 보내달라고 앞단에 신호를 보내야 합니다. 이러한 양방향 흐름 제어를 '배압(backpressure)'이라고 부릅니다.

읽기 속도가 쓰기 속도보다 빠를 때 배압을 무시하면 내부 버퍼가 팽창하여 메모리 누수 위험이 발생한다.

내부 버퍼와 highWaterMark

배압을 이해하려면 먼저 스트림이 데이터를 어떻게 임시 보관하는지 알아야 합니다. 모든 Node.js 스트림은 내부에 상태를 관리하는 버퍼 큐(Queue)를 가지고 있습니다. 이 버퍼가 무한정 커지는 것을 막기 위해 설정해 둔 일종의 경계선이 바로 highWaterMark(최고 수위선)입니다.

highWaterMark는 댐의 수위 한계선과 같습니다. 일반적인 바이트 기반 스트림(TCP 소켓 등)의 기본값은 16KB(16,384바이트)이며, 파일 시스템과 관련된 스트림(fs.createReadStream, fs.createWriteStream)은 디스크 입출력 성능 향상을 위해 64KB가 기본값으로 설정되어 있습니다.

여기서 중요한 오해가 자주 발생합니다. 많은 개발자가 highWaterMark를 절대 넘을 수 없는 하드 리미트(Hard Limit)로 생각합니다. 하지만 이는 소프트 리미트(Soft Limit)에 불과합니다. 버퍼에 쌓인 데이터가 이 수위선을 넘었다고 해서 스트림이 곧바로 오류를 던지거나 데이터를 강제로 거부하지 않습니다. 그저 지금 소화할 수 있는 적정량을 넘었으니 당분간 데이터를 그만 보내라는 상태 정보를 외부에 알릴 뿐입니다. 만약 개발자가 이 경고를 무시하고 write()를 계속 호출하면, 스트림은 메모리를 추가로 할당하여 데이터를 어떻게든 버퍼에 밀어 넣습니다. 메모리 증가는 바로 이 지점에서 시작됩니다.

따라서 highWaterMark 값을 적절히 설정하는 것만으로는 부족하며, 수위선에 도달했을 때 데이터 공급을 멈추는 로직을 반드시 구현해야 합니다.

객체 모드(Object Mode)에서의 배압

로그 수집 서버의 역할을 단순한 텍스트 파일 복사가 아니라, 로그를 분석하고 필터링하는 방향으로 고도화한다고 가정해 봅시다. 네트워크로 수신한 문자열 로그를 한 줄씩 잘라 자바스크립트 객체(Object)나 JSON으로 변환하는 작업이 추가될 것입니다. 이렇게 바이트 덩어리(Buffer) 대신 자바스크립트 객체를 주고받는 스트림을 객체 모드(Object Mode) 스트림이라고 부릅니다.

객체 모드에서 highWaterMark의 동작 방식은 바이트 모드와 다릅니다. 바이트 스트림에서는 용량(바이트 크기)을 기준으로 수위선을 계산하지만, 객체 모드에서는 오직 객체의 개수만을 기준으로 삼습니다. 크기가 큰 객체라도 무조건 1개로 계산되며, 객체 모드의 기본 highWaterMark 값은 16입니다. 즉, 16개의 로그 객체가 내부 버퍼에 쌓이면 write()는 false를 반환합니다.

객체 모드를 사용할 때는 한 객체가 차지하는 메모리 크기가 예측하기 어려우므로 버퍼 크기 관리에 더욱 주의해야 합니다. 객체 안에 매우 긴 문자열이 포함되어 있다면, 단 16개의 객체만으로도 예상보다 많은 메모리를 점유할 수 있습니다. 따라서 객체 모드 스트림을 파이프라인으로 연결할 때는 각 단계마다 배압이 제대로 작동하는지 확인하는 과정이 필수적입니다.

write 메서드와 drain 이벤트

그렇다면 데이터를 보내는 쪽(Readable)에서는 받는 쪽(Writable)의 댐이 가득 찼는지 어떻게 알 수 있을까요? Node.js는 아주 단순하고 명료한 방법으로 이를 알려줍니다. 쓰기 스트림의 write(chunk) 메서드가 반환하는 불리언(Boolean) 값을 확인하면 됩니다.

write() 메서드를 호출했을 때 true가 반환된다면 현재 내부 버퍼의 크기가 highWaterMark보다 작다는 의미입니다. 즉, 아직 여유가 있으니 계속 데이터를 보내도 좋다는 뜻입니다. 반대로 반환값이 false라면 버퍼에 쌓인 데이터가 highWaterMark와 같거나 이를 초과했다는 경고 신호입니다. 이때 데이터를 공급하는 쪽은 즉시 읽기나 생성 작업을 일시 정지(pause)해야 합니다.

멈춘 작업은 언제 다시 시작해야 할까요? 쓰기 스트림이 목적지(디스크, 네트워크 등)로 버퍼 안의 데이터를 열심히 밀어내어 내부 버퍼가 완전히 비워지면, 스트림은 drain(물이 빠짐) 이벤트를 발생시킵니다. 데이터를 보내는 쪽에서는 이 drain 이벤트를 리스닝하고 있다가, 이벤트가 발생했을 때 비로소 멈춰두었던 데이터 전송을 재개(resume)하면 됩니다.

이 단순한 false 확인과 drain 대기 로직이 바로 대용량 트래픽 앞에서도 Node.js 서버가 일정한 메모리를 유지하며 동작할 수 있게 만드는 핵심 원리입니다.

버퍼 한계에 도달해 write가 false를 반환하면 쓰기를 멈추고, 버퍼가 비워져 drain 이벤트가 발생하면 쓰기를 재개한다.

비동기 이터레이터와 pipeline으로 해결하기

개념은 명확하지만, 이를 코드로 직접 구현하는 것은 생각보다 까다롭습니다. 과거의 Node.js 코드베이스를 보면 readable.on('data')에서 write() 반환값을 검사하고 readable.pause()를 호출한 뒤, 다시 writable.on('drain')에서 readable.resume()을 호출하는 패턴을 흔히 볼 수 있습니다. 이 방식은 읽고 쓰는 로직을 여러 이벤트 콜백으로 파편화시켜 가독성을 떨어뜨렸고, 도중에 에러가 발생했을 때 양쪽 스트림을 모두 정리하는 것을 잊기 쉽게 만들었습니다.

최신 Node.js 환경에서는 두 가지 안정적인 도구를 제공합니다.

첫 번째 방법은 비동기 이터레이터(Async Iterator)입니다. 읽기 스트림은 비동기 이터러블 규약을 준수하므로 for await...of 반복문을 사용해 데이터를 순회할 수 있습니다. 반복문 안에서 write()가 false를 반환하면, 우리는 events.once() 유틸리티를 사용해 drain 이벤트가 발생할 때까지 현재 루프의 실행을 대기(await)시키면 됩니다. 코드가 직관적으로 실행되어 흐름을 파악하기 쉽고, 이벤트 리스너가 중첩되는 문제도 해결됩니다.

두 번째 방법은 node:stream/promises 모듈의 pipeline 함수입니다. 데이터를 중간에 가공할 필요 없이 한 스트림에서 다른 스트림으로 넘기기만 한다면 pipeline이 가장 안전한 선택입니다. pipeline(source, destination) 형태로 호출하면 내부적으로 복잡한 배압 제어를 훌륭하게 수행합니다. 파이프라인 중간에 오류가 발생하거나 클라이언트가 네트워크 연결을 끊어버릴 경우, 연결된 모든 스트림의 destroy() 메서드를 연쇄적으로 호출하여 메모리와 파일 핸들 누수를 원천 차단합니다.

완성 코드

server.mjs

import { createServer } from 'node:http';
import { createWriteStream } from 'node:fs';
import { once } from 'node:events';
import { pipeline } from 'node:stream/promises';

const server = createServer(async (req, res) => {
  // 1. 비동기 이터레이터와 수동 배압 제어 라우트
  // 데이터를 중간에 가공하거나 검사해야 할 때 유용한 방식입니다.
  if (req.method === 'POST' && req.url === '/logs/manual') {
    // 수신한 로그를 기록할 파일 스트림 생성
    const dest = createWriteStream('logs-manual.txt');
    
    try {
      // 스트림을 비동기 이터레이터로 순회
      for await (const chunk of req) {
        // write 반환값으로 내부 버퍼가 가득 찼는지(highWaterMark 도달) 확인
        const canContinue = dest.write(chunk);
        
        if (!canContinue) {
          // 버퍼가 꽉 찼다면 쓰기 스트림이 비워질 때까지 반복문 일시 정지
          await once(dest, 'drain');
        }
      }
      
      // 모든 데이터 수신이 끝나면 쓰기 스트림을 닫음
      dest.end();
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('Manual backpressure success\n');
    } catch (err) {
      // 네트워크 끊김 등의 예외 발생 시 자원 정리
      dest.destroy();
      res.writeHead(500, { 'Content-Type': 'text/plain' });
      res.end('Server error\n');
    }
    return;
  }

  // 2. pipeline을 이용한 자동 배압 제어 라우트
  // 데이터를 있는 그대로 전달할 때 가장 권장되는 안전한 방식입니다.
  if (req.method === 'POST' && req.url === '/logs/pipeline') {
    const dest = createWriteStream('logs-pipeline.txt');
    
    try {
      // req(Readable)에서 dest(Writable)로 데이터를 파이프라인으로 연결
      // 배압 조절과 에러 시 스트림 정리를 알아서 수행합니다.
      await pipeline(req, dest);
      
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('Pipeline success\n');
    } catch (err) {
      // 파이프라인 내부에서 이미 dest.destroy()가 안전하게 호출됨
      res.writeHead(500, { 'Content-Type': 'text/plain' });
      res.end('Server error\n');
    }
    return;
  }

  // 잘못된 경로나 메서드에 대한 처리
  res.writeHead(404);
  res.end('Not Found\n');
});

// 서버 실행
server.listen(3000, () => {
  console.log('Log server running on port 3000');
});

줄별 해설

작성한 로그 서버 코드의 핵심적인 부분을 하나씩 살펴봅니다.

  • for await (const chunk of req): Node.js의 HTTP 요청 객체(req)는 읽기 스트림입니다. 비동기 반복문을 사용하면 데이터 청크가 도착할 때마다 루프가 한 번씩 실행되며, 내부적으로 스트림의 데이터 흐름을 안전하게 가져옵니다.
  • const canContinue = dest.write(chunk);: 수신한 데이터 조각을 목적지 파일 스트림의 메모리 버퍼에 기록합니다. 이때 동기적으로 불리언 값을 반환하는데, 이 값이 배압 발생 여부를 판가름하는 가장 중요한 지표입니다.
  • if (!canContinue): 반환값이 false인 경우는 파일 쓰기 속도가 데이터를 네트워크로 수신하는 속도를 따라가지 못해 내부 버퍼가 이미 highWaterMark를 넘어섰음을 의미합니다. 이제 데이터 읽기를 멈추고 기다려야 합니다.
  • await once(dest, 'drain');: node:events 모듈이 제공하는 once 함수는 특정 이벤트가 발생할 때까지 기다리는 프로미스를 반환합니다. 파일 스트림의 내부 버퍼에 있던 데이터가 운영체제의 디스크로 밀려나가 빈 공간이 확보되면 drain 이벤트가 방출됩니다. await 키워드 덕분에 이 이벤트가 발생할 때까지 다음 청크를 읽어오는 반복문의 실행이 멈추게 되고, 소켓에서의 데이터 수신 속도도 제어되어 메모리 증가를 방지합니다.
  • await pipeline(req, dest);: 위에서 구현한 write() 검사와 drain 대기, 그리고 에러 처리 로직을 한 번에 해결해 주는 함수입니다. pipeline은 스트림 간의 속도 차이를 조절하는 배압 관리를 자체적으로 수행합니다. 클라이언트가 비정상적으로 연결을 끊거나 예외가 발생했을 때, 연결된 req와 dest 스트림을 모두 안전하게 해제(destroy)하여 보이지 않는 메모리 누수나 파일 디스크립터 고갈을 막아줍니다.

실행 결과

터미널을 두 개 열어서 실제 동작을 확인해 봅니다. 첫 번째 터미널에서는 우리가 작성한 서버 스크립트를 실행합니다.

$ node server.mjs
Log server running on port 3000

두 번째 터미널에서는 curl 명령어를 사용해 가상의 대용량 데이터를 서버로 전송합니다. 수백 메가바이트 이상의 커다란 텍스트 파일을 미리 준비해 두었다고 가정하고 요청을 보냅니다. 배압 제어가 정상적으로 동작하여 서버의 메모리 사용량이 급증하지 않고 안정적으로 파일을 저장하는 것을 확인할 수 있습니다.

$ curl -X POST --data-binary "@large-log-file.txt" http://localhost:3000/logs/manual
Manual backpressure success

$ curl -X POST --data-binary "@large-log-file.txt" http://localhost:3000/logs/pipeline
Pipeline success

실무에서 자주 틀리는 것

반환값을 무시하고 무작정 write() 호출하기

실무에서 아주 자주 발견되는 실수입니다. 배압을 고려하지 않고 이벤트 리스너 안에서 데이터를 무작정 쓰는 코드는 소규모 테스트에서는 아무런 문제 없이 동작하는 것처럼 보입니다. 하지만 실무 환경에서 트래픽이 몰리면 순식간에 서버 메모리를 고갈시킵니다.

// 틀린 코드: 배압을 무시하는 안티 패턴
req.on('data', (chunk) => {
  // write의 반환값이 false여도 무시하고 버퍼에 계속 푸시한다
  dest.write(chunk); 
});

과거의 이벤트 기반 방식으로 작성한다면 반드시 스트림을 직접 일시 정지시키고 재개해야 합니다.

// 고친 코드: pause와 resume을 명시적으로 사용
req.on('data', (chunk) => {
  const canContinue = dest.write(chunk);
  if (!canContinue) {
    req.pause(); // 쓰기 버퍼가 찼으므로 읽기를 일시 정지한다
  }
});

dest.on('drain', () => {
  req.resume(); // 쓰기 버퍼가 비워지면 읽기를 다시 시작한다
});

루프 안에서 이벤트 리스너 중복 등록 (메모리 누수)

비동기 이터레이터 안에서 on() 메서드를 잘못 사용하면 눈에 띄지 않는 또 다른 메모리 누수를 유발합니다. 반복문이 돌 때마다 이벤트 리스너가 끝없이 추가되기 때문입니다.

// 틀린 코드: 반복문 안에서 리스너가 계속 누적됨
for await (const chunk of req) {
  if (!dest.write(chunk)) {
    // 주의: 청크를 처리할 때마다 리스너가 쌓여 MaxListenersExceededWarning 발생
    await new Promise(resolve => dest.on('drain', resolve));
  }
}

반드시 한 번만 실행된 후 리스너가 자동으로 제거되는 once()를 사용해야 합니다.

// 고친 코드: events.once 유틸리티 사용
import { once } from 'node:events';

for await (const chunk of req) {
  if (!dest.write(chunk)) {
    // 이벤트가 한 번 발생하면 프로미스가 해결되고 리스너는 즉시 제거됨
    await once(dest, 'drain'); 
  }
}

pipe() 메서드를 사용하고 에러 처리를 누락하는 실수

오래된 블로그 글이나 문서를 보면 req.pipe(dest)를 사용하는 코드를 자주 접하게 됩니다. 하지만 pipe()는 중간에 에러가 났을 때 대상 스트림을 자동으로 닫아주지 않아 파일 찌꺼기가 남고 리소스가 낭비됩니다.

// 틀린 코드: 구식 pipe 사용과 부실한 에러 처리
req.pipe(dest);
req.on('error', (err) => {
  console.error(err);
  // req에서 에러가 났지만, dest 스트림은 닫히지 않고 계속 열려 자원을 점유함
});

스트림을 연결할 때는 가급적 자동 정리 기능이 내장된 프로미스 기반의 pipeline을 쓰는 것이 좋습니다.

// 고친 코드: 안전한 pipeline 사용
import { pipeline } from 'node:stream/promises';

try {
  await pipeline(req, dest);
} catch (err) {
  // 예외가 발생하면 파이프라인에 연결된 모든 스트림의 destroy()가 보장됨
  console.error('스트림 처리 중 에러 발생, 자원은 안전하게 정리됨', err);
}

한눈에 보기

스트림 배압 제어 방식 비교
제어 방식 주요 특징 및 장점 단점 및 주의사항 에러 처리 방식
이벤트 수동 제어
(on, pause)
세밀하고 정교한 흐름 제어가 가능합니다. 코드가 길고 파편화되며 논리적 오류를 내기 쉽습니다. 각 스트림 객체마다 개별적으로 error 이벤트를 등록해야 합니다.
비동기 이터레이터
(for await...of)
동기 코드처럼 흐름을 읽기 쉽고 중간에 데이터 가공 로직을 넣기 편합니다. write() 반환값 확인과 drain 대기 로직을 직접 작성해야 합니다. 루프 전체를 try-catch 블록으로 묶어 처리합니다.
pipeline 함수 배압 관리와 스트림 메모리 해제를 자동화합니다. 스트림 간 연결에 특화되어 중간에 복잡한 개입을 하려면 변환 스트림(Transform)이 필요합니다. 반환된 프로미스의 거절(rejection)을 catch로 일괄 처리합니다.
쓰기 스트림의 상태 변화와 대응 조치
write(chunk) 반환값 내부 버퍼 상태 개발자가 취해야 할 조치 관련 이벤트 및 시그널
true 버퍼 여유 공간 있음 (highWaterMark 미만) 안전한 상태이므로 데이터를 목적지로 계속 전송합니다. 추가 조치가 필요 없으며 특별한 이벤트가 발생하지 않습니다.
false 버퍼 한계선 도달 (highWaterMark 초과) 읽기나 생성 작업을 즉시 멈추고 버퍼가 비워지길 대기합니다. 이후 버퍼 데이터가 성공적으로 빠져나가면 drain 이벤트가 발생합니다.

연습 문제

  1. 성능 테스트를 위해 파일 쓰기 스트림을 생성할 때 highWaterMark 옵션을 아주 작은 값인 1바이트로 설정했습니다. 이렇게 설정했을 때 Node.js 프로그램의 처리 성능에는 어떤 변화가 생기는지 그 이유와 함께 설명해 봅시다.
  2. 데이터베이스로 로그를 전송하는 작업 중 write() 메서드가 false를 반환했음에도 이를 무시하고 계속해서 로그 데이터를 푸시(push)했습니다. 이 상태가 장시간 지속될 경우 Node.js 프로세스의 메모리 구조와 가비지 컬렉터에는 어떤 변화가 일어나는지 서술해 봅시다.
  3. 과거에 쓰이던 pipe() 메서드 대신 최근의 Node.js 환경에서 node:stream/promises 모듈의 pipeline() 함수를 도입해야 하는 가장 중요한 이유 두 가지를 찾아 적어 봅시다.

정답과 해설

1번 정답: highWaterMark가 1바이트라면 내부 버퍼가 거의 없는 것과 같습니다. 따라서 데이터를 1바이트 쓸 때마다 즉각적으로 버퍼 한계에 도달하여 write()가 매번 false를 반환하게 됩니다. 데이터를 보내는 쪽은 단 한 글자를 보낼 때마다 쓰기 작업이 완료되길 기다리며 drain 이벤트를 대기해야 하므로, 스트림의 빈번한 일시 정지와 재개로 인한 오버헤드가 커집니다. 결과적으로 전체적인 데이터 처리량(Throughput)이 크게 떨어집니다.

2번 정답: 데이터가 데이터베이스로 전송되어 빠져나가는 속도보다 쓰기 스트림의 내부 버퍼에 쌓이는 속도가 훨씬 빠르기 때문에, Node.js는 누락 없이 데이터를 보관하기 위해 힙(Heap) 메모리를 계속해서 새롭게 할당하여 버퍼 큐를 늘립니다. 힙 공간이 커지면서 가비지 컬렉터는 여유 메모리를 확보하기 위해 무리하게 동작하게 되고, 결국 시스템에서 할당 가능한 메모리 한계치를 넘어서서 'Out Of Memory(OOM)' 에러와 함께 프로세스가 강제로 종료되는 현상이 발생합니다.

3번 정답: 첫 번째 이유는 안전한 자원 해제입니다. 파이프라인 중간에 위치한 특정 스트림에서 문제가 발생하더라도, pipeline()은 연결된 다른 모든 스트림 객체의 destroy()를 연쇄적으로 호출하여 메모리와 파일 핸들 누수를 차단해 줍니다. 두 번째 이유는 비동기 제어의 편의성입니다. 배압 제어를 자동으로 처리해주면서도 결과를 프로미스(Promise)로 반환하기 때문에, async/await 문법과 결합하여 직관적인 try-catch 블록으로 파이프라인의 오류를 쉽게 처리할 수 있습니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.