웹에 아이폰을 띄워보자

BangDori·2024년 5월 11일
1
post-thumbnail

현재 SwiftUI에 내장될 웹뷰를 제작하고 있는데 매번 개발자도구에서 제공해주는 Toggle device toolbar를 이용하여 제작하고, 너비와 높이를 조정해나가며 반응형으로 이상이 없는지를 테스트하고 있었다.

하지만 웹 브라우저 환경에서 프로젝트를 진행하고 있기에, iOS에서 WebView를 렌더링하게 되면 UI가 어떻게 렌더링 될 지에 대한 예측이 전혀 이루어지지 않았다.

그래서 "실제 웹 브라우저에서도 iOS 환경과 동일하게 표시되도록 구축하면 어떨까?" 라는 생각을 가지게 되었다.

(1차 초안) iOS와 최대한 동일한 환경을 구축해보자

우선 처음에는 iOS와 최대한 동일한 환경으로 구축해보고자, The Ultimate Guide To iPhone Resolutions를 참고하여 iPhone SE를 기준으로 너비값을 고정하여 아래와 같이 브라우저에 렌더링되도록 하였다.

1차 초안

아이패드를 사용하는 유저가 있을 수 있기 때문에,
width 값이 1080px 이상일 경우에 대해 다음과 같이 스타일 시트를 설정해주었다.

@media (min-width: 1440px) {    
  background: #eff2f9;
  margin: 0 auto;
  
  /** ✨ iPhone SE 320 * 568 */
  width: 320px;
  
  #root {
    background: #fff;
  }
}

생각보다 빠르게 작업이 완료되었으며, 해당 작업을 통해 기존의 전체 화면으로 프로젝트를 진행할 때 보다는 더 나아진 모습이였다. 그리고 해당 화면을 통해 iPhone SE를 기준으로 실제로 해당 뷰가 어떤 식으로 보여지겠구나를 유추할 수 있게되었다.

그러나 아직까지 부족한 부분이 보인다.

  1. height 값이 설정되어 있지 않아, 실제 device에 렌더링 모습과 동일하다고 볼 수 없다.
  2. iOS에서 사용하는 단위와 브라우저에서 사용하는 단위가 다르다.

(2차 초안) iOS와 최대한 동일한 환경을 구축해보자

1️⃣ height 값 고정하기

우선 1번부터 해결해보자. height 값이 설정되어 있지 않다는 점이 문제였으니, 웹 브라우저에서도 height 값을 고정시켜서 보여주도록 해보자.

2차 초안

스타일 시트를 활용하여 레이아웃을 중앙에 배치되도록 한 이후 height 값을 고정시키고, 하단에 영역은 스크롤을 통해 확인할 수 있도록 설정하였다.

@media (min-width: 1440px) {
  background: #eff2f9;

  /** ✨ 레이아웃 중앙 배치 */
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);

  #root {
    background: #fff;
    
    /** ✨ 레이아웃의 크기 iPhone SE 1 320 * 568 */
    width: 320px;
    height: 568px;

    /** ✨  컨텐츠의 크기가 주어진 공간을 넘어가는 경우 스크롤바 생성 */
    overflow: auto;
  }
}
  1. height 값이 설정되어 있지 않아, 실제 device에 렌더링 모습과 동일하다고 볼 수 없다.

우선 위 작업을 통해서 기존의 문제점 중 1번 문제를 해결하였다. 하지만 과연 실제 device에 렌더링 모습과 동일하게 볼 수 있을까? 그렇다면 여기서 iPhone Layout을 만들고, iPhone Layout 내부에 콘텐츠들이 위치할 수 있도록 해주면 실제 device와 유사하게 렌더링되지 않을까?

2️⃣ 아이폰을 띄우자

height 값을 고정했으니 저 밋밋한 화면에서 iPhone을 적용시켜보자. 우선 자기 마음에 드는 이쁜 아이폰 사진을 하나 구하자! (나는 친구한테 이쁜 아이폰 사진을 받았다)

import { createBrowserRouter, RouteObject } from 'react-router-dom';

import { FeedMainPage } from '@/pages/index.ts';

// import { RootLayout } from '../layout'; 
import { IPhoneLayout } from '../layout'; // ✨

/**
 * 🚨 현재 iPhone Layout은 원활한 개발 환경 구축을 위해 설정되었습니다.
 * 배포할 경우에는 <IPhoneLayout />을 제거하고 <RootLayout />으로 변경해주세요.
 */
const root: RouteObject[] = [
  {
    path: '/',
    // element: <RootLayout />,
    element: <IPhoneLayout />, // ✨
    children: [{ index: true, element: <FeedMainPage /> }],
  },
];

