
현대 웹 애플리케이션에서는 사용자 편의성과 전환율을 높이기 위해 소셜 로그인 기능이 사실상 필수 요소가 되어가고 있다. 별도의 회원가입 절차 없이 구글이나 카카오처럼 이미 익숙한 플랫폼 계정을 통해 로그인할 수 있다는 점은, 사용자의 진입 장벽을 크게 낮춰준다.
OpenID Connect
소셜 로그인에 대해 이야기할 때 흔히 OAuth 2.0 프로토콜을 사용한다고 말하곤 한다. 하지만 실제로 “로그인”, 즉 사용자 인증(Authentication) 에 사용되는 것은 OAuth 2.0 그 자체가 아니라 OpenID Connect(OIDC) 이다.
OAuth 2.0은 본래 “인가(Authorization)”를 위한 프로토콜이다. 애플리케이션이 사용자를 대신해 다른 서비스의 API를 호출할 수 있도록, 액세스 토큰을 발급받는 것이 목적이다. 반면 OpenID Connect는 OAuth 2.0을 확장해 사용자 신원 인증 기능을 추가한 프로토콜이다.
즉,
OAuth 2.0 → 이 앱이 이 사용자의 권한으로 무엇을 할 수 있는가
OpenID Connect → 이 사용자가 누구인가
우리가 흔히 말하는 구글 로그인, 카카오 로그인은 모두 OpenID Connect 흐름 위에서 동작한다.

이미지 출처는 코드잇
Passport를 활용한 Strategy 구현
Passport는 Node.js 환경에서 OAuth 2.0과 OpenID Connect 같은 인증 프로토콜을 손쉽게 구현할 수 있도록 도와주는 미들웨어다. 인증 방식별로 Strategy라는 개념을 제공하며, Nest.js에서는 이 Strategy들이 모두 프로바이더로 취급된다. 따라서 반드시 모듈에 등록되어야 한다.
Strategy를 구현하기 위해 필요한 값은 다음과 같다.
clientIDclientSecretcallbackURL
clientID와 clientSecret은 각 OpenID Connect 제공자(구글, 카카오 등)에서 발급받는다.
callbackURL은 인증이 완료된 후 리다이렉트될 URL로, 백엔드 주소를 사용할 수도 있지만 에러 처리 및 UX 관점에서 프론트엔드 주소를 사용하는 편을 권장한다.
Google Strategy
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor() {
super({
clientID: process.env.OAUTH_GOOGLE_CLIENT_ID,
clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET,
callbackURL: process.env.BASE_URL + '/auth/google/callback',
scope: ['email', 'profile'],
});
}
// refreshToken이 필요한 경우 설정
authorizationParams(): { [key: string]: string } {
return {
access_type: 'offline',
prompt: 'select_account',
};
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
): Promise<void> {
const { name, emails, provider } = profile;
const socialLoginUserInfo = {
email: emails[0].value,
firstName: name.givenName,
lastName: name.familyName,
socialProvider: provider,
accessToken,
refreshToken,
};
done(null, socialLoginUserInfo);
}
}Kakao Strategy
@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
constructor() {
super({
clientID: process.env.OAUTH_KAKAO_CLIENT_ID,
callbackURL: process.env.BASE_URL + '/auth/kakao/callback',
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
): Promise<void> {
const { id, username, _json } = profile;
const socialLoginUserInfo = {
id,
username,
email: _json.kakao_account.email,
accessToken,
refreshToken,
};
done(null, socialLoginUserInfo);
}
}앞서 JWT 인증에서는 커스텀 가드를 사용해 토큰 검증 로직을 직접 구현했다. 이는 프로젝트 내부 규칙이었기 때문에 가능한 선택이었다. 하지만 OpenID Connect는 외부 표준 프로토콜이며, 이런 경우에는 검증된 구현체인 Passport를 사용하는 편이 훨씬 효율적이라고 판단했다.
Social Login Route 구현
Nest.js에서는 소셜 로그인을 위해 크게 두 개의 엔드포인트를 구현한다.
인증 요청을 시작하는 엔드포인트 @Get("google")
인증이 완료된 후 호출되는 callback 엔드포인트 @Post("google/callback")
import { AuthGuard as Auth } from '@nestjs/passport';
@UseGuards(Auth('google'))
@Get('google')
googleAuth() {
return 'Google OAuth';
}
@UseGuards(Auth('google'))
@Post('google/callback')
async loginGoogle(@Req() req, @Res() res: Response) {
const user = await this.authService.getUserByEmail(req.user.email);
const input = {
email: req.user.email,
password: 'OAuth',
nickname: req.user.firstName,
};
const { accessToken, refreshToken } = user
? await this.authService.signIn(input)
: await this.authService.signup(input);
res
.cookie('accessToken', accessToken, this.cookieOptions)
.cookie('refreshToken', refreshToken, this.cookieOptions)
.send({ message: 'google 로그인 성공' });
}참고로 유저 기능을 담당하는 커스텀 가드 이름이 AuthGuard라서 passport가 제공하는 AuthGuard 데코레이터를 as Auth로 불러왔다. 만약 카카오 OpenID라면 Auth('google')대신 Auth('kakao')를 사용해주면 된다.
클라이언트에서의 처리
클라이언트에서는 단순히 소셜 로그인 버튼 클릭 시 백엔드의 인증 시작 엔드포인트로 이동시키면 된다. 이는 사용자가 곧바로 구글 로그인 페이지로 이동하는 것이 아니라, 백엔드를 경유해 Passport가 인증 요청을 구성하기 때문이다.
<Link href={`${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/google`}>
구글 로그인
</Link>로그인이 완료되면 구글은 Strategy에서 설정한 callbackURL로 리다이렉션한다. 이때 callback URL에는 사용자를 식별하기 위한 쿼리 스트링이 함께 포함된다. 이 값이 있어야 Auth('google') 가드가 clientID와 clientSecret을 사용해 사용자 정보를 복원할 수 있다.
Next.js에서는 페이지가 완전히 준비되기 전까지 asPath가 /auth/[openID]/callback 형태로 유지되기 때문에, isReady를 확인한 뒤 요청을 보내도록 했다.
export default function Callback() {
const { asPath, query, push, isReady } = useRouter();
const { openId } = query;
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
if (!isReady) return;
fetcher<Response>({
url: process.env.NEXT_PUBLIC_BASE_URL + asPath,
method: 'post',
}).then((res) => {
if (res.message === `${openId} 로그인 성공`) push('/');
else setErrorMessage(res.message);
});
}, [asPath, openId, push, isReady]);
return <>{errorMessage}</>;
}더 읽어보기
2025.05.21
14. Redis를 사용한 세션 관리 및 캐싱
새로운 프로젝트를 준비하면서 인증 방식부터 다시 고민하게 되었다. 이전 프로젝트에서는 JWT를 사용해 유저 인증과 상태 관리를 처리했지만, 이번에는 Redis를 활용한 세션 방식으로 전환하기로 결정했다. 이 방식은 유저 상태를 서버에서 직접 관리할 수 있어 보안 측면에서도 유리하고, 필…
2025.01.30
비즈니스 로직은 어디에 있어야 할까
Nest.js에서 컨트롤러와 각각의 프로바이더는 분명한 책임을 갖는다. 하지만 막상 서버를 개발하다 보면, 이 책임들을 깊이 고민하지 않은 채 코드를 작성하게 되고, 그 결과 클래스들이 서로의 영역을 침범하는 상황이 반복된다. 나 역시 컨트롤러에 비즈니스 로직이 섞이거나, 서비스가 지나…
2024.12.07
라이브러리를 죽여버릴 수야 없겠지만
상황 Nest.js 서버에서 jest를 사용한 테스트 코드를 작성하고 있었다. 평소에는 아래와 같이 ConfigService를 모의하여 configService.get으로 환경 변수를 처리했다. 그런데 이런 방식이 마음에 들지 않았다. 가장 큰 이유는 필요한 문자열이 하드코딩 되어있어…
2024.12.07
13. Jest 테스트 구성
이 포스트를 시작하기에 앞서 한 가지 분명히 밝혀두고 싶은 점이 있다. 나는 컨트롤러(혹은 GraphQL 기준으로는 리졸버) 와 레포지토리 클래스에 대한 테스트를 거의 작성하지 않는다. 이유는 단순하다. 애플리케이션에서 가장 많은 비즈니스 규칙이 응집되어 있는 곳은 서비스 계층이며, 테…
2026.06.01
React Server Components를 위한 컴포넌트 아키텍처
이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는…
2026.05.26
차트는 멈췄는데 윈도우가 움직인다
상황 어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다. 진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
2026.05.21
7. Decorator — 토핑 추가할 때마다 클래스를 새로 만들 수 없다
에이든 피자에서 주문서를 객체로 만들자 취소와 재주문은 한결 편해졌다. 그런데 주문이 편해지자 손님들도 한결 편해졌다. 편해진 손님은 더 많은 요구를 한다. "치즈 추가요", "올리브도 추가요", "소스 많이요", "조금 더 바삭하게 구워주세요" 같은 요청이 주문대 위로 쌓이기 시작했다…
댓글
댓글을 불러오는 중...