안녕하세요, 신사업부문 띠잉셀의 오지산입니다.
저희 셀은 영상으로 놀 수 있는 소셜 서비스, 띠잉(Thiiing)을 개발하고 있어요.

이전 글에서 띠잉 채팅 서버의 개발 과정이 언급되었는데요.
이번 글에서는 채팅 기능을 싣고 있는, 띠잉의 웹뷰 전반에 대한 이야기를 다뤄보려고 합니다.

현재 저희 셀에는 프론트엔드 개발자가 따로 있지 않아, 백엔드 개발자들이 웹뷰를 개발하고 있는데요.
작년 9월에 웹뷰 개발을 시작한 이후 지금까지의 과정을 돌아보면서, 해결했던 문제들, 고민들,
그리고 소박하게나마 앞으로의 방향을 공유해 보려고 합니다.

다소 개인적인 얘기들이 포함되어 있지만, 부디 비슷한 상황에 처하신 분들께 도움이 되었으면 합니다.
긴 글이 될 것 같은데 최대한 압축해서 써보겠습니다. 🤩

띠잉 웹뷰의 탄생

저는 작년 9월부터 지금까지 띠잉 서비스의 작고 조그마한 웹뷰를 만들어 오고 있습니다.

띠잉에서는 앱 내부에 노출하는 공지사항, 도움말, 랭킹,
그리고 앱 외부에 노출하는 영상 공유 기능을 웹으로 구현하고 있는데요.
모두 작지만 서비스에서 빠뜨릴 수 없는 기능들입니다.

웹뷰의 작고 소중한 기능들

작년 9월에 제가 처음 웹뷰 개발 미션을 받았을 때는, 위 사진에서 가장 왼쪽에 있는 영상 공유 기능만 구현되어 있었는데요.
이때 사용되었던 기술들을 돌아보면…

  • React + TypeScript
  • React Router
  • webpack으로 빌드, express로 서빙합니다.
  • express app에서 request로 API를 찌르고, 응답을 pug 템플릿에 전달합니다.
  • Redux랑 Redux Saga도 왠지 모르겠지만 들어가 있었습니다.
  • 그외 잠깐 혹은 아예 사용하지 않는 의존성들…
    • styled-components, axios, request, video.js, …

였습니다. package.json에서 여기저기 고민의 흔적이 엿보였는데요. 🤔

당시 영상 공유 기능에서 가장 시급하게 해결해야 했던 문제는 “og 태그를 어떻게 응답 시점에 내려줄 수 있을까?” 였습니다.
영상 공유 페이지는 메신저나 SNS에 공유해서 앱으로 사람들을 유입시키는 데 의의가 있었으니,
섬네일이나 제목, 내용에 대한 og 태그를 필수적으로 넣어주어야 했어요.
그렇지만 React로 og 태그를 추가한다고 한들, 크롤러들이 자바스크립트를 잘 실행해서 그 정보를 읽어줄 리는 없었습니다.

“서버에서 React 코드를 렌더링해서 내려주면 되잖아?”라고 생각하셨다면…
맞습니다. 저도 그렇게 생각했어요! 아래 코드를 보고 난 후에 말입니다. 🧐

원래는 간단한 express 서버가 돌아가면서, 유저의 요청이 오면 콘텐츠 API에 요청을 보내고,
받은 응답에서 데이터를 뽑아내 pug 템플릿에 전달하는 방식으로 이 문제를 해결하고 있었습니다.

router.get("*", (req, res) => {
  const key = req.params[0];
  if (key && key !== "/") {
    fetch(`https://api.domain/contents/web/meta${key}`)
      .then((result) => result.json())
      .then((data) =>
        res.render("index", {
          ogTitle: data.message.meta.costumeName,
          ogDescription: data.message.meta.text,
          ogImageURL: data.message.meta.thumbnailImaggeUrl,
        })
      )
      .catch(() =>
        res.sendFile(path.resolve(__dirname, "../init_html/mobile.html"))
      );
  }
});

pug 템플릿에서는 렌더링할 때 전달된 정보들을 잘 받아서 og 태그를 만들고,
webpack으로 말아낸 애셋들을 불러와 애플리케이션을 작동시키는,
소박하지만 의심의 여지 없이 동작했던 코드였습니다.

doctype html
link(rel='stylesheet', href='/css/thiiing/mobile.css')
meta(property='og:title', content=ogTitle)
meta(property='og:description', content=ogDescription)
meta(property='og:image', content=ogImageURL)
#thiiing
script(type='text/javascript', src='/js/thiiing/mobile.js')

환경별로 api 도메인을 분리하기 전까지는 말이죠.

많은 서비스가 그렇듯 저희도 api.dev.domain, api.beta.domain처럼 도메인에 환경을 명시해서 API를 분리하는데요.
이 도메인을 node.js 환경 변수로 삽입하고 있는데, 클라이언트에게 깔끔하게 넘겨줄 방법이 안 보였습니다.
클라이언트에서도 fetch() 해야 할 API가 있어서 도메인 정보가 꼭 필요했거든요.

머리를 굴려도 이런 방법밖에 생각이 나지 않았고…

#thiiing
script.
  window.apiDomain = "#{apiDomain}";
script(type='text/javascript', src='/js/thiiing/mobile.js')

이것도 방법이긴 한데… 나중에는 어쩌지? 계속 이 pug 파일을 챙겨야 하나?

라고 고민하며 기술 조사를 하던 중, Next.js를 알게 되었고
이를 도입하면서 기존 의존성의 상당 부분을 걷어낼 수 있었습니다.

  • 우선 react-router를 걷어낼 수 있고
  • 내장된 webpack 설정으로 잘 빌드해주니 원래 있던 webpack도 걷어내고
  • API route를 통해 express도 걷어내고
  • styled-jsx가 번들로 들어가 있어서 styled-components도 걷어내고…

더불어 클라이언트로 설정값도 깔끔하게 전달할 수 있고, SSR 과정에서 필요한 API 호출도 getServerSideProps()에 넣으면 Next.js가 알아서 실행시켜 주었습니다. 별도 설정 없이 원하는 것이 가능해졌기 때문에 편하게 개발할 수 있었어요.

이때부터 웹뷰에 좀 더 욕심을 부리게 된 것 같습니다.

  • 안 쓰는 Redux, Redux Saga는 걷어내자.
  • video.js 말고 그냥 <video /> 쓰면 될 것 같다.
  • sass 문법은 하나도 안 쓰는데 그냥 sass 걷어내고 styled-jsx로 바꾸자.
  • 이렇게 된 이상 스타일시트도 컴포넌트별로 쪼개보자.
  • iOS에서는 왜 이렇게 나오는 걸까…?
  • 공유하기랑 도움말 좀 만들어 주세요. 근데 이제 디자인은 없는

하다 보니,

엇 다 갈아엎었네…?

해서 지금까지 웹뷰를 계속 개발해 오게 되었습니다. 🥰

채팅의 등장

올해 3월까지도 웹뷰는 시즌에 맞춰 이벤트 랭킹 페이지를 보여주는 게 주요 임무였습니다.
이벤트에 맞춰 페이지의 외관을 바꾸는 정도의 간단한 작업이 진행했고요.

학교대항전 이벤트

(당시 진행했던 학교대항전 이벤트)

그런데 2월 무렵부터 유저 사이의 소통 강화 방안으로 DM(Direct Messaging)이 제시되었고,
현재 인력의 한계가 있으니 우선 웹으로 빠르게 구현하고 추후 네이티브로 전환한다는 의사결정을 전달받게 되었습니다.

채팅은 기존 웹뷰에서 담당하던 기능과는 그 성격이 많이 달라서, 개인적으로는 어떤 결과물이 나올지 궁금했었어요.

당시에는 업무가 미리 배분되어서 제가 아니라 동료 백엔드 개발자분이 채팅 프론트엔드를 홀로 맡으셨었는데요.
바로 전 글을 써 주셨던 재욱님과 함께, 주고받는 메시지 스펙까지 같이 정하면서 긴밀하게 협업을 해 주셨습니다.
그 결과 서버와 웹 사이에서는 커맨드부터 아키텍처까지 상당한 공감대를 맞출 수 있었는데요.

문제는 웹뷰 안에서 터졌습니다. 당시 웹뷰 코드를 많이 작성했었던 제가 다른 업무를 담당하게 되면서,
채팅 프론트엔드를 맡으셨던 서버 개발자분과 충분한 커뮤니케이션을 하지 못했고, 코드 리뷰에서 이 점이 여실히 드러났어요.

가령 띠잉 채팅에서는 WebSocket을 통해 서버와 통신하는데, 당시의 채팅 프론트엔드는 WebSocket의 모든 응답을 ChatData라는 State에 집어넣고, 애플리케이션 전체가 그 State를 공유하는 식으로 설계되어 있었습니다.

const WebSocketContainer = () => {
  const [chatData, dispatch] = useReducer(chatDataReducer, {
    /* 거대하므로 생략 */
  });
  const webSocketRef = useRef();
 
  useEffect(() => {
    const webSocket = new WebSocket(process.env.webSocketURL);
 
    webSocket.onmessage = (e) => {
      const message = JSON.parse(e.data);
      /* 응답을 그대로 reducer의 액션으로 사용 */
      dispatch(message);
    };
  }, []);
 
  return <>{/*생략*/}</>;
};

그 결과 모든 컴포넌트가 ChatData라는 거대한 State에 의존할 수밖에 없었고, 로직이 develop되면서 리듀서도 극단적으로 길어지기 시작했습니다. 이걸 고치기에는 리뷰 시간이 충분하지 않았어요.

또한 리듀서에서 state를 직접 조작하거나, state의 배열에 push, pop, shift, unshift하는 코드들이 많았고,
특정 엘리먼트에 onClick 핸들러를 다는 것이 아닌, 아래 코드처럼 document에 onClick 핸들러를 달고, event.target의 classList를 보면서 어떤 엘리먼트인지 판별하는 등 쉽게 깨질 수 있는 코드들이 생겨나기도 했습니다.

const handleDocumentClick = (e) => {
  if (e.target instanceof HTMLElement) {
    const el = e.target;
    if (
      el.classList.contains("dialog-container") ||
      el.classList.contains("dialog-header")
    ) {
      return;
    }

    if (el.classList.contains("dialog-contents-unblock-ok")) {
      sendUserUnblockSet(unblockUserId);
    } else if (el.classList.contains("dialog-contents-unblock-cancel")) {
      // do nothing
    } else {
      // do nothing
    }

    setUnblockUserId(0);
    setIsUnblockPopupOn(false);
  }
};

물론 위 코드들은 잘 작동했고, 주어진 문제들을 잘 해결해 내고 있지만,
이슈가 발생했을 때 동작을 예측하거나 수정하기 까다롭다는 게 문제였습니다.

급하게라도 맞출 수 있는 컨벤션은 맞추려고 노력했고, 타입 강화 등은 급한대로 제가 직접 손을 대기도 했지만…
이미 QA를 진행하고 있는 상황에서 손을 댈 수 있는 부분은 한계가 있었고,
핵심적인 리듀서 로직을 거의 미지의 영역으로 남겨두면서 머지할 수밖에 없었습니다.

몸과 마음이 힘들었던 코드 리뷰로 기억에 남았어요.

(wip조차 떼지 못하고 머지된 게 참 가슴 아팠습니다 😭)

그 이후 지속적으로 스타일을 맞추고, 최근 컴포넌트 구조를 재설계하면서 ChatData를 완전히 걷어냈지만, 기능의 크기에 비해서는 꽤 오랜 시간을 들여 코드를 정리해야 했어요.

채팅 런칭 후 지금까지

다사다난한 런칭이었지만 그후 지금까지 협업을 이어오면서 채팅 프론트엔드도 발전해 왔습니다. 최근에는 컴포넌트 구조와 상태 변경 로직을 재설계해서 운영에 내보내기도 했어요. 그 과정에서 저희가 어렵게 해결했던 문제들을 소개하려고 합니다.

어디까지나 백엔드 개발자들끼리 고민했던 문제이기 때문에… 고난이도의 문제들은 아닐 거라는 생각이 듭니다.
채팅에는 이런 문제들이 있구나~라는 정도로만 봐주시면 감사하겠습니다. 🤠

REST API같은 WebSocket 통신 대응하기

띠잉 채팅에서는 서버와 웹이 커뮤니케이션할 때, 메시지의 종류와 의미를 command라는 문자열로 나타내고 있어요.
커맨드에는 CONNECT(최초 연결), CHAT_LOG_SEND(채팅 메시지 전송) 같은 것들이 있습니다.

웹에서 서버에 어떤 요청을 보내면, 서버는 그때 보냈던 커맨드를 응답과 함께 돌려줍니다.
가령 최초 연결 시 CONNECT 요청을 보내면…

{
  "token": "[TOKEN]",
  "command": "CONNECT",
  "body": {
    "platform": "WEB",
    "version": "1.0.0",
    "devicePlatform": "ANDROID"
  }
}

똑같은 커맨드값이 담긴 응답이 내려와서, 정상적으로 요청이 처리되었다는 것을 알려줍니다.

{
  "code": 9000,
  "message": {
    "hostId": "chat-app-[HOSTID]",
    "userId": 27
  },
  "error": null,
  "command": "CONNECT"
}

REST API를 사용할 때와 다를 바가 없어보이는 요청/응답 방식이지만,
WebSocket 통신을 사용하는 채팅의 특성상 모든 응답이 요청과 짝지어지지 않은 상태로 흘러들어오게 됩니다.

원래 채팅 프론트엔드에서는 이런 통신 방식을 대응하기 위해서, 보내는 코드와 응답을 받아오는 코드를 분리해 놓고 있었습니다.
위에서 잠깐 보여드린 코드인데요. 하위 컴포넌트에 메시지를 보낼 수 있는 함수를 만들어 전달해 주기는 하지만, 그 요청에 대한 응답은 onmessage 핸들러가 ChatData에 부어넣고 있었어요.

const WebSocketContainer = (props) => {
  const [chatData, dispatch] = useReducer(chatDataReducer, {
    /* 거대하므로 생략 */
  });
  const webSocketRef = useRef();

  useEffect(() => {
    const webSocket = new WebSocket(process.env.webSocketURL);

    webSocket.onmessage = (e) => {
      const message = JSON.parse(e.data);
      dispatch(message);
    };
  }, []);

  const sendMessage = (reqBody) => {
    const payload = createPayload(reqBody);
    wsRef.currrent.send(payload);
  };

  switch (props.viewType) {
    case "CHAT_ROOM_LIST":
      return <ChatRoomList sendMessage={sendMessage} />;

    case "CHAT_ROOM":
      return <ChatRoom sendMessage={sendMessage} />;
  }
};

이러다 보니 각 컴포넌트들이 ChatData에 모두 의존할 수밖에 없고, ChatData도 갈수록 커질 수밖에 없었습니다.
이걸 심각하게 걷어내고 싶었습니다.

마치 fetch()처럼, 요청을 보냈을 때 바로 다음 라인에서 응답에 접근할 수 있다면 ChatData를 없앨 수 있겠다 싶었어요.

그래서 WebSocket에 대고 fetch()마냥 쓸 수 있는 Promise를 하나 만들었습니다.
소켓이 열려 있을 때(혹은 열릴 때까지 기다렸다가) 메시지를 보내고, 그 직후 들어오는 메시지를 읽다가,
보냈을 때와 동일한 커맨드의 응답을 받으면 resolve하는 간단한 Promise입니다.

const sendToWebSocket = (webSocket, token, command, body) =>
  new Promise((resolve, reject) => {
    const onOpen = () => {
      // 페이로드를 생성해 전송
      const payload = createPayload(token, command, body);
      webSocket.send(payload);
    };
    const onMessage = (e) => {
      const data = JSON.parse(e.data);
      if (data.command === command) {
        // 보냈던 커맨드와 일치하면 resolve
        webSocket.removeEventListener("message", onMessage);
        resolve(data);
      }
    };

    webSocket.addEventListener("message", onMessage);

    const { CONNECTING, OPEN, CLOSING, CLOSED } = webSocket;
    switch (webSocket.readyState) {
      case CONNECTING:
        webSocket.addEventListener("open", onOpen, { once: true });
        break;
      case OPEN:
        onOpen();
        break;
      case CLOSING:
      case CLOSED:
        reject();
        break;
    }
  });

이 함수 하나 만들었다고, 하위 컴포넌트에서 WebSocket과 통신하는 게 상당히 편해졌던 기억이 납니다.
상위 컴포넌트에서 미리 가지고 있는 webSocket과 token만 넣어서 함수로 말고, Context로 넣어주면…

export const SendContext = createContext(null);

const send = useMemo(() => {
  if (webSocket && token) {
    return (command, body) =>
      sendToWebSocket(webSocket, token, command, body);
  }
  return null;
}, [webSocket, token]);

return (
  <SendContext.Provider value={send}>{children}</SendContext.Provider>
);

하위 컴포넌트에서는 Context에 접근해 손쉽게 가져다 쓸 수 있었습니다.

const send = useContext(SendContext);

const onClick = async () => {
  const response = await send("CHAT_LOG_SEND", {
    /* Request Body */
  });
  if (response.code === "SUCCESS") {
    clearInput();
    focus();
  } else {
    showRetryButton();
  }
};

가령 메시지 입력창에서 메시지를 보내는 동작은, 딱 이 정도의 코드만 남길 수 있었어요.

물론 Promise 말고도 하위 컴포넌트가 메시지를 보내고 받아오게 하는 방법은 많겠지만, send 함수 하나로 만들어서 WebSocket의 구현을 숨겨버리고 나니, 하위 컴포넌트를 작성할 때는 WebSocket의 구현을 거의 신경 쓰지 않게 되었어요. 개인적으로는 이런 비동기적인 동작을 Promise로 만들어 두면 왜 편한지를 확실히 알 수 있었던 것 같습니다. 😮

여기서 마무리되면 좋겠지만, 저희가 TypeScript를 쓴다고 말씀드렸었죠.
요청/응답 구조 또한 어떤 command냐에 따라서 크게 달라집니다. 그래서 좀 전의 함수에서, command 파라미터로 CONNECT를 넣었을 때 body와 반환값이 CONNECT에 맞는 타입으로 바뀌었으면 좋겠다는 생각을 하게 됐어요.

이전 글에서 재욱님이 얘기해 주셨지만, 띠잉 채팅의 command는 20개가 넘기 때문에,
효율적인 개발을 위해서는 자동 완성이 꼭 필요했습니다.

const sendToWebSocket = (
  webSocket: WebSocket,
  token: string,
  command: any, // 어떻게 해야 하지...?
  body: any
): Promise<any> =>
  new Promise((resolve, reject) => {
    /* 생략 */
  });

방법을 찾아보다가, 우연히 WebSocket에서 addEventListener의 타입을 이렇게 지정해 둔 것을 보게 됐어요.

interface WebSocket extends EventTarget {
  /* 생략 */
  addEventListener<K extends keyof WebSocketEventMap>(
    type: K,
    listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any,
    options?: boolean | AddEventListenerOptions
  ): void;
}

WebSocketEventMap을 찾아가 보니 이렇게 event명과 상응하는 이벤트 타입이 묶여 있었습니다.
keyof의 존재를 이때 처음 알게 됐고요.

interface WebSocketEventMap {
  close: CloseEvent;
  error: Event;
  message: MessageEvent;
  open: Event;
}

이 방법을 따라해서 커맨드에 상응하는 요청 타입과 응답 타입의 Map을 정의하고…

type ChatReqCommandMap = {
  CONNECT: ChatConnectReq;
  CHAT_LOG_SEND: ChatLogSendReq;
  /* 생략 */
};

type ChatResCommandMap = {
  CONNECT: ChatConnectRes;
  CHAT_LOG_SEND: ChatLogSendRes;
  /* 생략 */
};

keyof를 사용해서, command에 따라 body와 반환값의 타입이 바뀌게 했습니다.

const sendToWebSocket = <K extends keyof ChatReqCommandMap>(
  webSocket: WebSocket,
  token: string,
  command: K,
  body: ChatReqCommandMap[K]
): Promise<ChatResCommandMap[K]> =>
  new Promise((resolve, reject) => {
    /* 생략 */
  });

이렇게 정의해 두고 나니, 하위 컴포넌트에서는 WebSocket도, 요청과 응답 타입도 의식하지 않고 사용할 수 있게 되었습니다. 🥳
자동 완성도 얻을 수 있었고요.

IntelliJ에서의 자동 완성

복잡한 WebSocket 라이프사이클 Hook으로 조각내기

저희가 만들었던 채팅 프론트엔드에서 다소 복잡한 로직을 가진 부분이, WebSocket의 라이프사이클에 맞춰 지정된 동작을 실행하는 부분입니다. 구체적으로 로직을 밝힐 수는 없지만 간략하게 정리해 보면…

const ws = new WebSocket(process.env.webSocketURL);
// 별도 API를 찔러 몇 번째 시도인지 로깅
// + 시간 재기 시작

ws.onopen = () => {
  // 소켓이 열렸을 때는 필수적으로 CONNECT 요청을 보내야 함.
  // (그 이전에 보내는 모든 요청은 실패)
  // CONNECT 요청의 응답으로 받은 정보를 이후 요청에 사용하기 위해 저장.

  // + 소켓이 열릴 때까지 걸린 시간을 별도 API를 찔러 로깅
};

ws.onmessage = () => {
  // 특정 응답 코드의 메시지가 온 경우 소켓을 닫고 네이티브 앱으로 돌려 보냄
};

ws.onclose = () => {
  // 소켓이 에러로 인해 닫힌 경우 정해진 재시도 횟수 내에서 소켓 연결 재시도
};

document.onvisibilitychange = () => {
  // 앱이 백그라운드 상태면 소켓 닫기, 아니면 소켓 열기.
  // (재시도 횟수를 증가시키지 않음)
};

이렇습니다. 각 동작마다 액세스 토큰 등 다양한 state값을 접근하고 저장하고요.
이미 정립된 플로우였고, 그렇다고 로직을 뒤죽박죽 섞어 버리면 고통스럽게 유지보수해야 하니,
효율적으로 풀어나갈 방법을 생각해야 했습니다.

이때 의미가 비슷한 동작들을 hook으로 묶어보는 게 좋겠다는 생각이 들었습니다.
이전에 Functional Component를 사용하자고 강력하게 말씀드린 것을 매우… 다행으로 여겼는데요.
의미가 비슷한 동작끼리 hook을 만들어서 묶음으로써, 복잡한 로직을 조금은 정돈된 모습으로 정리할 수 있었던 것 같아요.

가령 탭의 활성화 여부에 따라 WebSocket을 열고 닫는 hook은 이렇게 만들어집니다.

const useVisibilityChange = (onVisible, onHide) => {
  useEffect(() => {
    const onVisibilityChange = () => {
      document.visibilityState === "visible" ? onVisible() : onHide();
    };

    document.addEventListener("visibilitychange", onVisibilityChange);
    return () => {
      document.removeEventListener("visibilitychange", onVisibilityChange);
    };
  }, [onHide, onVisible]);
};

실제로 hook을 사용할 때는 state를 조작할 수 있는 함수를 만들어서 파라미터로 제공했습니다.
이때 hook이 불필요하게 재실행되지 않도록 useCallback을 사용해서 함수의 재생성을 막았고요.

const create = useCallback(() => dispatch({ type: "ws/create" }), []);
const close = useCallback(() => dispatch({ type: "ws/close" }), []);
useVisibilityChange(token, create, close);

hook을 통해서 의미 단위로 WebSocket의 동작을 조각내면서,
무엇을 어디에서 하고 있는지 조금 더 알기 쉬운 코드로 바뀐 느낌이었습니다. 세부적인 구현이 숨겨졌고요.

const [state, dispatch] = useReducer(reducer, {
  webSocket: null,
  tryCount: 0,
  isError: false,
});

useLogging(state.webSocket, state.tryCount);

const create = useCallback(() => dispatch({ type: "ws/create" }), []);
useCreateOnStart(create);

const close = useCallback(() => dispatch({ type: "ws/close" }), []);
useVisibilityChange(create, close);

const createOnRetry = useCallback(
  () => dispatch({ type: "ws/create", retry: true }),
  []
);
useRetryOnClose(state.webSocket, createOnRetry);

실제로는 참조하는 state가 많다 보니 파라미터도 많아져서, 여전히 읽는 게 어렵다는 생각은 들었지만…
적어도 Ctrl+F로 찾지 않는 코드를 만든 점에서는 의미가 있다는 생각이 듭니다. 😅

iOS WebView 뷰포트 + 키보드 = 🤯

띠잉은 현재 Android, iOS에서 이용하실 수 있습니다.
그래서 띠잉 채팅도 Android, iOS에서 이용 가능해야 하겠습니다. 그것이 약속이니까

뜬금없이 이런 말을 왜 하냐면, 두 OS의 브라우저 간 차이가 상당했기 때문입니다.
특히 iOS WebView의 경우 생각지도 못한 UI 문제가 발생하는 경우를 자주 겪었는데요.
이 중 저희를 오랜 시간 괴롭혔던 문제를 소개해 드리고자 합니다.

iOS 문제 상황

위 사진에서 헤더와 입력창은 position: fixed; 상태여서 페이지 위아래에 고정되어 있는데요.
iOS에서 입력창을 눌러 키보드가 올라가면, 헤더가 화면 밖으로 사라지는 문제가 발생했습니다.
스크롤해서 헤더를 보려고 하면 입력창이 사라졌고요.
요컨대 position: fixed;가 무시되는 것처럼 보이는 현상이 발생했습니다.

안드로이드의 경우 이러한 문제가 발생하지 않았어요.

안드로이드에서 같은 화면

문제의 원인은 iOS WebView 및 Safari에서 온스크린 키보드를 화면에 나타낼 때,
뷰포트의 높이를 줄이지 않고, 높이는 그대로인 상태에서 뷰포트를 화면 바깥으로 밀어 버린다는 것이었습니다.

뷰포트가 화면 바깥으로 나가 버리니, 뷰포트에 position: fixed;로 붙어 있던 헤더도 화면 바깥으로 나갈 수밖에 없었습니다.

사태 파악에 큰 도움이 되었던 글을 하나 소개해 드립니다.

자료를 찾으면 찾을수록 이건 마치, 애플社의 보이지 않는 손이 뷰포트를 가린 것 같은 느낌을 받았습니다.
과연 손으로 얼마나 가렸는지,,, 코드에서 감지해 낼 수 있을까,,,

진짜 손으로 가려 보았습니다

싶었는데 다행히도 Safari 13에서 추가된 Visual Viewport API의 존재를 알게 되었습니다.
Visual Viewport API는 전체 페이지에서 유저가 보고 있는 영역에 대한 offsetTop, height, scale 등을 제공해 주고,
특히 확대/축소 비율과 🤩온스크린 키보드🤩를 감안한 값을 내려 주기 때문에 이걸로 문제 해결이 가능하지 않을까 생각했어요.

결과적으로 이런 동작을 하는 간단한 컴포넌트를 만들게 됐습니다.

  1. 자식 컴포넌트 전체를 div로 감싸는 컴포넌트를 만듭니다.
  2. window.visualViewport가 resize, scroll 이벤트를 발생시킬 때마다, 뷰포트의 높이, 그리고 페이지 최상단에서 뷰포트가 얼마나 내려왔는지를 읽습니다.
  3. div의 위치+높이를 읽어온 값으로 업데이트해 줍니다.
const FitToViewport = (props) => {
  const [viewportHeight, setViewportHeight] = useState(0);
  const [offsetTop, setOffsetTop] = useState(0);

  useEffect(() => {
    if (props.platform == "ANDROID") {
      return;
    }

    const onResize = () => {
      if (window.visualViewport) {
        setViewportHeight(window.visualViewport.height);
      }
    };

    const onScroll = () => {
      if (window.visualViewport) {
        // 페이지 끝에서 Rubber Band 효과가 발생하면 offsetTop이 음수가 되는데, 이것을 방지합니다.
        setOffsetTop(Math.max(window.visualViewport.offsetTop, 0));
      }
    };

    window.visualViewport?.addEventListener("resize", onResize);
    window.visualViewport?.addEventListener("scroll", onScroll);

    return () => {
      window.visualViewport?.removeEventListener("resize", onResize);
      window.visualViewport?.removeEventListener("scroll", onScroll);
    };
  }, [props.platform]);

  return (
    <>
      <div>{props.children}</div>
      {/*language=CSS*/}
      <style jsx>{`
        div {
          width: 100%;
          height: ${viewportHeight > 0 ? `${viewportHeight}px` : "100%"};
          transform: translateY(${offsetTop}px);
        }
      `}</style>
    </>
  );
};

처음 이 컴포넌트를 적용했을 때는… 잘 안 됐습니다.
헤더와 입력창이 굉장히 늦게 따라와서 사용하기 힘들어 보였는데요.

처음에는 “신기능이라 불안정한 거 아닐까?”라고 생각하면서, getBoundingClientRect() 등의 대체 방법을 사용해 보았지만 모두 비슷한 현상이 발생했습니다. 본질적으로 모멘텀 스크롤이 동작하면, 우리가 읽고 싶은 값들이 스크롤 중인 영역에 맞춰 업데이트되지 않고, 스크롤이 동작하고 난 후에 뒤늦게 업데이트되는 문제가 있었습니다. 😭

이 현상을 해결하는 게 쉽지 않았는데요. 결과적으로는 페이지 구조를 바꿔서 해결할 수 있었습니다.
기존의 페이지 구조는 메시지 목록이 body 전체에 늘어나 있고,
헤더와 입력창이 position: fixed;로 화면에 붙어 있는 구조였는데요.

이 메시지 리스트를 body가 아닌, 화면에 표시될 영역에 가두고 overflow-y: scroll;을 적용해 스크롤 가능하게 했습니다.
즉 전에는 body가 스크롤되고 있었다면, 이번에는 body가 아닌 메시지 목록 div가 스크롤 가능하도록 구조를 바꿔 보았어요.

그 결과 헤더와 입력창이 더 이상 광활한 Body를 활주하지 않게 되어, 깜빡임과 지연 현상도 없앨 수 있었습니다.

이로서 1차적인 해결은 했지만, Visual Viewport API가 iOS 13 이상에서만 지원되어,
iOS 12에서는 이 문제를 아직 해결하지 못했습니다.
이론적으로는 input의 focus 이벤트를 잡고 scrollTop, getBoundingClientRect() 등을 사용할 수는 있지만 세세한 문제가 있는데요.

가령 한국어/영어 키보드에서 이모티콘 키보드로 전환한 경우는
(위 영상에서 보실 수 있듯) 키보드의 높이가 살짝 높아지면서 입력창을 가리게 됩니다.

Visual Viewport API는 이때 resize 이벤트를 발생시켜 주지만…
iOS 12에서 이런 동작은 어떤 이벤트로 잡아낼 수 있을지 조사가 더 필요합니다.

당시 띠잉 iOS는 95% 가까운 유저가 iOS 13을 사용하고 있었기 때문에, iOS 12에서의 대응은 추후 작업으로 남겨두게 되었습니다.

남아있는 고민점

마크업, 스타일시트는 누가 짜야 할까?

띠잉셀에는 마크업과 스타일시트까지 모두 작성이 가능하신 디자이너님이 계십니다.
채팅 프론트엔드의 마크업과 스타일시트 또한 디자이너님의 손에서 완성되었는데요.

디자인을 완벽하게 구현하고 있는 마크업 및 스타일시트가 되겠지만, 이걸 그대로 애플리케이션에 적용할 수는 없습니다. 페이지에 대한 디자이너의 멘탈 모델과 개발자의 멘탈 모델이 다르기 때문입니다. 가령 컴포넌트 하나에 몰아 주어야 하는 스타일 룰이 여기저기 분산되어 있을 때에는, 원래 의도한 레이아웃에 영향이 가지 않도록 그것을 들어내는 게 쉽지 않고, 때로는 아예 새로 작성해야 할 때가 오기도 합니다.