export const router = createBrowserRouter(root);

그리고 기존에 Routing을 담당하고 있었던 RouteObject에서 RootLayout을 수정해주자. (iOS 개발자는 IPhone이라고 하는거 싫어하던데.. 컴포넌트명은 대문자 시작...)

RootLayout에서 iPhoneLayout을 구현하지 않는 이유

iPhoneLayout은 DEV 모드에서 원활한 개발 환경을 구축하기 위함이기 때문에, 실제 PROD 모드와는 전혀 무관한 레이아웃이다. 그러므로 iPhoneLayout 컴포넌트를 따로 구축하고 관리하는 것이 추후에 편리하게 배포를 진행할 수 있다.

삼항 연산자를 이용해서 개발 환경에 따라 레이아웃을 렌더링해주면 되지 않나요?

이 점도 고려하였지만, 디자이너들과의 원활한 소통을 하기 위해서는 현재 배포되어있는 개발 도메인 서버에 반영될 필요가 있다고 생각하였다. 그래서 우선 그 부분에 대해서는 고려하지 않고 RootLayout을 주석처리하고 iPhoneLayout을 렌더링하는 방향으로 코드를 작성하였다.

💡 더 좋은 아이디어가 있다면 알려주세요!!

우선 다음과 같이 IPhoneLayout을 만들어주자!

import { Outlet } from 'react-router-dom';

import iPhoneStatus from '/assets/image/iPhone_status.png';
import './IPhoneLayout.scss';

export const IPhoneLayout = () => {
  return (
    <div className='root-layout'>
      <div className='iPhone-layout'>
        <div className='client-area'>
          <img className='iPhone-status' src={iPhoneStatus} />
          <Outlet />
        </div>
      </div>
    </div>
  );
};

그리고 스타일을 적용해주면 되는데, 적용된 IPhoneLayout 스타일 시트는 현재 진행중인 프로젝트의 iPhone을 참고해서 작성하면 된다!! 이렇게 아이폰에 대한 스타일링을 입히고 웹 브라우저를 띄우면 다음과 같이 화면에 렌더링되는 것을 확인할 수 있다.

iPhone

짜잔~~ 아주 이쁜 아이폰이 보여지고, 아이폰 내부에 컨텐츠가 보여지고 있다. 정말 실제 device를 통해 내가 만든 프로젝트를 보고 있는 느낌이 든다. 자 그럼 이제 진짜 1번 문제는 해결되었다!

1. height 값이 설정되어 있지 않아, 실제 device에 렌더링 모습과 동일하다고 볼 수 없다.

"근데 생각해보니까 반응형 확인할 때는 똑같이 개발자 도구를 활용해야하잖아..?"

(최종) 반응형 아이폰 레이아웃을 구축해보자

우선 반응형 레이아웃을 구축하기 위해서는 css 속성 중 aspect-ratio에 대해서 알아야한다! 난 해당 키워드를 기억해낸다고 이곳 저곳을 뒤적거렸따.. 😵‍💫

아이폰 사이즈에 적합한 aspect-ratio 비율을 설정해주면, 아이폰의 높이가 변경됨에 따라 자동으로 너비가 변경된다. 그럼 우리는 아이폰의 높이만 관리해주면? 아이폰을 반응형으로 만들 수 있다.

우선 아이폰의 이미지를 동적으로 설정할 수 있는, 버튼을 추가해주자.

// ...
export const IPhoneLayout = () => {
  return (
    <div className='root-layout'>
      <div className='iPhone-layout'>
        // ...

        <div className='iPhone-utility-container'>
          <button className='size-down-btn'>-</button>
          <button className='retry-btn' onClick={() => location.reload()} />
          <button className='size-up-btn'>+</button>
        </div>
      </div>
    </div>
  );
};

이렇게 size를 조절해주는 버튼을 만들었다. (새로고침 버튼은 덤!) 그리고 이 버튼이 클릭되었을 때 실제로 아이폰의 크기가 조정되게 만들어줘야한다.

그러기 위해서는 아이폰 크기에 대한 값과 아이폰 Layout에 접근할 수 있는 ref를 가지고 있어야 하는데, 디자인이 수정되는 부분이라 상태가 변경되어 리렌더링 되어야할 필요는 없는 것 같다.

그래서 useRef를 활용하여 iPhoneSize와 iPhoneLayout을 동적으로 관리해주는 커스텀 훅을 뚝딱! 만들었다.

import { useEffect, useRef } from 'react';

const IPHONE_MIN_SIZE = 60;
const IPHONE_INIT_SIZE = 80;
const IPHONE_MAX_SIZE = 100;

/**
 * iPhone 레이아웃의 크기를 동적으로 변경하는 훅입니다.
 * @returns iPhone 레이아웃의 참조와 사이즈 변경 핸들러
 */
export const useDynamicSize = () => {
  const iPhoneSizeRef = useRef<number>(IPHONE_INIT_SIZE);
  const iPhoneLayoutRef = useRef<HTMLDivElement>(null);

  /**
   * iPhone 레이아웃의 크기를 변경합니다.
   */
  const changeStyle = () => {
    if (iPhoneLayoutRef.current) {
      iPhoneLayoutRef.current.style.height = `${iPhoneSizeRef.current}%`;
    }
  };

  const handleSizeUp = () => {
    if (iPhoneSizeRef.current >= IPHONE_MAX_SIZE) return;

    iPhoneSizeRef.current += 1;
    changeStyle();
  };

  const handleSizeDown = () => {
    if (iPhoneSizeRef.current <= IPHONE_MIN_SIZE) return;

    iPhoneSizeRef.current -= 1;
    changeStyle();
  };

  return { iPhoneLayoutRef, handleSizeUp, handleSizeDown };
};

handleSizeUp 함수가 + 버튼을 눌렀을 때, 아이폰의 크기를 1% 증가시키는 역할을 하고, handleSizeDown 함수가 - 버튼을 눌렀을 때 아이폰의 크기를 1% 감소시키는 역할을 한다. 이제 iPhoneLayout 함수에서 커스텀 훅을 불러와서 적용해보자.

최종

그럼 아이폰 하단에 - 버튼과 + 버튼이 생긴 것을 확인할 수 있을 것이다! - 버튼을 눌러 가장 작은 비율까지 만들고 가장 큰 비율까지 해보자.

최종_사이즈60

그럼 이제 이렇게 최대 사이즈(60%)의 아이폰과 최대 사이즈(100%)의 아이폰을 확인할 수 있다!!

최종_사이즈100

🚨 iPhone 무한 스크롤링 이슈

기존에 React Query와 react-infinite-scroller를 이용하여 피드 리스트에 대해 무한 스크롤 기능을 다음과 같이 적용하고 있었다.

export const FeedMainList = () => {
  const {
    feeds,
    fetchNextFeeds,
    isFetching,
    hasNextFeeds,
    isLoading,
    isError,
    refetchFeeds,
  } = useInfinityFeeds();
  
  // ...

  return (
    <section className='feed-list-section'>
      <InfiniteScroll
        className='feed-list'
        loadMore={() => {
          if (!isFetching) fetchNextFeeds();
        }}
        hasMore={hasNextFeeds}
        loader={<SkeletonFeedMainList key={0} count={3} />}
      >
        {feeds?.pages.map((pageData) => {
          return pageData.data.feeds.map((feed) => (
            <Feed key={feed.id} feed={feed} />
          ));
        })}
      </InfiniteScroll>
    </section>
  )
}

전혀 에러가 발생할 부분이 없어보였지만, 실제로 웹 브라우저에서 렌더링하는 아이폰 내부에서는 무한스크롤이 동작하고 있지 않았다.

해당 문제가 빠르게 해결되지 않아서 우선 팀원에게 알렸는데, 역시 우리 팀원은 너무 똑똑하다. 사용해본적 없다면서 항상 아주 잘 도와준다. (재능충인 이 남자가 궁금하다면,,, Legitgoons)

discord legitgoons

react-infinite-scroller 내부에서 무한 스크롤링을 어떻게 구현하고 있는지를 확인해보니, 실제로 scrollListener()가 아래와 같이 구현되어 있었다.

export default class InfiniteScroll extends Component {
  // ...

  scrollListener() {
    const el = this.scrollComponent; // 현재 스크롤되고 있는 노드
    const scrollEl = window; // 윈도우 객체
    const parentNode = this.getParentElement(el); // 현재 스크롤되고 있는 노드의 부모

    let offset;
    if (this.props.useWindow) {
      // useWindow 일 경우
    } else if (this.props.isReverse) {
      // isReverse 일 경우
    } else {
      // 1️⃣ 그 외의 경우
      // 현재 사용하고 있는 옵션에는 useWindow와 isRevser를 설정하고 있지 않다.
      // 그러므로 offset이 이 값으로 설정되어 있을 것이다.
      offset = el.scrollHeight - parentNode.scrollTop - parentNode.clientHeight;
    }

    // 2️⃣ offset 값과 threshold 값을 비교한다.
    if (
      offset < Number(this.props.threshold) &&
      (el && el.offsetParent !== null)
    ) {
      this.detachScrollListener();
      this.beforeScrollHeight = parentNode.scrollHeight;
      this.beforeScrollTop = parentNode.scrollTop;
 
      // 3️⃣ loadMore 함수가 등록되어 있다면, 다음 페이지를 fetching 한다.
      if (typeof this.props.loadMore === 'function') {
        this.props.loadMore((this.pageLoaded += 1));
        this.loadMore = true;
      }
    }
  }
  
