[처음부터 배우는 리액트 네이티브] -2. 로그인/회원가입 구현하기

최다연·2025년 12월 12일

ReactNative

목록 보기
8/8

깃허브 레포지토리이다.
https://github.com/choi-day/react-native-simple-chat

프로젝트 준비

내비게이션

화면 이동에 필요한 내비게이션을 이용하여 화면을 구성한다.

npm install @react-navigation/native
expo install react-native-gesture-handler react-native-reanimated react-native-
screens react-native-safe-area-context @react-native-community/masked-view
npm install @react-navigation/stack @react-navigation/bottom-tabs

라이브러리

  • styled-component: 스타일 작성을 위한 라이브러리
    npm install styled-components
  • pro-types: 타입 확인을 위한 라이브러리
    npm install styled-components prop-types
  • expo-image-picker: 기기의 사진이나 영상을 가져올 수 있도록 도움
  • moment: 시간과 관련된 많은 기능을 제공
  • react-naitive-keyboard-aware-scroll-view: 키보드가 화면을 가리면서 생기는 불편한 점을 해결하기 위해 사용되는 라이브러리
  • react-naitive-gifted-chat: 메세지를 주고받는 채팅 화면을 쉽게 구현할 수 있도록 돕는 라이브러리

프로젝트 구조

react-native-simple-chat
│
├── assets
│   ├── icon.png
│   └── splash.png
│
├── src
│   ├── components
│   │
│   ├── contexts
│   │
│   ├── navigations
│   │
│   ├── screens
│   │
│   ├── utils
│   │
│   ├── App.js
│   └── theme.js
│
└── App.js
  • components: 컴포넌트 파일 관리
  • contexts: Context API 파일 관리
  • navigations: 내비게이션 파일 관리
  • screens: 화면 파일 관리
  • utils: 프로젝트에서 이용할 기타 기능 관리

theme.js - 공통으로 사용할 색을 정의하고 배경색과 글자색을 미리 정의했다.

const colors = {
  white: '#ffffff',
  black: '#000000',
  grey_0: '#d5d5d5',
  grey_1: '#a6a6a6',
  red: '#e84118',
  blue: '#3679fe',
};

export const theme = {
  background: colors.white,
  text: colors.black
}

src/App.js - 컴포넌트에 정의된 theme를 사용할 수 있도록 한다.

import React from 'react';
import { StatusBar } from 'react-native';
import { ThemeProvider } from 'styled-components/native';
import { theme } from './theme';

const App = () => {
    return (
        <ThemeProvider theme={theme}>
        <StatusBar barStyle="dark-content" />
        </ThemeProvider>
    );
};

export default App;

App.js - App컴포넌트가 메인 파일이 되도록 한다

import App from './src/App';

export default App;

이 프로젝트에서는 서버 대신 파이어베이스를 이용한다. 설정과 관련된 부분은 아래 글을 참고하자.

파이어베이스 설정

앱 아이콘과 로딩화면

src/App.js

import React, { useState } from 'react';
import { StatusBar, Image } from 'react-native';
import AppLoading from 'expo-app-loading';
import { Asset } from 'expo-asset';
import * as Font from 'expo-font';
import { ThemeProvider } from 'styled-components/native';
import { theme } from './theme';

const cacheImages = images => {
  return images.map(image => {
    if (typeof image === 'string') {
      return Image.prefetch(image);
    } else {
      return Asset.fromModule(image).downloadAsync();
    }
  });
};

const cacheFonts = fonts => {
  return fonts.map(font => Font.loadAsync(font));
};

const App = () => {
  const [isReady, setIsReady] = useState(false);

  const _loadAssets = async () => {
    const imageAssets = cacheImages([require('../assets/splash.png')]);
    const fontAssets = cacheFonts([]);

    await Promise.all([...imageAssets, ...fontAssets]);
  };

  return isReady ? (
    <>
      <ThemeProvider theme={theme}>
        <StatusBar barStyle="dark-content" />
      </ThemeProvider>
    </>
  ) : (
    <AppLoading
      startAsync={_loadAssets}
      onFinish={() => setIsReady(true)}
      onError={console.warn}
    />
  );
};

export default App;
  • _loadAssets함수 : 이미지나 폰트를 미리 불러와 느리게 적용되는 문제를 개선 유저 경험 개선에 큰 도움을 준다
    • 스플래시 이미지 로딩

    • (필요하다면) 폰트 로딩

    • Promise.all로 모든 로딩이 끝날 때까지 기다림

      로딩이 끝나야 실제 화면이 렌더링된다.

  • cacheImages 함수 이미지를 미리 불러오는 함수이다.
    • URL 형태의 문자열 → Image.prefetch

    • require로 불러오는 로컬 이미지 → Asset.fromModule().downloadAsync()

      둘 다 Promise를 반환하기 때문에 나중에 Promise.all()로 처리할 수 있다.

  • cacheFonts 폰트도 배열로 받아서 Promise 배열로 만들어 준다.
    필요한 폰트가 있다면 여기에 추가해서 로딩하면 된다.

책에서는 expo에서 AppLoading을 import 하고 있으나 Expo SDK 45 이후로 AppLoading은 expo에서 제거되었다.

npm install expo-app-loading

위와 같이 설치 후 'expo-app-loading’으로 바꿔주면 정상적으로 스플래쉬 화면이 뜬다

인증화면

로그인 및 회원가입 기능을 구현한다. 로그인 화면에서 이메일과 비밀번호를 입력받고 회원가입시 서비스에서 이용할 이름과 프로필 사진을 받는다.

네비게이션

로그인 화면과 회원가입 화면을 구현한다

screens/Login.js

import React from 'react';
import styled from 'styled-components/native';
import { Text, Button } from 'react-native';

const Container = styled.View`
    flex: 1;
    justify-content: center;
    align-items: center;
    background-color: ${({ theme }) => theme.background};
`;

const Login = ({ navigation }) => {
    return (
        <Container>
            <Text style={{ fontSize: 30 }}>Login Screen</Text>
            <Button title="Signup" onPress={() => navigation.navigate('Signup')} />
        </Container>
    );
};

export default Login;

screens/Signup.js

import React from 'react';
import styled from 'styled-components/native';
import { Text } from 'react-native';

