에뮬레이터, 또는 Expo로 연결된 모바일 기기를 가로로 기울인다고 해서 화면이 자동으로 돌아가지는 않는다.

landscape 모드를 적용하고자 한다면, 이에 따라 설정과 스타일링을 바꿔줘야 한다.

 

 

 

우선 app.json파일을 열고, orientation 속성을 "default"로 바꿔준다.

  "expo": {
    "name": "navigate",
    "slug": "navigate",
    "version": "1.0.0",
    "orientation": "default",	//기본값은 portrait
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "light",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },

 

 

참고로 orientation은 아래 값을 가질 수 있다.

  • default : 가로, 세로 모드 모두 지원
  • portrait : 세로 모드로 고정
  • landscape : 가로 모드로 고정
  • portrait_up : 세로 모드이되, 상단이 항상 위쪽을 향함
  • portrait_down : 세로 모드이되, 상단이 아래쪽을 향함
  • landscape_left : 가로 모드이되, 기기의 왼쪽이 위쪽을 향함
  • landscape_right : 가로 모드이되, 기기의 오른쪽이 위쪽을 향함

 

 

app.json의 변경 사항을 저장했다면, 에뮬레이터를 회전시켰을 때 정상적으로 반응하는 것을 확인할 수 있다.

컴포넌트로 돌아가 스타일링을 해보자.

 

 


 

 

 

현재 모드가 가로 모드인지 세로 모드인지 어떻게 추적할 수 있을까?

기본적으로 현재 화면의 가로, 세로 길이를 측정하고, 길이를 비교하여 조건을 적용하면 된다.

 

 

 

가로, 세로 길이 재는 방법은 두가지가 있다.

Dimensions

import { Dimensions, Text, View } from 'react-native';	//임포트 잊지 않을 것

const ScreenInfo = () => {
 const { width, height } = Dimensions.get('window');
  const isWideView = width > height;


  return (
    <View>
      <Text>{ isWideView ? 가로 모드 : 세로 모드 }</Text>
    </View>
  )
}

 

 

Dimensions의 get 메소드를 통해 가로와 세로의 길이를 구한다.

  • Dimensions.get('window') : 상태바나 네비게이션 바를 무시하고, 사용자가 상호작용할 수 있는 앱의 영역을 측정
  • Dimensions.get('screen') : 상태바와 네비게이션 바를 모두 포함한 전체 화면의 길이 측정
    • 다만 초기값만 가져온다는 단점이 있다.
    • 즉, 기기를 반복적으로 뒤집더라도 이 상태 변화를 감지하지 못하기 때문에 별도의 eventListener를 생성해야 한다.

 

 

 

useWindowDimensions()

import { useWindowDimensions, Text, View } from 'react-native';	//임포트 잊지 않을 것

const ScreenInfo = () => {
 const { width, height } = useWindowDimensions();
  const isWideView = width > height;


  return (
    <View>
      <Text>{ isWideView ? 가로 모드 : 세로 모드 }</Text>
    </View>
  )
}

 

useWindowDimenstions() 는 Dimensions와 유사하지만, 가로와 세로 변화를 실시간으로 감지하고 업데이트 해준다는 장점이 있다.

 

 

 


 

 

 

이것을 현재 실습 중인 프로젝트에 적용해보았다.

스타일링은 우선 StyleSheet (내장 라이브러리)를 사용했다.

 

import { StyleSheet, View, Text, Pressable, useWindowDimensions } from "react-native";

interface ProjectProps {
  title: string;
  color: string;
}

export default function Project({ title, color }: ProjectProps) {
    const { width, height } = useWindowDimensions()
    const isWideView = width > height;
  
    return (
    <View style={[styles.gridItem, isWideView && styles.gridItemWide]}>
      <Pressable>
        <View>
          <Text>{title}</Text>
        </View>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  gridItem: {
    flex: 1,
    margin: 16,
    height: 150,
    borderWidth: 1,
    borderColor: 'red',
  },

  gridItemWide: {
    height: 180,
    margin: 20,
  }
});

 

 

에뮬레이터를 회전할 때마다 덮어쓴 height와 margin이 잘 적용 및 해제되는 것을 확인할 수 있다.

 

 

 

 


 

 

 

작은 문제가 생겼다.

위 영상처럼 현재 데이터 리스트를 카드 형태로 FlatList에 순서대로 뿌려주고 있었다.

세로뷰에서는 2줄 씩 출력되던 것을 가로뷰에서는 3줄로 출력되도록 수정하고 싶었다.

따라서 아래와 같이 해당 출력 컴포넌트의 코드를 수정했다.

import { StyleSheet, FlatList, useWindowDimensions } from "react-native";
import { CATEGORIES } from "../assets/data/dummy";
import CategoryGridTile from "../components/CategoryGridTile";

function renderCategoryItem(itemData: any) {
  return (
    <CategoryGridTile title={itemData.item.title} color={itemData.item.color} />
  );
}

