Repository: auction-guys/aucjangpan
Pull request #10: feat(user) : 유저 CRUD 구현 및 Spring Security, OAuth 적용.
Author: login: sshkim1013
Labels: name: enhancement
description: New feature or request
Created: 2025-04-09T04:49:18Z | Merged: 2025-04-10T06:39:26Z

😄 구현 완료한 파트 😄

1. 유저 CRUD(회원가입, 로그인, 로그아웃, 회원탈퇴) 기능.
2. Spring Security + JWT 를 통한 유저 인증.
3. OAuth(Google)를 통한 로그인 기능.

Postman과 브라우저를 통해 모든 테스트에 성공적으로 통과하였습니다.

미흡한 부분이 많을 것으로 예상됩니다. 
많은 피드백 부탁 드리겠습니다.
감사합니다 👍

[ 2025.04.10 ]
- AuthController : signup() 메서드 반환 타입 빈 배열로 설정.
- User : hard-delete 대신 soft-delete 적용.
- UserController : URL 및 파라미터(userId 전달 X) 수정.
- UserService : UserController 연장선.
- build.gradle : OpenFeign이 Spring Boot 3.4.x 이상에서 지원하지 않는 이슈로 인해 3.3.0 버전으로 다운.


Diff:
diff --git a/build.gradle b/build.gradle
index aa2caac7..9d4ef6e7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,7 @@
 plugins {
     id 'java'
-    id 'org.springframework.boot' version '3.4.4'
-    id 'io.spring.dependency-management' version '1.1.7'
+    id 'org.springframework.boot' version '3.3.0'
+    id 'io.spring.dependency-management' version '1.1.4'
 }
 
 group = 'com.fifteen'
@@ -23,10 +23,20 @@ repositories {
     mavenCentral()
 }
 
+dependencyManagement {
+    imports {
+        mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.1"
+    }
+}
+
 dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
     implementation 'org.springframework.boot:spring-boot-starter-validation'
     implementation 'org.springframework.boot:spring-boot-starter-web'
+
+    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
+
     implementation 'mysql:mysql-connector-java:8.0.33'
     implementation 'com.amazonaws:aws-java-sdk-s3:1.12.670'
     implementation 'software.amazon.awssdk:s3:2.25.10'