const Container = styled.View`
  flex: 1;
  justify-content: center;
  align-items: center;
  background-color: ${({ theme }) => theme.background};
`;

const Signup = () => {
  return (
    <Container>
      <Text style={{ fontSize: 30 }}>Signup Screen</Text>
    </Container>
  );
};

export default Signup;

screens/index.js

import Login from './Login';
import Signup from './Signup';

export { Login, Signup};

화면을 한 곳에서 관리하기 위해 필요한 파일이다.

navigationsAuthStack.js

import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components/native';
import { createStackNavigator } from '@react-navigation/stack';
import { Login, Signup } from '../screens';

const Stack = createStackNavigator();

const AuthStack = () => {
  const theme = useContext(ThemeContext);
  return (
    <Stack.Navigator
      initialRouteName="Login"
      screenOptions={{
        headerTitleAlign: 'center',
        cardStyle: { backgroundColor: theme.background },
      }}
    >
      <Stack.Screen name="Login" component={Login} />
      <Stack.Screen name="Signup" component={Signup} />
    </Stack.Navigator>
  );
};

export default AuthStack;

📝 전체 동작 요약

  1. createStackNavigator()로 만든 Stack 안에 로그인과 회원가입 두 화면을 등록한다.
  2. AuthStack이 사용되면 처음에는 항상 Login 화면이 보인다(initialRouteName).
  3. Login 화면에서 navigation.navigate('Signup')을 호출하면 Signup 화면으로, Signup에서 navigation.navigate('Login')을 호출하면 다시 Login으로 이동한다.

navigations/index.js

import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import AuthStack from './AuthStack';

const Navigation = () => {
  return (
    <NavigationContainer>
      <AuthStack />
    </NavigationContainer>
  );
};

export default Navigation;

📝 전체 동작 요약

  1. NavigationContainer가 앱의 전체 네비게이션 상태를 관리한다.
  2. 그 안에서 AuthStack이 실행되며 Login → Signup 같은 화면 이동을 담당한다.
  3. 앱이 실행되면 AuthStack의 initialRouteName 설정에 따라 Login 화면이 처음 보여진다.
  4. 다른 화면은 navigation.navigate(‘Signup’) 같은 코드로 이동할 수 있다.

로그인화면

components/Image.js

import React from 'react';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';

const Container = styled.View`
  align-self: center;
  margin-bottom: 30px;
`;

const StyledImage = styled.Image`
  background-color: ${({ theme }) => theme.imageBackground};
  width: 100px;
  height: 100px;
`;

const Image = ({ uri, imageStyle }) => {
  return (
    <Container>
      <StyledImage source={{ uri: uri }} style={imageStyle} />
    </Container>
  );
};

Image.propTypes = {
  uri: PropTypes.string,
  imageStyle: PropTypes.object,
};

export default Image;

📝 전체 동작 요약

  1. Image 컴포넌트는 네트워크 이미지 URL(uri)을 받아 화면에 표시한다.
  2. 이미지는 StyledImage로 스타일링되어 크기(100×100)와 배경색(theme.imageBackground)이 적용된다.
  3. Container는 이미지를 화면 중앙에 배치하고 아래쪽 여백을 준다.
  4. imageStyle prop을 전달하면 기본 스타일 위에 추가 스타일을 덮어 적용할 수 있다.

components/Image.js

import Image from './Image'

export {Image}

파이어베이스 Storage에 logo 파일을 업로드한다.

이름을 클릭하면 이미지파일 링크를 얻을 수 있다.

service firebase.storage {
  match /b/{bucket}/o {
    match /logo.png {
      allow read;
    }
  }
}

규칙 또는 Rules탭으로 들어가 로그인하지 않아도 이미지에 접근할 수 있도록 수정한다.

잘 받아와지는지 궁금하다면 이미지 파일 링크를 외부에서 접속해본다. 이미지가 잘 뜬다면 적용이 된것이다.

utils/image.js

const prefix = 'https://firebasestorage.googleapis.com/v0/b/react-native-simple-chat-469fb.firebasestorage.app/o';

export const images = {
    logo: `${prefix}/logo.png?alt=media`,
};

이미지를 불러오는 코드이다

App.js