function CategoryScreen() {
    const { width, height } = useWindowDimensions()
    const isWideView = width > height;
  

  return (
    <FlatList
      data={CATEGORIES}
      keyExtractor={(item) => item.id}
      renderItem={renderCategoryItem}
      numColumns={isWideView ? 3 : 2}	// 출력될 줄 수에 삼항연산자를 적용했다.
    />
  );
}

export default CategoryScreen;

 

 

 

그러나 저장 후 가로뷰로 에뮬레이터를 뒤집는 순간, 아래 에러가 발생했다.

더보기

Invariant Violation: Changing numColumns on the fly is not supported. Change the key prop on FlatList when changing the number of columns to force a fresh render of the component.

위 에러는 FlatList의 numColumns를 실시간으로 변경할 수 없다는 뜻이다.

 

 

 

이런 경우는 해당 FlatList를 재렌더링을 하는 방식으로 줄 수를 바꿔야 한다.

 

 

 

그렇다면 어떻게 재렌더링을 트리거할 수 있을까?

기기를 회전했을 때 해당 리스트의 key를 바꿔서 다시 받아오는 방법으로 재렌더링이 가능하다.

 

//전략
  return (
    <FlatList
      data={CATEGORIES}
      keyExtractor={(item) => item.id}
      renderItem={renderCategoryItem}
      numColumns={isWideView ? 3 : 2}	// 삼항연산자 그대로 사용
      key={isWideView ? 'wide' : 'portrait'}  // FlatList의 key를 바꿔서 강제 리렌더링
    />
  );

 

 

위처럼 item 각각의 key는 이전과 마찬가지로 keyExtractor로 부여한다.

그리고 FlatList 덩어리 자체에 key를 추가하고, 이것을 가로, 세로 모드가 바뀔 때 다르게 적용하여 다시 렌더링되게 한다.

 

 

 

 

 

 

위 방법은 유저가 화면을 반복적으로 돌리는 경우 계속해서 컴포넌트 리렌더링이 일어나기 때문에 성능에 영향을 줄 수 있다.

 

다만, FlatList는 많은 데이터를 열거하더라도 보여지는 화면 영역에 대한 데이터를 우선 처리하기 때문에 성능 문제를 조금 줄일 수 있다.

만일 해당 컴포넌트가 복잡한 계산을 요하는 함수로 이루어져 있다면, useMemo() useCallback() 사용을 고려해보아도 좋겠다.

 

'React Native' 카테고리의 다른 글

Navigator 스타일링  (0) 2024.10.15
숫자 맞추기 게임 - 히스토리 상태 받아오기  (1) 2024.10.03
숫자 맞추기 게임  (8) 2024.09.30
React Native 기초  (0) 2024.09.26

 

 

타입스크립트로 작성하다 보면 경우에 따라 타입 선언 방식의 차이가 보인다.

 

interface AProps {
  title: string;
}

// FC: Functional Component
const AComponent: React.FC<AProps> = ({ title }) => {
  return (
    <h1>{title}</h1>
  )
}

 

interface BProps {
  title: string;
}

const BComponent = ({ title }: BProps) => {
  return (
    <h1>{title}</h1>
  )
}

 

 

위 A, B 컴포넌트의 props 타입 선언 방식은 조금 다르지만 같은 기능을 수행하는 것처럼 보인다. 

가장 큰 차이점은 아래와 같다.

 

React.FC 선언

  • chidren이 prop에 자동으로 포함되고, 사용할 수 있다.
  • 반환 타입이 ReactElement 으로 고정되어, 따로 반환 타입을 선언할 필요가 없다.
  • 다만 children이 자동으로 포함되기 때문에 children을 반환하지 않는 컴포넌트에서는 사용이 불필요할 수 있다.

직접 선언

  • 필요한 prop 만 사용하므로 코드의 의도를 보다 명확하게 표현할 수 있다.
  • 반환 타입을 명시하지 않으면 Typescript가 자동으로 추론한다. 
  • children이 필요하다면 따로 타입 선언을 해주면 된다. ( children: React.ReactNode; )

 

 

실제로 더욱 명확한 타입 선언을 위해 B 케이스 작성 방식을 선호하는 추세라고 하고, 가독성과 정확성이 우수해보인다.

 

프로그램이 이전에 던진 정수를 화면에 나타내어 쌓아볼 수 있을까?

이 추측 히스토리(?)를 배열 형태로 만들고, 상태 관리를 해보자.

 

const [rounds, setRounds] = useState([initialGuess]) // 배열에 최초 정수 하나로 시작

 

참고로 initialGuess를 설정하는 방법은 바로 이전 글에 메모해두었다.

 

 

 

이 상태를 유저가 up, down 버튼을 눌렀을 때, 즉 nextGuessHandler가 동작할 때마다 추가되도록 설정해준다.

const nextGuessHandler = () => {

// 중략

  const newRandomNum = generatFeNumBetween(
    minBoundary,
    maxBoundary,
    currentGuess
  );

  setCurrentGuess(newRandomNum);
    
  setRounds((prevRound) => [newRandomNum, ...prevRound]);
};

 

 

