코드스테이츠 이머시브 과정은 약 3개월간 진행된다. 그 중 절반인 첫 6주 가량은 data structure, algorithm, server, database, client 등 웹개발의 기본을 집중적으로 배운다. 이후 6주동안은 그간 배운 웹개발의 기본을 바탕으로 3-4인으로 한 팀을 구성해서 2주 프로젝트와 4주 프로젝트 총 2개의 프로젝트를 진행한다.

이번 글에서는 2주 프로젝트였던 “뉴스 읽어주는 Sunny”를 소개해보려고 한다.

기획 의도

“뉴스 읽어주는 Sunny”는 내가 기획한 아이디어였는데 (누구나 자신의 프로젝트 아이디어를 pitch할 수 있다) 아이디어의 계기는 이렇다. 난 경기도민이기 때문에 성수동에서 집까지 거의 대부분 버스를 타고 이동한다. 매일 11시간이상 프로그래밍을 하며 하루를 보내느라 집에 갈 때는 거의 녹초가 되다시피 했다. 그럴 때마다 내가 좋아하는 뉴스를 누군가가 읽어주면 좋겠다고 생각했다. 흔들리는 버스 안에서 작은 핸드폰 화면을 뚫어져라 쳐다보며 집중하기엔 난 늘 너무 지쳐있었다. 현재 인기 있는 기사를 유저가 선택해서 클릭/터치하기만 하면 기사 내용이 마치 라디오나 팟캐스트 듣듯이 음성으로 플레이되면 편할 것 같았다.

네이버 클로버 API의 장점

기본적인 기획안은 나왔는데, 텍스트를 음성으로 읽어주는 기능이 제일 핵심이었다. 다행히도 다음과 네이버 모두 각각의 TTS (Text-To-Speech) API를 제공하고 있어서 그 중 사용 편의성과 음성의 퀄리티를 비교해보고 네이버 클로버의 API를 사용하기로 결정했다. 여러 차례 테스트를 해보니 네이버가 주장하듯이 꽤나 합성음이 자연스러워서 컴퓨터가 읽어주는 티가 덜했다.

사용 편의성면에서도 합격이었다. request 모듈로 폼 데이터에 기사 내용을 문자열로 담아서 네이버 TTS API에 post 요청을 하면, 텍스트가 음성으로 변환되고 음성 데이터가 스트리밍 되는 방식이었다.

// 실제 사용 코드

const fs = require('fs');
const request = require('request');

const options = {
  url: 'https://naveropenapi.apigw.ntruss.com/voice/v1/tts',
  form: { speaker: 'mijin', speed: '-1', text: '기사 내용' },
  headers: { 
    'X-NCP-APIGW-API-KEY-ID': process.env.NAVER_CLOVA_ID, 
    'X-NCP-APIGW-API-KEY': process.env.NAVER_CLOVA_SECRET }
};

const writeStream = fs.createWriteStream(`./tmp/${someFileName}.mp3`);
const _req = request.post(options).on('response', (response) => {
  _req.pipe(writeStream); // file로 출력
  _req.pipe(res); // 브라우저로 출력
});

텍스트가 음성으로 변환되는 속도도 꽤 훌륭해서 실제 서비스를 하는데도 무리가 없었다.

과금 체계에 효과적인 대응, file serving

물론 실제 서비스를 하기 위해서 고려해야 할 사항이 있었는데 그건 클로버 API의 과금 체계였다. Freemium 제도를 갖고 있는 많은 API들과 다르게 클로버 API는 사용한만큼 과금한다. 단 한 단어라도 음성으로 변환할 때마다 과금되는 구조라 처음부터 제대로 된 아키텍처를 가져가는 것이 백엔드를 맡은 내게 큰 숙제였다. 왜냐하면 자칫하다가 “뉴스 읽어주는 Sunny”가 대박을 쳐서 수백만이 사용하면 엄청난 금액이 청구될 것은 불보듯 뻔했기 때문이다.

