Skip to content

Feat/#15/week6 hw - #16

Open
byunhm02 wants to merge 14 commits into
mainfrom
feat/#15/week6_hw
Open

Feat/#15/week6 hw#16
byunhm02 wants to merge 14 commits into
mainfrom
feat/#15/week6_hw

Conversation

@byunhm02

@byunhm02 byunhm02 commented May 22, 2026

Copy link
Copy Markdown
Collaborator

🔥Pull requests

👷 과제 구현

필수과제

  • 실습한 JWT + Spring Security 인증을 에브리타임 클론 프로젝트 전체에 적용. (로그인/토큰 재발급 API는 인증 없이 접근 가능하고, 게시글 작성/수정/삭제, 좋아요 추가/취소는 인증이 필요하도록 설정)
  • 비밀번호를 평문으로 저장하는 건 위험-> BCryptPasswordEncoder를 사용해서 비밀번호를 암호화해서 저장하고, 로그인 시 matches()로 검증하도록 수정.

선택과제

  • 로그아웃 API를 구현. (로그아웃 시 DB에서 해당 유저의 Refresh Token을 삭제하고, 현재 Access Token을 블랙리스트에 추가해서 만료 전에도 사용할 수 없도록. Access Token이 만료됐을 때 클라이언트가 어떻게 401을 감지해서 로그인 페이지로 이동시킬지 흐름도 함께 작성)

  • Kakao 또는 Google OAuth 2.0 소셜 로그인을 구현. 외부 인증 서버로부터 유저 정보를 받아온 후, 우리 서버에서 JWT(Access Token + Refresh Token)를 발급하는 흐름까지 완성. 신규 유저라면 자동으로 회원가입 처리하고, 기존 유저라면 로그인 처리)


구현한 내용에 대해서 설명해주세요

회원가입 / 일반 로그인

  • 이메일 + 비밀번호 기반 회원가입 및 로그인을 구현했음
  • BCryptPasswordEncoder로 비밀번호를 암호화해서 DB에 저장하고, 로그인 시 matches()로 검증하도록 했음
  • 회원가입 성공 시 바로 토큰을 발급해서 다시 로그인할 필요 없도록 했음

카카오 로그인

  • OAuth 2.0 프로토콜을 직접 구현해서 카카오 로그인을 처리했음
  • KakaoService가 인가코드 → 카카오 액세스토큰 → 유저 정보(닉네임) 순서로 카카오 API를 호출하고, 기존 유저면 로그인, 신규 유저면 자동 가입 처리했음

유저 타입 분리

  • LoginType enum(GENERAL, KAKAO)을 User 엔티티에 추가해서 가입 경로를 구분했음
  • 카카오로 가입한 유저가 일반 로그인을 시도하면 "카카오 로그인을 이용해주세요" 에러를 반환하도록 처리했음
  • 일반 유저는 email + password, 카카오 유저는 kakaoId로 식별하고, User 엔티티에 정적 팩토리 메서드(createGeneralUser, createKakaoUser)를 두어 생성 방식을 명확히 했음

JWT 인증/인가

  • 액세스 토큰은 응답 헤더(Authorization: Bearer)에, 리프레시 토큰은 HttpOnly 쿠키에 담아서 반환하도록 했음
  • JwtAuthFilter에서 매 요청마다 액세스 토큰을 검증하고 SecurityContext에 인증 정보를 넣어주는 구조로 구현했음
  • Controller에서는 @AuthenticationPrincipal Long memberId로 인증된 유저 정보를 받도록 통일했음

토큰 재발급

  • 쿠키에서 리프레시 토큰을 꺼내서 Redis에 저장된 값과 비교한 뒤 새 토큰 쌍을 발급하도록 구현했음
  • 재발급 시 기존 리프레시 토큰은 무효화되고 새 토큰이 Redis에 저장됨 (Refresh Token Rotation)

로그아웃 + 블랙리스트

  • 로그아웃 시 Redis에서 리프레시 토큰을 삭제하고, 현재 액세스 토큰을 블랙리스트에 등록해서 만료 전에도 사용할 수 없도록 했음
  • 블랙리스트는 액세스 토큰의 남은 만료시간만큼만 Redis에 TTL로 유지되어 자동 정리됨
  • JwtAuthFilter에서 블랙리스트 체크를 추가해서, 로그아웃된 토큰으로 요청하면 인증을 거부하도록 했음