다시 말해 initialGuess가 50이라고 가정하고, 차례로 5, 12, 80, 40 이라는 랜덤 정수가 생성됐다면 배열은 아래처럼 변한다.

  1. [50] 
  2. [5, 50]
  3. [12, 5, 50]
  4. [80, 12, 5, 50]
  5. [40, 80, 12, 5, 50]

 

 

막연히 unshift 메소드를 사용하려 시도했는데, 오히려 스프레드 연산자를 사용하는 편이 직관적이어 보인다.

 

 

 

+

아래처럼 순서를 바꾼다면 배열의 맨 뒤에 추가되도록 만들 수도 있다.

  setRounds((prevRound) => [...prevRound], newRandomNum]);

 

 

 

이제 화면에 배열이 업데이트될 때마다 추가되도록 작성한다.

  <View>
   {rounds.map(rounds => <Text key={rounds}>{rounds}</Text>)}
  </View>

 

 

 

 

 

유저가 입력한 1 이상 99 이하의 정수 를 컴퓨터가 맞춰내는 게임을 만들고 있다.

 

  1. 유저가 입력한 정수를 제외하고 (즉, 프로그램은 첫번째 시도에서 정답을 맞출 수는 없다)
  2. 랜덤 정수를 생성한다.
  3. 이에 따라 유저가 Up or Down 을 입력하면
  4. 프로그램은 그 랜덤 정수를 최대값 or 최소값 으로 업데이트하여
  5. 새로운 랜덤 정수를 생성한다.
  6. 이를 정답을 맞출 때까지 반복한다.

 

(임포트 문은 생략했다)

// min, max : 최소값, 최대값
// exclude: 제외할 정수 (최초 입력값 & 이미 생성한 랜덤값)
const generateNumBetween = ( min: number, max: number, exclude: number): number => {
  const randomNum = Math.floor(Math.random() * (max - min)) + min; // 랜덤 정수 생성

  //컴퓨터는 첫 시도에서 답을 맞출 수 없음.
  if (randomNum === exclude) {
    //생성된 숫자가 제외돼야하는 정수와 같다면 다시 생성
    return generateNumBetween(min, max, exclude);
  } else {
    return randomNum;
  }
};

 

 

매개변수 min, max, exclude는 숫자형이고, 이들을 통해 실행된 함수 generateNumBetween은 숫자형을 반환함을 명시한다.

 

 

랜덤 정수를 생성한 방법을 보다 자세히 설명해보자면 아래와 같다.

 const randomNum = Math.floor(Math.random() * (max - min)) + min
 
 
 
 max - min // 범위 크기 설정 (예: 1부터 100이라면 범위는 99)
 
 Math.random() // 0부터 1사이의 난수 생성
 
 Math.random() * (max - min) // 범위 내의 난수 생성
 
 Math.floor() // 소수점 아래 자리 반올림
 
 Math.floor(Math.random() * (max - min)) // 생성한 랜덤 난수의 소수점 아래 자리 반올림
 
 + min // min 기준으로 원하는 범위로 이동 (현재는 100 - 1 = 99 -> 0부터 99 이므로, 1부터 100으로 교정)
 
 Math.floor(Math.random() * (max - min)) + min // 완성

 

 

이제 게임 화면을 구성한다.

// 타입 선언
interface GameScreenProps {
  userNumber: number;
  onGameOver: () => void;
}

  // 최소값, 최대값 변수 설정
  let minBoundary = 1;
  let maxBoundary = 100;