결국 다음과 같이 AWS S3에 파일을 저장하는 것으로 과금 문제를 해결하기로 했다.

  • 유저가 기사를 클릭하면 AWS S3에서 해당 음성 파일을 검색한다.
  • 음성 파일이 있으면 스트리밍 한다.
  • 음성 파일이 없으면 기사 내용을 클로버 API에 전송해서 음성 파일로 받는다.
  • 유저에게 음성 파일을 스트리밍 한다.
  • 그 후 음성 파일을 AWS S3에 저장한다.

즉, 유저가 기사를 클릭할 때마다 무조건적으로 클로버 API를 이용하는 것이 아니라 S3에 저장되어 있는 음성 파일을 스트리밍 해주는 것이다. 이런 방식이라면 기사 하나당 클로버 API 이용횟수는 최대 1회로 제한되기 때문에 비용적으로 가장 합리적이고 아키텍쳐면에서 가장 단순하고 효과적인 솔루션이라고 판단했다.

기사 목록은 어떻게 가져오나?

“뉴스 읽어주는 Sunny” 아키텍처에서 두 번째 고려사항은 실시간 인기 기사 목록을 어떻게 가져오고 주기적으로 갱신할 수 있는가였다.

먼저 실시간 인기 기사라는 것이 정의내리기에 따라 성격이 굉장히 달라질 수 있다. 이를테면 내가 생각하는 인기 기사와 다른 사람이 생각하는 인기 기사는 다를 수도 있다. 그렇기 때문에 프로젝트의 단순함을 유지하고 팀원간 인기 기사의 정의를 좁게 한정시키고자 ‘인기 기사’를 현 시간 네이버 랭킹 뉴스로 한정하기로 했다.

네이버 랭킹 뉴스는 정치, 경제, 사회 등 총 8개 분야에서 실시간으로 사람들이 많이 읽는 뉴스를 분야별 30개씩 제공한다. 공식 API로 제공하는 것은 아니라서 개발자가 직접 기사 목록을 가져오는 scraper를 만들어야 한다.

다음은 네이버 랭킹 뉴스 목록을 가져오는 실제 사용 코드이다.

async getArticleList (categoryID) {
  let categoryName;

  await Category.findByPk(categoryID)
    .then(returnedCategory => {
      if (returnedCategory) {
        categoryName = returnedCategory.get('name');
      }
    })
    .catch(err => {
      throw err;
    });

  const listURL = this.newsCategories()[categoryName];

  const res = await axios.get(listURL);

  const $ = cheerio.load(res.data);
  let listArray = [];

  $('.commonlist').children().each((i, el) => {
    const articleObj = {
      category_id: categoryID,
      rank: $(el).find('i.commonlist_num').text(),
      title: $(el).find('.commonlist_tx_headline').text().trim(),
      image_url: $(el).find('img').attr('src'),
      source_url: this.naverRootURL + $(el).find('a').attr('href'),
      sid: this.naverURLParser(this.naverRootURL + $(el).find('a').attr('href')).sid1,
      oid: this.naverURLParser(this.naverRootURL + $(el).find('a').attr('href')).oid,
      aid: this.naverURLParser(this.naverRootURL + $(el).find('a').attr('href')).aid
    };

    listArray.push(articleObj);
  });

  return listArray;
}

위와 같은 코드를 이용해서 매 시간마다 cron 등으로 지정된 분야별 기사 목록 페이지에 들어가서 기사 목록을 가져오고 기사 제목과 분야, 랭킹 등을 데이터베이스에 저장한다.

기사 내용은 어떻게 가져오나?