const _loadAssets = async () => {
    const imageAssets = cacheImages([require('../assets/splash.png'),
        ...Object.values(images),
    ]);
    const fontAssets = cacheFonts([]);

파이어베이스의 이미지 파일도 함께 불러오도록 수정한다

Login.js

const Login = ({ navigation }) => {
    return (
        <Container>
            <Image uri={images.logo} imageStyle={{borderRadius: 8}}/>
            <Text style={{ fontSize: 30 }}>Login Screen</Text>
            <Button title="Signup" onPress={() => navigation.navigate('Signup')} />
        </Container>
    );
};

이미지를 로그인화면에 삽입한다.

Simulator Screenshot - iPhone 16 Pro Max - 2025-12-12 at 12.24.23.png

theme.js

label: colors.grey_1,
inputPlaceholder: colors.grey_1,
inputBorder: colors.grey_1,

input에 들어갈 색상을 정의해준다

compoenets/Input.js

import React, { useState } from 'react';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';

const Container = styled.View`
  flex-direction: column;
  width: 100%;
  margin: 10px 0;
`;

const Label = styled.Text`
  font-size: 14px;
  font-weight: 600;
  margin-bottom: 6px;
  color: ${({ theme, isFocused }) => (isFocused ? theme.text : theme.label)};
`;

const StyledTextInput = styled.TextInput.attrs(({ theme }) => ({
  placeholderTextColor: theme.inputPlaceholder,
}))`
  background-color: ${({ theme }) => theme.background};
  color: ${({ theme }) => theme.text};
  padding: 20px 10px;
  font-size: 16px;
  border: 1px solid
    ${({ theme, isFocused }) => (isFocused ? theme.text : theme.inputBorder)};
  border-radius: 4px;
`;

const Input = ({
  label,
  value,
  onChangeText,
  onSubmitEditing,
  onBlur,
  placeholder,
  isPassword,
  returnKeyType,
  maxLength,
}) => {
  const [isFocused, setIsFocused] = useState(false);

  return (
    <Container>
      <Label isFocused={isFocused}>{label}</Label>
      <StyledTextInput
        isFocused={isFocused}
        value={value}
        onChangeText={onChangeText}
        onSubmitEditing={onSubmitEditing}
        onFocus={() => setIsFocused(true)}
        onBlur={() => {
          setIsFocused(false);
          onBlur();
        }}
        placeholder={placeholder}
        secureTextEntry={isPassword}
        returnKeyType={returnKeyType}
        maxLength={maxLength}
        autoCapitalize="none"
        autoCorrect={false}
        textContentType="none" // iOS only
        underlineColorAndroid="transparent" // Android only
      />
    </Container>
  );
};

Input.defaultProps = {
  onBlur: () => {},
};

Input.propTypes = {
  label: PropTypes.string.isRequired,
  value: PropTypes.string.isRequired,
  onChangeText: PropTypes.func.isRequired,
  onSubmitEditing: PropTypes.func.isRequired,
  onBlur: PropTypes.func,
  placeholder: PropTypes.string,
  isPassword: PropTypes.bool,
  returnKeyType: PropTypes.oneOf(['done', 'next']),
  maxLength: PropTypes.number,
};

export default Input;

📝 Input 컴포넌트 전체 동작 요약

  1. 사용자가 입력창에 포커스하면(isFocused) 라벨 색상과 입력창(border 색상)이 강조 색(theme.text)으로 바뀐다.
  2. 포커스가 해제되면 onBlur()를 실행하고 다시 기본 색상으로 돌아간다.
  3. 입력값(value), 텍스트 변경(onChangeText), 제출(onSubmitEditing) 등은 부모에서 전달받아 처리한다.
  4. 비밀번호 입력 여부, placeholder, returnKeyType, maxLength 등의 설정을 지원한다.

Login.js

const Login = ({ navigation }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  return (
    <Container>
      <Image url={images.logo} imageStyle={{ borderRadius: 8 }} />
      <Input
        label="Email"
        value={email}
        onChangeText={text => setEmail(text)}
        onSubmitEditing={() => {}}
        placeholder="Email"
        returnKeyType="next"
      />
      <Input
        label="Password"
        value={password}
        onChangeText={text => setPassword(text)}
        onSubmitEditing={() => {}}
        placeholder="Password"
        returnKeyType="done"
        isPassword
      />
    </Container>
  );
};

📝 수정된 동작 요약

  1. 이메일/패스워드 입력 UI 커스텀 Input 컴포넌트로 구현
  2. 입력 필드에 label·placeholder·returnKeyType·isPassword 등 다양한 속성을 추가하여 기능 향상됨

Login.js

...

const passwordRef = useRef();

...

  return (
    <Container>
      <Image url={images.logo} imageStyle={{ borderRadius: 8 }} />
      <Input
        label="Email"
        value={email}
        onChangeText={text => setEmail(text)}
        onSubmitEditing={() => passwordRef.current.focus()}
        placeholder="Email"
        returnKeyType="next"
      />
      
...

      <Input
        ref = {password}
        label="Password"

📝 수정된 동작 요약

  1. useRef()로 passwordRef를 만들고, Password 에 ref={passwordRef}로 연결한다.
  2. Email 입력칸에서 enter를 입력하면 passwordRef.current.focus()를 호출해서 자동으로 Password 입력칸으로 커서가 이동한다.

Input.js

...
onBlur={() => {
  setIsFocused(false);
  if (typeof onBlur === 'function') {
    onBlur();
  }
}}
...

갑자기 onBlur();가 작동하지 않아 에러가 떴다. onBlur prop이 없거나(undefined), 함수가 아닐 때도 크래시가 나지 않도록 방어 코드를 추가했다.

비밀번호를 입력할 때 키보드에 가려 보이지 않는다. 키보드 스크롤 뷰를 이용하여 사용성을 개선한다.

npm install react-native-keyboard-aware-scroll-view

react-native-keyboard-aware-scroll-view 를 설치한다

Login.js

import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';

...

return (
  <KeyboardAwareScrollView
    contentContainerStyle={{ flex: 1 }}
    extraScrollHeight={20}
  >
    <Container>
      ...
    </Container>
  </KeyboardAwareScrollView>
);

📝 수정된 동작 요약

  1. KeyboardAwareScrollView를 사용해 키보드가 올라올 때 화면이 자동으로 스크롤되도록 수정함
  2. 입력창(Input)이 키보드에 가려지지 않고 항상 화면에 보이도록 UX 개선
  3. extraScrollHeight 옵션을 통해 키보드와 입력창 사이 여유 공간을 추가함
  4. 로그인/회원가입 화면에서 키보드 대응 로직을 컴포넌트 외부가 아닌 레이아웃 레벨에서 처리하도록 구조 정리

사용자가 이메일을 틀린 형식으로 입력했다면 이메일을 다시 입력하라는 경고를 띄워야 한다.

theme.js

 errorText: colors.red,

오류 메세지 색상을 정의한다

utils/common.js

export const validateEmail = email => {
    const regex = /^[0-9?A-z0-9?]+(\.[0-9?A-z0-9?]+)*@[0-9?A-z]+\.[A-z]{2,}(\.[A-z]{0,3})?$/;
    return regex.test(email);
};

export const removeWhitespace = text => {
    const regex = /\s/g;
    return text.replace(regex, '');
};

📝 이메일 유효성 검사 추가 동작 요약

  1. 이메일 형식 검증 로직 추가
    • 정규표현식을 사용해 이메일 문자열이 올바른 형식인지 검사하는 validateEmail 함수가 새로 구현됨
    • 입력된 이메일이 example@domain.com 과 같은 형태인지 boolean 값으로 판단 가능
  2. 입력값 공백 제거 유틸 함수 추가
    - 문자열 내 모든 공백을 제거하는 removeWhitespace 함수가 추가됨
    - 이메일·비밀번호 입력 시 의도치 않은 공백으로 인한 오류를 사전에 방지

Login.js

...
import { Image, Input } from '../components';
import { images } from '../utils/images';
import { validateEmail, removeWhitespace } from '../utils/common';

...

const ErrorText = styled.Text`
  align-self: flex-start;
  width: 100%;
  height: 20px;
  margin-bottom: 10px;
  line-height: 20px;
  color: ${({ theme }) => theme.errorText};
`;

const Login = ({ navigation }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errorMessage, setErrorMessage] = useState('');

  const passwordRef = useRef(null);

  const _handleEmailChange = email => {
    const changedEmail = removeWhitespace(email);
    setEmail(changedEmail);
    setErrorMessage(validateEmail(changedEmail) ? '' : 'Please verify your email.');
  };

  const _handlePasswordChange = password => {
    setPassword(removeWhitespace(password));
  };

  return (
    <KeyboardAwareScrollView contentContainerStyle={{ flex: 1 }} extraScrollHeight={20}>
      <Container>
        <Image url={images.logo} imageStyle={{ borderRadius: 8 }} />

        <Input
          label="Email"
          value={email}
          onChangeText={_handleEmailChange}
          onSubmitEditing={() => passwordRef.current?.focus()}
          placeholder="Email"
          returnKeyType="next"
        />

        <Input
          ref={passwordRef}
          label="Password"
          value={password}
          onChangeText={_handlePasswordChange}
          onSubmitEditing={() => {}}
          placeholder="Password"
          returnKeyType="done"
          isPassword
        />

        <ErrorText>{errorMessage}</ErrorText>
      </Container>
    </KeyboardAwareScrollView>
  );
};
...

📝 수정된 동작 요약

  1. 이메일 입력값이 바뀔 때마다 removeWhitespace()로 공백을 제거한 뒤 setEmail()로 상태를 갱신한다.
  2. 공백 제거된 이메일을 validateEmail()로 검사해서, 올바르면 errorMessage를 ''로 비우고 아니면 "Please verify your email."을 세팅한다.
  3. 비밀번호도 입력 시 removeWhitespace()로 공백을 제거해서 setPassword()에 저장한다.
  4. 화면 하단에 {errorMessage}를 추가해서, 이메일 형식이 틀리면 즉시 빨간 안내문이 보이도록 한다.

로그인 버튼을 구현해보자

theme.js

  buttonBackground: colors.blue,
  buttonTitle: colors.white,
  buttonUnfilledTitle: colors.blue,

버튼과 관련된 색상을 정의한다

components/Button.js

import React from 'react';
import styled from 'styled-components/native';
import PropTypes from 'prop-types';

const TRANSPARENT = 'transparent';

const Container = styled.TouchableOpacity`
  background-color: ${({ theme, isFilled }) =>
    isFilled ? theme.buttonBackground : TRANSPARENT};
  align-items: center;
  border-radius: 4px;
  width: 100%;
  padding: 10px;
`;

const Title = styled.Text`
  height: 30px;
  line-height: 30px;
  font-size: 16px;
  color: ${({ theme, isFilled }) =>
    isFilled ? theme.buttonTitle : theme.buttonUnfilledTitle};
`;

const Button = ({ containerStyle, title, onPress, isFilled }) => {
  return (
    <Container style={containerStyle} onPress={onPress} isFilled={isFilled}>
      <Title isFilled={isFilled}>{title}</Title>
    </Container>
  );
};

Button.defaultProps = {
  isFilled: true,
};

Button.propTypes = {
  containerStyle: PropTypes.object,
  title: PropTypes.string,
  onPress: PropTypes.func.isRequired,
  isFilled: PropTypes.bool,
};

export default Button;

📝 수정된 동작 요약

  1. styled-components를 활용해 isFilled 속성에 따라 배경색을 채울지 투명하게 할지 결정하는 동적 스타일링을 적용
  2. Title 텍스트 색상 또한 isFilled 속성에 따라 테마에 맞춰 자동으로 변경되도록 설정함
  3. defaultProps를 통해 isFilled 값을 별도로 지정하지 않으면 기본적으로 채워진 버튼(true)으로 렌더링되도록 처리함

버튼 컴포넌트를 컴포넌트의 index.js에 추가하고 로그인 화면에 적용한다

Login.js

import { Image, Input, Button } from '../components';

...

  const _handleLoginButtonPress = () => {};

  return (
    <KeyboardAwareScrollView
      contentContainerStyle={{ flex: 1 }}
      extraScrollHeight={20}
    >
      <Container>
        ...
        <Input
          label="Password"
          value={password}
          onChangeText={_handlePasswordChange}
          onSubmitEditing={_handleLoginButtonPress}
          placeholder="Password"
          returnKeyType="done"
          isPassword
        />

        <ErrorText>{errorMessage}</ErrorText>
        <Button title="Login" onPress={_handleLoginButtonPress} />
        <Button
          title="Sign up with email"
          onPress={() => navigation.navigate('Signup')}
          isFilled={false}
        />
      </Container>
    </KeyboardAwareScrollView>
  );

📝 수정된 동작 요약

  1. 커스텀 Button 컴포넌트를 import하여 로그인과 회원가입 버튼을 화면에 추가함
  2. 로그인 버튼을 눌렀을 때 실행될 이벤트 핸들러 함수를 정의하고 버튼 onPress 이벤트에 연결함
  3. 비밀번호 입력창에서 작성을 마치고 키보드의 완료 버튼을 누를 때도 로그인 함수가 실행되도록 연결함
  4. 회원가입 버튼을 누르면 Signup 화면으로 이동하는 내비게이션 로직을 작성함
  5. isFilled 속성을 false로 설정하여 회원가입 버튼이 배경색 없이 테두리만 있는 스타일로 렌더링되도록 함

Login.js

import React, { useState, useRef, useEffect } from 'react';

...

  const [disabled, setDisabled] = useState(true);

  useEffect(() => {
    setDisabled(!(email && password && !errorMessage));
  }, [email, password, errorMessage]);

...

        <ErrorText>{errorMessage}</ErrorText>
        <Button
          title="Login"
          onPress={_handleLoginButtonPress}
          disabled={disabled}
        />
        <Button
          title="Sign up with email"
          onPress={() => navigation.navigate('Signup')}
          isFilled={false}
        />
      </Container>
    </KeyboardAwareScrollView>
  );

