React Native에서 이메일 인증 구현하기

oversleep·2025년 3월 5일

app-development

목록 보기
34/38
post-thumbnail

들어가며

회원가입 프로세스는 모든 앱의 중요한 부분입니다.
특히 이메일 인증은 사용자의 신원을 확인하고 보안을 강화하는 필수적인 단계입니다.
React Native 앱에서 이메일 중복 체크, 인증 코드 발송, 그리고 인증 코드 확인까지 이메일 인증 기능을 구현하는 방법을 기록합니다.

구현 목표

우리가 구현할 이메일 인증 프로세스는 다음과 같습니다:

  1. 사용자가 이메일을 입력합니다.
  2. 이메일 중복 체크를 수행합니다.
  3. 중복되지 않은 이메일인 경우, 자동으로 인증 코드를 발송합니다.
  4. 사용자가 수신한 인증 코드를 입력합니다.
  5. 인증 코드가 일치하면 회원가입 과정을 계속 진행합니다.

필요한 컴포넌트와 유틸리티

이 기능을 구현하기 위해 다음 파일들을 생성 및 수정했습니다:

  1. EmailVerificationInput.tsx - 이메일 입력과 인증 UI 컴포넌트
  2. emailVerification.ts - 이메일 인증 관련 API 호출 함수
  3. axios-interceptor.ts - API 요청 인터셉터 (토큰 처리)
  4. SignupStep1.tsx - 회원가입 1단계 화면
  5. SignupScreen.tsx - 전체 회원가입 프로세스 관리

구현 단계

1. 이메일 인증 컴포넌트 생성

먼저 이메일 입력, 중복 체크, 인증 코드 입력을 처리할 컴포넌트를 생성합니다:

type EmailVerificationInputProps = {
  email: string;
  onEmailChange: (email: string) => void;
  onVerificationStatusChange: (isVerified: boolean, email: string) => void;
};

const EmailVerificationInput = ({
  email,
  onEmailChange,
  onVerificationStatusChange,
}: EmailVerificationInputProps) => {
  const [emailCheckMessage, setEmailCheckMessage] = useState<string>("");
  const [isEmailVerified, setIsEmailVerified] = useState(false);
  const [verifiedEmail, setVerifiedEmail] = useState("");
  const [verificationCode, setVerificationCode] = useState("");
  const [isCodeSent, setIsCodeSent] = useState(false);
  const [codeMessage, setCodeMessage] = useState("");
  const [isChecking, setIsChecking] = useState(false);

  const handleEmailChange = (text: string) => {
    onEmailChange(text);
    if (text !== verifiedEmail) {
      setIsEmailVerified(false);
      setEmailCheckMessage("");
      setIsCodeSent(false);
      setVerificationCode("");
      onVerificationStatusChange(false, "");
    }
  };

  const handleCheckEmailDuplicate = async () => {
    if (isEmailVerified) return;

    setIsChecking(true);
    const formatResult = validateEmailFormat(email);
    if (!formatResult.isValid) {
      setEmailCheckMessage(formatResult.message);
      setIsChecking(false);
      return;
    }

    const result = await checkEmailDuplicate(email);
    setEmailCheckMessage(result.message);

    if (result.isValid) {
      setIsEmailVerified(true);
      setVerifiedEmail(email);
      setIsCodeSent(true);
      await sendVerificationCode(email);
    } else {
      setIsEmailVerified(false);
      onVerificationStatusChange(false, "");
    }
    setIsChecking(false);
  };

  const handleVerifyCode = async () => {
    const result = await verifyEmailCode(email, Number(verificationCode));
    setCodeMessage(result.message);
    if (result.success) {
      onVerificationStatusChange(true, email);
    }
  };

  return (
    <>
      <View style={styles.emailInputContainer}>
        <TextInput
          style={[
            styles.emailInput,
            isEmailVerified && { borderColor: "green", borderWidth: 1 },
          ]}
          value={email}
          onChangeText={handleEmailChange}
          placeholder="example@gmail.com"
          placeholderTextColor="#666"
          editable={!isEmailVerified}
        />
        <TouchableOpacity
          style={[styles.button, isEmailVerified ? styles.disabledButton : {}]}
          onPress={handleCheckEmailDuplicate}
          disabled={isEmailVerified || isChecking}
        >
          <Text style={styles.buttonText}>
            {isEmailVerified
              ? "확인됨 ✓"
              : isChecking
              ? "확인 중..."
              : "중복 확인"}
          </Text>
        </TouchableOpacity>
      </View>
      {emailCheckMessage ? (
        <Text style={styles.errorText}>{emailCheckMessage}</Text>
      ) : null}
      {isCodeSent && (
        <View style={styles.verificationContainer}>
          <TextInput
            style={styles.codeInput}
            value={verificationCode}
            onChangeText={setVerificationCode}
            placeholder="인증 코드 입력"
            placeholderTextColor="#666"
            keyboardType="number-pad"
          />
          <TouchableOpacity style={styles.button} onPress={handleVerifyCode}>
            <Text style={styles.buttonText}>인증 확인</Text>
          </TouchableOpacity>
        </View>
      )}
      {codeMessage ? <Text style={styles.errorText}>{codeMessage}</Text> : null}
    </>
  );
};

export default EmailVerificationInput;

2. 이메일 인증 관련 API 함수 구현

이메일 검증, 중복 체크, 인증 코드 발송, 코드 확인 등을 위한 함수들을 구현합니다:

type EmailVerificationResult = {
  isValid: boolean;
  message: string;
};

// 이메일 인증 코드 전송
export const sendVerificationCode = async (email: string) => {
  try {
    const response = await axiosInstance.post("/mail/issue-mail", { email });
    return { success: true, message: "인증 코드가 전송되었습니다." };
  } catch (error) {
    console.error("인증 코드 전송 실패:", error);
    return { success: false, message: "인증 코드 전송 실패" };
  }
};

// 이메일 인증 코드 확인
export const verifyEmailCode = async (
  email: string,
  verificationCode: number
) => {
  try {
    const response = await axiosInstance.post("/mail/verify-mail", {
      email,
      verificationCode,
    });
    return { success: true, message: "이메일 인증 성공" };
  } catch (error) {
    console.error("인증 코드 확인 실패:", error);
    
    // 서버에서 반환하는 에러 메시지 확인
    if (axios.isAxiosError(error) && error.response) {
      const serverMessage = error.response.data?.message;
      if (serverMessage) {
        return { success: false, message: serverMessage };
      }
    }
    
    return { success: false, message: "인증 코드가 올바르지 않습니다." };
  }
};

// 이메일 형식 검증
export const validateEmailFormat = (email: string): EmailVerificationResult => {
  if (!email) {
    return {
      isValid: false,
      message: "이메일을 입력해주세요.",
    };
  }

  const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  if (!emailRegex.test(email)) {
    return {
      isValid: false,
      message: "유효한 이메일 형식이 아닙니다.",
    };
  }

  return {
    isValid: true,
    message: "",
  };
};

// 이메일 중복 체크
export const checkEmailDuplicate = async (
  email: string
): Promise<EmailVerificationResult> => {
  try {
    const response = await axiosInstance.post("/member/check-email", {
      email: email,
    });

    if (response.data === false) {
      return {
        isValid: false,
        message: "이미 사용 중인 이메일입니다.",
      };
    } else {
      return {
        isValid: true,
        message: "사용 가능한 이메일입니다.",
      };
    }
  } catch (error) {
    console.error("이메일 중복 확인 실패:", error);

    if (axios.isAxiosError(error)) {
      // 네트워크 또는 서버 관련 오류 처리
      if (error.code === "ECONNABORTED") {
        return {
          isValid: false,
          message: "요청 시간이 초과되었습니다. 다시 시도해 주세요.",
        };
      } else if (!error.response) {
        return {
          isValid: false,
          message: "서버에 연결할 수 없습니다. 네트워크 상태를 확인해 주세요.",
        };
      } else {
        // 서버에서 오는 다양한 상태 코드에 따른 처리
        const statusCode = error.response.status;

        switch (statusCode) {
          case 400:
            return {
              isValid: false,
              message: "잘못된 요청입니다. 이메일 형식을 확인해 주세요.",
            };
          case 429:
            return {
              isValid: false,
              message:
                "너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해 주세요.",
            };
          case 500:
          default:
            return {
              isValid: false,
              message: "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
            };
        }
      }
    }

    return {
      isValid: false,
      message: "이메일 확인 중 오류가 발생했습니다.",
    };
  }
};

3. Axios Interceptor 수정

인증이 필요하지 않은 회원가입 및 이메일 인증 관련 API 요청을 처리하기 위해 Axios 인터셉터를 수정합니다:

// src/api/axios-interceptor.ts
// 이미 존재하는 코드에서 변경된 부분만 표시

// 모든 요청에 디버깅 추가
axiosInstance.interceptors.request.use(
  async (config) => {
    console.log("🚀 요청 시작:", config.method?.toUpperCase(), config.url);
    console.log("📦 요청 데이터:", config.data);
    console.log("🔧 요청 헤더:", config.headers);

    if (
      config.url === "/auth/login" ||
      config.url === "/auth/signup" ||
      config.url === "/auth/refresh" ||
      config.url === "/member/check-email" ||
      config.url === "/mail/issue-mail" ||
      config.url === "/mail/verify-mail"
    ) {
      return config;
    }

    // 나머지 코드는 변경 없음...
  },
  (error) => {
    console.error("❌ 요청 오류:", error.message);
    return Promise.reject(error);
  }
);

4. 회원가입 1단계 화면에 이메일 인증 추가

이메일 인증 컴포넌트를 회원가입 1단계에 통합합니다:

const SignupStep1: React.FC<TSignupStep1Props> = ({ onNext }) => {
  const [formData, setFormData] = useState<TStep1Form>({
    email: "",
    password: "",
    nickname: "",
  });
  const [passwordConfirm, setPasswordConfirm] = useState("");
  const [errors, setErrors] = useState<string[]>([]);
  const [isEmailVerified, setIsEmailVerified] = useState(false);
  const [verifiedEmail, setVerifiedEmail] = useState("");

  const handleSubmit = () => {
    if (!isEmailVerified || formData.email !== verifiedEmail) {
      setErrors(["이메일 중복 확인이 필요합니다."]);
      return;
    }
    const validationErrors = validateSignupStep1(formData);
    if (validationErrors.length > 0) {
      setErrors(validationErrors);
      return;
    }
    onNext(formData);
  };

  const handleEmailChange = (email: string) => {
    setFormData((prev) => ({ ...prev, email }));
  };

  const handleVerificationStatusChange = (
    isVerified: boolean,
    email: string
  ) => {
    setIsEmailVerified(isVerified);
    setVerifiedEmail(email);
  };

  return (
    <SafeAreaView>
      <View style={styles.inputContainer}>
        <Text style={styles.label}>이메일</Text>
        <EmailVerificationInput
          email={formData.email}
          onEmailChange={handleEmailChange}
          onVerificationStatusChange={handleVerificationStatusChange}
        />

        <Text style={styles.label}>비밀번호</Text>
        <TextInput
          style={styles.input}
          value={formData.password}
          onChangeText={(text) =>
            setFormData((prev) => ({ ...prev, password: text }))
          }
          placeholder="8자리 이상 입력해주세요"
          placeholderTextColor="#666"
          secureTextEntry
        />

        <Text style={styles.label}>비밀번호 확인</Text>
        <TextInput
          style={styles.input}
          value={passwordConfirm}
          onChangeText={setPasswordConfirm}
          placeholder="비밀번호를 다시 입력해주세요."
          placeholderTextColor="#666"
          secureTextEntry
        />

        <Text style={styles.label}>닉네임</Text>
        <TextInput
          style={styles.nickNameInput}
          value={formData.nickname}
          onChangeText={(text) =>
            setFormData((prev) => ({ ...prev, nickname: text }))
          }
          placeholder="사용하실 닉네임을 입력해주세요."
          placeholderTextColor="#A0A0A0"
        />
      </View>
      {errors.map((error, index) => (
        <Text key={index} style={styles.errorText}>
          {error}
        </Text>
      ))}
      <View style={styles.signupStageContainer}>
        <TouchableOpacity style={[styles.signupButton]} onPress={handleSubmit}>
          <Text style={styles.signupButtonText}>다음</Text>
        </TouchableOpacity>
      </View>
    </SafeAreaView>
  );
};

export default SignupStep1;

테스트 및 결과

구현이 완료된 후 테스트 결과는 다음과 같습니다:

(NOBRIDGE) LOG  ✅ 응답 성공: 200
(NOBRIDGE) LOG  📄 응답 데이터: true
(NOBRIDGE) LOG  🚀 요청 시작: POST /member/check-email
(NOBRIDGE) LOG  📦 요청 데이터: {"email": "cyw7715@gmail.com"}
(NOBRIDGE) LOG  🔧 요청 헤더: {"Accept": "application/json, text/plain, */*", "Content-Type": "application/json"}
(NOBRIDGE) LOG  ✅ 응답 성공: 200
(NOBRIDGE) LOG  📄 응답 데이터: true
(NOBRIDGE) LOG  🚀 요청 시작: POST /mail/issue-mail
(NOBRIDGE) LOG  📦 요청 데이터: {"email": "cyw7715@gmail.com"}
(NOBRIDGE) LOG  🔧 요청 헤더: {"Accept": "application/json, text/plain, */*", "Content-Type": "application/json"}
(NOBRIDGE) LOG  ✅ 응답 성공: 200
(NOBRIDGE) LOG  📄 응답 데이터: 
(NOBRIDGE) LOG  🚀 요청 시작: POST /mail/verify-mail
(NOBRIDGE) LOG  📦 요청 데이터: {"email": "cyw7715@gmail.com", "verificationCode": 853021}
(NOBRIDGE) LOG  🔧 요청 헤더: {"Accept": "application/json, text/plain, */*", "Content-Type": "application/json"}
(NOBRIDGE) LOG  ✅ 응답 성공: 200
(NOBRIDGE) LOG  📄 응답 데이터: true
(NOBRIDGE) LOG  🚀 요청 시작: POST /member
(NOBRIDGE) LOG  📦 요청 데이터: {"email": "cyw7715@gmail.com", "height": 199, "level": "ADVANCED", "nickname": "123", "password": "test1234@", "position": "SG", "weight": 96}
(NOBRIDGE) LOG  🔧 요청 헤더: {"Accept": "application/json, text/plain, */*", "Content-Type": "application/json"}
(NOBRIDGE) LOG  ✅ 응답 성공: 201

위 로그에서 볼 수 있듯이 이메일 중복 체크, 인증 코드 발송, 인증 코드 확인, 회원가입 과정이 모두 성공적으로 작동했습니다.

주요 포인트 및 트러블슈팅

1. Axios Interceptor 설정의 중요성

프로젝트의 초기 단계에서 이메일 인증 관련 API 요청이 제대로 작동하지 않았습니다. 문제는 Axios Interceptor에서 토큰이 필요하지 않은 API 요청(이메일 인증 관련)에 대한 예외 처리가 되어 있지 않았기 때문이었습니다.

// 잘못된 부분
if (
  config.url === "/auth/login" ||
  config.url === "/auth/signup" ||
  config.url === "/auth/refresh"
) {
  return config;
}

// 수정된 부분
if (
  config.url === "/auth/login" ||
  config.url === "/auth/signup" ||
  config.url === "/auth/refresh" ||
  config.url === "/member/check-email" ||
  config.url === "/mail/issue-mail" ||
  config.url === "/mail/verify-mail"
) {
  return config;
}

2. API 요청 시 올바른 인스턴스 사용

처음에는 axios 대신 axiosInstance를 사용하지 않아 baseURL이 설정되지 않는 문제가 있었습니다:

// 잘못된 부분
const response = await axios.post("/mail/issue-mail", { email });

// 수정된 부분
const response = await axiosInstance.post("/mail/issue-mail", { email });

3. 에러 처리 개선

더 나은 사용자 경험을 위해 서버에서 반환하는 에러 메시지를 활용하도록 개선했습니다:

// 개선된 에러 처리
if (axios.isAxiosError(error) && error.response) {
  const serverMessage = error.response.data?.message;
  if (serverMessage) {
    return { success: false, message: serverMessage };
  }
}

결론

React Native에서 이메일 인증 기능을 구현하는 것은 여러 컴포넌트와 API 호출, 상태 관리가 복잡하게 얽혀 있는 작업입니다. 그러나 이 글에서 소개한 방법을 따르면 체계적으로 구현할 수 있습니다. 특히 Axios Interceptor 설정, API 호출 함수 구현, 컴포넌트 상태 관리에 주의하면 효과적인 이메일 인증 기능을 구현할 수 있습니다.

이메일 인증은 보안의 첫 단계로, 사용자의 신원을 확인하고 스팸 계정 생성을 방지하는 중요한 기능입니다. 이 글이 여러분의 React Native 앱에 이메일 인증 기능을 추가하는 데 도움이 되길 바랍니다.

참고 자료

profile
궁금한 것, 했던 것, 시행착오 그리고 기억하고 싶은 것들을 기록합니다.

0개의 댓글