구글독스에서 워드프레스로 콘텐츠를 원격 배포하려면

🧐 | 2021-09-28

본 이야기에 앞서 제 소개를 간략하게 드리면, 저는 2020년 하반기 공채로 입사해 현재 넷마블 기술전략실 기술전략팀 팀원으로 일하고 있는 김성윤입니다.

입사전 한창 취업 준비를 하던 때, 어디로 가면 유니콘 같은 개발자가 될 수 있을지 고민 하던 적이 많았습니다. 많은 IT회사가 기술블로그를 운영하며 이런 메세지를 던졌습니다.

‘우리 조직에 오면 이렇게 훌륭한 사람들과 영감 넘치는 분위기에 동화될 수 있다’, ‘실제로 우리는 이런 일을 하면서 성장할 수 있다’라는 메세지로 무수한 꿈, 희망, 동경을 심어줬습니다.

저는 처음부터 게임사를 희망했기에, 대충 IT회사에 가면 어떤 분위기일지만 봤던 것 같습니다.

각설하고, 당시 넷마블에는 개발자와 훌륭한 대외 커뮤니케이션을 할 수 있도록 허브 역할을 할 기술블로그가 존재하지않았습니다. 다행히 제가 오고 얼마 지나지 않아, 저희 팀에서 기술블로그 개설 프로젝트를 시작했습니다.

신입이긴 해도 팀내 몇 안되는(아닌가?) 개발자 포지션이기에, 개발 파트로 저도 참여했습니다.

이 포스팅의 제목은 참고로 기술블로그 ‘관리 서버’ 구축기 입니다. ‘기술블로그에 무슨 관리 서버냐’라고 여쭈시면 대답해 드리는게 인지상정. 콘텐츠를 포스팅하실 테크니컬라이터님과 함께 논의하면서 몇 가지 필요한 기능을 정의했고, 문서 작성용 툴과 배포용 홈페이지 중간에 둘 서버 플러그인을 개발해 정의한 기능을 충족하기로 했습니다.

필요했던 기능

  1. 기술블로그 포스팅의 원본 파일 형상관리
  2. 교정교열한 원고를 원작자가 쉽게 확인
  3. 사용자가 복잡한 설정 없이 블로그에 포스팅을 발행

그렇게 리서치를 시작했습니다.

리서치한 내용들

원본파일 형상관리는 구글 드라이브를 사용하기로 했습니다. 거기다가 구글 독스에서 제안 모드를 이용해 교정교열한 부분을 원작자가 확인할 수 있게 하는 방향으로 정했습니다.

그렇다면 남은 과제는 3번. 작성한 문서를 어떻게 쉽고, 빠르고, 아름답게 블로그로 퍼블리싱 할 수 있을까? 고민하다 내린 결론은 마크다운을 활용하는 방안이었습니다.

우선 문서에서 본문, 헤딩, 서식, 표, 등을 파싱 및 추출해서 마크다운 형식으로 변환하고, 이미지와 함께 관리 서버로 전송합니다.

이후 관리 서버에서는 REST API를 이용해 이미지를 블로그에 업로드하고 이미지 경로를 받아와 마크다운 문자열에 있는 이미지 링크와 치환하고, 다시 HTML 형식으로 바꿔 블로그로 전송합니다.

크게보면, ‘마크다운 변환까지 파싱하는 기능’과 ‘파싱 결과물을 블로그에 업로드하는 서버 기능’이 필요했습니다.

파싱 기능은 구글 앱 스크립트를 이용해 플러그인으로 만들었고, 서버는 node 기반인 express를 이용해, 워드프레스가 제공하는 REST API를 요청하도록 했습니다.

물론 이 모든 건 처음 해봤기 때문에, 한동안 스택오버플로우 지박령이 되는 걸 피할 수 없었습니다.

첫 기획 방향

첫 기획한 사용자 시나리오는 다음과 같았습니다.

  1. 교정교열을 마친 문서에서 플러그인을 실행하고, 마크다운 변환 버튼을 누른다.
  2. 변환을 완료하면 결과를 확인하고, 업로드 옵션을 설정한 뒤 전송 버튼을 누른다.
  3. 앱 스크립트 백엔드에서 HTTP통신으로 관리 서버에 메타데이터, 본문, 이미지를 전송한다.
  4. 관리 서버에서는 3번에서 받는 내용을 업로드하고, 요청 응답값을 다시 사용자가 보고 있는 플러그인으로 띄울 수 있도록 앱 스크립트로 응답한다.
  5. 사용자에게 결과를 알려준다.

간단히 구현 시나리오를 로컬 환경에서 검증한 후, 해피케이스로 상정했습니다. 이외 예외 처리나 응답 실패 등에 대한 무수한 백로그를 남겨둔 채 사내 개발망으로 옮겼습니다.

그런데 해 보니

너무 잘 풀리면 불안하죠. 내부 네트워크 환경을 고려하지 않은 채 개발한 소스는 사내망에 있던 VM과 구글 앱 스크립트 사이 HTTP 통신을 이어주지 못했습니다. 그래서 초기 기획의 2번 상태로 돌아가야 했습니다.

새로운 선택지가 생겼습니다.

  1. 내부 네트워크 환경에 완벽히 대응해, 구글과 통신할 수 있게 개발한다.
  2. 플러그인에서 HTTP 통신하는 대신, 사용자가 관리 서버에 웹앱으로 접근해 직접 변환 파일을 업로드 한다.

전자의 경우에는 필요한 작업 공수와 검수 소요 기간 등을 생각했을 때 빠른 시일 내에 결과를 보기가 막막했고, 후자의 경우에는 사용자가 해야할 작업이 한 단계 추가되는 단점이 있었습니다.

물론 단기간 내에 이 앱을 직접 써야하는 사용자는 테크니컬라이터님 혼자였기에 협의해서 후자로 진행하기로 했습니다.

추가 기획과 리서치

이렇게 플로우가 바뀌었고, 추가로 필요한 리서치가 생겼습니다.

  1. 앱 스크립트에서 메타데이터, 본문, 이미지를 zip파일로 내보내는 기능
  2. 웹앱을 만들 스택, zip파일 핸들링 및 통신

1번은 zip 파일로 내보내는 기능은 이미 앞서서 앱 스크립트에 대한 경험이 많이 생긴 덕분에 금방 할 수 있었습니다. 하지만 2번은 웹앱을 처음 만들어 보는 경험을 하게 돼, 시간이 오래 걸렸습니다. 그래도 사내에서 Vue를 쓰고 계신 분들께 조언을 구하며 진행한 덕에, 하나씩 해쳐갈 수 있었습니다.

결과물

마크다운 변환

구글 독스는 다음과 같이 Document → Body → Element(sequence)로 구성돼 있습니다.

따라서 Document.Body에 있는 여러 Element를 순회하며 문단, 텍스트, 리스트, 테이블, 이미지를 처리해야 합니다.

먼저, 구글이 제공하는 DocumentApp에서 메소드를 이용해 Body를 가져왔습니다.

function example() {
    let body = DocumentApp.getActiveDocument().getBody()
    handleElements(body)
}

이후 Body 객체에서 하위 Element를 순회할 handleElements 함수를 실행해 각각 하위 Element를 처리했습니다.

const handleElements = (elements) => {
    if(!elements) return
    for (let i = 0; i < elements.getNumchildren(); i++) {
        handleChildElements(elements.getChild(i))
    }
}

하위 Element는 handleChildElement 함수를 활용해 타입별로 처리했습니다.

const handleChildElements = (element) => {
    let elementType = element.getType()
    // 가장 흔한 타입부터 처리
    switch (elementType) {
        case DocumentApp.ElementType.PARAGRAPH:
            handleParagraph(element)
            break
        default:
            console.log(“unknown type”)
    }
}

처리할 타입은 PARAGRAPH, TEXT, LIST_ITEM, TABLE, TABLE_ROW, TABLE_CELL, INLINE_IMAGE가 있지만, 여기선 문단만 예시로 들었습니다.