📝 수정된 동작 요약

  1. disabled 상태 변수를 추가하여 버튼의 활성화/비활성화 상태를 관리함
  2. useEffect 훅을 사용하여 이메일(email), 비밀번호(password) 입력 여부와 에러 메시지(errorMessage) 존재 여부를 감시함
  3. 이메일과 비밀번호가 모두 입력되고 에러 메시지가 없을 때만 버튼이 활성화되도록 로직을 구현함
  4. 로그인 버튼 컴포넌트에 disabled props를 전달하여 조건 불충족 시 버튼을 누를 수 없도록 처리함

Button.js

...

const Container = styled.TouchableOpacity`
  ...
  opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
`;

...

const Button = ({
  containerStyle,
  title,
  onPress,
  isFilled,
  disabled,
}) => {
  return (
    <Container
      style={containerStyle}
      onPress={onPress}
      isFilled={isFilled}
      disabled={disabled}
    >
      <Title isFilled={isFilled}>{title}</Title>
    </Container>
  );
};

Button.defaultProps = {
  isFilled: true,
  disabled: false,
};

Button.propTypes = {
  ...
  disabled: PropTypes.bool,
};

export default Button;

📝 수정된 동작 요약

  1. Button 컴포넌트에 disabled 속성을 처리하는 스타일과 로직을 추가함
  2. disabled 상태가 true일 경우 버튼의 투명도(opacity)를 0.5로 설정하여 비활성화된 시각적 효과를 줌
  3. TouchableOpacity의 disabled 속성에 값을 전달하여 클릭 이벤트가 발생하지 않도록 막음
  4. PropTypes에 disabled 타입을 boolean으로 정의하고 기본값을 false로 설정함

스택 네비게이션을 사용하기때문에 헤더가 있어야 한다.(이전 화면으로 돌아가기 위함)

theme.js

headerTintColor: colors.black,

헤더에서 사용할 색상을 정의한다

AuthStack.js

...

    <Stack.Navigator
      ...
    >
      <Stack.Screen
        name="Login"
        component={Login}
        options={{ headerShown: false }}
      />
      <Stack.Screen name="Signup" component={Signup} />
    </Stack.Navigator>

...

📝 수정된 동작 요약

  1. Login 화면의 옵션에 headerShown 속성을 false로 설정하여 헤더를 숨김
  2. 내비게이션 스택에서 로그인 화면만 상단 헤더가 보이지 않도록 UI를 수정함
  3. 나머지 화면(예: Signup)은 기존대로 헤더가 유지됨

AuthStack.js

...

    <Stack.Navigator
      initialRouteName="Login"
      screenOptions={{
        headerTitleAlign: 'center',
        cardStyle: { backgroundColor: theme.background },
        headerTintColor: theme.headerTintColor,
      }}
    >
      <Stack.Screen
        name="Login"
        component={Login}
        options={{ headerShown: false }}
      />
      <Stack.Screen
        name="Signup"
        component={Signup}
        options={{ headerBackTitleVisible: false }}
      />
    </Stack.Navigator>

...

📝 수정된 동작 요약

  1. AuthStack 내비게이션의 공통 옵션에 headerTintColor를 설정하여 헤더 아이콘 및 텍스트 색상을 테마에 맞춤
  2. Signup 화면의 옵션에 headerBackTitleVisible을 false로 설정하여 뒤로가기 버튼 옆의 타이틀(이전 화면 이름)을 숨김
  3. 기존에는 한 줄로 작성되어 있던 Signup 화면 설정을 가독성을 위해 여러 줄로 분리하여 옵션을 명확히 함

아이폰의 노치디자인에 가려 헤더가 안 보일 수 있다. UI를 개선해보자.

Login.js

import { useSafeAreaInsets } from 'react-native-safe-area-context';

const Container = styled.View`
  ...
  padding: 0 20px;
  padding-top: ${({ insets: { top } }) => top}px;
  padding-bottom: ${({ insets: { bottom } }) => bottom}px;
`;

...

const Login = ({ navigation }) => {
  const insets = useSafeAreaInsets();
  ...
  return (
    <KeyboardAwareScrollView ...>
      <Container insets={insets}>
        ...
      </Container>
    </KeyboardAwareScrollView>
  );
};

📝 수정된 동작 요약

  1. react-native-safe-area-context의 useSafeAreaInsets 훅을 도입하여 노치 디자인이나 홈바 영역의 안전한 여백 값을 가져옴
  2. 기존의 고정된 padding 값을 제거하고 상단(Top)과 하단(Bottom)에 디바이스별 안전 영역(Inset)만큼의 패딩을 동적으로 부여함
  3. Container 컴포넌트에 insets 값을 props로 전달하여 스타일 내부에서 해당 값을 계산하도록 수정함

회원가입시 이미지를 원형으로 보이게 할 예정이다.

Image.js

const StyledImage = styled.Image`
  background-color: ${({ theme }) => theme.imageBackground};
  width: 100px;
  height: 100px;
  border-radius: ${({ rounded }) => (rounded ? 50 : 0)}px;
`;

const Image = ({ uri, imageStyle, rounded }) => {
  return (
    <Container>
      <StyledImage source={{ uri: uri }} style={imageStyle} rounded={rounded} />
    </Container>
  );
};

Image.defaultProps = {
  rounded: false,
};

Image.propTypes = {
  uri: PropTypes.string,
  imageStyle: PropTypes.object,
  rounded: PropTypes.bool,
};

export default Image;

📝 수정된 동작 요약

  1. Image 컴포넌트에 rounded 속성을 추가하여 이미지를 원형으로 렌더링할 수 있는 기능을 구현함
  2. StyledImage 스타일 정의에서 rounded 값이 true일 경우 border-radius를 50px로 설정하여 100x100 크기의 이미지를 완벽한 원형으로 만듦
  3. 컴포넌트의 props로 rounded를 받아 하위 스타일 컴포넌트에 전달하도록 수정함
  4. defaultProps를 통해 rounded 속성의 기본값을 false로 설정하여, 별도 지정이 없으면 사각형 이미지가 출력되도록 함
  5. PropTypes에 rounded 속성을 boolean 타입으로 정의하여 타입 안정성을 확보함

Signup.js

import React, { useState, useRef, useEffect } from 'react';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { Image, Input, Button } from '../components';
import { validateEmail, removeWhitespace } from '../utils/common';

const ErrorText = styled.Text`
  align-items: flex-start;
  width: 100%;
  height: 20px;
  margin-bottom: 10px;
  line-height: 20px;
  color: ${({ theme }) => theme.errorText};
`;