Redis 활용

  • 리프레시 토큰과 블랙리스트를 모두 Redis로 관리했음
  • 리프레시 토큰은 만료 시간이 있는 임시 데이터라 Redis의 TTL 기능이 적합했고, 매 요청마다 조회하는 블랙리스트도 인메모리인 Redis가 DB보다 빠르다고 판단했음

Spring Security 설정

  • SecurityConfig에서 세션을 STATELESS로 설정하고, JWT 기반 인증으로 전환했음

게시글 권한 체크

  • 게시글 수정/삭제 시 @AuthenticationPrincipal로 받은 memberId와 게시글 작성자를 비교해서 본인만 수정/삭제할 수 있도록 했음
  • Post 엔티티에 isWrittenBy(Long userId) 메서드를 두어 권한 확인 로직을 도메인에 위치시켰음

서비스 책임 분리

  • AuthService — 회원가입, 일반 로그인
  • KakaoAuthService — 카카오 로그인
  • TokenService — 토큰 발급, 재발급, 로그아웃(블랙리스트 포함)


*구현하며 고민했던 내용을 적어주세요 (사소한 것도 좋아요)

1. 카카오 로그인을 Spring OAuth2 Client로 할지, 직접 구현할지

처음에 Spring OAuth2 Client(spring-boot-starter-oauth2-client)로 전환하려고 했는데, 카카오는 Spring의 기본 provider에 등록되어있지 않아서 직접 provider 설정을 넣어야 했음. 한국에서는 카카오/네이버를 직접 구현하는 게 더 일반적이라는 점도 고려해서, KakaoService에서 WebClient로 직접 카카오 API를 호출하는 방식을 선택했음

2. 리프레시 토큰을 DB에 저장할지, Redis에 저장할지

처음엔 DB에 RefreshToken 엔티티를 만들어서 저장했음. 하지만 리프레시 토큰은 만료되면 사라져야 하는 임시 데이터인데 DB에 두면 별도 배치로 정리해야 하고, 매 토큰 검증마다 DB 쿼리가 나가는 게 비효율적이라 판단했음. Redis의 TTL을 활용하면 만료된 토큰이 자동으로 삭제되고, 인메모리라 조회도 빨라서 Redis로 전환했음

3. 로그아웃 시 액세스 토큰을 어떻게 무효화할지

JWT는 stateless라 서버에서 강제로 만료시킬 수 없는 문제가 있었음. 로그아웃해도 액세스 토큰이 만료 전까지 유효한 게 보안상 위험하다고 판단해서, Redis 블랙리스트를 도입했음. 블랙리스트에 액세스 토큰의 남은 만료시간만큼만 TTL을 설정해서, 토큰이 원래 만료될 시간이 지나면 Redis에서도 자동 삭제되도록 했음

4. 카카오 유저와 일반 유저를 어떻게 구분할지

같은 User 테이블을 쓰되 LoginType enum으로 가입 경로를 구분하는 방식을 선택했음. 카카오 유저는 email과 password가 null일 수 있고, 일반 유저는 kakaoId가 null이라 각 필드가 nullable이 되는 트레이드오프가 있지만, 테이블을 분리하면 게시글/좋아요에서 유저를 참조할 때 복잡해지기 때문에 하나의 테이블로 유지했음

5. Controller에서 Authentication vs @AuthenticationPrincipal

처음엔 Authentication authentication으로 받아서 Long.parseLong(authentication.getName())으로 변환하고 null 체크도 직접 했음. @AuthenticationPrincipal Long memberId로 바꾸니 타입 변환과 null 체크가 불필요해져서 훨씬 깔끔해졌음. JwtAuthFilter에서 principal을 Long 타입으로 넣어주는 게 핵심이었음

6. 인가코드를 프론트에서 받을지, 백엔드에서 받을지