export default function GameScreen({ userNumber, onGameOver }: GameScreenProps) {
  // 첫번째로 추측할 랜덤 정수
  const initialGuess = generateNumBetween(minBoundary, maxBoundary, userNumber);

  const [currentGuess, setCurrentGuess] = useState(initialGuess);

  useEffect(() => {
    if (currentGuess === userNumber) {
      onGameOver()	// 정답을 맞춘다면 게임오버 화면으로
    }
  }, [currentGuess, userNumber, onGameOver]);	

  //유저가 +,-를 올바른 방향으로 사용하도록 유도
  const nextGuessHandler = (direction: string) => {
    if (
      (direction === "lower" && currentGuess < userNumber) ||
      (direction === "greater" && currentGuess > userNumber)
    ) {
      Alert.alert("정말인가요?", "다시 한 번 생각해보세요.", [
        { text: "아차차", style: "cancel" },
      ]);
      return;	// return을 쓰지 않으면 alert창이 뜨는 동시에 새로운 정수가 생성된다.
    }

    //수를 올릴 방향. 키울지 줄일지?
    if (direction === "lower") {
      maxBoundary = currentGuess;
    } else {
      minBoundary = currentGuess + 1;
    }
    console.log(minBoundary, maxBoundary); //범위값이 계속 업데이트 되는 것을 확인할 수 있다
    
    // 새로운 랜덤 정수 생성
    const newRandomNum = generateNumBetween( minBoundary, maxBoundary, currentGuess );
    setCurrentGuess(newRandomNum); //업데이트된 최소값, 최대값, 최근에 썼던 정수를 배제
  };

 

 

useEffect문의 의존성 배열에 currentGuess, userNumber, onGameOver 가 들어가는 이유?

currentGuess

  • currentGuess와 userNumber가 일치하는지 여부를 확인하여 게임을 종료해야 한다.
  • 따라서 currentGuess가 바뀔 때마다 if (currentGuess === userNumber) 를 검사한다.

userNumber

  • userNumber가 바뀐다는 것은 새로운 게임이 시작되었다는 것을 의미하므로, (유저가 게임 도중 바꾸는 경우 포함( 이 로직을 다시 실행해야한다

onGameOver

  • onGameOver 는 정답 맞췄을 때 실행되는 콜백함수이다.
  • 따라서 onGameOver의 함수 구현이 변경되었다면 useEffect문을 수행해야 한다.

 

 

 

 

마지막으로 리턴문을 작성한다.

  return (
    <View>
      <NumberContainer>{currentGuess}</NumberContainer>
      <View>
        <Text>Higher or Lower?</Text>
        <View>
          <Button onPress={nextGuessHandler.bind(this, "lower")}>
            -
          </Button>
          <Button onPress={nextGuessHandler.bind(this, "greater")}>
            +
          </Button>
        </View>
      </View>
    </View>
  );
}

 

 

 

 


 

 

 

 

그런데 여기서 프로그램이 정답을 맞춘 순간 Render Error: Maximun call stack size exceeded 가 반환된다.

 

 

 

 

이 문제는 렌더링 순서와 관련이 있다.

 

기본적으로 React의 렌더링 순서는 아래와 같다.

  1. 컴포넌트의 모든 함수를 실행
  2. useState 초기화
  3. useEffect 실행

 

이 경우에 대입하여 설명하자면, 렌더링은 아래와 같은 순서로 실행된다.

  1. generateNumBetween 함수를 실행하여 initialGuess 생성
  2. initialGuess 값을 받아 상태를 초기화
  3. initialGuess 와 currentGuess 에 초기값을 설정한 후 useEffect 함수 실행

 

결론

  1. useEffect문은 currentGuess가 변경될 때마다 실행되어 게임 종료 조건을 확인하는 것이 최종 목적인데-
  2. 정답을 고른 순간, nextGuessHandler가 실행되고 setCurrentGuess를 호출하여 상태를 업데이트하고 리렌더링을 준비한다.
  3. 그러나 정답은 minBoundary와 maxBoundary가 같은 경우이므로, 유효한 범위가 없기 때문에 nextGuessHandler는 새로운 랜덤 정수를 생성할 수 없다.
  4. 이 때 generateNumBetween는 유효한 범위를 찾을 때까지 재귀적으로 계속 호출되게 된다.
  5. 따라서 useEffect문보다 먼저 실행되는 generateNumBetween함수가 무한 루프에 걸려 useEffect문은 실행되지 못하고, 이에 따라 재렌더링이 미처 이루어지지 못한다.

 

 

 

해결 방법

절대적으로 안전한 방법일 것 같지는 않아, 추후 더 고민해보기

//이전 코드
const initialGuess = generateNumBetween(minBoundary, maxBoundary, userNumber);

//바꾼 코드
const initialGuess = generateNumBetween(1, 100, userNumber);

 

위처럼 초기 최소값, 최대값을 고정하여 하드코딩 하는 방법이 있다.

이렇게 하면 generateNumBetween은 항상 유효한 범위를 가질 수 있다.

따라서 유효한 범위는 지키는 선에서 에러를 반환하던 함수를 해결하고, useEffect문을 수행해 화면 전환을 이룰 수 있다.

 

React.ts 프로젝트를 거의 마무리하며, 비로소 타입, 컴포넌트, 상태 관리 등 웹 개발에서의 가장 기본적인 개념을 알게 됐다. 

다음 프로젝트는 또다른 프레임워크를 경험해보기 위해 React Native를 익히려 한다.

 

 

지금까지 사용해온 React.ts는 웹 애플리케이션을 만들 수 있지만, 모바일 애플리케이션을 직접 만들 수는 없다. 

모바일 애플리케이션을 개발하기 위해서는 추가적인 프레임워크가 필요한데, React Native가 대표적인 예이다.

주요 특징은 아래와 같다.

 

  • 브라우저가 아닌 네이티브 모바일 UI 컴포넌트 (Android, iOS)를 사용한다. 
  • 두가지 OS를 동시에 개발할 수 있다.
  • React와 코드 작성 방식이 유사하다.
  • 네이티브 모듈을 통한 성능 최적화가 가능하다.

 

 

그렇다면 회사에서 본 QA팀처럼 거의 모든 디바이스를 가지고 있어야 하나...? 나는 아이폰 한대 뿐인데...

 

 

 

 

 

 

이러한 문제점을 해결하기 위해 다양한 디바이스 환경을 쉽게 확인하도록 해주는 상위 프레임워크가 있다.

기본적으로 React Native CLI(Command Line Interface)와 Expo로 나뉜다.

 

 

React Native CLI

장점

  • 네이티브 코드에 접근 및 커스터마이징이 가능하다.
  • 더 많은 네이티브 기능(카메라, 지도 등) 및 모듈을 사용할 수 있다.

단점

  • 초기 설정이 복잡하다.
  • 별도의 Android와 iOS 빌드를 설정하고 배포해야 한다.

 

Expo

장점

  • 초기 구성이 이미 구성되어 있어 가벼운 앱을 빠르게 만들기에 유용하다.
  • 앱 푸쉬, 애플 로그인 등 기능이 탑재되어 있다.
  • Expo Go를 통해 실제 디바이스 환경을 빠르게 확인할 수 있다.

단점

  • 네이티브 코드를 수정할 수 없어 개발의 한계가 있다.
  • Expo에서 지원하지 않는 라이브러리를 사용할 수 없다.

 

 

 


 

 

 

 

이제 프로젝트를 생성해보자.

npm install -g expo-cli

 

이번에도 타입스크립트 탬플릿을 사용하려 한다.

npx create-expo-app MyNewApp --template blank-typescript

 

생성된 프로젝트를 실행시키기 위해서는 아래 명령어를 입력한다.

npx expo start

 

 


 

 

 

로컬 서버를 실행시키니 EMFILE: too many open files 에러가 발생했다. 

 brew update
 brew install watchman

 

위 방법으로 해결할 수 있다는 글이 많았으나, 나의 경우 해결되지 않았다.

프로젝트 폴더로 이동하여 node_modules 폴더를 삭제하고, 아래 명령어로 다시 설치했더니 정상적으로 실행할 수 있었다.

Stack Overflow

npm install

 

 

 

 

 

이전에 구현했던 좋아요 기능에서 한가지 문제가 있었다.

firebase db에서 사용자가 좋아요 표시한 목록을 가져와 버튼 눌림 여부가 잘 표시되다가, 해당 페이지에서 새로고침을 하면 버튼 상태가 모두 초기화됐다. (db에는 정보가 잘 남아있는데도 불구하고)

 

가장 먼저 일차원적인 해결책을 생각해보았으나, 다음과 같은 문제가 있었다.

  •  로컬에 상태값을 저장해두기?
    • 로그아웃 한 경우, 이 값이 남아있을 수 있다. 
  • 해당 페이지 새로고침 방지?
    • 당장으로써는 좋을 수 있으나, 서비스 사용성을 생각한다면 이 기능은 회원가입 또는 정보 입력 등 호흡이 긴 페이지에서 유저의 노력(?)이 사라지지 않도록 방지하기 위함 등 보수적으로 사용하는 편이 좋다.

 

 

기본적으로 클라이언트에서 관리하는 전역 상태 관리는 새로고침 했을 때 초기화된다.

다시 말해, 로그인 정보나 인증 상태 등을 클라이언트 애플리케이션 내부에서 관리한다는 뜻이다. 현재 사용하고 있는 React와 같은 프레임워크에서는 이러한 상태를 전역 상태로 저장하여 기억한다. 그러나 새로고침을 한다면 클라이언트가 새로 렌더링되기 때문에 메모리 안의 상태가 모두 사라진다.

 

이 문제를 해결하기 위해 세션 저장소 또는 쿠키 등 영구적인 저장소를 이용해 유지해야 한다.

  • 세션(Session): 브라우저 탭 또는 윈도우가 열려있는 동안에만 데이터를 유지
  • 쿠키(Cookie): 서버 또는 클라이언트 측에서 일정 기간을 설정하여 유지.

 

 

우선 기존 로그인 로직을 확인해보니, 세션 스토리지를 잘 형성해두었다.

useEffect(() => {
    if (currentlyLoggedIn) {
      navigate("/profile"); // 이미 로그인된 상태라면 프로필 페이지로
    }
  }, [currentlyLoggedIn, navigate]);

  const handleSignIn = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    try {
      await setPersistence(auth, browserSessionPersistence);  // 세션에 유저 정보 저장
      const userCredential = await signInWithEmailAndPassword(
        auth,
        signInEmail,
        signInPw
      );
      console.log(userCredential);
      const user = userCredential.user;

      if (rememberMe) {
        // 체크된 경우
        localStorage.setItem("username", signInEmail);
      } else {
        localStorage.removeItem("username");
      }

      setCurrentlyLoggedIn(true);
      setIsLoggedIn(true);
      navigate("/"); // 성공 시 홈으로 이동
    } catch (error) {

 

기존 로그아웃 버튼은 아래와 같다.

import React from "react";
import { auth } from "../firebase/firebaseConfig";
import { useNavigate } from "react-router-dom";
import { useContext } from "react";
import { AuthContext } from "../context/AuthContext";
import s from "../stores/styling";
import useModal from "../hooks/ModalHook";
import Modal from "../components/Modal";

interface LogoutButtonProps {
  className?: string;
}

const LogoutButton: React.FC<LogoutButtonProps> = ({
  className,
}) => {
  const { setCurrentlyLoggedIn } = useContext(AuthContext);
  const { isModalOpen, handleOpenModal, handleCloseModal } = useModal();
  const navigate = useNavigate();


  const handleLogout = () => {
    setCurrentlyLoggedIn(false); // 로그아웃 처리
    handleCloseModal(); // 모달 닫기
    navigate("/"); // 로그아웃 후 홈으로 이동
  };
  
  return (
    <>
      <Modal
        isOpen={isModalOpen}
        onClose={handleCloseModal}
        modalTitle={"잠깐!"}
        showCheckbox={false}
        text={
          <s.StyledP className="modal-text">
            정말 로그아웃 하시겠습니까?
          </s.StyledP>
        }
        modalButtonClose={"취소"}
        addButton={true}
        modalButtonOption={"로그아웃"}
        onOptionClick={handleLogout}
      />

      <s.OutIcon onClick={handleOpenModal} />
    </>
  );
};

export default LogoutButton;

 

 

세션이 제대로 형성되었음에도 왜 이같은 오류가 나타나는 것일까?

문제는 await auth.signOut()으로 명확한 로그아웃을 설정해주지 않았다는 것이다.

 

 

로그아웃을 확실하게 설정하지 않은 경우, 다음과 같은 문제가 발생할 수 있다.

  • 세션에 상태가 제대로 저장되지 않아 로그인 정보가 손실될 수 있다.
  • 새로고침, 탭 다시 열기 등 동작을 수행했을 때 세션 정보가 불완전하게 남아있거나, 비정상적으로 초기화 될 수 있다.

 

따라서 아래와 같이 코드를 일부 수정했다.

  const handleLogout = async () => {
    try {
      await auth.signOut(); // Firebase 로그아웃 처리
      setCurrentlyLoggedIn(false); // 로그아웃 후 로컬 상태 업데이트
      navigate("/"); // 로그아웃 후 홈으로 이동
      handleCloseModal(); // 모달 닫기
    } catch (error) {
      console.error("로그아웃 중 오류 발생:", error);
    }
  };

 

위와 같이 로그아웃 로직을 정리해주니, 위시리스트 목록에 있는 요소의 버튼 상태가 페이지 새로고침 이후에도 정상적으로 표시됐다.

 

 


 

 

 

비슷한 맥락으로, 유저 프로필 페이지에서도 로그인을 했는데도 새로고침을 하는 순간 모든 userdata가 받아지지 않고 초기화되는 현상이 있었다.

아래는 기존 프로필 페이지에서 유저 정보를 가져오는 방법이다.

  useEffect(() => {
    const fetchUserData = async () => {
      if (auth.currentUser) {
        const userDocRef = doc(db, "users", auth.currentUser.uid);
        const userDoc = await getDoc(userDocRef);
        if (userDoc.exists()) {
          const data = userDoc.data();
          setUserData(data);
          setPhotoURL(data.photoURL);

        } else {
          console.log("No such document!");
        }
      }
    };
    fetchUserData();
  }, []);

 

위 코드의 문제점은, 유저의 로그인과 로그아웃 상태를 auth.currentUser를 직접 사용하여 확인하는데, 컴포넌트 마운트 시 한 번만 실행된다는 점이다. 

auth.currentUser가 존재할 때 데이터를 가져와야 하지만  auth.currentUser의 값이 즉시 업데이트 되지 않을 수 있다. 따라서 사용자 인증 상태가 변경된 경우 바로 반영되지 않을 가능성이 있다. 

만일 사용자의 로그인 상태가 바뀌면 이 변화를 감지하지 못하고, 새로고침을 해야 데이터를 다시 불러올 수 있다.

 

useEffect(() => {
  const unsubscribe = onAuthStateChanged(auth, (user) => {
    if (user) {
      // 유저가 로그인되어 있을 때만 데이터를 가져옴
      fetchUserData(user.uid);
    } else {
      setUserData(null); // 유저가 없다면 null로 설정
    }
  });
  return () => unsubscribe(); // 컴포넌트 언마운트 시 구독 해제
}, []);

 

수정된 코드에는 onAuthStateChanged()사용하여 인증 상태의 변화를 실시간으로 자동 감지하도록 보완했다.

이로써 사용자가 로그인, 또는 로그아웃 할 때마다 이벤트가 발생하기 때문에 별도의 새로고침을 할 필요가 없다.

 

 

 

 

지난 글에서 언급했듯이, 어떻게 하면 이전 요소가 뷰포트 상단을 기준으로 고정되고 스크롤 하면 그 다음 요소가 이전 요소 위에 카드처럼 덮이면서 쌓여나가도록 만들 수 있을까?

 

GSAP의 Pin 개념을 익힌다면 가능하다.

  • pinning의 기준점은 trigger 요소의 start 와 일치하고, scroller-start에 고정된다.
  • trigger 요소의 end 점이 scroller-end에 도달하면 pin이 풀리게 된다.

 

아래 영상에 나오는 tomato 색 div에 주목해보자.

 

 

1. pin 적용 전

gsap.to(".c", {
    scrollTrigger: {
      trigger: ".c",
      start: "top center",
      end: "top 100px",
      scrub: 1,
      markers: true,
    },
    x: 1200,

    ease: "none",
    duration: 3,
  });

 

 

2. pin 적용 후

gsap.to(".c", {
    scrollTrigger: {
      trigger: ".c",
      start: "top center",
      end: "top 100px",
      scrub: 1,
      pin: true <- 바뀐 부분
      markers: true,
    },
    x: 1200,
    ease: "none",
    duration: 3,
  });

 

 

 

위처럼 스크롤했을 때 c블럭이 scroller-start 지점에 들어가는 순간부터 애니메이션이 진행되는데, Y좌표는 시작점에서 벗어나지 않다가 scroller-end 지점을 만나고서야 일반적인 스크롤이 되는 것을 확인할 수 있었다.

 

 

 

 


 

 

 

 

이제 조금 더 응용해서 만들고자 했던 것에 가까워져 보자.

기존의 div 구성은 같되 너비와 높이가 화면에 가득차게 만들었다.

그리고 .box 라는 공통 클래스를 기준으로 배열을 생성하고, 요소마다 각각 ScrollTrigger을 달아준다.

gsap.utils.toArray(".box").forEach((box, i) => {
    ScrollTrigger.create({
        trigger: box,
        start: "top top",
        markers: true,
    })
})

 

 

이렇게 하면 일반적인 스크롤과 다를 바 없다.

이제 pin을 추가해보자.

gsap.utils.toArray(".box").forEach((box, i) => {
    ScrollTrigger.create({
        trigger: box,
        start: "top top",
        pin: true,
        markers: true,
    })
})

 

 

이전 블럭의 end 포인트가 scroller-end 지점을 나가기 전까지는 화면에 고정되어 있는 것처럼 보인다.

그러나 그 end 포인트가 나가면 해당 블럭도 다시 스크롤되어 올라간다.

이것까지 막으면 정말 레이어가 쌓이는 것처럼 만들 수 있을 것으로 보인다.

이 점은 pinSpacing 속성을 활용해준다.

gsap.utils.toArray(".box").forEach((box, i) => {
    ScrollTrigger.create({
        trigger: box,
        start: "top top",
        pin: true,
        pinSpacing: false,
        markers: true,
    })
})

 

원했던 대로 각 블럭이 카드가 쌓이듯 동작하는 것을 확인할 수 있다.

pinSpacing의 경우, boolean 값을 받는다.

  • true : Default. 고정된 요소가 차지하고 있던 공간을 그대로 유지한다.
  • false : 고정된 요소의 자리를 제거하고, 다른 요소가 그 자리를 차지한다.

 

 


 

 

 

 

마지막으로 스크롤했을 때 요소의 최상단 및 최하단에 자동으로 snap 되도록 해보자.

 

 

 

프로젝트 초반부터 꼭 구현하고 싶었던 애니메이션 중 하나는-

아티스트 검색 창에서 사진을 스크롤 했을 때, 해당 이미지가 스크롤 양에 상관 없이 화면 상단 또는 하단에 snap 되고, 이전 / 다음 사진이 레이어 카드처럼 쌓여나가는 방식이다. (ZARA 홈페이지 메인화면을 참고했다)

이 부분을 구현하기 위해 position: sticky 를 사용하여 스크롤 이벤트를 감지하여 생성된 div가 뷰포트 상단에 맞춰 쌓여나가도록 하려 했다.

또한 1초 동안 이벤트 핸들러를 제거하여 지나치게 빠른 스크롤을 방지하려 했다.

그러나 스크롤을 조금만 하더라도 이전 / 다음 div가 스냅되듯이 나타나는 방식을 구현하는 데 어려움을 겪고 있었다.

 

이 때 우연히 GSAP 이라는 라이브러리를 알게 되었다.

 

공식 홈페이지의 튜토리얼 영상을 보고 그대로 따라하며 익혀보려 한다.

 

 

 

우선 플러그인을 설치해준다.

yarn add gsap

 

 

 

튜토리얼 영상에서는 vanila JS를 사용하기에, 오랜만에 새로운 프로젝트를 생성해 따라하며 실습해보았다.

 

기본적인 HTML, CSS, JS 구조를 생성하고, CSS파일과 JS파일, GSAP을 연결한다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>gsap test project</title>
    <link rel="stylesheet" href="styles.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.0/gsap.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.0/ScrollTrigger.min.js"></script>
  </head>
  <body>
    <div class="box a">a</div>
    <div class="box b">b</div>
    <div class="box c">c</div>
    <script src="script.js"></script>
  </body>
</html>

 

body {
    font-family: Arial, sans-serif;
    margin: 0;
    padding: 0;
    background-color: #f0f0f0;
}

.box {
    width: 100px;
    height: 100px;
}

.a {
    background-color: aqua;
    margin-bottom: 300px;
}

.b {
    background-color: antiquewhite;
    margin-bottom: 300px;
}

.c {
    background-color: tomato;
    margin-bottom: 300px;
}

 

 

 

 

사용법은 매우 직관적이고 간단하다.

gsap.to("해당 애니메이션을 입힐 클래스 또는 ID", {
  scrollTrigger: 화면에 보여졌을 때 실행할 기준 클래스 또는 ID,
  x: 가로축 이동 끝 좌표 (단위는 px, %, 함수 모두 가능),
  rotation: 각도,
  duration: 지속시간,
});

 

객체 형태로 작성하기 때문에 ',' 를 잊지 말고 써주자.

 

 

 

더 나아가 scrollTrigger의 값을 객체로 입력하면 더욱 다양하고 유연한 동작을 끌어낼 수 있다.

gsap.to("해당 애니메이션을 입힐 클래스 또는 ID", {
  scrollTrigger: {
    trigger: "뷰포트 등장 기준 클래스 또는 ID",
    toggleActions: "정방향진입 정방향퇴장 역방향진입 역방향퇴장", 
  },
  x: 가로축 이동 끝 좌표 (단위는 px, %, 함수 모두 가능),
  rotation: 각도,
  duration: 지속시간,
});

 

 

toggleActions 에 들어가는 4가지 요소에 주목해보자.

초기값은 아래와 같다.

toggleActions: "play none none none"

 

재생 옵션은 다음과 같다.

  • play : 일반 재생
  • pause : 뷰포트에서 보이지 않을 경우 그 자리에서 정지
  • resume : 정지 시점에서부터 다시 재생
  • reverse : 거꾸로 재생 (보통 다시 scroll up할 때 적용)
  • restart : 처음부터 다시 재생
  • reset : 초기 상태로
  • complete : 완료 상태로
  • none: 아무 동작도 하지 않음

 

 

 

이것을 바탕으로 c 블럭이 화면에 나타난 경우 c 블럭에 다음 조건의 애니메이션을 재생시켜보자.

1. c 블럭에 GSAP 지정

2. c 블럭이 화면에 나타난 경우 애니메이션 실행

3. 정방향으로 스크롤 했을 때 재생

4. 뷰포트에서 사라졌을 때 애니메이션 잠시 멈춤

5. 역방향으로 스크롤해서 c 블럭이 다시 뷰포트에 나타난 경우 거꾸로 재생

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    toggleActions: "play pause reverse none",
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

 

 


 

 

 

 

참고로 뷰포트의 진입, 퇴장 기준점은 뷰포트 최상단과 최하단이 default로 지정되어 있다.

만일 이 좌표를 바꾸고 싶다면?

scrollTrigger의 객체값에 start end 속성을 추가해주자.

 

두 기준점이 정확히 어디인지 시각적으로 확인하기 위해 markers 속성을 추가하면 편하다.

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    toggleActions: "play pause reverse none",
    markers: true,
    start: "요소의기준점 뷰포트기준점",
    end: "요소의기준점 뷰포트기준점",
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

start과 end의 값으로는 top, bottom, center 뿐 아니라 해당 요소의 최상단을 기준으로 px 및 % 단위도 사용할 수 있음을 참고하자.

또한 end의 경우, '+=' 를 사용하여 start값의 상대값을 입력할 수 있다.

예를 들어, 요소의 중심을 시작점으로 삼고 100px아래에서 애니메이션을 끝내고 싶다면 아래와 같이 작성한다.

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    toggleActions: "play pause reverse none",
    markers: true,
    start: "center 80%",
    end: "+=100",
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

그러나 반응형으로 구현한다면 고정값을 매기는 것이 부적합할 수 있다.

이 때 end값에 함수를 직접 넣을 수도 있다.

(개인적으로 GSAP의 가장 멋진 부분이라고 생각한다)

 

참고: end값을 하나만 입력하면 그 값은 트리거 요소의 end point로 간주되고, 뷰포트의 end point는 start에서 지정한 값과 일치한다.

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    toggleActions: "play pause reverse none",
    markers: true,
    start: "center 80%",
    end: () => "+=" + document.querySelector(".c").offsetWidth,
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

위와 같이 작성한다면 end점은 c블럭의 오프셋너비 / 2 만큼 아래에 지정된다. 

start point가 center이기 때문에 2로 나눈 것이다.

 

 

물론 적용할 대상, trigger 대상, endTrigger 대상 모두 다른 요소로 지정할 수도 있다.

 

 

 


 

 

 

 

그렇다면 스크롤을 위 아래로 짧게 이동할때마다 애니메이션이 왔다갔다(?) 하게 해보자.

scrub 속성을 통해 구현할 수 있다.

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    start: "top center",
    end: "top 100px",
    scrub: boolean 또는 초단위
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

이렇게 하면 가로로 이동하는 도중 역방향 스크롤이 감지되면 즉시 애니메이션이 reverse 된다. 

scrub의 값으로 들어간 초는 latency를 나타낸다.

start point와 end point까지 거리에 비례하여 애니메이션 타임라인이 진행된다.

다시 말해, start와 end의 거리가 500px 이고 250px만큼 스크롤 했다면, 애니메이션은 절반만큼 진행되어 있다.

 

이 타임라인을 변수로 선언하고 재사용한다면 다음과 같다.

 

let customTimeline = gsap.timeline({  
	scrollTrigger: {
        trigger: ".c",
        start: "top center",
        end: "top 100px",
        scrub: 1,
       }
  });

customTimeline.to(".c", {
  x: 1000,
  rotation: 360,
  duration: 3,
  ease: "none",
});

 

 

기본적인 사용 방법을 익혔으니, 다음 글에서 구현하고 싶었던 기능을 구현해보자.

+ Recent posts