기사 내용이 약간 골칫덩이였다. 왜냐하면 처음 생각했던 건 단순하게도 모든 기사마다 일일이 기사 내용 역시 scrape해서 데이터베이스에 제목과 함께 저장하려 했다. 하지만 몇번 테스트하다 보니 굳이 그럴 필요가 있을까 싶었다. 그 이유는 모든 유저가 모든 기사를 읽는, 아니 듣는다는 보장이 없기 때문이다. 달리 말하면 유저가 기사 제목을 클릭하면 그제서야 기사를 가져오는 것도 무방할 것도 같았다. 어차피 서비스 초기에는 더더욱 모든 기사 내용이 준비되어 있을 필요가 없기 때문에 모든 기사 내용을 미리 데이터베이스에 저장해놓는건 조금 오버였다.

아래는 뉴스 기사 내용을 scrape하는 실제 사용 코드이다. 기사 목록을 가져오는 함수와 구조적으로는 동일하다. 기사 url을 인자로 넘기면 axios가 해당 페이지의 html 문자열을 반환하고 cheerio가 문자열을 parse해서 필요한 부분만 선별해 사용할 수 있다.

async getMobileContent (url) {
  return new Promise(async (resolve, reject) => {
    var res = await axios.get(url);
    var html = res.data;
    const $ = cheerio.load(html);

    // remove unnessary parts from DOM
    $('span.end_photo_org').remove();
    $('#dic_area > a').remove();

    let result = {
      title: $('h2.media_end_head_headline').text(),
      content: $('#dic_area').text().trim().replace(/\[.*\] /gm, '').replace(/(\r\n|\n|\r|\t)/gm, "").replace(/\s+/g, " "),
      publisher: $('img.media_end_head_top_logo_img').attr('alt'),
      url: url,
      sid1: this.naverURLParser(url).sid1,
      oid: this.naverURLParser(url).oid,
      aid: this.naverURLParser(url).aid
    };

    if (result) {
      resolve(result);
    } else {
      reject(new Error('failed to getMobileContent'));
    }
  });
}

뉴스 읽어주는 Sunny의 아키텍처

다시 정리하자면 다음과 같이 설계했다.

  • 매 시간마다 네이버 랭킹 뉴스 목록을 분야별로 데이터베이스에 저장한다.
  • 이 때 기사 내용은 저장하지 않는다.
  • 데이터베이스에 저장되어 있는 인기 기사 목록을 화면에 표시한다.
  • 유저가 기사를 클릭하면 AWS S3에서 해당 음성 파일을 검색한다.
  • 음성 파일이 있으면 스트리밍 한다.
  • 음성 파일이 없으면 기사 내용 scrape하고 클로버 API에 전송해서 음성 파일로 받는다.
  • 유저에게 음성 파일을 스트리밍 한다.
  • 그 후 음성 파일을 AWS S3에 저장한다.

그래서 그 결과는?

팀 이슈가 있었지만 그래도 꽤나 만족스러운 프로젝트였다. 그 이유는 다음과 같다.

  • 제대로 된 첫 Node.js+Express 프로젝트였다. 서버 세팅부터 Sequelize ORM을 이용한 MySQL 데이터베이스까지 Node의 많은 부분을 약간은 deep하게 경험해 볼 수 있었다.
  • 아무래도 자바스크립트이다보니 콜백함수 경험을 많이 쌓을 수 있었다.
  • 콜백함수를 좀 더 낫게 refactoring하는 과정에서 Promise 및 async/await에 이르기까지 자바스크립트의 비동기 처리를 다루는데 조금 더 능숙해질 수 있었다. (데이터베이스 read/write 등과 클로버 API 사용과 같이 전형적인 비동기 처리가 많이 필요했다.)
  • Cheerio를 이용해서 네이버 뉴스 목록과 기사 내용을 scrape해야 했는데 그 부분에 꽤나 많은 경험을 쌓을 수 있었다.
  • node-cronshelljs 모듈을 이용해서 주기적으로 네이버 뉴스 내용을 가져오는 Cron task를 자바스크립트로 만들어 볼 수 있었다.
  • Sequelize cli를 이용해서 seed data를 생성할 수 있었다.