@@ -38,6 +48,22 @@ dependencies {
     testImplementation 'org.springframework.boot:spring-boot-starter-test'
     testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
 
+    // bcrypt
+    implementation 'at.favre.lib:bcrypt:0.10.2'
+
+    // jwt
+    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
+    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
+    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
+
+    //security
+    implementation 'org.springframework.boot:spring-boot-starter-security'
+    testImplementation 'org.springframework.security:spring-security-test'
+
+    //OAuth2
+    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
+    implementation 'com.fasterxml.jackson.core:jackson-databind'
+    
     // querydsl
     implementation "com.querydsl:querydsl-jpa:5.1.0:jakarta"
     annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
diff --git a/src/main/java/com/fifteen/auction/AuctionApplication.java b/src/main/java/com/fifteen/auction/AuctionApplication.java
index 5c7087ec..69ca06e7 100644
--- a/src/main/java/com/fifteen/auction/AuctionApplication.java
+++ b/src/main/java/com/fifteen/auction/AuctionApplication.java
@@ -2,8 +2,10 @@
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.openfeign.EnableFeignClients;
 
 @SpringBootApplication
+@EnableFeignClients
 public class AuctionApplication {
 
     public static void main(String[] args) {
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/OAuthTokenClient.java b/src/main/java/com/fifteen/auction/domain/user/auth/OAuthTokenClient.java
new file mode 100644
index 00000000..a3f5bd67
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/OAuthTokenClient.java
@@ -0,0 +1,13 @@
+package com.fifteen.auction.domain.user.auth;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+@FeignClient(name = "google-oauth", url = "https://oauth2.googleapis.com")
+public interface OAuthTokenClient {
+    @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+    JsonNode getToken(@RequestBody String body);
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/OAuthUserInfoClient.java b/src/main/java/com/fifteen/auction/domain/user/auth/OAuthUserInfoClient.java
new file mode 100644
index 00000000..7260892e
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/OAuthUserInfoClient.java
@@ -0,0 +1,12 @@
+package com.fifteen.auction.domain.user.auth;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+@FeignClient(name = "google-userinfo", url = "https://www.googleapis.com")
+public interface OAuthUserInfoClient {
+    @GetMapping("/oauth2/v3/userinfo")
+    JsonNode getUserInfo(@RequestParam("access_token") String accessToken);
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/controller/AuthController.java b/src/main/java/com/fifteen/auction/domain/user/auth/controller/AuthController.java
new file mode 100644
index 00000000..1deca699
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/controller/AuthController.java
@@ -0,0 +1,56 @@
+package com.fifteen.auction.domain.user.auth.controller;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.service.AuthService;
+import com.fifteen.auction.domain.user.auth.service.OAuthService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Collections;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/auth")
+public class AuthController {
+
+    private final AuthService authService;
+    private final OAuthService oAuthService;
+
+    @PostMapping("/signup")
+    public ResponseEntity<Object> signup(@RequestBody SignupRequest signupRequest) {
+        authService.signup(signupRequest);
+        return ResponseEntity.status(HttpStatus.CREATED).body(Collections.emptyList());
+    }
+
+    @PostMapping("/login")
+    public ResponseEntity<SigninResponse> login(@RequestBody SigninRequest signinRequest) {
+        SigninResponse response = authService.login(signinRequest);
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.AUTHORIZATION, response.getJwt())
+                .build(); // 바디 없이 헤더만 응답
+    }
+
+    @PostMapping("/logout")  // 수정
+    public ResponseEntity<String> logout(@RequestHeader("Authorization") String authorizationHeader) {
+        authService.logout(authorizationHeader);  // 헤더 그대로 전달
+        return ResponseEntity.ok("로그 아웃이 완료되었습니다.");
+    }
+
+    @DeleteMapping("/withdraw")
+    public void withdraw(@RequestBody WithdrawRequest withdrawRequest) {
+        authService.withdraw(withdrawRequest);
+    }
+
+    // 구글 OAuth 로그인 콜백
+    @GetMapping("/google/callback")
+    public ResponseEntity<SigninResponse> googleCallback(@RequestParam("code") String code) {
+        return ResponseEntity.ok(oAuthService.loginWithGoogle(code));
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/SigninRequest.java b/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/SigninRequest.java
new file mode 100644
index 00000000..c2b1f016
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/SigninRequest.java
@@ -0,0 +1,10 @@
+package com.fifteen.auction.domain.user.auth.dto.request;
+
+import lombok.Getter;
+
+@Getter
+public class SigninRequest {
+
+    private String email;
+    private String password;
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/SignupRequest.java b/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/SignupRequest.java
new file mode 100644
index 00000000..8cbca524
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/SignupRequest.java
@@ -0,0 +1,44 @@
+package com.fifteen.auction.domain.user.auth.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+
+@Getter
+public class SignupRequest {
+
+    @NotBlank
+    @Email(message = "이메일 형식이어야 합니다.")
+    private String email;
+
+    @NotBlank
+    private String nickname;
+
+    @NotBlank
+    private String name;
+
+    @NotBlank
+    private String gender;
+
+    @NotBlank
+    private String ageGroup;
+
+    @NotBlank
+    @Size(min = 8, max = 24, message = "비밀번호는 8자 이상 24자 이하이어야 합니다.")
+    @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[^a-zA-Z\\d]).{8,24}$", message = "비밀번호는 영어 대소문자, 숫자, 특수문자를 포함해야 합니다.")
+    private String password;
+
+    @NotBlank
+    private String address;
+
+    @NotBlank
+    private String contactNumber;
+
+    @NotBlank
+    private String preferCategory;
+
+    @NotBlank
+    private String accountNumber;
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/WithdrawRequest.java b/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/WithdrawRequest.java
new file mode 100644
index 00000000..9c851aab
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/dto/request/WithdrawRequest.java
@@ -0,0 +1,10 @@
+package com.fifteen.auction.domain.user.auth.dto.request;
+
+import lombok.Getter;
+
+@Getter
+public class WithdrawRequest {
+
+    private String email;
+    private String password;
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/dto/response/SigninResponse.java b/src/main/java/com/fifteen/auction/domain/user/auth/dto/response/SigninResponse.java
new file mode 100644
index 00000000..508c7e7c
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/dto/response/SigninResponse.java
@@ -0,0 +1,11 @@
+package com.fifteen.auction.domain.user.auth.dto.response;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class SigninResponse {
+
+    private final String jwt;
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/fifteen/auction/domain/user/auth/filter/JwtAuthenticationFilter.java
new file mode 100644
index 00000000..cde6d1fb
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/filter/JwtAuthenticationFilter.java
@@ -0,0 +1,71 @@
+package com.fifteen.auction.domain.user.auth.filter;
+
+import com.fifteen.auction.domain.user.auth.entity.AuthUser;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.MalformedJwtException;
+import io.jsonwebtoken.UnsupportedJwtException;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+    private final JwtUtil jwtUtil;
+
+    @Override
+    protected void doFilterInternal(
+            HttpServletRequest httpRequest,
+            @NonNull HttpServletResponse httpResponse,
+            @NonNull FilterChain chain
+    ) throws ServletException, IOException {
+        String authorizationHeader = httpRequest.getHeader("Authorization");
+
+        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
+            String jwt = jwtUtil.substringToken(authorizationHeader);
+            try {
+                Claims claims = jwtUtil.extractClaims(jwt);
+
+                if (SecurityContextHolder.getContext().getAuthentication() == null) {
+                    setAuthentication(claims);
+                }
+            } catch (SecurityException | MalformedJwtException e) {
+                log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
+                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
+            } catch (ExpiredJwtException e) {
+                log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
+                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
+            } catch (UnsupportedJwtException e) {
+                log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
+                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
+            } catch (Exception e) {
+                log.error("Internal server error", e);
+                httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+            }
+        }
+        chain.doFilter(httpRequest, httpResponse);
+    }
+
+    private void setAuthentication(Claims claims) {
+        Long userId = Long.valueOf(claims.getSubject());
+        String email = claims.get("email", String.class);
+        String nickname = claims.get("nickname", String.class);
+
+        AuthUser authUser = new AuthUser(userId, email, nickname);
+        JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
+        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/filter/JwtAuthenticationToken.java b/src/main/java/com/fifteen/auction/domain/user/auth/filter/JwtAuthenticationToken.java
new file mode 100644
index 00000000..b22e77b4
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/filter/JwtAuthenticationToken.java
@@ -0,0 +1,27 @@
+package com.fifteen.auction.domain.user.auth.filter;
+
+import com.fifteen.auction.domain.user.auth.entity.AuthUser;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+
+import java.util.Collections;
+
+public class JwtAuthenticationToken extends AbstractAuthenticationToken {
+
+    private final AuthUser authUser;
+
+    public JwtAuthenticationToken(AuthUser authUser) {
+        super(Collections.emptyList());  // 권한 없이 인증 처리
+        this.authUser = authUser;
+        setAuthenticated(true);
+    }
+
+    @Override
+    public Object getCredentials() {
+        return null;
+    }
+
+    @Override
+    public Object getPrincipal() {
+        return authUser;
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java b/src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
new file mode 100644
index 00000000..2657a05c
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
@@ -0,0 +1,107 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public void signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmailAndDeletedFalse(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+    }
+
+    @Transactional
+    public SigninResponse login(SigninRequest signinRequest) {
+
+        User user = userRepository.findByEmailAndDeletedFalse(signinRequest.getEmail()).orElseThrow(
+                () -> new ClientException(ErrorCode.USER_NOT_FOUND)
+        );
+
+        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.INVALID_PASSWORD);
+        }
+
+        String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getNickname());
+        String jwt = jwtUtil.substringToken(bearerToken);
+
+        return new SigninResponse(jwt);
+    }
+
+    @Transactional
+    public void logout(String authorizationHeader) {
+
+        // 1. Authorization 헤더에서 토큰 추출
+        if (!StringUtils.hasText(authorizationHeader) || !authorizationHeader.startsWith("Bearer ")) {
+            throw new ClientException(ErrorCode.INVALID_TOKEN);
+        }
+        String token = authorizationHeader.substring(7);
+
+        // 2. 토큰 유효성 검사
+        if (!jwtUtil.validateToken(token)) {
+            throw new ClientException(ErrorCode.INVALID_TOKEN);
+        }
+
+        // 3. Redis에 이미 로그아웃된 토큰인지 확인 + 저장 (원자적으로 처리)
+        String key = "BLACKLIST:" + token;
+        Long expiration = jwtUtil.getTokenExpiration(token);
+        Boolean isSet = redisTemplate.opsForValue().setIfAbsent(key, "logout", expiration, TimeUnit.MILLISECONDS);
+
+        // 4. 이미 존재한다면 → 이미 로그아웃된 토큰
+        if (Boolean.FALSE.equals(isSet)) {
+            throw new ClientException(ErrorCode.ALREADY_LOGOUT);
+        }
+    }
+
+    @Transactional
+    public void withdraw(WithdrawRequest withdrawRequest) {
+        User user = userRepository.findByEmailAndDeletedFalse(withdrawRequest.getEmail()).orElseThrow(
+                () -> new ClientException(ErrorCode.USER_NOT_FOUND)
+        );
+
+        if (!passwordEncoder.matches(withdrawRequest.getPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.INVALID_PASSWORD);
+        }
+
+        userRepository.softDeleteByEmail(user.getEmail()); // soft-delete 기능 구현
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/service/OAuthService.java b/src/main/java/com/fifteen/auction/domain/user/auth/service/OAuthService.java
new file mode 100644
index 00000000..04b977f6
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/service/OAuthService.java
@@ -0,0 +1,71 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.OAuthTokenClient;
+import com.fifteen.auction.domain.user.auth.OAuthUserInfoClient;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+public class OAuthService {
+
+    private final OAuthTokenClient tokenClient;
+    private final OAuthUserInfoClient infoClient;
+    private final UserRepository userRepository;
+    private final JwtUtil jwtUtil;
+
+    @Value("${jwt.security.oauth2.client.registration.google.client-id}")
+    private String clientId;
+
+    @Value("${jwt.security.oauth2.client.registration.google.client-secret}")
+    private String clientSecret;
+
+    @Value("${jwt.security.oauth2.client.registration.google.redirect-uri}")
+    private String redirectUri;
+
+    public SigninResponse loginWithGoogle(String code) {
+
+        String body = String.format(
+                "code=%s&client_id=%s&client_secret=%s&redirect_uri=%s&grant_type=authorization_code",
+                code, clientId, clientSecret, redirectUri
+        );
+
+        JsonNode tokenNode = tokenClient.getToken(body);
+        String accessToken = tokenNode.get("access_token").asText();
+
+        JsonNode userInfo = infoClient.getUserInfo(accessToken);
+
+        String email = userInfo.get("email").asText();
+        String name = userInfo.get("name").asText();
+
+        Optional<User> userOpt = userRepository.findByEmailAndDeletedFalse(email);
+
+        User user = userOpt.orElseGet(() -> {
+            User newUser = new User(
+                    email,
+                    name,
+                    name,
+                    "male",
+                    "20대",
+                    "unknown",
+                    "서울특별시 성북구",
+                    "010-9876-5432",
+                    "설윤 포토카드",
+                    "110-353-844210"
+            );
+            return userRepository.save(newUser);
+        });
+
+        String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getNickname());
+        String jwt = jwtUtil.substringToken(bearerToken);
+        return new SigninResponse(jwt);
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/auth/util/JwtUtil.java b/src/main/java/com/fifteen/auction/domain/user/auth/util/JwtUtil.java
new file mode 100644
index 00000000..c2e25e87
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/auth/util/JwtUtil.java
@@ -0,0 +1,81 @@
+package com.fifteen.auction.domain.user.auth.util;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.security.Key;
+import java.util.Base64;
+import java.util.Date;
+
+@Slf4j(topic = "JwtUtil")
+@Component
+public class JwtUtil {
+
+    private static final String BEARER_PREFIX = "Bearer ";
+    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
+
+    @Value("${jwt.secret.key}")
+    private String secretKey;
+    private Key key;
+    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
+
+    @PostConstruct
+    public void init() {
+        byte[] bytes = Base64.getDecoder().decode(secretKey);
+        key = Keys.hmacShaKeyFor(bytes);
+    }
+
+    public String createToken(Long userId, String email, String nickname) {
+        Date date = new Date();
+
+        return BEARER_PREFIX +
+                Jwts.builder()
+                        .setSubject(String.valueOf(userId))
+                        .claim("email", email)
+                        .claim("nickname", nickname)
+                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
+                        .setIssuedAt(date) // 발급일
+                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
+                        .compact();
+    }
+
+    public String substringToken(String tokenValue) {
+        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
+            return tokenValue.substring(7);
+        }
+        throw new IllegalStateException("Not Found Token");
+    }
+
+    public Claims extractClaims(String token) {
+        return Jwts.parserBuilder()
+                .setSigningKey(key)
+                .build()
+                .parseClaimsJws(token)
+                .getBody();
+    }
+
+    // 토큰의 남은 만료 시간 계산 (ms 단위)
+    public Long getTokenExpiration(String token) {
+        Claims claims = extractClaims(token);
+        Date expiration = claims.getExpiration();
+        return expiration.getTime() - System.currentTimeMillis();
+    }
+
+    // 토큰 유효성 검사 (예외 발생 시 false)
+    public boolean validateToken(String token) {
+        try {
+            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
+            return true;
+        } catch (Exception e) {
+            log.warn("Invalid JWT Token: {}", e.getMessage());
+            return false;
+        }
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/controller/UserController.java b/src/main/java/com/fifteen/auction/domain/user/controller/UserController.java
new file mode 100644
index 00000000..b4850739
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/controller/UserController.java
@@ -0,0 +1,44 @@
+package com.fifteen.auction.domain.user.controller;
+
+import com.fifteen.auction.domain.user.auth.entity.AuthUser;
+import com.fifteen.auction.domain.user.dto.request.UserUpdatePasswordRequest;
+import com.fifteen.auction.domain.user.dto.request.UserUpdateRequest;
+import com.fifteen.auction.domain.user.dto.response.UserResponse;
+import com.fifteen.auction.domain.user.dto.response.UserUpdateResponse;
+import com.fifteen.auction.domain.user.service.UserService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/users")
+public class UserController {
+
+    private final UserService userService;
+
+    @GetMapping("/{userId}")
+    public ResponseEntity<UserResponse> findUser(
+            @PathVariable Long userId
+    ) {
+        return ResponseEntity.ok(userService.findUser(userId));
+    }
+
+    @PutMapping("/profile")
+    public ResponseEntity<UserUpdateResponse> updateProfile(
+            @AuthenticationPrincipal AuthUser authUser,
+            @RequestBody @Valid UserUpdateRequest request
+    ) {
+        return ResponseEntity.ok(userService.updateProfile(authUser, request));
+    }
+
+    @PutMapping("/password")
+    public void updatePassword(
+            @AuthenticationPrincipal AuthUser authUser,
+            @RequestBody @Valid UserUpdatePasswordRequest request
+    ) {
+        userService.updatePassword(authUser, request);
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/dto/request/UserUpdatePasswordRequest.java b/src/main/java/com/fifteen/auction/domain/user/dto/request/UserUpdatePasswordRequest.java
new file mode 100644
index 00000000..9b823c34
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/dto/request/UserUpdatePasswordRequest.java
@@ -0,0 +1,10 @@
+package com.fifteen.auction.domain.user.dto.request;
+
+import lombok.Getter;
+
+@Getter
+public class UserUpdatePasswordRequest {
+
+    private String oldPassword;
+    private String newPassword;
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/dto/request/UserUpdateRequest.java b/src/main/java/com/fifteen/auction/domain/user/dto/request/UserUpdateRequest.java
new file mode 100644
index 00000000..88435be3
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/dto/request/UserUpdateRequest.java
@@ -0,0 +1,11 @@
+package com.fifteen.auction.domain.user.dto.request;
+
+import lombok.Getter;
+
+@Getter
+public class UserUpdateRequest {
+
+    private String email;
+    private String nickname;
+    private String address;
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/dto/response/UserResponse.java b/src/main/java/com/fifteen/auction/domain/user/dto/response/UserResponse.java
new file mode 100644
index 00000000..790b4c47
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/dto/response/UserResponse.java
@@ -0,0 +1,17 @@
+package com.fifteen.auction.domain.user.dto.response;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class UserResponse {
+
+    private final Long id;
+
+    private final String email;
+
+    private final String nickName;
+
+    private final String preferCategory;
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/dto/response/UserUpdateResponse.java b/src/main/java/com/fifteen/auction/domain/user/dto/response/UserUpdateResponse.java
new file mode 100644
index 00000000..27c7f468
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/dto/response/UserUpdateResponse.java
@@ -0,0 +1,20 @@
+package com.fifteen.auction.domain.user.dto.response;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class UserUpdateResponse {
+
+    private final Long id;
+    private final String email;
+    private final String nickname;
+    private final String name;
+    private final String gender;
+    private final String ageGroup;
+    private final String address;
+    private final String contactNumber;
+    private final String preferCategory;
+    private final String accountNumber;
+}
diff --git a/src/main/java/com/fifteen/auction/domain/user/entity/User.java b/src/main/java/com/fifteen/auction/domain/user/entity/User.java
index c7a22090..01125ce7 100644
--- a/src/main/java/com/fifteen/auction/domain/user/entity/User.java
+++ b/src/main/java/com/fifteen/auction/domain/user/entity/User.java
@@ -44,6 +44,9 @@ public class User {
     @Column(nullable = false)
     private String accountNumber;
 
+    @Column(nullable = false)
+    private boolean deleted = false; // 기본값 false
+
     public User(String email, String nickname, String name, String gender, String ageGroup, String password, String address, String contactNumber, String preferCategory, String accountNumber) {
         this.email = email;
         this.nickname = nickname;
diff --git a/src/main/java/com/fifteen/auction/domain/user/repository/UserRepository.java b/src/main/java/com/fifteen/auction/domain/user/repository/UserRepository.java
index 71f82647..1c5f5841 100644
--- a/src/main/java/com/fifteen/auction/domain/user/repository/UserRepository.java
+++ b/src/main/java/com/fifteen/auction/domain/user/repository/UserRepository.java
@@ -2,14 +2,21 @@
 
 import com.fifteen.auction.domain.user.entity.User;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 import java.util.Optional;
 
 public interface UserRepository extends JpaRepository<User, Long> {
 
-    boolean existsByEmail(String email);
+    Optional<User> findByEmailAndDeletedFalse(String email);
 
-    Optional<User> findByEmail(String email);
+    boolean existsByEmailAndDeletedFalse(String email);
 
     boolean existsByNickname(String nickname);
+
+    @Modifying
+    @Query("UPDATE User u SET u.deleted = true WHERE u.email = :email")
+    void softDeleteByEmail(@Param("email") String email);
 }
diff --git a/src/main/java/com/fifteen/auction/domain/user/service/UserService.java b/src/main/java/com/fifteen/auction/domain/user/service/UserService.java
new file mode 100644
index 00000000..e1d4ad85
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/domain/user/service/UserService.java
@@ -0,0 +1,80 @@
+package com.fifteen.auction.domain.user.service;
+
+import com.fifteen.auction.domain.user.auth.entity.AuthUser;
+import com.fifteen.auction.domain.user.dto.request.UserUpdatePasswordRequest;
+import com.fifteen.auction.domain.user.dto.request.UserUpdateRequest;
+import com.fifteen.auction.domain.user.dto.response.UserResponse;
+import com.fifteen.auction.domain.user.dto.response.UserUpdateResponse;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class UserService {
+
+    private final UserRepository userRepository;
+    private final PasswordEncoder passwordEncoder;
+
+    @Transactional(readOnly = true)
+    public UserResponse findUser(Long userId) {
+
+        User user = userRepository.findById(userId)
+                .orElseThrow(() -> new ClientException(ErrorCode.USER_NOT_FOUND));
+
+        return new UserResponse(user.getId(), user.getEmail(), user.getNickname(), user.getPreferCategory());
+    }
+
+    @Transactional
+    public UserUpdateResponse updateProfile(AuthUser authUser, UserUpdateRequest request) {
+
+        User user = userRepository.findById(authUser.getId())
+                .orElseThrow(() -> new ClientException(ErrorCode.UNAUTHORIZED_PROFILE_UPDATE));
+
+        //이미 존재하는 닉네임.
+        if (userRepository.existsByNickname(request.getNickname())) {
+            throw new ClientException(ErrorCode.DUPLICATE_NICKNAME);
+        }
+
+        //유저의 프로필 정보(이메일, 이름) 수정.
+        user.updateProfile(request.getEmail(), request.getNickname(), request.getAddress());
+
+        return new UserUpdateResponse(user.getId(),
+                                      user.getEmail(),
+                                      user.getNickname(),
+                                      user.getName(),
+                                      user.getGender(),
+                                      user.getAgeGroup(),
+                                      user.getAddress(),
+                                      user.getContactNumber(),
+                                      user.getPreferCategory(),
+                                      user.getAccountNumber()
+        );
+    }
+
+    @Transactional
+    public void updatePassword(AuthUser authUser, UserUpdatePasswordRequest request) {
+
+        User user = userRepository.findById(authUser.getId())
+                .orElseThrow(() -> new ClientException(ErrorCode.UNAUTHORIZED_PASSWORD_UPDATE));
+
+        //기존의 비밀번호와 같은 경우에만 수정 가능.
+        if (!passwordEncoder.matches(request.getOldPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.INVALID_OLD_PASSWORD);
+        }
+
+        //기존의 비밀번호와 새 비밀번호가 같은 경우.
+        if (passwordEncoder.matches(request.getNewPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.PASSWORD_NOT_CHANGED);
+        }
+
+        //비밀번호 변경.
+        String encodedPassword = passwordEncoder.encode(request.getNewPassword());  //비밀번호 변경 후, 인코딩!!
+        user.updatePassword(encodedPassword);
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/global/config/RedisConfig.java b/src/main/java/com/fifteen/auction/global/config/RedisConfig.java
new file mode 100644
index 00000000..5381d35b
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/global/config/RedisConfig.java
@@ -0,0 +1,20 @@
+package com.fifteen.auction.global.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+@Configuration
+public class RedisConfig {
+
+    @Bean
+    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, String> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+        template.setKeySerializer(new StringRedisSerializer());
+        template.setValueSerializer(new StringRedisSerializer());
+        return template;
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/global/config/SecurityConfig.java b/src/main/java/com/fifteen/auction/global/config/SecurityConfig.java
new file mode 100644
index 00000000..a341c872
--- /dev/null
+++ b/src/main/java/com/fifteen/auction/global/config/SecurityConfig.java
@@ -0,0 +1,51 @@
+package com.fifteen.auction.global.config;
+
+import com.fifteen.auction.domain.user.auth.filter.JwtAuthenticationFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter;
+
+@Configuration
+@RequiredArgsConstructor
+@EnableWebSecurity
+@EnableMethodSecurity(securedEnabled = true)    //@Secured 어노테이션 사용 가능
+public class SecurityConfig {
+
+    private final JwtAuthenticationFilter jwtAuthenticationFilter;
+
+    @Bean
+    public PasswordEncoder passwordEncoder() {
+        return new BCryptPasswordEncoder();
+    }
+
+    @Bean
+    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+        return http
+                .csrf(AbstractHttpConfigurer::disable)
+                .sessionManagement(session -> session
+                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+                )
+                .addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class)
+                .formLogin(AbstractHttpConfigurer::disable)
+                .anonymous(AbstractHttpConfigurer::disable)
+                .httpBasic(AbstractHttpConfigurer::disable)
+                .logout(AbstractHttpConfigurer::disable)
+                .rememberMe(AbstractHttpConfigurer::disable)
+                .authorizeHttpRequests(auth -> auth
+                        .requestMatchers(request -> request.getRequestURI().startsWith("/api/v1/auth")).permitAll()
+//                        .requestMatchers("/test").hasAuthority(UserRole.Authority.ADMIN)
+                        .requestMatchers("/open").permitAll()
+                        .anyRequest().authenticated()
+                )
+                .build();
+    }
+}
diff --git a/src/main/java/com/fifteen/auction/global/dto/error/ErrorCode.java b/src/main/java/com/fifteen/auction/global/dto/error/ErrorCode.java
index 95219be3..61df8269 100644
--- a/src/main/java/com/fifteen/auction/global/dto/error/ErrorCode.java
+++ b/src/main/java/com/fifteen/auction/global/dto/error/ErrorCode.java
@@ -14,12 +14,21 @@ public enum ErrorCode {
      * 상수를 선언하실 때, code 부분은 '도메인-숫자'의 형식으로 해주세요. 예) USER-1
      */
 
+    //User 에러 코드
+    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER-1", "해당 유저를 찾을 수 없습니다."),
+    INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "USER-2", "비밀번호가 올바르지 않습니다."),
+    UNAUTHORIZED_PROFILE_UPDATE(HttpStatus.UNAUTHORIZED, "USER-3", "해당 유저에 대한 프로필 수정 권한이 없습니다."),
+    UNAUTHORIZED_PASSWORD_UPDATE(HttpStatus.UNAUTHORIZED, "USER-4", "해당 유저에 대한 비밀번호 수정 권한이 없습니다."),
+    DUPLICATE_NICKNAME(HttpStatus.BAD_REQUEST, "USER-5", "이미 존재하는 닉네임입니다."),
+    DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "USER-6", "이미 존재하는 이메일입니다."),
+    INVALID_OLD_PASSWORD(HttpStatus.BAD_REQUEST, "USER-7", "기존의 비밀번호가 아닙니다."),
+    PASSWORD_NOT_CHANGED(HttpStatus.BAD_REQUEST, "USER-8", "기존의 비밀번호와 다르게 입력해 주세요."),
+    INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "USER-9", "잘못된 토큰입니다."),
+    ALREADY_LOGOUT(HttpStatus.BAD_REQUEST, "USER-10", "이미 로그아웃 되었습니다."),
+
     // Auction Exceptions
     AUCTION_NOT_FOUND(HttpStatus.NOT_FOUND, "AUCTION-1", "경매가 존재하지 않습니다."),
 
-    // Uncaught Exceptions
-    EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "EXCEPTION", "알 수 없는 에러입니다."),
-
     // Product Custom Exception
     PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT-1", "존재하지 않는 상품입니다."),
     PRODUCT_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT-2", "존재하지 않는 카테고리입니다."),
@@ -36,7 +45,11 @@ public enum ErrorCode {
     S3_DELETE_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "S3-2", "이미지 삭제에 실패했습니다."),
     S3_KEY_EXTRACTION_FAIL(HttpStatus.BAD_REQUEST, "S3-3", "키 추출에 실패했습니다."),
     S3_INVALID_EXTENSION(HttpStatus.BAD_REQUEST, "S3-4", "파일 확장자를 찾을 수 없습니다."),
-    ;
+    
+  
+    // Uncaught Exceptions
+    EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "EXCEPTION", "알 수 없는 에러입니다.");
+  
     private final HttpStatus status;
     private final String code;
     private final String message;


Reviews:
user: login: yeonic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:08:47Z
user: login: sshkim1013
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:10:08Z
user: login: friedSaewoo
body: 전체적으로 잘 작성하신것 같습니다. 
OAuth도 처음 써보셨을텐데 잘 작성해주신것같습니다. 고생 많으셨습니다 😊
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:11:03Z
user: login: sshkim1013
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:12:44Z
user: login: yeonic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:12:58Z
user: login: yeonic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:15:55Z
user: login: sshkim1013
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:17:08Z
user: login: yeonic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:19:25Z
user: login: yeonic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:20:53Z
user: login: yeonic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:23:43Z
user: login: ParkKyoungMin
body: 고생하셨습니다. 제가 궁금했던 부분도 종연님께서 다 체크해주셔서 이외 별다른 수정사항은 없는 것 같습니다. 정말 고생많으셨습니다! 👍😊👍
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T05:42:39Z
user: login: yeonic
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T06:39:15Z

Review comments:
diff_hunk: @@ -0,0 +1,108 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public String signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmail(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+
+        return "회원가입이 완료되었습니다";
path: src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
user: login: friedSaewoo
body: 현재 응답으로 문자열을 리턴하고 있는데, void 또는 ResponseEntity<Void>로 처리하고 HTTP 상태 코드만으로 성공 여부를 전달하는 방식이 더 명확할 수 있을 것 같습니다.
추가로, 회원가입과 동시에 로그인이 이루어지는 구조라면,  JWT를 바로 반환하는 것도 고려해볼 수 있을 것 같습니다.
created_at: 2025-04-09T05:08:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,108 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public String signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmail(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+
+        return "회원가입이 완료되었습니다";
path: src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
user: login: yeonic
body: 만약에 별다른 정보를 내려주지 않기로 결정했다면,
201 Created와 함께 빈 배열을 주는 게 좋을 것 같습니다 :)
created_at: 2025-04-09T05:08:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,108 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public String signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmail(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+
+        return "회원가입이 완료되었습니다";
path: src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
user: login: sshkim1013
body: 아 수정한다는 걸 깜빡했네요... 감사합니당 바로 수정하겠습니다:)
created_at: 2025-04-09T05:10:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,108 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public String signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmail(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+
+        return "회원가입이 완료되었습니다";
path: src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
user: login: sshkim1013
body: 좋은 피드백 감사드립니다 :) 종연님도 피드백 주신 부분이라 신속히 수정해보겠습니다 감사합니다!
created_at: 2025-04-09T05:12:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,108 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public String signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmail(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+
+        return "회원가입이 완료되었습니다";
+    }
+
+    @Transactional
+    public SigninResponse login(SigninRequest signinRequest) {
+
+        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
+                () -> new ClientException(ErrorCode.USER_NOT_FOUND)
+        );
+
+        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.INVALID_PASSWORD);
+        }
+
+        String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getNickname());
+        String jwt = jwtUtil.substringToken(bearerToken);
+
+        return new SigninResponse(jwt);
path: src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
user: login: yeonic
body: 토큰을 헤더로 내려주는 방식도 한 번 고려해보시면 좋을 것 같습니다 👍 
관련한 글 하나 추천드립니다!
https://ha-jenong.tistory.com/142
created_at: 2025-04-09T05:12:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,108 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public String signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmail(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+
+        return "회원가입이 완료되었습니다";
+    }
+
+    @Transactional
+    public SigninResponse login(SigninRequest signinRequest) {
+
+        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
+                () -> new ClientException(ErrorCode.USER_NOT_FOUND)
+        );
+
+        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.INVALID_PASSWORD);
+        }
+
+        String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getNickname());
+        String jwt = jwtUtil.substringToken(bearerToken);
+
+        return new SigninResponse(jwt);
+    }
+
+    @Transactional
+    public void logout(String authorizationHeader) {
+
+        // Authorization 헤더에서 토큰 추출
+        if (!StringUtils.hasText(authorizationHeader) || !authorizationHeader.startsWith("Bearer ")) {
+            throw new ClientException(ErrorCode.INVALID_TOKEN);
+        }
+        String token = authorizationHeader.substring(7);
+
+        // 토큰 유효성 검사
+        if (!jwtUtil.validateToken(token)) {
+            throw new ClientException(ErrorCode.INVALID_TOKEN);
+        }
+
+        String isBlacklisted = redisTemplate.opsForValue().get("BLACKLIST:" + token);
path: src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
user: login: yeonic
body: 이쪽은 redisTemplate의 setIfAbsent로 구현해보는 것도 좋은 방법일 것 같습니다.
`조회 -> 검증 -> 블랙리스트 반영` 이 코드가 atomic하지 않아서요!
created_at: 2025-04-09T05:15:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,108 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public String signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmail(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+
+        return "회원가입이 완료되었습니다";
+    }
+
+    @Transactional
+    public SigninResponse login(SigninRequest signinRequest) {
+
+        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
+                () -> new ClientException(ErrorCode.USER_NOT_FOUND)
+        );
+
+        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.INVALID_PASSWORD);
+        }
+
+        String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getNickname());
+        String jwt = jwtUtil.substringToken(bearerToken);
+
+        return new SigninResponse(jwt);
+    }
+
+    @Transactional
+    public void logout(String authorizationHeader) {
+
+        // Authorization 헤더에서 토큰 추출
+        if (!StringUtils.hasText(authorizationHeader) || !authorizationHeader.startsWith("Bearer ")) {
+            throw new ClientException(ErrorCode.INVALID_TOKEN);
+        }
+        String token = authorizationHeader.substring(7);
+
+        // 토큰 유효성 검사
+        if (!jwtUtil.validateToken(token)) {
+            throw new ClientException(ErrorCode.INVALID_TOKEN);
+        }
+
+        String isBlacklisted = redisTemplate.opsForValue().get("BLACKLIST:" + token);
path: src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
user: login: sshkim1013
body: 넵! 주신 말씀 반영해보도록 하겠습니다 :) 감사합니닷
created_at: 2025-04-09T05:17:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,108 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.request.SigninRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.SignupRequest;
+import com.fifteen.auction.domain.user.auth.dto.request.WithdrawRequest;
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.TimeUnit;
+
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+    private final JwtUtil jwtUtil;
+    private final PasswordEncoder passwordEncoder;
+    private final UserRepository userRepository;
+    private final RedisTemplate<String, String> redisTemplate;
+
+    @Transactional
+    public String signup(SignupRequest signupRequest) {
+
+        if (userRepository.existsByEmail(signupRequest.getEmail())) {
+            throw new ClientException(ErrorCode.DUPLICATE_EMAIL);
+        }
+
+        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
+
+        User user = new User(signupRequest.getEmail(),
+                             signupRequest.getNickname(),
+                             signupRequest.getName(),
+                             signupRequest.getGender(),
+                             signupRequest.getAgeGroup(),
+                             encodedPassword,
+                             signupRequest.getAddress(),
+                             signupRequest.getContactNumber(),
+                             signupRequest.getPreferCategory(),
+                             signupRequest.getAccountNumber());
+
+        userRepository.save(user);
+
+        return "회원가입이 완료되었습니다";
+    }
+
+    @Transactional
+    public SigninResponse login(SigninRequest signinRequest) {
+
+        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
+                () -> new ClientException(ErrorCode.USER_NOT_FOUND)
+        );
+
+        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.INVALID_PASSWORD);
+        }
+
+        String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getNickname());
+        String jwt = jwtUtil.substringToken(bearerToken);
+
+        return new SigninResponse(jwt);
+    }
+
+    @Transactional
+    public void logout(String authorizationHeader) {
+
+        // Authorization 헤더에서 토큰 추출
+        if (!StringUtils.hasText(authorizationHeader) || !authorizationHeader.startsWith("Bearer ")) {
+            throw new ClientException(ErrorCode.INVALID_TOKEN);
+        }
+        String token = authorizationHeader.substring(7);
+
+        // 토큰 유효성 검사
+        if (!jwtUtil.validateToken(token)) {
+            throw new ClientException(ErrorCode.INVALID_TOKEN);
+        }
+
+        String isBlacklisted = redisTemplate.opsForValue().get("BLACKLIST:" + token);
+        if ("logout".equals(isBlacklisted)) {
+            throw new ClientException(ErrorCode.ALREADY_LOGOUT);  // 커스텀 예외 코드 사용
+        }
+
+        // Redis에 블랙리스트 등록
+        Long expiration = jwtUtil.getTokenExpiration(token);
+        redisTemplate.opsForValue().set("BLACKLIST:" + token, "logout", expiration, TimeUnit.MILLISECONDS);
+    }
+
+    @Transactional
+    public void withdraw(WithdrawRequest withdrawRequest) {
+        User user = userRepository.findByEmail(withdrawRequest.getEmail()).orElseThrow(
+                () -> new ClientException(ErrorCode.USER_NOT_FOUND)
+        );
+
+        if (!passwordEncoder.matches(withdrawRequest.getPassword(), user.getPassword())) {
+            throw new ClientException(ErrorCode.INVALID_PASSWORD);
+        }
+
+        userRepository.delete(user);
path: src/main/java/com/fifteen/auction/domain/user/auth/service/AuthService.java
user: login: yeonic
body: delete가 hard delete인 것 같은데, 의도하신 걸까요?
created_at: 2025-04-09T05:19:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+package com.fifteen.auction.domain.user.auth.service;
+
+import com.fifteen.auction.domain.user.auth.dto.response.SigninResponse;
+import com.fifteen.auction.domain.user.auth.util.JwtUtil;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+public class OAuthService {
+
+    private final UserRepository userRepository;
+    private final JwtUtil jwtUtil;
+
+    @Value("${jwt.security.oauth2.client.registration.google.client-id}")
+    private String clientId;
+
+    @Value("${jwt.security.oauth2.client.registration.google.client-secret}")
+    private String clientSecret;
+
+    @Value("${jwt.security.oauth2.client.registration.google.redirect-uri}")
+    private String redirectUri;
+
+    public SigninResponse loginWithGoogle(String code) {
+
+        RestTemplate restTemplate = new RestTemplate();
+
+        String tokenUri = "https://oauth2.googleapis.com/token";
+
+        // 요청 바디 구성
+        String tokenRequestBody = String.format(
+                "code=%s&client_id=%s&client_secret=%s&redirect_uri=%s&grant_type=authorization_code",
+                code, clientId, clientSecret, redirectUri
+        );
+
+        // 요청 헤더 설정
path: src/main/java/com/fifteen/auction/domain/user/auth/service/OAuthService.java
user: login: yeonic
body: 이 부분 대정님께도 공유드렸던 부분인데, 성능 향상 세션에서 OpenFeign 활용하면 코드 깔끔해질 것 같습니다!! 

https://velog.io/@choidongkuen/FeignClient-%EC%82%AC%EC%9A%A9%EB%B2%95%EC%97%90-%[…]5%8C%EC%95%84%EB%B4%85%EC%8B%9C%EB%8B%A4.-Spring-Cloud-OpenFeign

created_at: 2025-04-09T05:20:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,91 @@
+package com.fifteen.auction.domain.user.service;
+
+import com.fifteen.auction.domain.user.auth.entity.AuthUser;
+import com.fifteen.auction.domain.user.dto.request.UserUpdatePasswordRequest;
+import com.fifteen.auction.domain.user.dto.request.UserUpdateRequest;
+import com.fifteen.auction.domain.user.dto.response.UserResponse;
+import com.fifteen.auction.domain.user.dto.response.UserUpdateResponse;
+import com.fifteen.auction.domain.user.entity.User;
+import com.fifteen.auction.domain.user.repository.UserRepository;
+import com.fifteen.auction.global.dto.error.ErrorCode;
+import com.fifteen.auction.global.dto.exception.ClientException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class UserService {
+
+    private final UserRepository userRepository;
+    private final PasswordEncoder passwordEncoder;
+
+    @Transactional(readOnly = true)
+    public UserResponse findUser(Long userId) {
+
+        User user = userRepository.findById(userId)
+                .orElseThrow(() -> new ClientException(ErrorCode.USER_NOT_FOUND));
+
+        return new UserResponse(user.getId(), user.getEmail(), user.getNickname(), user.getPreferCategory());
+    }
+
+    @Transactional
+    public UserUpdateResponse updateProfile(Long userId, AuthUser authUser, UserUpdateRequest request) {
path: src/main/java/com/fifteen/auction/domain/user/service/UserService.java
user: login: yeonic
body: userId와 AuthUser 둘 다 받을 필요는 없어 보입니다..!

AuthUser의 id를 활용하면, 아래에서 '본인의 프로필을 수정하는지' 검증할 필요도 없어질 것 같아요.

find를 AuthUser의 id로 하게 될 테니까요!
created_at: 2025-04-09T05:23:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: k1130k
created_at: 2025-04-09T05:13:31Z
body: 처음 구현하시느라 시행착오 많으셨을텐데 고생많으셨습니다 👍
author_association: MEMBER
user: login: yeonic
created_at: 2025-04-09T05:25:16Z
body: 리뷰가 좀 많아졌네요ㅠㅠㅠ 구현하시느라 고생하셨습니다!!
처음 다뤄보신다 했는데, 너무너무 잘 이해하고 사용하신 것 같습니다!!
author_association: CONTRIBUTOR