예전에 카카오 Redirect URI를 백엔드로 설정해서 백엔드가 직접 인가코드를 받는 방식으로 구현했던 경험이 있었음. 이 경우 카카오 로그인 완료 후 백엔드로 리다이렉트되면서 토큰을 발급하는데, 이 토큰을 프론트에 어떻게 전달할지가 문제였음. 브라우저 리다이렉트로 들어온 요청이라 프론트 앱이 응답을 직접 받을 수 없었고, 결국 백엔드가 oneTimeToken이라는 임시 토큰을 발급하고 프론트 URL로 다시 리다이렉트하면서 쿼리파라미터에 담아 전달하는 방식을 썼었음. 프론트가 이 임시 토큰으로 다시 백엔드에 요청해서 실제 액세스/리프레시 토큰을 받는 구조였는데, 흐름이 복잡하고 임시 토큰 관리가 추가로 필요했음.

최종적으로는 Redirect URI를 프론트로 설정하고, 프론트가 인가코드를 받아서 POST /auth/kakao로 백엔드에 전달하는 방식으로 변경했음. 이러면 백엔드는 순수 API 서버 역할만 하고, 프론트-백 역할 분리가 깔끔해짐. 임시 토큰도 필요 없어졌음. 보안상 백으로 설정하는게 맞지만 과제에선 편의성을 위해 프론트로 설정.

7. OpenID Connect(OIDC)를 쓸지 말지

예전에 카카오 설정에서 OpenID Connect를 활성화해본 적도 있음. OIDC를 쓰면 카카오가 ID 토큰(JWT)을 추가로 발급해줘서 별도 유저 정보 API 호출 없이 토큰에서 바로 유저 정보를 꺼낼 수 있는 장점이 있었음. 하지만 카카오 로그인 예제 대부분이 OIDC 없이 구현하고 있었고, 유저 정보 API를 한 번 더 호출하는 게 큰 비용이 아니라고 판단해서 OIDC 없이 진행했음. (나중에 구글 로그인을 추가하면 구글은 OIDC가 기본이라고 알고 있어서 그때 도입을 고려할 수 있을 것 같음)



키워드 과제 정리내용

OAuth 2.0이란 무엇인가요? 인증 흐름을 정리해보세요.

OAuth 2.0이란 무엇인가요?

OAuth 2.0은 사용자가 자신의 비밀번호를 제3자 서비스에 직접 제공하지 않고도, 해당 서비스가 사용자의 리소스(프로필, 이메일 등)에 접근할 수 있도록 허가하는 인가(Authorization) 프로토콜임. 예를 들어 우리 서비스에서 "카카오로 로그인"을 누르면, 사용자는 카카오에 직접 로그인하고, 카카오가 우리 서비스에 "이 사용자의 닉네임을 가져가도 됩니다"라는 허가를 내려주는 구조임

주요 구성 요소

  • Resource Owner — 사용자 (카카오 계정을 가진 사람)
  • Client — 우리 서비스 (사용자 정보를 요청하는 쪽)
  • Authorization Server — 카카오 인증 서버 (인가코드, 액세스토큰 발급)
  • Resource Server — 카카오 API 서버 (사용자 정보를 제공)

인증 흐름 (Authorization Code Grant)

1. 사용자가 "카카오로 로그인" 클릭
   → 프론트가 카카오 인증 페이지로 리다이렉트
   https://kauth.kakao.com/oauth/authorize
   ?client_id=앱키&redirect_uri=프론트주소&response_type=code

2. 사용자가 카카오에서 로그인 + 동의
   → 카카오가 프론트의 redirect_uri로 인가코드(code)를 전달
   http://프론트주소?code=인가코드

3. 프론트가 인가코드를 백엔드에 전달
   POST /auth/kakao { "code": "인가코드" }

4. 백엔드가 인가코드로 카카오에 액세스토큰 요청
   POST https://kauth.kakao.com/oauth/token
   → 카카오가 액세스토큰 발급

5. 백엔드가 액세스토큰으로 카카오에 유저 정보 요청
   GET https://kapi.kakao.com/v2/user/me
   Authorization: Bearer {카카오액세스토큰}
   → 카카오가 닉네임, kakaoId 등 반환