const Signup = ({ navigation }) => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [passwordConfirm, setPasswordConfirm] = useState('');
  const [errorMessage, setErrorMessage] = useState('');
  const [disabled, setDisabled] = useState(true);

  const emailRef = useRef(null);
  const passwordRef = useRef(null);
  const passwordConfirmRef = useRef(null);

  useEffect(() => {
    let _errorMessage = '';
    if (!name) {
      _errorMessage = 'Please enter your name.';
    } else if (!validateEmail(email)) {
      _errorMessage = 'Please verify your email.';
    } else if (password.length < 6) {
      _errorMessage = 'The password must contain 6 characters at least.';
    } else if (password !== passwordConfirm) {
      _errorMessage = 'Passwords need to match.';
    } else {
      _errorMessage = '';
    }
    setErrorMessage(_errorMessage);
  }, [name, email, password, passwordConfirm]);

  useEffect(() => {
    setDisabled(!(name && email && password && passwordConfirm && !errorMessage));
  }, [name, email, password, passwordConfirm, errorMessage]);

  const _handleSignupButtonPress = () => {
    // TODO: signup logic
  };

  return (
    <KeyboardAwareScrollView ...>
      <Container>
        <Image rounded />
        <Input ... label="Name" value={name} ... />
        <Input ... label="Email" value={email} ... />
        <Input ... label="Password" value={password} ... />
        <Input ... label="Password Confirm" value={passwordConfirm} ... />
        
        <ErrorText>{errorMessage}</ErrorText>
        <Button title="Signup" onPress={_handleSignupButtonPress} disabled={disabled} />
      </Container>
    </KeyboardAwareScrollView>
  );
};

📝 수정된 동작 요약

  1. 회원가입 화면(Signup.js)의 전체 UI와 유효성 검사 로직을 구현함
  2. 이름, 이메일, 비밀번호, 비밀번호 확인을 입력받기 위한 상태(State)와 Input 컴포넌트들을 배치함
  3. useEffect를 사용하여 입력값이 변경될 때마다 실시간으로 유효성 검사를 수행하고 에러 메시지를 업데이트함
  4. 모든 필드가 유효하고 에러 메시지가 없을 경우에만 회원가입 버튼이 활성화되도록 disabled 상태를 관리함
  5. useRef를 활용하여 엔터키 입력 시 다음 입력창으로 포커스가 자동으로 이동하도록 사용자 경험(UX)을 개선함

Signup.js

...
  const didMountRef = useRef();

  useEffect(() => {
    if (didMountRef.current) {
      let _errorMessage = '';

      if (!name) {
        _errorMessage = 'Please enter your name.';
      } else if (!validateEmail(email)) {
        _errorMessage = 'Please verify your email.';
      } else if (password.length < 6) {
        _errorMessage = 'The password must contain 6 characters at least.';
      } else if (password !== passwordConfirm) {
        _errorMessage = 'Passwords need to match.';
      } else {
        _errorMessage = '';
      }
      setErrorMessage(_errorMessage);
    } else {
      didMountRef.current = true;
    }
  }, [name, email, password, passwordConfirm]);
...

📝 수정된 동작 요약

  1. useRef를 사용하여 컴포넌트 마운트 여부를 추적하는 didMountRef 변수를 추가함
  2. useEffect 내부에서 didMountRef.current 값을 확인하여 초기 렌더링 시에는 유효성 검사 로직이 실행되지 않도록 수정함
  3. 사용자가 입력을 시작한 이후(update 시점)부터 유효성 검사를 수행하여 초기 화면 진입 시 불필요한 에러 메시지가 노출되는 문제를 해결함
  4. Signup 화면의 패딩 값을 0 20px에서 40px 20px로 수정하여 상하 여백을 확보함

utils/image.js

photo: `${prefix}/photo.png?alt=media`,

회원가입 이미지를 파이어베이스에서 불러오도록 한다

Signup.js

import { images } from '../utils/images'

...

const Signup = ({ navigation }) => {
  const [photoUrl, setPhotoUrl] = useState(images.photo)
  ...
  return (
    ...
      <Container>
        <Image rounded url={photoUrl}/>
        ...
      </Container>
    ...
  );
};

📝 수정된 동작 요약

  1. utils/images에서 정의한 기본 프로필 이미지를 불러오기 위해 images 객체를 import함
  2. 프로필 이미지 URL을 관리하기 위한 photoUrl 상태 변수를 선언하고 초기값을 images.photo로 설정함
  3. Image 컴포넌트에 url 속성으로 photoUrl 상태값을 전달하여 화면에 기본 프로필 사진이 표시되도록 구현함

유저가 직접 갤러리에 있는 이미지를 삽입할 수 있도록 버튼을 만든다.

theme.js

  imageBackground: colors.grey_0,
  imageButtonBackground: colors.grey_1,
  imageButtonIcon: colors.white,

버튼에 들어갈 색상을 정의한다

image.js

import { MaterialIcons } from '@expo/vector-icons';

...

const ButtonContainer = styled.TouchableOpacity`
  background-color: ${({ theme }) => theme.imageButtonBackground};
  position: absolute;
  bottom: 0;
  right: 0;
  width: 30px;
  height: 30px;
  border-radius: 15px;
  justify-content: center;
  align-items: center;
`;

const ButtonIcon = styled(MaterialIcons).attrs({
  name: 'photo-camera',
  size: 22,
})`
  color: ${({ theme }) => theme.imageButtonIcon};
`;

const PhotoButton = ({ onPress }) => {
  return (
    <ButtonContainer onPress={onPress}>
      <ButtonIcon />
    </ButtonContainer>
  );
};

...

const Image = ({ url, imageStyle, rounded, showButton, onPress }) => {
  return (
    <Container>
      <StyledImage source={{ uri: url }} style={imageStyle} rounded={rounded} />
      {showButton && <PhotoButton onPress={onPress} />}
    </Container>
  );
};

...

📝 수정된 동작 요약

  1. 이미지 우측 하단에 카메라 아이콘 버튼을 표시하기 위해 PhotoButton, ButtonContainer, ButtonIcon 컴포넌트를 구현함
  2. Image 컴포넌트의 props로 showButton을 전달받아 true일 경우에만 카메라 버튼이 렌더링되도록 조건부 렌더링을 적용함
  3. showButton이 활성화된 경우 사용자가 버튼을 클릭하면 onPress 함수가 실행되도록 연결함
  4. 기존 uri prop의 이름을 url로 변경하고, Image 컴포넌트 사용처에서도 이를 반영하도록 수정함