그러다 보니 채팅 프론트엔드는 일정을 이유로, 최상위 컴포넌트에 스타일시트를 전부 몰아넣는 방법을 택했습니다. 1100줄 가까이 되는 스타일시트 하나에 모든 컴포넌트의 css rule이 존재했어요. 컴포넌트 로직은 해당 컴포넌트에서 수정하면서, 외관은 최상위 컴포넌트에서 수정해야 한다는 점이 어색하고 불편했습니다. 최근에서야 이 스타일시트를 컴포넌트별로 쪼개어 styled-jsx를 쓰는 의미를 다시 찾을 수 있었어요. 😅

이상적으로는 처음부터 개발자가 제플린을 보면서 컴포넌트에 맞는 스타일시트를 짜면 되겠지만, 백엔드 개발자에게 CSS를 부탁하는 것도 쉽지는 않아 어떤 방법이 좋을지 고민하게 됩니다.

백엔드 개발자는 웹뷰에 얼마나 투자할 수 있을까?

웹뷰에 올라오는 PR이 두 분 이상에게 코드 리뷰를 받는 일은 흔치 않습니다. 이전에 웹뷰 작업을 하셨던 분들께 리뷰를 부탁드리고 있지만, 오랜 시간이 지난 경우 생소하게 느껴지기도 하고, 또 채팅과 기존의 기능이 너무 다르다는 점도 리뷰를 어렵게 만들곤 합니다. 가령 채팅 이전에는 리듀서도, Context도 사용하지 않았거든요.

이런 상황에서 웹뷰에 새로운 기능이 추가된다는 의사결정이 들어오면, “프론트엔드 개발자는 언제 뽑나요”라는 얘기가 나오기도 합니다.

사실 저희 웹뷰는 채팅처럼 네이티브로 교체될 예정인 기능, 혹은 공지사항처럼 너무나도 작은 기능들로만 구성되어 있어서, 프론트엔드 개발자를 뽑아서 일을 맡기기에는 규모가 너무 작습니다. 그럼에도 프론트엔드 개발자를 기다리는 것은 그만큼 웹뷰를 개발하는 게 부담이 된다는 의미인 것 같아요. 그런 부담이 어디에서 나오는 것인지, 어떻게 하면 그런 부담을 줄일 수 있을지는 아직 고민하고 있는 부분입니다.

그럼에도 웹뷰를 해야 한다면

크리티컬한 기능 구현에 바로 손을 내밀 수 있다는 효능감

말 그대로, 클라이언트의 배포 주기와 독립적으로 UI를 개발 및 배포할 수 있다는 것은 웹뷰의 가장 큰 매력입니다.
클라이언트가 핵심 기능을 만드는 중에, 웹뷰는 빠르게 내놓고 반응을 보아야 하는 기능들을 병렬로 만들게 됩니다.

사랑스러운 프론트엔드 개발 환경

백엔드 개발자로서 웹뷰나 어드민을 만져보면 새삼 느낄 수 있는 부분입니다.
수정사항이 새로고침 없이 반영되는 것부터 참 매력적이지 않을 수 없는데요. 🥰

특히 채팅의 경우, 페이지 호출 시 특정 헤더값을 받아야 하는 등 앱의 구현에 의존하는 부분이 있습니다.
브라우저에서 이런 걸 재현해 내려면 확장 프로그램을 쓰는 등 불편한 점이 많은데요.
최근 Storybook을 사용해 격리된 환경에서 컴포넌트를 개발하기 시작하면서 이런 불편함을 줄일 수 있었던 것 같습니다.

팀 내에서의 성취감

iOS 뷰포트 + 키보드 이슈는 셀 내에서도 논이슈로 가야 한다, 본질적으로 해결이 어렵다 등의 의견이 제시되었는데,
미완이지만 어느 정도 문제를 해결했다는 점에서 성취감을 느낄 수 있었습니다. 😊

저만이 해결할 수 있는 문제는 아니었겠지만, 시행 착오를 거치며 문제를 해결했던 과정이 마음에 오래 남았던 것 같습니다.

마치며

비전을 제시하기가 어색할 정도로 작은 웹뷰이지만…
가능하면 앱의 디자인과 align하면서 잘 만든 웹의 느낌이 나도록 웹뷰를 개발하고 싶습니다.
그 과정에서 진입 장벽을 낮출 수 있도록 문서화에 더 신경을 쓰고, 내부적으로 지식을 공유하는 데 더 노력을 기울일 생각입니다.

(저희는 웹뷰도 IntelliJ로 개발하기 때문에 😎)

큰 변화를 앞두고 있는 띠잉 서비스를 앞으로도 많이 지켜봐 주세요.

긴 글 읽어주셔서 다시 한 번 감사드립니다!