6. 백엔드가 유저 조회/생성 후 자체 JWT 발급
   → 액세스토큰은 응답 헤더, 리프레시토큰은 쿠키에 담아 반환

왜 인가코드를 바로 액세스토큰으로 안 주고 중간 단계를 거치는가?

인가코드는 프론트(브라우저)를 거쳐서 전달되는데, 브라우저는 URL이 노출될 수 있어서 보안에 취약함. 만약 액세스토큰이 URL에 직접 담기면 브라우저 히스토리나 로그에 남을 수 있음. 인가코드는 일회용이고 짧은 유효시간을 가지며, 이걸로 액세스토큰을 받는 과정은 백엔드 → 카카오 서버 간 서버 통신이라 외부에 노출되지 않음. 이 방식이 Authorization Code Grant가 가장 안전한 이유임

OAuth 2.0의 다른 Grant Type

  • Authorization Code Grant — 이번 프로젝트에서 사용한 방식. 서버 사이드 앱에서 가장 일반적
  • Implicit Grant — 인가코드 없이 바로 토큰을 받는 방식. 보안이 약해서 현재는 권장되지 않음
  • Client Credentials Grant — 사용자 없이 서비스 간 통신에 사용
  • Resource Owner Password Grant — 사용자 비밀번호를 직접 전달. 신뢰할 수 있는 자사 앱에서만 사용


🚨 참고 사항

@tnals0924 tnals0924 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

과제하느라 고생 많으셨어요!!👍
PR 이름 컨벤션만 한 번 확인해서 바꿔주면 완벽할 것 같습니다🙂


private void setTokens(HttpServletResponse response, TokenResponse tokens) {
HeaderUtil.setAuthorizationHeader(response, tokens.accessToken());
CookieUtil.addRefreshTokenCookie(response, tokens.refreshToken());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쿠키 관련 세팅 함수들을 util 클래스로 빼서 코드가 간결해진 거 같아서 리뷰어 입장에서 읽기가 좋네요👍

FilterChain filterChain
) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
System.out.println(">>> Header: " + header);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로깅할 때 @Slf4j 스프링 자체에서 제공하는 로깅 프레임워크를 사용하시는 걸 추천드려요!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 같은 생각이에요!! @slf4j + log.debug()로 교체하거나, 디버깅용이었다면 제거하는 게 좋을 것 같아요!

import org.sopt.auth.exception.code.AuthErrorCode;
import org.sopt.global.api.exception.BaseException;

