projects#project#회고

ComNCheck 기술 회고

Zero-Runtime 스타일링, 플랫폼별 구글 로그인

2025년 10월 22일4분 읽기조회 12
공유

이 글은 React Native(Expo) + Next.js 기반 사이드 프로젝트 ComNCheck를 구현하면서 마주한 6가지 기술적 의사결정과 트러블슈팅 경험을 코드와 함께 정리한 회고입니다.

GitHub: https://github.com/ComNCheck

서비스: https://www.comncheck.com/


1. Styled-components → Tailwind CSS + NativeWind 마이그레이션

문제

초기에는 styled-components로 스타일링했다. 그런데 웹(Next.js)과 앱(Expo) 코드를 함께 운영하면서 두 가지 문제가 겹쳤다.

첫째, styled-components는 런타임에 CSS를 생성하기 때문에 JS 번들이 실행된 뒤에야 스타일이 적용된다. 컴포넌트가 늘어날수록 초기 렌더링 지연이 눈에 띄게 쌓였다.

둘째, 웹과 앱에서 같은 디자인 토큰을 쓰고 싶었는데, styled-components는 React Native와 완전히 호환되지 않아 결국 스타일 코드를 두 벌 작성하게 됐다.

해결

웹은 Tailwind CSS, 앱은 NativeWind로 전환했다. NativeWind는 Tailwind의 유틸리티 클래스를 React Native에서 그대로 쓸 수 있게 해주는 라이브러리다. 두 환경에서 동일한 클래스명을 사용하므로 디자인 토큰이 자연스럽게 통일된다.

// 변경 전 - styled-components
const Button = styled.TouchableOpacity`
  background-color: #3b82f6;
  padding: 12px 24px;
  border-radius: 8px;
`;

// 변경 후 - NativeWind (웹 Tailwind와 동일한 클래스명)
<TouchableOpacity className="bg-blue-500 px-6 py-3 rounded-lg" />

Tailwind / NativeWind는 빌드 타임에 스타일을 생성하는 Zero-Runtime 방식이라, 런타임에 CSS를 동적으로 계산하는 오버헤드가 없다.

결과

  • 런타임 스타일 생성 비용 제거 → 초기 렌더링 성능 개선
  • 웹/앱 동일한 클래스명으로 스타일 코드 공유 가능
  • 더 이상 플랫폼별로 스타일 파일을 이중 관리하지 않아도 됨

2. Axios Interceptor 기반 중앙화된 토큰 관리 및 환경별 로깅 분리

문제

각 API 호출마다 Authorization 헤더를 직접 붙이고, 401 에러 처리도 제각각이었다. 파일이 늘어날수록 인증 코드가 여기저기 흩어졌고, 개발/프로덕션 환경 구분 없이 민감한 요청 데이터가 콘솔에 그대로 출력되고 있었다.

해결

axios 인터셉터에서 토큰 주입과 로깅을 한 곳에서 처리했다. 로깅은 process.env.NODE_ENV로 환경을 분기해 개발 환경에서만 출력되도록 제한했다.

// src/lib/api/instance.ts

const apiInstance = axios.create({
  baseURL: process.env.EXPO_PUBLIC_API_URL,
  timeout: 10000,
});

// 요청 인터셉터 - 토큰 주입 + 개발 환경 로깅
apiInstance.interceptors.request.use(async config => {
  const token = await SecureStore.getItemAsync('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  if (__DEV__) {
    console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`);
  }

  return config;
});

// 응답 인터셉터 - 401 처리 + 에러 로깅
apiInstance.interceptors.response.use(
  response => response,
  async error => {
    if (__DEV__) {
      console.error(`[API Error] ${error.response?.status}`, error.config?.url);
    }

    if (error.response?.status === 401) {
      await SecureStore.deleteItemAsync('accessToken');
      router.replace('/login');
    }

    return Promise.reject(error);
  }
);

결과

  • 인증 코드가 instance.ts 한 곳에 완전 통합 - 신규 API 추가 시 인증 코드 불필요
  • 프로덕션 빌드에서 요청/응답 로그가 노출되지 않아 보안 강화
  • __DEV__ 플래그로 개발 중 디버깅 편의성은 그대로 유지

3. iOS / Android 구글 로그인 - 같은 방식, 다른 Client ID

문제

@react-native-google-signin/google-signin 라이브러리로 구글 로그인을 구현했다. 코드 흐름은 iOS/Android 모두 동일하게 GoogleSignin.signIn() → idToken 획득 → 서버 검증 순서였다.

그런데 막상 설정 단계에서 막혔다. Google Cloud Console에서 iOS용 앱과 Android용 앱을 각각 별도로 등록해야 했고, 발급받는 Client ID의 종류도 달랐다.

  • iOS: Google Cloud Console에서 iOS 앱을 등록하면 iOS Client ID가 발급된다. 이 값을 iosClientId로 넘기고, Info.plist에 REVERSED_CLIENT_ID를 등록해 URL Scheme 기반으로 인증 콜백을 받는다.
  • Android: Android 앱을 등록하면 Web Client ID가 발급된다. 라이브러리의 기술적 특성상 Android에서도 webClientId를 사용하며, google-services.json을 앱에 포함시키고 SHA-1 서명 지문을 콘솔에 등록해야 앱 검증이 통과된다.

Android에서만 로그인이 계속 실패했던 이유가 SHA-1 지문 등록 누락 때문이었다. iOS는 Bundle ID만 맞으면 됐지만, Android는 패키지명과 서명키를 둘 다 맞춰야 구글 서버가 앱을 신뢰한다.

해결

플랫폼을 분기해 각각의 Client ID를 주입하고, 환경변수로 분리 관리했다.

// src/hooks/useGoogleSignIn.ts

useEffect(() => {
  GoogleSignin.configure({
    // Android는 webClientId, iOS는 iosClientId 각각 사용
    webClientId: process.env.EXPO_PUBLIC_WEB_CLIENT_ID,
    iosClientId:
      Platform.OS === 'ios'
        ? process.env.EXPO_PUBLIC_IOS_CLIENT_ID
        : undefined,
    offlineAccess: false,
  });
}, []);

const onPressGoogle = async () => {
  try {
    await GoogleSignin.hasPlayServices();
    const userInfo = await GoogleSignin.signIn();
    const { idToken } = userInfo;

    // idToken을 서버로 전송해 최종 인증
    await loginWithGoogle({ idToken });
  } catch (error) {
    if (isErrorWithCode(error)) {
      if (error.code === statusCodes.SIGN_IN_CANCELLED) return;
    }
  }
};

expo-auth-session 기반의 웹뷰 OAuth 방식도 검토했지만, 해당 방식은 인앱 브라우저를 통해 인증 페이지를 여는 구조라 UX가 끊기는 느낌이 있었다. 네이티브 SDK 방식은 기기에 등록된 구글 계정을 바로 불러오기 때문에 로그인 흐름이 훨씬 자연스럽다.

결과

  • iOS / Android 모두 네이티브 계정 선택창으로 끊김 없는 로그인 UX 제공
  • 플랫폼별 Client ID를 환경변수로 분리해 코드 내 하드코딩 없음
  • SHA-1 지문 누락으로 인한 Android 로그인 실패 문제 해결

4. Expo Router + Reanimated 기반 네이티브 네비게이션 및 Splash Screen 제어

문제

초기 앱 구동 시 두 가지 문제가 있었다. 첫째, 화면 전환 애니메이션이 없어 페이지가 툭툭 끊기는 느낌이었다. 둘째, 인증 상태 확인 전에 Splash Screen이 너무 빨리 사라져 로그인 화면이 잠깐 번쩍이는 현상이 있었다.

해결

Expo Router의 파일 기반 라우팅으로 네비게이션 구조를 정리하고, Reanimated로 화면 전환 시 자연스러운 애니메이션을 추가했다. Splash Screen은 인증 상태 확인이 완료될 때까지 수동으로 제어했다.

// app/_layout.tsx

import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useState } from 'react';

// Splash Screen을 자동으로 숨기지 않도록 설정
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    const prepare = async () => {
      // 토큰 확인, 폰트 로드 등 초기 작업 수행
      await checkAuthStatus();
      await loadFonts();
      setIsReady(true);
    };
    prepare();
  }, []);

  useEffect(() => {
    if (isReady) {
      // 준비 완료 후에만 Splash Screen 해제
      SplashScreen.hideAsync();
    }
  }, [isReady]);

  if (!isReady) return null;

  return <Stack />;
}

화면 전환 애니메이션은 Reanimated의 FadeIn, SlideInRight 등을 적용해 각 화면 진입 시 자연스럽게 연결되도록 했다.

결과

  • Splash Screen이 인증 상태 확인 전에 사라지는 번쩍임 현상 제거
  • 화면 전환 시 네이티브 수준의 애니메이션으로 UX 개선
  • 파일 기반 라우팅으로 딥링크 및 탭 네비게이션 구조가 명확해짐

5. Next.js App Router + Turbopack 적용 및 SEO 최적화

문제

웹 서비스는 Next.js로 구성했는데, 기존 Pages Router 구조에서 HMR(핫 모듈 교체) 속도가 느려 개발 생산성이 저하됐다. 또한 검색 엔진에 노출되어야 하는 페이지들의 메타태그가 제대로 설정되어 있지 않았다.

해결

App Router로 전환하고, next.config.ts에 Turbopack을 활성화해 빌드/HMR 속도를 단축했다. SEO는 Next.js의 Metadata API로 페이지별 메타태그를 정적/동적으로 구성했다.

// next.config.ts
const nextConfig = {
  experimental: {
    turbo: {},
  },
};

// app/layout.tsx - 기본 메타태그
export const metadata: Metadata = {
  title: {
    default: 'ComNCheck',
    template: '%s | ComNCheck',
  },
  description: '커뮤니티 공지를 한눈에 확인하세요',
  openGraph: {
    siteName: 'ComNCheck',
    images: [{ url: '/og-image.png' }],
  },
};

// app/notice/[id]/page.tsx - 동적 메타태그
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const notice = await getNotice(params.id);
  return {
    title: notice.title,
    description: notice.summary,
  };
}

결과

  • Turbopack 적용으로 HMR 속도 체감상 유의미하게 단축
  • 페이지별 title, description, OpenGraph 태그 자동 생성으로 검색 노출 개선
  • App Router의 Server Component로 초기 로딩 시 JS 번들 크기 감소

6. Google Analytics 모듈화 - 플랫폼별 최적화 탑재

문제

웹과 앱 모두 사용자 행동 데이터를 수집하고 싶었지만, 각 플랫폼의 GA 연동 방식이 달라 코드가 파편화될 위험이 있었다. 또한 개발 환경에서 이벤트가 실제 GA로 전송되면 데이터가 오염된다.

해결

GA 이벤트 전송 로직을 플랫폼별로 모듈화하고, 개발 환경에서는 콘솔 출력만 하도록 분기했다.

// src/lib/analytics/index.ts

const isDev = process.env.NODE_ENV === 'development' || __DEV__;

export const logEvent = (eventName: string, params?: Record<string, unknown>) => {
  if (isDev) {
    // 개발 환경: GA 전송 없이 콘솔 출력만
    console.log(`[Analytics] ${eventName}`, params);
    return;
  }

  if (Platform.OS === 'web') {
    // 웹: gtag 사용
    window.gtag?.('event', eventName, params);
  } else {
    // 앱: @react-native-firebase/analytics 사용
    analytics().logEvent(eventName, params);
  }
};

// 사용 예시
logEvent('notice_view', { notice_id: id, category: '공지사항' });

웹의 경우 app/layout.tsx에서 환경변수가 있을 때만 GA 스크립트를 삽입해 개발/프로덕션 환경을 완전히 분리했다.

결과

  • 웹/앱 모두 동일한 logEvent 인터페이스로 이벤트 전송
  • 개발 환경에서 GA 데이터 오염 없이 콘솔로 검증 가능
  • 페이지뷰·이벤트 데이터 기반으로 서비스 개선 우선순위 결정 가능

마치며

이번 프로젝트에서 얻은 핵심 인사이트는 "어디서 처리할 것인가"를 먼저 결정하면 나머지는 따라온다는 것이다.

인터셉터로 인증을 한 곳에 묶고, Analytics를 모듈화하고, Splash Screen 제어권을 직접 쥔 것 - 전부 같은 맥락이다. 기능마다 흩어져 있던 책임을 경계를 그어 모아놓으면, 플랫폼이 둘(웹+앱)이어도 코드는 훨씬 단순해진다.

iOS와 Android 구글 로그인을 맞추면서 배운 것도 비슷하다. 코드 흐름은 같아도 플랫폼이 앱을 신뢰하는 방식(Bundle ID vs SHA-1)이 달랐다. 라이브러리 문서만 읽어서는 몰랐을 내용을 직접 막혀보고 나서야 정확히 이해하게 됐다.

다음엔 "막히면 파악한다"보다 "플랫폼 특성을 초기 설계에 반영한다"로 한 단계 올라가고 싶다.

관련 포스트

댓글

방명록을 남겨주세요