개요

  1. userType을 쿼리 문자열로 넣어 Client/Mover 통합.
  2. service 단까지는 각자 구현하다가 controller 단위에서 통합하면서 토큰을 넣음. 일반 회원가입/로그인에서 body로 토큰을 보냈기 때문에, 그에 맞춰 BE에서 accessToken을 쿼리로 넘김
  3. res.jsonres.redirect 동시 적용이 안 되므로, BE에서는 res.redirect를 적용하고 FE에서 api route 처리

FE

  1. Client/Mover 모두 href로 기본 url 연결하고 상위 페이지에서 userType을 내려줌.
export default function EasyLoginForm({ userType }: Prop) {
   const t = useTranslations("Sign");

   const handleGoogleLogin = () => {
      location.href = `${BASE_URL}/auth/google?userType=${userType}`;
   };

   const handlekakaoLogin = () => {
      location.href = `${BASE_URL}/auth/kakao?userType=${userType}`;
   };

   const handlenaverLogin = () => {
      location.href = `${BASE_URL}/auth/naver?userType=${userType}`;
   };
  1. app/api/auth/callback 경로에서 route.ts 파일 작성. 해당 페이지에서 오류/토큰 여부 판별하고 곧바로 페이지를 넘겨줌.
import { setServerToken } from "@/lib/utils/server-token.util";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
   const token = req.nextUrl.searchParams.get("token");
   const error = req.nextUrl.searchParams.get("error");
   if (error) {
      return NextResponse.redirect(new URL(`/sign-in?error=${error}`, req.url));
   }

   if (token) {
      await setServerToken(token); // ✅ 서버 쿠키 저장

      return NextResponse.redirect(new URL("/mover-search", req.url));
   }

   return NextResponse.redirect(new URL("/sign-in?error=no_token", req.url));
}

BE 작업 과정

  1. 첫 등록/수정 시 같은 엔드포인트를 적용. service 단위에서 분기처리.
  2. 프로필 수정 시 덮어쓴 이름을 원상복구하지 않도록 update 시 이름은 따로 뺌.
// 소셜 로그인
async function save(user: SignUpDataSocial) {
  const newClient = await prisma.client.create({
    data: {
      name: user.name,
      email: user.email!,
      phone: user.phone,
      provider: user.provider,
      providerId: user.providerId,
    },
  });

  return { ...newClient, userType: "client" }; // userType: 헤더에서 씀
}

async function update(id: string, user: Omit<SignUpDataSocial, "email" | "name">) {
  const newClient = await prisma.client.update({
    where: { id },
    data: user, // 이름은 빼고 받음 (덮어쓰기 방지)
  });

  return { ...newClient, userType: "client" };
}

//

async function oAuthCreateOrUpdate(data: SignUpDataSocial) {
  // 1. 이메일로 사용자가 있는지 찾음
  const { email, ...rest } = data;
  const existingUser = await authClientRepository.findByEmailRaw(email);

  // 2. 이미 가입된 이메일로 또 가입하려 할 때 오류 뱉음 = 소셜끼리
  // ex. 카카오 회원가입 시 네이버 이메일을 썼는데 네이버로 가입하려는 경우
  let user;
  if (existingUser) {
    if (existingUser.provider !== data.provider)
      throw new BadRequestError(`이미 ${existingUser.provider} 가입 시 사용된 이메일입니다.`);

    // + 사용자가 이름을 수정할 수 있게 덮어쓰기 하지 않음
    const { name, ...dataWithoutName } = rest;
    user = await authClientRepository.update(existingUser.id, dataWithoutName);
  } else {
    // 3. 없으면 자료 자체를 새로 생성
    user = await authClientRepository.save(data);
  }

  return filterSensitiveUserData(user);
}
  1. controller에서 토큰 넣고 redirect 처리 — 한 것을 router에서 씀
  2. passport에서 userType 처리
export const googleStrategyOptions = {
  clientID: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  callbackURL: "/auth/google/callback",
  passReqToCallback: true as const, // 쿼리 문자열에 userType 넣음
};

// 인증 함수 실행해서 프로필 정보를 id 코드로 넘김
export async function verify(
  req: Request,
  accessToken: string,
  refreshToken: string,
  profile: Profile,
  done: (error: any, user?: any) => void,
) {
  try {
    // enum <-> string 변환
    const providerEnumValue = providerMap[profile.provider];
    const userType = req.query.state || "client";

    // 이메일 없으면 오류 처리
    if (!profile.emails || profile.emails.length === 0) {
      return done(new BadRequestError("이메일을 받지 못해 구글 로그인에 실패했습니다."));
    }

    // 사용자 데이터
    let userInfo;
    if (userType === "client") {
      // 사용자 데이터
      userInfo = await authClientService.oAuthCreateOrUpdate({
        provider: providerEnumValue,
        providerId: profile.id,
        email: profile.emails[0].value,
        name: "구글", // 구글 이름 안 받음
      });
    } else if (userType === "mover") {
      userInfo = await authMoverService.oAuthCreateOrUpdate({
        provider: providerEnumValue,
        providerId: profile.id,
        email: profile.emails[0].value,
        name: "구글",
      });
    } else {
      throw new BadRequestError("소셜 로그인: userType을 식별하지 못했습니다.");
    }

    done(null, userInfo); // req.user = user;
  } catch (error: any) {
    if (error.name === "Bad Request") {
      return done(error);
    }
    return done(new BadRequestError("소셜 로그인 중 오류가 발생했습니다."));
  }
}

const googleStrategy = new GoogleStrategy(googleStrategyOptions, verify);

export default googleStrategy;

  1. 콜백 엔드포인트와 로그인 시도 엔드포인트를 따로 제작(router), 각각 passport 미들웨어와 controller를 불러옴
authRouter.get("/google", (req, res, next) => {
  const userType = (req.query.userType as string) || "client";
  passport.authenticate("google", {
    scope: ["profile", "email"],
    state: userType,
  })(req, res, next);
});

authRouter.get(
  "/google/callback",
  createSocialAuthMiddleware("google"),
  authController.signInEasily,
);