Signup.js

...
  <Container>
    <Image rounded url={photoUrl} showButton />
    ...
  </Container>
...

📝 수정된 동작 요약

  1. 회원가입 화면(Signup)에서 Image 컴포넌트에 showButton 속성을 추가함
  2. showButton 값을 true로 설정하여 프로필 이미지 우측 하단에 카메라 아이콘 버튼이 보이도록 활성화함

갤러리에 직접 접근하여 사진을 불러오도록 구현해보자

npm install expo-image-picker

expo-image-picker 설치

Image.js

import { Platform, Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';

...

const Image = ({ url, imageStyle, rounded, showButton, onChangeImage }) => {
  useEffect(() => {
    (async () => {
      try {
        if (Platform.OS === 'ios') {
          const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
          if (status !== 'granted') {
            Alert.alert(
              'Photo Permission',
              'Please turn on the camera roll permissions.'
            );
          }
        }
      } catch (e) {
        Alert.alert('Photo Permission Error', e.message);
      }
    })();
  }, []);

  const _handleEditButton = async () => {
    try {
      const result = await ImagePicker.launchImageLibraryAsync({
        mediaTypes: ImagePicker.MediaTypeOptions.Images,
        allowsEditing: true,
        aspect: [1, 1],
        quality: 1,
      });

      if (!result.canceled) {
        onChangeImage(result.assets[0].uri);
      }
    } catch (e) {
      Alert.alert('Photo Error', e.message);
    }
  };

  return (
    <Container>
      <StyledImage source={{ uri: url }} style={imageStyle} rounded={rounded} />
      {showButton && <PhotoButton onPress={_handleEditButton} />}
    </Container>
  );
};

📝 수정된 동작 요약

  1. useEffect를 통해 컴포넌트 마운트 시, iOS 환경에서는 갤러리 접근 권한을 요청하고 거부될 경우 알림을 띄우도록 처리함
  2. 버튼 클릭 핸들러(_handleEditButton) 내부에서 launchImageLibraryAsync를 호출하여 이미지 라이브러리를 실행함
  3. 사용자가 이미지를 선택하고 편집(crop)을 마치면 onChangeImage 콜백 함수를 통해 선택된 이미지 URI를 부모 컴포넌트로 전달함
  4. 이미지 선택 과정에서 예외가 발생할 경우 Alert을 통해 에러 메시지를 사용자에게 알리도록 함

Signup.js

...
  <Container>
    <Image
      rounded
      url={photoUrl}
      showButton
      onChangeImage={url => setPhotoUrl(url)}
    />
    ...
  </Container>
...

📝 수정된 동작 요약

  1. Signup 화면의 Image 컴포넌트에 onChangeImage props를 전달함
  2. Image 컴포넌트 내부에서 이미지가 선택되면 호출되는 콜백 함수를 연결함
  3. 선택된 이미지의 URL을 받아 setPhotoUrl 상태 업데이트 함수를 실행하여 화면에 즉시 반영되도록 구현함

이제 파이어베이스에서 정보를 요청하고 전송, 인증받는 코드를 구현해보자

Login.js

import { Alert } from 'react-native';
import { login } from '../utils/firebase';

...

  const _handleLoginButtonPress = async () => {
    try {
      const user = await login({ email, password });
      Alert.alert('Login Success', user.email);
    } catch (e) {
      Alert.alert('Login Error', e.message);
    }
  };

...

        <Button title="Login" onPress={_handleLoginButtonPress} disabled={disabled} />

📝 수정된 동작 요약

  1. Firebase의 login 함수를 호출하여 이메일과 비밀번호로 로그인을 시도하는 비동기 함수 _handleLoginButtonPress를 구현함
  2. 로그인 성공 시 사용자 이메일을 포함한 성공 알림창을 띄우고, 실패 시 에러 메시지 알림창을 표시함
  3. Login 버튼의 onPress 이벤트에 해당 로그인 핸들러 함수를 연결함
  4. 입력값 유효성에 따라 버튼 활성화 상태를 제어하는 disabled 속성을 Login 버튼에 적용함

Signup.js

import { Alert } from 'react-native';
import { signup } from '../utils/firebase';

...

  const _handleSignupButtonPress = async () => {
    try {
      if (disabled) return;

      const trimmedName = name.trim();
      const trimmedEmail = removeWhitespace(email);
      const trimmedPassword = removeWhitespace(password);

      // (중략: 유효성 검사 로직)

      const user = await signup({
        email: trimmedEmail,
        password: trimmedPassword,
        name: trimmedName,
        photoUrl,
      });

      Alert.alert('Signup Success', user?.email ?? trimmedEmail);
      navigation.navigate('Login');
    } catch (e) {
      Alert.alert('Signup Error', e?.message ?? 'Unknown error');
    }
  };

...

📝 수정된 동작 요약

  1. Firebase의 signup 함수를 호출하여 회원가입 정보를 전송하는 비동기 함수 _handleSignupButtonPress를 구현함
  2. 회원가입 버튼 클릭 시 이름, 이메일, 비밀번호의 공백을 제거하고 최종 유효성 검사를 수행함
  3. 회원가입 성공 시 성공 알림창을 띄우고 로그인 화면(Login)으로 이동하도록 내비게이션 처리를 추가함
  4. 실패 시에는 catch 블록에서 에러 메시지를 알림창으로 표시하여 사용자에게 안내함

Signup.js

const _handleSignupButtonPress = async () => {
    try {
      const trimmedName = name.trim();
      const trimmedEmail = removeWhitespace(email);
      const trimmedPassword = removeWhitespace(password);
      const trimmedPasswordConfirm = removeWhitespace(passwordConfirm);

      const user = await signup({
        email: trimmedEmail,
        password: trimmedPassword,
        name: trimmedName,
        photoUrl,
      });

      console.log(user);
      Alert.alert('Signup Success', user.email);
    } catch (e) {
      Alert.alert('Signup Error', e.message);
    }
  };

📝 수정된 동작 요약

  1. 기존에 함수 내부에서 수행하던 중복된 유효성 검사 로직(if문들)을 제거하고 코드를 간소화함
  2. 이미 useEffect에서 disabled 상태를 관리하고 있으므로, 버튼 클릭 시에는 별도의 중복 검사 없이 바로 회원가입 요청을 보내도록 수정함
  3. 회원가입 성공 시 콘솔에 사용자 정보를 출력하는 디버깅용 코드를 추가하고, 알림창 메시지를 간결하게 변경함

로그인과 회원가입이 완전히 진행 되기 전 다른 버튼이 눌리지 않도록 대기시키는 스피너를 구현해보자

theme.js

  spinnerBackground: colors.black,
  spinnerIndicator: colors.white,

색상을 정의한다

Spinner.js

import React, { useContext } from 'react';
import { ActivityIndicator } from 'react-native';
import styled, { ThemeContext } from 'styled-components/native';

const Container = styled.View`
  position: absolute;
  z-index: 2;
  opacity: 0.3;
  width: 100%;
  height: 100%;
  justify-content: center;
  background-color: ${({ theme }) => theme.spinnerBackground};
`;

const Spinner = () => {
  const theme = useContext(ThemeContext);

  return (
    <Container>
      <ActivityIndicator size="large" color={theme.spinnerIndicator} />
    </Container>
  );
};

export default Spinner;

📝 수정된 동작 요약

  1. 로딩 상태를 표시하기 위한 재사용 가능한 Spinner 컴포넌트를 새로 생성함
  2. ActivityIndicator를 사용하여 기본 로딩 인디케이터를 화면 중앙에 배치함
  3. styled-components를 활용해 화면 전체를 덮는 절대 위치(position: absolute)의 반투명 배경 컨테이너를 구현함
  4. z-index를 2로 설정하여 다른 UI 요소들보다 위에 나타나도록 처리함
  5. useContext로 현재 테마 정보를 가져와 인디케이터 색상(color)을 동적으로 설정함

Spinner.js 를 components의 index와 navigations의 index에 등록한다

contexts/Progress.js

import React, { useState, createContext } from 'react';

const ProgressContext = createContext({
  inProgress: false,
  spinner: () => {},
});

const ProgressProvider = ({ children }) => {
  const [inProgress, setInProgress] = useState(false);

  const spinner = {
    start: () => setInProgress(true),
    stop: () => setInProgress(false),
  };

  const value = { inProgress, spinner };

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

export { ProgressContext, ProgressProvider };

📝 수정된 동작 요약

  1. 앱 전역에서 로딩 상태(스피너)를 관리하기 위해 Context API를 활용한 ProgressContext와 ProgressProvider를 구현함
  2. useState를 사용하여 로딩 중인지 여부를 나타내는 inProgress 상태 변수를 생성함
  3. 로딩 상태를 제어하기 위한 spinner 객체를 정의하고, start(로딩 시작)와 stop(로딩 종료) 함수를 구현함
  4. Provider를 통해 하위 컴포넌트들이 inProgress 상태와 spinner 제어 함수에 접근할 수 있도록 value를 전달함

contexts/index.js

import { ProgressContext, ProgressProvider } from './Progress';

export { ProgressContext, ProgressProvider };

contexts/index.js에 Progress.js를 등록해준다. 파일을 더 쉽게 관리하기 위함이다.

App.js

import { ProgressProvider } from './contexts';

...

const App = () => {
  ...
  return isReady ? (
    <ThemeProvider theme={theme}>
      <ProgressProvider>
        <StatusBar barStyle="dark-content" />
        <Navigation />
      </ProgressProvider>
    </ThemeProvider>
  ) : (
    ...
  );
};

📝 수정된 동작 요약

  1. Navigation 컴포넌트를 ProgressProvider로 감싸 앱 전역에서 로딩 상태(스피너)를 사용할 수 있도록 설정함
  2. 이를 통해 하위 컴포넌트(로그인, 회원가입 등)에서 Context를 통해 스피너를 제어할 수 있게 됨

Login.js

import React, { useState, useRef, useEffect, useContext } from 'react';
import { ProgressContext } from '../contexts';

...

const Login = ({ navigation }) => {
  const { spinner } = useContext(ProgressContext);

  ...

  const _handleLoginButtonPress = async () => {
    try {
      spinner?.start?.();
      const user = await login({ email, password });
      Alert.alert('Login Success', user.email);
    } catch (e) {
      Alert.alert('Login Error', e.message);
    } finally {
      spinner?.stop?.();
    }
  };

  ...
};

📝 수정된 동작 요약

  1. 로그인 버튼 핸들러(_handleLoginButtonPress)의 시작 부분에서 spinner.start()를 호출하여 로딩 인디케이터를 화면에 표시함
  2. 비동기 로그인 작업(await login)이 완료되거나 에러가 발생하면, finally 블록에서 spinner.stop()을 호출하여 스피너를 숨김
  3. 이를 통해 사용자가 로그인 요청 중임을 시각적으로 인지할 수 있도록 UX를 개선함

Signup.js

import React, { useState, useRef, useEffect, useContext } from 'react';
import { ProgressContext } from '../contexts';

...

const Signup = ({ navigation }) => {
  const { spinner } = useContext(ProgressContext);

  ...

  const _handleSignupButtonPress = async () => {
    try {
      spinner.start();

      const trimmedName = name.trim();
      const trimmedEmail = removeWhitespace(email);
      const trimmedPassword = removeWhitespace(password);
      
      ...

      Alert.alert('Signup Success', user.email);
    } catch (e) {
      Alert.alert('Signup Error', e.message);
    } finally {
      spinner.stop();
    }
  };

  ...
};

📝 수정된 동작 요약

  1. spinner 객체를 Context로부터 구조 분해 할당으로 받아옴
  2. 회원가입 버튼 핸들러(_handleSignupButtonPress) 시작 부분에 spinner.start()를 호출하여 로딩 화면을 표시함
  3. 비동기 작업 종료 시(finally 블록) spinner.stop()을 호출하여 스피너를 제거함으로써, 회원가입 진행 중임을 사용자에게 시각적으로 알리는 로직을 완성함

현재 파이어베이스에서 문제가 생겨 무한 로딩이 된다... 책과 달라진 부분이 많아 찾으면서 오류를 고치고 있는 중이다. 다 고쳐지면 글 수정해야지 ㅜ

0개의 댓글