public class MalformedTokenException extends BaseException {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

에러코드 별로 Exception 클래스를 따로 만들면 확실히 에러 추적에 용이할 것 같네요!
배워갑니다~

@Service
public class KakaoService {

private final WebClient webClient;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스프링에서 카카오 로그인 상황처럼 외부 API를 호출할 때 사용할 수 있는 툴이 RestTemplate, RestClient, WebClient 등이 있는데요. 이 중에 WebClient를 선택하신 이유가 궁금합니다!

아래에 외부 API 호출 툴들을 비교한 아티클 첨부드려요~
https://velog.io/@regular_jk_kim/RestTemplate-vs-WebClient-vs-FeignClient

private String nickname;

@Column(unique = true)
private Long kakaoId;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 소셜 로그인 방식이 추가될 걸 대비해 socialId 정도로 이름을 짓는 건 어떨까요..?

@aneykrap aneykrap left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

카카오 로그인도 인가 코드부터 자체 JWT 발급까지 직접 구현하셔서 리뷰하면서 OAuth 흐름을 이해하기가 좋았습니다! 인증/인가, Redis, OAuth까지 고려할 부분이 많았을 텐데 과제 구현하시느라 고생 많으셨습니다!

String token = header.substring("Bearer ".length()).trim();
try {
if (blacklistService.isBlacklisted(token)) {
filterChain.doFilter(request, response);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그아웃된 Access Token을 JwtAuthFilter 단계에서 블랙리스트로 확인하도록 한 점이 좋은 것 같습니다! JWT는 만료 전까지 유효할 수 있는데 블랙리스트를 통해 로그아웃된 토큰을 추가로 검증하는 흐름이 보안 측면에서 좋다고 느꼈습니다.👍👍

근데 블랙리스트에 해당하는 경우 지금 코드에서는 filterChain으로 넘기는 구조로 보여서 로그아웃된 토큰이라는 상황이 응답에서 명확히 드러나는지 한 번 확인해보면 좋을 것 같아요!

Comment on lines +21 to +26
redisTemplate.opsForValue().set(
PREFIX + memberId,
refreshToken,
EXPIRATION_DAYS,
TimeUnit.DAYS
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refresh Token을 Redis에 저장하면서 TTL을 함께 설정한 점이 좋은 것 같아요! PR에서 고민하신 것처럼 Refresh Token은 만료 시간이 있는 임시 데이터라 Redis TTL을 활용하면 별도 정리 배치 없이 관리할 수 있어서 DB에 저장하는 방식보다 더 적절하다고 생각해요. 이 방식까지는 생각하지 못했는데 덕분에 배워갑니다!

.bodyToMono(JsonNode.class)
.block();

return response.get("access_token").asText();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인가코드로 Access Token을 받아오는 흐름이 간결하게 잘 정리된 것 같아요! 👍 근데 카카오 응답이 실패하거나 access_token 필드가 없을 경우 예외가 발생할 수 있을 것 같아서 응답 필드 존재 여부를 확인하고 공통 예외로 변환해주는 처리도 추가하면 더 안정적일 것 같습니다!

FilterChain filterChain
) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
System.out.println(">>> Header: " + header);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 같은 생각이에요!! @slf4j + log.debug()로 교체하거나, 디버깅용이었다면 제거하는 게 좋을 것 같아요!

Comment thread build.gradle
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spring-boot-starter-security가 8번째 줄과 16번째 줄에 중복으로 선언되어 있어요!! 둘 중 하나 제거하면 될 것 같습니다!

public UserResponse loginWithCredentials(String email, String password) {
User user = memberRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다."));
.orElseThrow(UserNotFoundException::new);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseException(AuthErrorCode.INVALID_PASSWORD)으로 통일하면 에러 응답 형식이 일관될 것 같아요!

this.user = user;
}

public boolean isWrittenBy(Long userId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

소유권 검증을 서비스 레이어에서 직접 id 비교하는 대신 Post 엔티티에 isWrittenBy(Long userId) 메서드로 캡슐화한 게 좋네요. 도메인 로직이 엔티티 안에 있어서 가독성도 높고, 재사용도 쉽겠어요. 배워갑니다!

}

@Transactional
public TokenResponse login(LoginRequest request) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 login()에서 유저가 없으면 즉시 예외를 던지고, 유저가 있으면 BCrypt를 실행해서 응답 시간이 달라져요. 공격자가 응답 시간 차이로 이메일 존재 여부를 파악할 수 있습니다.
유저가 없는 경우에도 dummyHash로 BCrypt를 한 번 돌려서 응답 시간을 동일하게 맞춰주면 더 안전해요!

@jsshin8128 jsshin8128 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6주차 과제 하느라 고생 많으셨습니다!!! @AuthenticationPrincipal은 진짜 몰랐는데 배워 가요 ㅎㅎ

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 LoginUserIdArgumentResolver라는 커스텀 리졸버를 따로 만들었는데 스프링이 기본 제공하는 @AuthenticationPrincipal만으로 같은 결과를 내신 것 같아서 코드가 훨씬 간결해진 것 같아요👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 Provider(LOCAL/KAKAO) enum과 local()/oauth() 팩토리로 회원가입 경로를 나눴었는데요! LoginType을 두는 방식은 생각 못해봤네요! LoginType.KAKAO면 먼저 KAKAO_USER_CANNOT_LOGIN으로 제한을 둬서 사용자에게 카카오로 로그인을 하도록 정확히 안내할 수 있어서 훨씬 더 친절한 응답을 내려줄 수 있지 않을까 생각이 듭니다!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis 키를 JWT 문자열 전체로 하지 않고 토큰의 jti(UUID 클레임)만 키로 쓰는 방법도 있다고 하더라고요! jti는 블랙리스트에 토큰 전체가 남지 않아서 Redis가 털려도 액세스 토큰이 노출되지 않을 수 있다는 장점을 가지고 있다고 해요!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants