Repository: URECA-DangSim/dangsim-be
Pull request #10: Feat: social login api
Author: login: hayong39
Created: 2025-05-07T02:02:34Z | Merged: 2025-05-08T07:43:20Z

## 관련 이슈

- 이슈: #4 

## 📑 작업 상세 내용
- 카카오 로그인 구현

## 💫 작업 요약
카카오 OAuth 로그인 기능 구현하고, 로그아웃 및 토큰 재발급 로직 추가했습니다.

## 🔍 집중적으로 리뷰할 부분 (확인 및 점검 사항)

## 📜 참고 자료(선택) 


Diff:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9612409..cac9c3e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -62,4 +62,5 @@ jobs:
           message: |
             ${{ github.event.pull_request.head.ref }} 브랜치 빌드 테스트에 실패했습니다! ❌
             아래 로그를 확인해주세요!
-            [실행 로그](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
\ No newline at end of file
+            [실행 로그](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
+          
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 8e21c82..46261dd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -20,6 +20,7 @@ repositories {
 dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-web'
     implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+    implementation 'org.springframework.boot:spring-boot-starter-webflux'
 
     // QueryDsl
     implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
@@ -62,6 +63,8 @@ dependencies {
     testImplementation "org.junit.jupiter:junit-jupiter:5.8.1"
 
     implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
+
+    testRuntimeOnly 'com.h2database:h2'
 }
 
 // Querydsl
diff --git a/src/main/java/com/dangsim/auth/controller/AuthController.java b/src/main/java/com/dangsim/auth/controller/AuthController.java
index 63c5662..6d62930 100644
--- a/src/main/java/com/dangsim/auth/controller/AuthController.java
+++ b/src/main/java/com/dangsim/auth/controller/AuthController.java
@@ -1,10 +1,57 @@
 package com.dangsim.auth.controller;
 
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import com.dangsim.auth.dto.request.ReissueRequest;
+import com.dangsim.auth.dto.response.AuthTokenResponse;
+import com.dangsim.auth.service.AuthService;
+import com.dangsim.token.service.TokenService;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 
 @RestController
 @RequiredArgsConstructor
 public class AuthController {
-}
+
+	private final AuthService authService;
+	private final TokenService tokenService;
+
+	/**
+	 * 카카오 로그인
+	 */
+	@GetMapping("/login/kakao")
+	public ResponseEntity<AuthTokenResponse> kakaoLogin(@RequestParam("code") String code) {
+		AuthTokenResponse response = authService.handleKakaoLogin(code);
+		return ResponseEntity.ok(response);
+	}
+
+	/**
+	 * 로그아웃
+	 */
+	@PostMapping("/logout")
+	public ResponseEntity<Void> logout(
+		HttpServletRequest request,
+		@RequestHeader("Authorization") String accessToken
+	) {
+		Long userId = (Long)request.getAttribute("userId");
+		tokenService.logout(userId);
+		return ResponseEntity.ok().build();
+	}
+
+	/**
+	 * 토큰 재발급
+	 */
+	@PostMapping("/reissue")
+	public ResponseEntity<AuthTokenResponse> reissue(@Valid @RequestBody ReissueRequest request) {
+		AuthTokenResponse response = tokenService.reissue(request.userId(), request.refreshToken());
+		return ResponseEntity.ok(response);
+	}
+}
\ No newline at end of file
diff --git a/src/main/java/com/dangsim/auth/dto/request/ReissueRequest.java b/src/main/java/com/dangsim/auth/dto/request/ReissueRequest.java
new file mode 100644
index 0000000..07b3531
--- /dev/null
+++ b/src/main/java/com/dangsim/auth/dto/request/ReissueRequest.java
@@ -0,0 +1,13 @@
+package com.dangsim.auth.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record ReissueRequest(
+	@NotNull(message = "userId는 필수입니다.")
+	Long userId,
+
+	@NotBlank(message = "refreshToken은 공백일 수 없습니다.")
+	String refreshToken
+) {
+}
diff --git a/src/main/java/com/dangsim/auth/dto/response/AuthTokenResponse.java b/src/main/java/com/dangsim/auth/dto/response/AuthTokenResponse.java
new file mode 100644
index 0000000..3120986
--- /dev/null
+++ b/src/main/java/com/dangsim/auth/dto/response/AuthTokenResponse.java
@@ -0,0 +1,8 @@
+package com.dangsim.auth.dto.response;
+
+public record AuthTokenResponse(
+	String accessToken,
+	String refreshToken,
+	String role
+) {
+}
diff --git a/src/main/java/com/dangsim/auth/dto/response/KakaoUserResponse.java b/src/main/java/com/dangsim/auth/dto/response/KakaoUserResponse.java
new file mode 100644
index 0000000..3a1966d
--- /dev/null
+++ b/src/main/java/com/dangsim/auth/dto/response/KakaoUserResponse.java
@@ -0,0 +1,27 @@
+package com.dangsim.auth.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record KakaoUserResponse(
+	Long id,
+	@JsonProperty("kakao_account")
+	KakaoAccount kakaoAccount
+) {
+	public String profileImage() {
+		if (kakaoAccount == null || kakaoAccount.profile == null || kakaoAccount.profile.profileImageUrl == null) {
+			return null;
+		}
+		return kakaoAccount.profile.profileImageUrl;
+	}
+
+	public record KakaoAccount(
+		KakaoProfile profile
+	) {
+	}
+
+	public record KakaoProfile(
+		@JsonProperty("profile_image_url")
+		String profileImageUrl
+	) {
+	}
+}
diff --git a/src/main/java/com/dangsim/auth/entity/Auth.java b/src/main/java/com/dangsim/auth/entity/Auth.java
index 047f3e9..2108488 100644
--- a/src/main/java/com/dangsim/auth/entity/Auth.java
+++ b/src/main/java/com/dangsim/auth/entity/Auth.java
@@ -59,4 +59,12 @@ private Auth(AuthProvider provider, String providerId, User user) {
 		this.providerId = providerId;
 		this.user = user;
 	}
+
+	public static Auth of(AuthProvider provider, String providerId, User user) {
+		return Auth.builder()
+			.provider(provider)
+			.providerId(providerId)
+			.user(user)
+			.build();
+	}
 }
diff --git a/src/main/java/com/dangsim/auth/exception/AuthErrorCode.java b/src/main/java/com/dangsim/auth/exception/AuthErrorCode.java
index 6be5d44..f6e5431 100644
--- a/src/main/java/com/dangsim/auth/exception/AuthErrorCode.java
+++ b/src/main/java/com/dangsim/auth/exception/AuthErrorCode.java
@@ -7,7 +7,11 @@
 
 @Getter
 @RequiredArgsConstructor
-public class AuthErrorCode implements ErrorCode {
+public enum AuthErrorCode implements ErrorCode {
+
+	INVALID_TOKEN("유효하지 않은 토큰입니다.", "AUTH_001"),
+	EXPIRED_TOKEN("만료된 토큰입니다.", "AUTH_002"),
+	UNAUTHORIZED_ACCESS("인가되지 않은 접근입니다.", "AUTH_003");
 
 	private final String message;
 	private final String code;
diff --git a/src/main/java/com/dangsim/auth/repository/AuthRepository.java b/src/main/java/com/dangsim/auth/repository/AuthRepository.java
index 414e361..3f8e3a3 100644
--- a/src/main/java/com/dangsim/auth/repository/AuthRepository.java
+++ b/src/main/java/com/dangsim/auth/repository/AuthRepository.java
@@ -1,10 +1,14 @@
 package com.dangsim.auth.repository;
 
+import java.util.Optional;
+
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.stereotype.Repository;
 
 import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
 
 @Repository
 public interface AuthRepository extends JpaRepository<Auth, Long> {
+	Optional<Auth> findByProviderAndProviderId(AuthProvider provider, String providerId);
 }
diff --git a/src/main/java/com/dangsim/auth/service/AuthProcessor.java b/src/main/java/com/dangsim/auth/service/AuthProcessor.java
new file mode 100644
index 0000000..cb9ff0a
--- /dev/null
+++ b/src/main/java/com/dangsim/auth/service/AuthProcessor.java
@@ -0,0 +1,43 @@
+package com.dangsim.auth.service;
+
+import org.springframework.stereotype.Component;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class AuthProcessor {
+
+	private final AuthRepository authRepository;
+	private final UserRepository userRepository;
+
+	public User process(AuthProvider provider, KakaoUserResponse userInfo) {
+		String oauthId = provider.getProvider() + "_" + userInfo.id();
+
+		return authRepository.findByProviderAndProviderId(provider, oauthId)
+			.map(auth -> {
+				auth.getUser().updateProfileImage(userInfo.profileImage());
+				return auth.getUser();
+			})
+			.orElseGet(() -> {
+				User newUser = userRepository.save(User.of(userInfo.profileImage()));
+				authRepository.save(Auth.of(provider, oauthId, newUser));
+				return newUser;
+			});
+
+	}
+
+	private User createAuthAndUser(KakaoUserResponse user, AuthProvider provider, String oauthId) {
+		String profileImage = user.profileImage();
+		User newUser = userRepository.save(User.of(profileImage));
+		authRepository.save(Auth.of(provider, oauthId, newUser));
+		return newUser;
+	}
+}
diff --git a/src/main/java/com/dangsim/auth/service/AuthService.java b/src/main/java/com/dangsim/auth/service/AuthService.java
index 4945fcc..2524721 100644
--- a/src/main/java/com/dangsim/auth/service/AuthService.java
+++ b/src/main/java/com/dangsim/auth/service/AuthService.java
@@ -1,10 +1,33 @@
 package com.dangsim.auth.service;
 
+import static com.dangsim.auth.entity.AuthProvider.*;
+
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.dangsim.auth.dto.response.AuthTokenResponse;
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.token.service.TokenService;
+import com.dangsim.user.entity.User;
 
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class AuthService {
+
+	private final KakaoOauthClient kakaoOauthClient;
+	private final AuthProcessor authProcessor;
+	private final TokenService tokenService;
+
+	@Transactional
+	public AuthTokenResponse handleKakaoLogin(String code) {
+		String accessToken = kakaoOauthClient.getAccessToken(code);
+		KakaoUserResponse userInfo = kakaoOauthClient.getUserInfo(accessToken);
+		User user = authProcessor.process(KAKAO, userInfo);
+		return tokenService.issueTokensFor(user);
+	}
+
 }
diff --git a/src/main/java/com/dangsim/auth/service/KakaoOauthClient.java b/src/main/java/com/dangsim/auth/service/KakaoOauthClient.java
new file mode 100644
index 0000000..90f01c3
--- /dev/null
+++ b/src/main/java/com/dangsim/auth/service/KakaoOauthClient.java
@@ -0,0 +1,65 @@
+package com.dangsim.auth.service;
+
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class KakaoOauthClient {
+
+	private final WebClient webClient;
+
+	@Value("${oauth.kakao.token-uri}")
+	private String kakaoTokenUri;
+
+	@Value("${oauth.kakao.user-info-uri}")
+	private String kakaoUserInfoUri;
+
+	@Value("${oauth.kakao.client-id}")
+	private String kakaoClientId;
+
+	@Value("${oauth.kakao.client-secret}")
+	private String kakaoClientSecret;
+
+	@Value("${oauth.kakao.redirect-uri}")
+	private String kakaoRedirectUri;
+
+	public String getAccessToken(String code) {
+		MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
+		params.add("grant_type", "authorization_code");
+		params.add("client_id", kakaoClientId);
+		params.add("redirect_uri", kakaoRedirectUri);
+		params.add("client_secret", kakaoClientSecret);
+		params.add("code", code);
+
+		return webClient.post()
+			.uri(kakaoTokenUri)
+			.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+			.bodyValue(params)
+			.retrieve()
+			.bodyToMono(Map.class)
+			.map(body -> (String)body.get("access_token"))
+			.block();
+	}
+
+	@Transactional(readOnly = true)
+	public KakaoUserResponse getUserInfo(String accessToken) {
+		return webClient.get()
+			.uri(kakaoUserInfoUri)
+			.headers(headers -> headers.setBearerAuth(accessToken))
+			.retrieve()
+			.bodyToMono(KakaoUserResponse.class)
+			.block();
+	}
+}
diff --git a/src/main/java/com/dangsim/common/config/CorsConfig.java b/src/main/java/com/dangsim/common/config/CorsConfig.java
new file mode 100644
index 0000000..1eaf50e
--- /dev/null
+++ b/src/main/java/com/dangsim/common/config/CorsConfig.java
@@ -0,0 +1,22 @@
+package com.dangsim.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class CorsConfig {
+	@Bean
+	public WebMvcConfigurer corsConfigurer() {
+		return new WebMvcConfigurer() {
+			@Override
+			public void addCorsMappings(CorsRegistry registry) {
+				registry.addMapping("/**")
+					.allowedOrigins("http://localhost:3000")
+					.allowedMethods("*")
+					.allowCredentials(true);
+			}
+		};
+	}
+}
diff --git a/src/main/java/com/dangsim/common/config/SecurityConfig.java b/src/main/java/com/dangsim/common/config/SecurityConfig.java
new file mode 100644
index 0000000..1e5d9fa
--- /dev/null
+++ b/src/main/java/com/dangsim/common/config/SecurityConfig.java
@@ -0,0 +1,34 @@
+package com.dangsim.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.jwt.filter.JwtAuthenticationFilter;
+import com.dangsim.user.repository.UserRepository;
+
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+	@Bean
+	public SecurityFilterChain filterChain(HttpSecurity http, JwtProvider jwtProvider,
+		UserRepository userRepository) throws Exception {
+		http
+			.csrf(csrf -> csrf.disable())
+			.cors(cors -> {
+			})
+			.formLogin(form -> form.disable())// CORS 기본 설정 활성화
+			.authorizeHttpRequests(auth -> auth
+				.anyRequest().permitAll()
+			).addFilterBefore(
+				new JwtAuthenticationFilter(jwtProvider, userRepository),
+				UsernamePasswordAuthenticationFilter.class
+			);
+		return http.build();
+	}
+}
diff --git a/src/main/java/com/dangsim/common/config/TimeConfig.java b/src/main/java/com/dangsim/common/config/TimeConfig.java
new file mode 100644
index 0000000..ae113da
--- /dev/null
+++ b/src/main/java/com/dangsim/common/config/TimeConfig.java
@@ -0,0 +1,14 @@
+package com.dangsim.common.config;
+
+import java.time.Clock;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class TimeConfig {
+	@Bean
+	public Clock clock() {
+		return Clock.systemUTC();
+	}
+}
diff --git a/src/main/java/com/dangsim/common/config/WebClientConfig.java b/src/main/java/com/dangsim/common/config/WebClientConfig.java
new file mode 100644
index 0000000..14aa9a9
--- /dev/null
+++ b/src/main/java/com/dangsim/common/config/WebClientConfig.java
@@ -0,0 +1,13 @@
+package com.dangsim.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.reactive.function.client.WebClient;
+
+@Configuration
+public class WebClientConfig {
+	@Bean
+	public WebClient webClient(WebClient.Builder builder) {
+		return builder.build();
+	}
+}
diff --git a/src/main/java/com/dangsim/jwt/JwtProvider.java b/src/main/java/com/dangsim/jwt/JwtProvider.java
new file mode 100644
index 0000000..1d00d76
--- /dev/null
+++ b/src/main/java/com/dangsim/jwt/JwtProvider.java
@@ -0,0 +1,78 @@
+package com.dangsim.jwt;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.util.Date;
+
+import javax.crypto.SecretKey;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class JwtProvider {
+	@Value("${jwt.secret}")
+	private String secret;
+
+	private final Clock clock;
+	private SecretKey key;
+
+	private static final long ACCESS_EXP = 1000 * 60 * 60 * 1;  // 1시간
+	private static final long REFRESH_EXP = 1000 * 60 * 60 * 24; // 1일
+
+	@PostConstruct
+	public void init() {
+		this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+	}
+
+	public String createAccessToken(Long userId) {
+		Date now = Date.from(clock.instant());
+		Date expiry = new Date(now.getTime() + ACCESS_EXP);
+
+		return Jwts.builder()
+			.subject(String.valueOf(userId))
+			.issuedAt(now)
+			.expiration(expiry)
+			.signWith(key, Jwts.SIG.HS256)
+			.compact();
+	}
+
+	public String createRefreshToken() {
+		Date now = Date.from(clock.instant());
+		Date expiry = new Date(now.getTime() + REFRESH_EXP);
+
+		return Jwts.builder()
+			.issuedAt(now)
+			.expiration(expiry)
+			.signWith(key, Jwts.SIG.HS256)
+			.compact();
+	}
+
+	public boolean validateToken(String token) {
+		try {
+			Jwts.parser()
+				.verifyWith(key)
+				.build()
+				.parseSignedClaims(token);
+			return true;
+		} catch (JwtException e) {
+			return false;
+		}
+	}
+
+	public Claims getClaims(String token) {
+		return Jwts.parser()
+			.verifyWith(key)
+			.build()
+			.parseSignedClaims(token)
+			.getPayload();
+	}
+}
diff --git a/src/main/java/com/dangsim/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/dangsim/jwt/filter/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..bfaafa6
--- /dev/null
+++ b/src/main/java/com/dangsim/jwt/filter/JwtAuthenticationFilter.java
@@ -0,0 +1,60 @@
+package com.dangsim.jwt.filter;
+
+import static com.dangsim.jwt.util.TokenUtil.*;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import com.dangsim.common.exception.runtime.BaseException;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.exception.UserErrorCode;
+import com.dangsim.user.repository.UserRepository;
+
+import io.jsonwebtoken.Claims;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+	private static final String AUTH_HEADER = "Authorization";
+
+	private final JwtProvider jwtProvider;
+	private final UserRepository userRepository;
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+		FilterChain filterChain) throws ServletException, IOException {
+		String token = extractToken(request.getHeader(AUTH_HEADER));
+
+		if (token != null && jwtProvider.validateToken(token)) {
+			Claims claims = jwtProvider.getClaims(token);
+			Long userId = Long.valueOf(claims.getSubject());
+
+			// user 엔티티 조회
+			User user = userRepository.findById(userId)
+				.orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND));
+
+			// 권한 설정
+			SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + user.getRole().name());
+
+			// 인증 객체 생성
+			UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null,
+				List.of(authority));
+
+			//SecurityContext에 등록
+			SecurityContextHolder.getContext().setAuthentication(authentication);
+
+		}
+		filterChain.doFilter(request, response);
+	}
+}
diff --git a/src/main/java/com/dangsim/jwt/util/TokenUtil.java b/src/main/java/com/dangsim/jwt/util/TokenUtil.java
new file mode 100644
index 0000000..4c78039
--- /dev/null
+++ b/src/main/java/com/dangsim/jwt/util/TokenUtil.java
@@ -0,0 +1,18 @@
+package com.dangsim.jwt.util;
+
+import static lombok.AccessLevel.*;
+
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = PRIVATE)
+public class TokenUtil {
+	private static final String BEARER_PREFIX = "Bearer ";
+
+	public static String extractToken(String bearerToken) {
+		if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
+			return bearerToken.substring(BEARER_PREFIX.length());
+		}
+		throw new IllegalArgumentException("유효하지 않은 Authorization 헤더입니다.");
+	}
+
+}
diff --git a/src/main/java/com/dangsim/token/entity/Token.java b/src/main/java/com/dangsim/token/entity/Token.java
new file mode 100644
index 0000000..7e04e93
--- /dev/null
+++ b/src/main/java/com/dangsim/token/entity/Token.java
@@ -0,0 +1,54 @@
+package com.dangsim.token.entity;
+
+import static lombok.AccessLevel.*;
+
+import java.time.LocalDateTime;
+
+import com.dangsim.user.entity.User;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "token")
+@Getter
+@NoArgsConstructor(access = PROTECTED)
+public class Token {
+
+	@Id
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	@Column(name = "token_id")
+	private Long id;
+
+	@Column(nullable = false, length = 500)
+	private String refreshToken;
+
+	@Column(nullable = false)
+	private LocalDateTime expiryTime;
+
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "user_id", nullable = false)
+	private User user;
+
+	@Builder
+	private Token(String refrestToken, LocalDateTime expiryTime, User user) {
+		this.refreshToken = refrestToken;
+		this.expiryTime = expiryTime;
+		this.user = user;
+	}
+
+	public void updateRefreshToken(String newToken, LocalDateTime newExpiry) {
+		this.refreshToken = newToken;
+		this.expiryTime = newExpiry;
+	}
+}
diff --git a/src/main/java/com/dangsim/token/exception/TokenErrorCode.java b/src/main/java/com/dangsim/token/exception/TokenErrorCode.java
new file mode 100644
index 0000000..9df4fda
--- /dev/null
+++ b/src/main/java/com/dangsim/token/exception/TokenErrorCode.java
@@ -0,0 +1,17 @@
+package com.dangsim.token.exception;
+
+import com.dangsim.common.exception.runtime.ErrorCode;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum TokenErrorCode implements ErrorCode {
+	REFRESH_TOKEN_NOT_FOUND("TOKEN_001", "RefreshToken 없음. 다시 로그인하세요."),
+	REFRESH_TOKEN_MISMATCH("TOKEN_002", "RefreshToken 불일치. 다시 로그인 필요."),
+	REFRESH_TOKEN_EXPIRED("TOKEN_003", "RefreshToken 만료. 다시 로그인하세요.");
+
+	private final String message;
+	private final String code;
+}
diff --git a/src/main/java/com/dangsim/token/repository/TokenRepository.java b/src/main/java/com/dangsim/token/repository/TokenRepository.java
new file mode 100644
index 0000000..6e19f6e
--- /dev/null
+++ b/src/main/java/com/dangsim/token/repository/TokenRepository.java
@@ -0,0 +1,14 @@
+package com.dangsim.token.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.dangsim.token.entity.Token;
+import com.dangsim.user.entity.User;
+
+public interface TokenRepository extends JpaRepository<Token, Long> {
+	Optional<Token> findByUser(User user);
+
+	void deleteByUser(User user);
+}
diff --git a/src/main/java/com/dangsim/token/service/TokenService.java b/src/main/java/com/dangsim/token/service/TokenService.java
new file mode 100644
index 0000000..0553e03
--- /dev/null
+++ b/src/main/java/com/dangsim/token/service/TokenService.java
@@ -0,0 +1,86 @@
+package com.dangsim.token.service;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.dangsim.auth.dto.response.AuthTokenResponse;
+import com.dangsim.common.exception.runtime.BaseException;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.exception.TokenErrorCode;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.exception.UserErrorCode;
+import com.dangsim.user.repository.UserRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class TokenService {
+
+	private final JwtProvider jwtProvider;
+	private final TokenRepository tokenRepository;
+	private final UserRepository userRepository;
+
+	private static final long REFRESH_TOKEN_EXPIRE_DAYS = 1L;
+
+	@Transactional
+	public AuthTokenResponse issueTokensFor(User user) {
+		String accessToken = jwtProvider.createAccessToken(user.getId());
+		String refreshToken = jwtProvider.createRefreshToken();
+
+		Token token = tokenRepository.findByUser(user)
+			.map(t -> {
+				t.updateRefreshToken(refreshToken, LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS));
+				return t;
+			})
+			.orElse(Token.builder()
+				.refrestToken(refreshToken)
+				.expiryTime(LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS))
+				.user(user)
+				.build());
+
+		tokenRepository.save(token);
+		return new AuthTokenResponse(accessToken, refreshToken, user.getRole().name());
+	}
+
+	@Transactional
+	public AuthTokenResponse reissue(Long userId, String oldRefreshToken) {
+		User user = userRepository.findById(userId)
+			.orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND));
+
+		Token token = tokenRepository.findByUser(user)
+			.orElseThrow(() -> new BaseException(TokenErrorCode.REFRESH_TOKEN_NOT_FOUND));
+
+		if (!Objects.equals(token.getRefreshToken(), oldRefreshToken)) {
+			throw new BaseException(TokenErrorCode.REFRESH_TOKEN_MISMATCH);
+		}
+
+		if (token.getExpiryTime().isBefore(LocalDateTime.now())) {
+			tokenRepository.delete(token);
+			throw new BaseException(TokenErrorCode.REFRESH_TOKEN_EXPIRED);
+		}
+
+		// 새 access/refresh 발급
+		String newAccessToken = jwtProvider.createAccessToken(user.getId());
+		String newRefreshToken = jwtProvider.createRefreshToken();
+
+		token.updateRefreshToken(newRefreshToken, LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS));
+		tokenRepository.save(token);
+
+		return new AuthTokenResponse(newAccessToken, newRefreshToken, user.getRole().name());
+	}
+
+	@Transactional
+	public void logout(Long userId) {
+		//토큰 테이블 삭제
+		User user = userRepository.findById(userId)
+			.orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND));
+		tokenRepository.deleteByUser(user);
+	}
+
+}
diff --git a/src/main/java/com/dangsim/user/entity/User.java b/src/main/java/com/dangsim/user/entity/User.java
index d489fae..f62daf1 100644
--- a/src/main/java/com/dangsim/user/entity/User.java
+++ b/src/main/java/com/dangsim/user/entity/User.java
@@ -65,4 +65,18 @@ public static User of(Role role, BigDecimal reward) {
 			.reward(reward)
 			.build();
 	}
+
+	public static User of(String profileImage) {
+		return User.builder()
+			.nickname(null)  // 아직 미정
+			.profileImage(profileImage)
+			.address(null)   // 아직 미정
+			.role(Role.TMP_USER)
+			.reward(BigDecimal.ZERO)
+			.build();
+	}
+
+	public void updateProfileImage(String profileImage) {
+		this.profileImage = profileImage;
+	}
 }
diff --git a/src/main/java/com/dangsim/user/exception/UserErrorCode.java b/src/main/java/com/dangsim/user/exception/UserErrorCode.java
index 4a393eb..0a903d5 100644
--- a/src/main/java/com/dangsim/user/exception/UserErrorCode.java
+++ b/src/main/java/com/dangsim/user/exception/UserErrorCode.java
@@ -7,7 +7,9 @@
 
 @Getter
 @RequiredArgsConstructor
-public class UserErrorCode implements ErrorCode {
+public enum UserErrorCode implements ErrorCode {
+
+	USER_NOT_FOUND("존재하지 않는 사용자입니다.", "U_004");
 
 	private final String message;
 	private final String code;
diff --git a/src/test/java/com/dangsim/auth/service/AuthServiceTest.java b/src/test/java/com/dangsim/auth/service/AuthServiceTest.java
new file mode 100644
index 0000000..c131f01
--- /dev/null
+++ b/src/test/java/com/dangsim/auth/service/AuthServiceTest.java
@@ -0,0 +1,57 @@
+package com.dangsim.auth.service;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.dangsim.auth.dto.response.AuthTokenResponse;
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.token.service.TokenService;
+import com.dangsim.user.entity.User;
+
+@ExtendWith(MockitoExtension.class)
+class AuthServiceTest {
+
+	@Mock
+	private KakaoOauthClient kakaoOauthClient;
+	@Mock
+	private AuthProcessor authProcessor;
+	@Mock
+	private TokenService tokenService;
+
+	@InjectMocks
+	private AuthService authService;
+
+	@Test
+	@DisplayName("카카오 로그인 시 사용자 정보를 기반으로 토큰을 발급한다.")
+	void handleKakaoLogin_success() {
+		//given
+		String code = "auth-code";
+		String accessToken = "auth-access-token";
+		KakaoUserResponse.KakaoProfile profile = new KakaoUserResponse.KakaoProfile("https://test.com/image.png");
+		KakaoUserResponse.KakaoAccount account = new KakaoUserResponse.KakaoAccount(profile);
+		KakaoUserResponse userInfo = new KakaoUserResponse(123L, account);
+		User user = mock(User.class);
+		AuthTokenResponse expected = new AuthTokenResponse("access", "refresh", "USER");
+
+		when(kakaoOauthClient.getAccessToken(code)).thenReturn(accessToken);
+		when(kakaoOauthClient.getUserInfo(accessToken)).thenReturn(userInfo);
+		when(authProcessor.process(AuthProvider.KAKAO, userInfo)).thenReturn(user);
+		when(tokenService.issueTokensFor(user)).thenReturn(expected);
+
+		//when
+		AuthTokenResponse response = authService.handleKakaoLogin(code);
+
+		//then
+		assertThat(response).isEqualTo(expected);
+
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/com/dangsim/token/service/TokenServiceTest.java b/src/test/java/com/dangsim/token/service/TokenServiceTest.java
new file mode 100644
index 0000000..fa03b1e
--- /dev/null
+++ b/src/test/java/com/dangsim/token/service/TokenServiceTest.java
@@ -0,0 +1,126 @@
+package com.dangsim.token.service;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.dangsim.auth.dto.response.AuthTokenResponse;
+import com.dangsim.common.exception.runtime.BaseException;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.exception.TokenErrorCode;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.Role;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
+
+@ExtendWith(MockitoExtension.class)
+class TokenServiceTest {
+
+	@Mock
+	private JwtProvider jwtProvider;
+	@Mock
+	private UserRepository userRepository;
+	@Mock
+	private TokenRepository tokenRepository;
+
+	@InjectMocks
+	private TokenService tokenService;
+
+	@Test
+	@DisplayName("로그아웃 시 사용자에 해당하는 토큰 정보를 삭제한다.")
+	void logout() {
+		// given
+		User user = mock(User.class);
+		when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+		doNothing().when(tokenRepository).deleteByUser(user);
+
+		// when
+		tokenService.logout(1L);
+
+		// then
+		verify(tokenRepository, times(1)).deleteByUser(user);
+	}
+
+	@Test
+	@DisplayName("refresh 토큰이 유효할 경우 새로운 access, refresh 토큰을 발급한다.")
+	void reissue() {
+		// given
+		User user = mock(User.class);
+		Token token = mock(Token.class);
+
+		when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+		when(tokenRepository.findByUser(user)).thenReturn(Optional.of(token));
+		when(token.getRefreshToken()).thenReturn("oldToken");
+		when(token.getExpiryTime()).thenReturn(LocalDateTime.now().plusMinutes(10));
+		when(jwtProvider.createAccessToken(any())).thenReturn("newAccessToken");
+		when(jwtProvider.createRefreshToken()).thenReturn("newRefreshToken");
+		when(user.getRole()).thenReturn(Role.TMP_USER);
+
+		// when
+		AuthTokenResponse result = tokenService.reissue(1L, "oldToken");
+
+		// then
+		assertThat(result.accessToken()).isEqualTo("newAccessToken");
+		assertThat(result.refreshToken()).isEqualTo("newRefreshToken");
+	}
+
+	@Test
+	@DisplayName("refresh 토큰이 저장되어 있지 않으면 예외가 발생한다.")
+	void reissue_whenTokenNotFound_thenThrows() {
+		//given
+		User user = mock(User.class);
+		when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+		when(tokenRepository.findByUser(user)).thenReturn(Optional.empty());
+
+		//when&then
+		assertThatThrownBy(() -> tokenService.reissue(1L, "refreshToken"))
+			.isInstanceOf(BaseException.class)
+			.hasMessageContaining(TokenErrorCode.REFRESH_TOKEN_NOT_FOUND.getMessage());
+	}
+
+	@Test
+	@DisplayName("refresh 토큰이 저장된 값과 다르면 예외가 발생한다.")
+	void reissue_whenTokenMismatch_thenThrows() {
+		// given
+		User user = mock(User.class);
+		Token token = mock(Token.class);
+
+		when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+		when(tokenRepository.findByUser(user)).thenReturn(Optional.of(token));
+		when(token.getRefreshToken()).thenReturn("storedToken");
+
+		// when & then
+		assertThatThrownBy(() -> tokenService.reissue(1L, "wrongToken"))
+			.isInstanceOf(BaseException.class)
+			.hasMessageContaining(TokenErrorCode.REFRESH_TOKEN_MISMATCH.getMessage());
+	}
+
+	@Test
+	@DisplayName("refresh 토큰이 만료된 경우 예외가 발생한다.")
+	void reissue_whenTokenExpired_thenThrows() {
+		// given
+		User user = mock(User.class);
+		Token token = mock(Token.class);
+
+		when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+		when(tokenRepository.findByUser(user)).thenReturn(Optional.of(token));
+		when(token.getRefreshToken()).thenReturn("validToken");
+		when(token.getExpiryTime()).thenReturn(LocalDateTime.now().minusMinutes(1));
+
+		// when & then
+		assertThatThrownBy(() -> tokenService.reissue(1L, "validToken"))
+			.isInstanceOf(BaseException.class)
+			.hasMessageContaining(TokenErrorCode.REFRESH_TOKEN_EXPIRED.getMessage());
+	}
+}
\ No newline at end of file


Reviews:
user: login: dudxo
body: 고생하셨습니다 하영님!

OAuth 구현이 쉽지 않았을텐데 빠른 시간내에 작업해주셨네요!
OAuth2.0 client를 사용하지 않고 프론트 - 백엔드 API 를 통해서 구현하신 부분이 신기했네요 ㅎㅎ

테스트 코드는 아직 미숙하시니 짧은 일정 상 구현 후 시간이 남는다면 작성해보도록 하고, 이외에 부분들은 조금 더 보강해준 후 다시 리뷰 요청 부탁드립니다!! 🙏
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-07T06:39:44Z

Review comments:
diff_hunk: @@ -1,10 +1,163 @@
 package com.dangsim.auth.service;
 
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
 
+import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
path: src/main/java/com/dangsim/auth/service/AuthService.java
user: login: dudxo
body: Class Level의 `@Transactional`은 지양하는게 좋아요~!!

불필요한 메서드에 해당 어노테이션이 적용될 수도 있고, `readOnly-true`을 설정할 메서드는 또 다르니깐요!
created_at: 2025-05-07T06:14:32Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,163 @@
 package com.dangsim.auth.service;
 
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
 
+import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
 public class AuthService {
+
+	private final WebClient webClient;
+	private final JwtProvider jwtProvider;
+	private final UserRepository userRepository;
+	private final AuthRepository authRepository;
+	private final TokenRepository tokenRepository;
+
+	@Value("${oauth.kakao.token-uri}")
+	private String kakaoTokenUri;
+
+	@Value("${oauth.kakao.user-info-uri}")
+	private String kakaoUserInfoUri;
+
+	@Value("${oauth.kakao.client-id}")
+	private String kakaoClientId;
+
+	@Value("${oauth.kakao.client-secret}")
+	private String kakaoClientSecret;
+
+	@Value("${oauth.kakao.redirect-uri}")
+	private String kakaoRedirectUri;
+
+	private static final long REFRESH_TOKEN_EXPIRE_DAYS = 1L;
+
+	public Map<String, String> handleKakaoLogin(String code) {
+		String kakaoAccessToken = getKakaoAccessToken(code);
+		KakaoUserResponse kakaoUser = getKakaoUserInfo(kakaoAccessToken);
+
+		String oauthId = "kakao_" + kakaoUser.id();
path: src/main/java/com/dangsim/auth/service/AuthService.java
user: login: dudxo
body: "kakao_"와 같은 하드 코딩보다 상수를 지향합시다!
created_at: 2025-05-07T06:15:42Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,163 @@
 package com.dangsim.auth.service;
 
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
 
+import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
 public class AuthService {
+
+	private final WebClient webClient;
+	private final JwtProvider jwtProvider;
+	private final UserRepository userRepository;
+	private final AuthRepository authRepository;
+	private final TokenRepository tokenRepository;
+
+	@Value("${oauth.kakao.token-uri}")
+	private String kakaoTokenUri;
+
+	@Value("${oauth.kakao.user-info-uri}")
+	private String kakaoUserInfoUri;
+
+	@Value("${oauth.kakao.client-id}")
+	private String kakaoClientId;
+
+	@Value("${oauth.kakao.client-secret}")
+	private String kakaoClientSecret;
+
+	@Value("${oauth.kakao.redirect-uri}")
+	private String kakaoRedirectUri;
+
+	private static final long REFRESH_TOKEN_EXPIRE_DAYS = 1L;
+
+	public Map<String, String> handleKakaoLogin(String code) {
+		String kakaoAccessToken = getKakaoAccessToken(code);
+		KakaoUserResponse kakaoUser = getKakaoUserInfo(kakaoAccessToken);
+
+		String oauthId = "kakao_" + kakaoUser.id();
+		AuthProvider provider = AuthProvider.KAKAO;
+
+		Optional<Auth> authOpt = authRepository.findByProviderAndProviderId(provider, oauthId);
path: src/main/java/com/dangsim/auth/service/AuthService.java
user: login: dudxo
body: 62, 66-72 라인을 다음과 같이 합치면 더 좋을 것 같습니다.

```java
User user = authRepository.findByProviderAndProviderId(provider, oauthId)
       .map(u -> {
                   u.updateProfileImage // 회원 프로필 정보 업데이트 빠짐
                   return u;
        })
       .orElseGet(() -> createAuthAndUser(kakaoUser, provider, oauthId));   // 추가 구현

return user;
```
created_at: 2025-05-07T06:17:18Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,163 @@
 package com.dangsim.auth.service;
 
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
 
+import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
 public class AuthService {
+
+	private final WebClient webClient;
+	private final JwtProvider jwtProvider;
+	private final UserRepository userRepository;
+	private final AuthRepository authRepository;
+	private final TokenRepository tokenRepository;
+
+	@Value("${oauth.kakao.token-uri}")
+	private String kakaoTokenUri;
+
+	@Value("${oauth.kakao.user-info-uri}")
+	private String kakaoUserInfoUri;
+
+	@Value("${oauth.kakao.client-id}")
+	private String kakaoClientId;
+
+	@Value("${oauth.kakao.client-secret}")
+	private String kakaoClientSecret;
+
+	@Value("${oauth.kakao.redirect-uri}")
+	private String kakaoRedirectUri;
+
+	private static final long REFRESH_TOKEN_EXPIRE_DAYS = 1L;
+
+	public Map<String, String> handleKakaoLogin(String code) {
+		String kakaoAccessToken = getKakaoAccessToken(code);
+		KakaoUserResponse kakaoUser = getKakaoUserInfo(kakaoAccessToken);
+
+		String oauthId = "kakao_" + kakaoUser.id();
+		AuthProvider provider = AuthProvider.KAKAO;
path: src/main/java/com/dangsim/auth/service/AuthService.java
user: login: dudxo
body: `AuthProvider.KAKAO` static 화 시켜서 `authRepository.findByProviderAndProviderId(KAKO, oauthId)`로 라인을 짧게 가져가도 이해하는데 어려움이 없는 것 같아요!

created_at: 2025-05-07T06:18:37Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,163 @@
 package com.dangsim.auth.service;
 
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
 
+import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
 public class AuthService {
path: src/main/java/com/dangsim/auth/service/AuthService.java
user: login: dudxo
body: AuthService에서 OAuth 로그인 부터 Jwt 토큰 재발급까지 너무 많은 책임을 지고 있어요.
과연 AuthService에서 위 책임을 전부 짊어질 필요가 있을까요??
created_at: 2025-05-07T06:25:28Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,163 @@
 package com.dangsim.auth.service;
 
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
 
+import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
 public class AuthService {
+
+	private final WebClient webClient;
+	private final JwtProvider jwtProvider;
+	private final UserRepository userRepository;
+	private final AuthRepository authRepository;
+	private final TokenRepository tokenRepository;
+
+	@Value("${oauth.kakao.token-uri}")
+	private String kakaoTokenUri;
+
+	@Value("${oauth.kakao.user-info-uri}")
+	private String kakaoUserInfoUri;
+
+	@Value("${oauth.kakao.client-id}")
+	private String kakaoClientId;
+
+	@Value("${oauth.kakao.client-secret}")
+	private String kakaoClientSecret;
+
+	@Value("${oauth.kakao.redirect-uri}")
+	private String kakaoRedirectUri;
+
+	private static final long REFRESH_TOKEN_EXPIRE_DAYS = 1L;
+
+	public Map<String, String> handleKakaoLogin(String code) {
+		String kakaoAccessToken = getKakaoAccessToken(code);
+		KakaoUserResponse kakaoUser = getKakaoUserInfo(kakaoAccessToken);
+
+		String oauthId = "kakao_" + kakaoUser.id();
+		AuthProvider provider = AuthProvider.KAKAO;
+
+		Optional<Auth> authOpt = authRepository.findByProviderAndProviderId(provider, oauthId);
+
+		User user = authOpt
+			.map(Auth::getUser)
+			.orElseGet(() -> {
+				User newUser = userRepository.save(User.of(kakaoUser.profileImage()));
+				authRepository.save(Auth.of(provider, oauthId, newUser));
+				return newUser;
+			});
+
+		//JWT 발급
+		String accessToken = jwtProvider.createAccessToken(user.getId());
+		String refreshToken = jwtProvider.createRefreshToken();
+
+		Token token = tokenRepository.findByUser(user)
+			.map(t -> {
+				t.updateRefreshToken(refreshToken, LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS));
+				return t;
+			})
+			.orElse(Token.builder()
+				.refrestToken(refreshToken)
+				.expiryTime(LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS))
+				.user(user)
+				.build());
+
+		tokenRepository.save(token);
+
+		return Map.of(
+			"access_token", accessToken,
+			"refresh_token", refreshToken,
+			"role", user.getRole().name()
path: src/main/java/com/dangsim/auth/service/AuthService.java
user: login: dudxo
body: 하드 코딩 대신 상수화!
created_at: 2025-05-07T06:27:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,54 @@
+package com.dangsim.token.entity;
+
+import static lombok.AccessLevel.*;
+
+import java.time.LocalDateTime;
+
+import com.dangsim.user.entity.User;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "token")
+@Getter
+@NoArgsConstructor(access = PROTECTED)
+public class Token {
+
+	@Id
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	@Column(name = "token_id")
+	private Long id;
+
+	@Column(nullable = false, length = 500)
+	private String refreshToken;
+
+	@Column(nullable = false)
+	private LocalDateTime expiryTime;
+
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "user_id", nullable = false)
+	private User user;
+
+	@Builder
+	private Token(String refrestToken, LocalDateTime expiryTime, User user) {
+		this.refreshToken = refrestToken;
+		this.expiryTime = expiryTime;
+		this.user = user;
+	}
+
+	public void updateRefreshToken(String newToken, LocalDateTime newExpiry) {
path: src/main/java/com/dangsim/token/entity/Token.java
user: login: dudxo
body: 객체가 스스로 일을 하도록 하는 메서드 좋습니다!!  👍 👍 
created_at: 2025-05-07T06:27:40Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,163 @@
 package com.dangsim.auth.service;
 
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
 
+import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
 public class AuthService {
+
+	private final WebClient webClient;
+	private final JwtProvider jwtProvider;
+	private final UserRepository userRepository;
+	private final AuthRepository authRepository;
+	private final TokenRepository tokenRepository;
+
+	@Value("${oauth.kakao.token-uri}")
+	private String kakaoTokenUri;
+
+	@Value("${oauth.kakao.user-info-uri}")
+	private String kakaoUserInfoUri;
+
+	@Value("${oauth.kakao.client-id}")
+	private String kakaoClientId;
+
+	@Value("${oauth.kakao.client-secret}")
+	private String kakaoClientSecret;
+
+	@Value("${oauth.kakao.redirect-uri}")
+	private String kakaoRedirectUri;
+
+	private static final long REFRESH_TOKEN_EXPIRE_DAYS = 1L;
+
+	public Map<String, String> handleKakaoLogin(String code) {
+		String kakaoAccessToken = getKakaoAccessToken(code);
+		KakaoUserResponse kakaoUser = getKakaoUserInfo(kakaoAccessToken);
+
+		String oauthId = "kakao_" + kakaoUser.id();
+		AuthProvider provider = AuthProvider.KAKAO;
+
+		Optional<Auth> authOpt = authRepository.findByProviderAndProviderId(provider, oauthId);
+
+		User user = authOpt
+			.map(Auth::getUser)
+			.orElseGet(() -> {
+				User newUser = userRepository.save(User.of(kakaoUser.profileImage()));
+				authRepository.save(Auth.of(provider, oauthId, newUser));
+				return newUser;
+			});
+
+		//JWT 발급
+		String accessToken = jwtProvider.createAccessToken(user.getId());
+		String refreshToken = jwtProvider.createRefreshToken();
+
+		Token token = tokenRepository.findByUser(user)
+			.map(t -> {
+				t.updateRefreshToken(refreshToken, LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS));
+				return t;
+			})
+			.orElse(Token.builder()
+				.refrestToken(refreshToken)
+				.expiryTime(LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS))
+				.user(user)
+				.build());
+
+		tokenRepository.save(token);
+
+		return Map.of(
+			"access_token", accessToken,
+			"refresh_token", refreshToken,
+			"role", user.getRole().name()
+		);
+	}
+
+	private String getKakaoAccessToken(String code) {
+		MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
+		params.add("grant_type", "authorization_code");
+		params.add("client_id", kakaoClientId);
+		params.add("redirect_uri", kakaoRedirectUri);
+		params.add("client_secret", kakaoClientSecret);
+		params.add("code", code);
+
+		return webClient.post()
+			.uri(kakaoTokenUri)
+			.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+			.bodyValue(params)
+			.retrieve()
+			.bodyToMono(Map.class)
+			.map(body -> (String)body.get("access_token"))
+			.block();
+	}
+
+	private KakaoUserResponse getKakaoUserInfo(String accessToken) {
+		return webClient.get()
+			.uri(kakaoUserInfoUri)
+			.headers(headers -> headers.setBearerAuth(accessToken))
+			.retrieve()
+			.bodyToMono(KakaoUserResponse.class)
+			.block();
+	}
+
+	@Transactional
+	public void logout(Long userId, String accessToken) {
+		//토큰 테이블 삭제
+		User user = userRepository.findById(userId)
+			.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
+		tokenRepository.deleteByUser(user);
+	}
+
+	@Transactional
+	public Map<String, String> reissue(Long userId, String oldRefreshToken) {
+		User user = userRepository.findById(userId)
+			.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
path: src/main/java/com/dangsim/auth/service/AuthService.java
user: login: dudxo
body: 커스텀한 런타입 예외(`new BaseException`)을 사용해봅시다!
created_at: 2025-05-07T06:30:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,7 @@
+package com.dangsim.auth.dto.request;
+
+public record ReissueRequest(
path: src/main/java/com/dangsim/auth/dto/request/ReissueRequest.java
user: login: dudxo
body: Validation이 추가되면 더 안전할 것 같아요~!!
created_at: 2025-05-07T06:30:31Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,163 @@
 package com.dangsim.auth.service;
 
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import com.dangsim.auth.dto.response.KakaoUserResponse;
+import com.dangsim.auth.entity.Auth;
+import com.dangsim.auth.entity.AuthProvider;
+import com.dangsim.auth.repository.AuthRepository;
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
 
+import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
 public class AuthService {
+
+	private final WebClient webClient;
+	private final JwtProvider jwtProvider;
+	private final UserRepository userRepository;
+	private final AuthRepository authRepository;
+	private final TokenRepository tokenRepository;
+
+	@Value("${oauth.kakao.token-uri}")
+	private String kakaoTokenUri;
+
+	@Value("${oauth.kakao.user-info-uri}")
+	private String kakaoUserInfoUri;
+
+	@Value("${oauth.kakao.client-id}")
+	private String kakaoClientId;
+
+	@Value("${oauth.kakao.client-secret}")
+	private String kakaoClientSecret;
+
+	@Value("${oauth.kakao.redirect-uri}")
+	private String kakaoRedirectUri;
+
+	private static final long REFRESH_TOKEN_EXPIRE_DAYS = 1L;
+
+	public Map<String, String> handleKakaoLogin(String code) {
+		String kakaoAccessToken = getKakaoAccessToken(code);
+		KakaoUserResponse kakaoUser = getKakaoUserInfo(kakaoAccessToken);
+
+		String oauthId = "kakao_" + kakaoUser.id();
+		AuthProvider provider = AuthProvider.KAKAO;
+
+		Optional<Auth> authOpt = authRepository.findByProviderAndProviderId(provider, oauthId);
+
+		User user = authOpt
+			.map(Auth::getUser)
+			.orElseGet(() -> {
+				User newUser = userRepository.save(User.of(kakaoUser.profileImage()));
+				authRepository.save(Auth.of(provider, oauthId, newUser));
+				return newUser;
+			});
+
+		//JWT 발급
+		String accessToken = jwtProvider.createAccessToken(user.getId());
+		String refreshToken = jwtProvider.createRefreshToken();
+
+		Token token = tokenRepository.findByUser(user)
+			.map(t -> {
+				t.updateRefreshToken(refreshToken, LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS));
+				return t;
+			})
+			.orElse(Token.builder()
+				.refrestToken(refreshToken)
+				.expiryTime(LocalDateTime.now().plusDays(REFRESH_TOKEN_EXPIRE_DAYS))
+				.user(user)
+				.build());
+
+		tokenRepository.save(token);
+
+		return Map.of(
+			"access_token", accessToken,
+			"refresh_token", refreshToken,
+			"role", user.getRole().name()
+		);
+	}
+
+	private String getKakaoAccessToken(String code) {
+		MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
+		params.add("grant_type", "authorization_code");
+		params.add("client_id", kakaoClientId);
+		params.add("redirect_uri", kakaoRedirectUri);
+		params.add("client_secret", kakaoClientSecret);
+		params.add("code", code);
+
+		return webClient.post()
+			.uri(kakaoTokenUri)
+			.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+			.bodyValue(params)
+			.retrieve()
+			.bodyToMono(Map.class)
+			.map(body -> (String)body.get("access_token"))
+			.block();
+	}
+
+	private KakaoUserResponse getKakaoUserInfo(String accessToken) {
+		return webClient.get()
+			.uri(kakaoUserInfoUri)
+			.headers(headers -> headers.setBearerAuth(accessToken))
+			.retrieve()
+			.bodyToMono(KakaoUserResponse.class)
+			.block();
+	}
+
+	@Transactional
+	public void logout(Long userId, String accessToken) {
+		//토큰 테이블 삭제
+		User user = userRepository.findById(userId)
+			.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
+		tokenRepository.deleteByUser(user);
+	}
+
+	@Transactional
+	public Map<String, String> reissue(Long userId, String oldRefreshToken) {
+		User user = userRepository.findById(userId)
+			.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
+
+		Token token = tokenRepository.findByUser(user)
+			.orElseThrow(() -> new IllegalStateException("RefreshToken 없음. 다시 로그인하세요."));
+
+		if (!token.getRefreshToken().equals(oldRefreshToken)) {
path: src/main/java/com/dangsim/auth/service/AuthService.java
user: login: dudxo
body: oldRefreshToken == null 이라면 어떻게 될까요?
`NPE`는 아주 치명적인 예외입니다. 가급적 피해봅시다!
created_at: 2025-05-07T06:31:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,24 @@
+package com.dangsim.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+	@Bean
+	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
path: src/main/java/com/dangsim/common/config/SecurityConfig.java
user: login: dudxo
body: 기본 로그인 form 제외해주세요!
created_at: 2025-05-07T06:31:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,70 @@
+package com.dangsim.jwt;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+
+import javax.crypto.SecretKey;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class JwtProvider {
+	@Value("${jwt.secret}")
+	private String secret;
+
+	private SecretKey key;
+
+	private static final long ACCESS_EXP = 1000 * 60 * 60 * 1;  // 1시간
+	private static final long REFRESH_EXP = 1000 * 60 * 60 * 24; // 1일
+
+	@PostConstruct
+	public void init() {
+		this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+	}
+
+	public String createAccessToken(Long userId) {
path: src/main/java/com/dangsim/jwt/JwtProvider.java
user: login: dudxo
body: 문제는 없지만 `New Date()` 보다는 테스트 시 제어할 수 없는 외부 환경 변수(날짜, 시간 등)을 파라미터로 받아서, 테스트 작성 시 외부 환경 변수를 제어할 수 있도록 바꾼다면 보다 테스트 작성이 쉬워집니다
created_at: 2025-05-07T06:33:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,39 @@
+package com.dangsim.jwt.filter;
+
+import static com.dangsim.jwt.util.TokenUtil.*;
+
+import java.io.IOException;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import com.dangsim.jwt.JwtProvider;
+
+import io.jsonwebtoken.Claims;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+	private final JwtProvider jwtProvider;
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+		FilterChain filterChain) throws ServletException, IOException {
+		String token = extractToken(request.getHeader("Authorization"));
path: src/main/java/com/dangsim/jwt/filter/JwtAuthenticationFilter.java
user: login: dudxo
body: 하드코딩 보다 상수화!
created_at: 2025-05-07T06:34:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,15 @@
+package com.dangsim.jwt.util;
+
+public class TokenUtil {
+	private static final String BEARER_PREFIX = "Bearer ";
+
+	public static String extractToken(String bearerToken) {
+		if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
+			return bearerToken.substring(BEARER_PREFIX.length());
+		}
+		throw new IllegalArgumentException("유효하지 않은 Authorization 헤더입니다.");
+	}
+
+	private TokenUtil() {
path: src/main/java/com/dangsim/jwt/util/TokenUtil.java
user: login: dudxo
body: @NoArgsConstructor()를 사용할 수 있을 것 같아요
created_at: 2025-05-07T06:35:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,14 @@
+package com.dangsim.token.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.dangsim.token.entity.Token;
+import com.dangsim.user.entity.User;
+
+public interface TokenRepository extends JpaRepository<Token, Long> {
+	Optional<Token> findByUser(User user);
+
+	void deleteByUser(User user);
path: src/main/java/com/dangsim/token/repository/TokenRepository.java
user: login: dudxo
body: 기본으로 제공되는 CRUD 메서드 아닌가요?
created_at: 2025-05-07T06:36:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+package com.dangsim.auth.service;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.dangsim.jwt.JwtProvider;
+import com.dangsim.token.entity.Token;
+import com.dangsim.token.repository.TokenRepository;
+import com.dangsim.user.entity.User;
+import com.dangsim.user.repository.UserRepository;
+
+@ExtendWith(MockitoExtension.class)
+class AuthServiceTest {
+
+	@Mock
+	private JwtProvider jwtProvider;
+	@Mock
+	private UserRepository userRepository;
+	@Mock
+	private TokenRepository tokenRepository;
+
+	@InjectMocks
+	private AuthService authService;
+
+	@Test
+	@DisplayName("로그아웃 시 사용자에 해당하는 토큰 정보를 삭제한다.")
+	void logout() {
+		// given
+		User user = mock(User.class);
+		when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+		doNothing().when(tokenRepository).deleteByUser(user);
+
+		// when
+		authService.logout(1L, "access");
+
+		// then
+		verify(tokenRepository, times(1)).deleteByUser(user);
+	}
+
+	@Test
+	@DisplayName("refresh 토큰이 유효할 경우 새로운 access, refresh 토큰을 발급한다.")
path: src/test/java/com/dangsim/auth/service/AuthServiceTest.java
user: login: dudxo
body: refresh 토큰이 유효하지 않는 경우 예외가 잘 발생하고 있을까요??

가능하다면 검증해봅시다!
created_at: 2025-05-07T06:37:40Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-07T02:03:51Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14873513601)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-07T05:00:25Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14873513601)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-07T05:42:52Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14876018535)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-07T06:12:05Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 성공했습니다! 🎉
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T01:19:11Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14896500656)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T01:38:22Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14896734660)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T01:45:20Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14896734660)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T01:47:18Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14896734660)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T02:05:28Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14896734660)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T02:14:26Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 실패했습니다! ❌
아래 로그를 확인해주세요!
[실행 로그](https://github.com/URECA-DangSim/dangsim-be/actions/runs/14897122198)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T02:16:28Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 성공했습니다! 🎉
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T02:21:01Z
body: feat-4-socialLoginAPI 브랜치 빌드 테스트에 성공했습니다! 🎉
author_association: NONE