const handleParagraph = (paragraph) => {
    // 헤딩 속성을 파악하고 문법에 맞게 버퍼를 쓴다
    switch(paragraph.getHeading()) {
        case DocumentApp.ParagraphHeading.HEADING3: writeStringToBuffer(“#”)
        case DocumentApp.ParagraphHeading.HEADING2: writeStringToBuffer(“#”)
        case DocumentApp.ParagraphHeading.HEADING1: writeStringToBuffer(“#”)
    }
    // paragraph는 StructureElement 역할도 하기에, 하위 Element를 재처리
    for (let i = 0; i < paragraph.getNumchildren(); i ++) {
        handleChildElement(paragraph.getChild(i))
    }
    // 한 문단이 처리가 끝나면 그동안 버퍼에 쓰인 문자열을 내보낸다
    flushBuffer()
}

이런 방식으로 다른 타입도 처리합니다. 텍스트의 경우, 볼드나 이탤릭 같은 속성도 가져올 수 있는 메소드를 제공하고 있습니다. Body 순회가 끝나면 문서에 있는 모든 내용을 담은 결과물이 생깁니다.

이미지 추출

이미지 부분은 위에서 설명한 Body 순회만으로는 완벽히 처리할 수 없었습니다. 그래서 Inline_Image로 만났을 때 이를 처리하는 방법, HTTP 통신, Zip파일로 내보내기, 워드프레스에 REST API를 이용해 업로드한 방법 등을 소개합니다.

이미지 핸들링

먼저, Element 중 case가 INLINE_IMAGE일 경우 아래 함수를 실행했습니다.

const handleImage = (imageElement) => {
    let
        imgBlob = imageElement.getBlob()
        fileType = ‘ ’

    switch(imgBlob.getContentType()) {
        case ‘image/jpeg’:
            fileType = ‘.jpg’
            break
        case ‘image/png’:
            fileType = ‘.png’
            break
        case ‘image/gif’:
            fileType = ‘.gif’
            break
    }
    let base64Image = Utilities.base64Encode(ImgBlob.getBytes())
}

위 코드에서는 이미지 파일 Bytes만 base64 문자열로 바꿨지만, 실제에서는 파일이름, 콘텐츠 타입 등의 정보도 전달하기 위해 문자열이 가진 정보를 추가했습니다.

HTTP로 보내기

Blob 이미지를 form-data에 file로 전달하도록 옵션을 설정했습니다.

form-data 형태인 payload로 싣기 위해, 다음과 같은 작업을 했습니다.

let base64Image = Utilities.base64Encode(imgBlob.getBytes())
payload = ‘ ’
let pre = “--” + boundary + “\r\n”
pre += “Content-Disposition: form-data; name=\” ” + name + “\; filename=\” “ + fileName + “\”\r\n”
pre += “Content-type:” + fileType + “\r\n\r\n”
payload = payload.concat(Utilities.newBlob(pre).getBytes())
.concat(base64Image)
.concat(Utitlities.newBlob(“\r\n--”+boundary+”--”).getBytes())

let options = {
    method:  “POST”,
    contentType: “multipart/form-data; boundary=” + boundary,
    payload: payload,
    muteHttpExceptions: true
}
let response = UrlFetchApp.fetch(url,options)

실제에서는 다수 이미지를 보내기 위해 pre부분을 여러번 반복하며 각 이미지에 base64 문자열을 하나씩 추가하는 작업을 했습니다. multipart/form-data 형식을 이용하는 이유중 하나기도 하죠.

Zip파일로 내보내기

Zip파일로 내보내는건 쉽습니다. 여러 blob을 받아줄 배열을 선언하고, blob을 푸시한 뒤, DriveApp이 제공하는 메소드를 이용하면 됩니다.

let folder = DriveApp.getFolderById(folderID)
let blobs = []
for (let image in base64Images) {
    blobs.push(Utilities.newBlob(Utilities.base64Decode(image), imageType, imageName))
}
let file = Uilities.zip(blobs, ‘test.zip’)
let downloadUrl = folder.createFile(file).getDownloadUrl()

드라이브 내에 ID로 지정한 폴더에서 zip파일을 생성하고, 해당 파일 다운로드 링크를 받아 스크립트를 써서 강제로 다운로드했습니다.

워드프레스 REST API로 미디어 파일 업로드하기

워드프레스에 업로드할 때는 ford-data 형식을 사용해도 되지만, 비동기식으로 하나씩 보내는 것이 파일 업로드 실패시 생길 수 있는 예외를 처리하기 좋습니다.

content-Disposition을 attachment로 해야 Bad Request를 피할 수 있습니다. data에는 base64로 전달한 이미지에서 생성한 버퍼를 입력했습니다. 인증은 워드프레스 사용자 이름과 애플리케이션 비밀번호를 활용했습니다. 애플리케이션 비밀번호에 대한 자세한 내용은 이 링크를 참조해 주세요.

let config = {
    method: post’,
    url: url,
    headers: {
        “content-Disposition”: “\”attachment; filename=\””+encodeURI(fileName)+”\””,
        “Access-Control-Allow-Origin”: ‘*’,
        “content-type”: contentType
        },
    data: Buffer.from(base64string,’base64’),
    auth: {
        “username”: {WpUserID},
        “password”: {WpApplicationPassword},
    }
}
axios(config)

최종 결과물

구글 플러그인

아래 스크린샷은 문서에서 플러그인을 실행하고 변환한 결과입니다.

이후 변환된 본문과 사진을 업로드하면 워드프레스로 원격 배포를 할 수 있습니다. 최소 기능 동작을 중심으로 완성한 상태라, 워드프레스 자체로 진입하지 않고 배포하려면 아직 구현해야 하는 기능이 많이 남아 있습니다. 본문에 등장하는 다양한 글 스타일을 마크다운과 워드프레스 블록과 매칭하다보면, 예외 처리나 구현 요소 추가건이 계속 발생할 수밖에 없었습니다. 아직 1.0 버전으로 가기엔 가야할 길이 아득합니다.

남은 과제와 소감

눈에 넣어도 안 아플 내 새끼라고, 고양이 손이긴 해도 스스로 뚝딱뚝딱한 결과물을 보니 애정이 가고, 아직 더 고도화 하고 싶은 작업도 많이 보입니다. 예외처리, TC 작성, 리팩토링, 프런트 고도화 등등 갈길이 멀었네요.

혹시나 이 글을 보고 넷마블 IT조직이 이렇게 얼렁뚱땅 돌아가는가? 하는 의문을 가질 분들이 계실수도 있을것 같습니다. 그래서 소감을 남기자면, 저의 경우 팀이 개발 포지션이 아닐뿐더러, 사수 개발자분이 부재하셔서 위 같은 개발경험을 가졌던 특수한 경우였습니다. 프로젝트 기간 동안 제가 속한 기술전략팀은 엄밀히 개발 포지션이 아니기도 하고, 팀내에 코드를 직접 봐주실 수 있는 분이 없는 팀이었습니다. 그럼에도 팀원분들께서 다른 팀에 계신 개발자분들을 소개해주셔서 코드나 설계 리뷰, 검수 등을 받을 수 있도록 도와주셔서 막막하지 않았습니다. 결정적으로 내가 사용하고싶은 스택을 사용해 개발해 볼 수 있었던 덕분에 괜히 특별한 기분도 들었습니다. 비록 과정이 험난했을 지라도 많은 분들과 소통하고, 실제로 서비스에서 사용하는 무언가를 만들 수 있었던 귀중한 경험을 한 프로젝트였습니다.

아직 구글 프로젝트 등록, 라이브 서비스 이후 초기 피드백 반영 등 남아있는 작업이 더 있습니다. 플러그인과 관련한 더 상세한 설명과 남은 프로젝트 내용으로 다시 인사드리겠습니다. 그때까지 우리 넷마블의 기술블로그, 많이 사랑해주세요!