  // ...

위에서 무한 스크롤을 구현한 컴포넌트를 확인해보면, loadMore props로 함수를 정상적으로 추가해준 것을 확인할 수 있다. 그러므로 1번 혹은 2번에서 발생한 이슈라는 것을 확인할 수 있었다.

근데 이를 해결하기 위한 과정에서 기본적으로 잘 동작하고 있던 코드를 수정해야 하는 부분이었기에, 기존 코드를 수정하기보다는 react-intersection-observer를 통해 옵저버를 감지하고 무한 스크롤링을 구현하는 것이 더 명시적이고 빠른 해결책이 될 것이라는 아이디어가 나오게 되었다.

그래서 우선 기존에 사용하고 있던 react-infinite-scroller와 react-intersection-observer를 비교해보았다.

react-infinite-scroller@1.2.6react-intersection-observer@9.10.2
BUNDLE SIZE6.6kB(MINIFIED), 2.2kB(MINIFIED + GZIPPED)4kB(MINIFIED), 1.6kB(MINIFIED + GZIPPED)
DOWNLOAD TIME44ms(SLOW 3G), 3ms(EMERGING 4G)32ms(SLOW 3G), 2ms(EMERGING 4G)

react-intersection-observer 번들러 사이즈가 현재 사용하고 있던 번들러 사이즈에 비해 사이즈가 작았으며, 옵저버를 이용한 무한 스크롤링 구현은 생각보다 크게 어렵지 않다고 생각되어 팀원과 이야기 한 끝에 observer를 적용하는 것으로 결정되었다.

옵저버를 구현해주고, 기존에 피드 메인 리스트 컴포넌트에 추가해주면 된다!

export const FeedMainList = () => {  
  // ...

  return (
    <section className='feed-list-section'>
      <div className='feed-list'>
        {feeds?.pages.map((pageData) => {
          return pageData.data.feeds.map((feed) => (
            <Feed key={feed.id} feed={feed} />
          ));
        })}
        {!isFetching && (
          <Observer
            isReadyForNextPage={!isFetching && hasNextFeeds}
            fetch={fetchNextFeeds}
          />
        )}
        {hasNextFeeds && <SkeletonFeedMainList count={3} />}
      </div>
    </section>
  )
}

이제 아이폰으로 돌아가 확인해보면 정상적으로 렌더링되는 것을 확인할 수 있다!!!!! 🎉🎊

후기

프로젝트를 진행하면서 이 부분에 대해 크게 불편하다고 느꼈는데, "iPhone을 웹에 띄워서 내부에 콘텐츠를 렌더링하면 프론트 팀원과 디자이너 분들이 실제 환경처럼 웹뷰를 확인하실 수 있으니까 좋아하지 않을까?" 라는 마음과 "iPhone을 웹에 띄운다니? 너무 재미있는데?" 라는 생각으로 무작정 진행했던 작업이였다.

작업하는 도중 라이브러리 충돌, 이슈 발생 등 개발 환경의 편의성을 신경 쓴다고, 실제 프로덕션 코드에 영향을 미치는 것은 아닐지에 대해 끊임없이 갈등을 가지게 되었다.

하지만 이 과정에서 실제 사용하고 있는 라이브러리를 분석하고, DEV 환경과 PROD 환경에 대해 끊임없이 생각해볼 수 있었다.

그 결과 실제 프로덕션 코드에는 영향을 미치지 않는 선에서 개발 환경의 편의성을 향상해낼 수 있었다. 너무 뿌듯하고, 개발자 참 잘 선택했다는 생각이 들었다~ (팀원들의 무수한 따봉 👍)

디스코드 따봉!

P.S.

아직 iPhone 레이아웃을 렌더링해줄 때 width와 height에서 적용되는 값이 아이폰에서 사용되는 단위와 다르다는 점에서 부족한 부분이 있지만, 해당 작업은 우선 순위가 크지 않기 때문에 시간이 나면 해결할 예정이다!

만약 진행되고 있는 프로젝트가 궁금하거나, 조언을 주시고 싶으신 분이라면 언제든 저희 Pennyway Webview에 놀러오세요~

참고

profile
Happy Day 😊❣️

0개의 댓글