Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d71bfb7
:sparkles: feat: 마이페이지 Response DTO 추가
ojy0903 Feb 6, 2026
cfefe28
:sparkles: feat: 마이페이지 캐싱을 위한 RedisConfig 추가
ojy0903 Feb 6, 2026
f5b69d0
:sparkles: feat: 마이페이지 Entity -> Respone DTO 변환 Converter 메서드 추가
ojy0903 Feb 6, 2026
243fac4
:sparkles: feat: 마이페이지 Service 메서드 추가
ojy0903 Feb 6, 2026
b0b4a43
:sparkles: feat: 마이페이지 API 추가 & Swagger Docs 추가
ojy0903 Feb 6, 2026
d2cc693
Merge remote-tracking branch 'origin/develop' into feat/#31
ojy0903 Feb 9, 2026
f35a7f1
:sparkles: feat: SecurityConfig 마이페이지 접근 시 인증 요구 메서드 추가
ojy0903 Feb 9, 2026
e8f39ec
:sparkles: feat: 마이페이지 Provider enum 에 이메일 로그인 유형 추가
ojy0903 Feb 14, 2026
ae0d98a
:sparkles: feat: 소셜 로그인 Provider 반환 로직 추가
ojy0903 Feb 14, 2026
f9414ab
:sparkles: feat: 일반 이메일 로그인 Provider 반환 로직 추가
ojy0903 Feb 14, 2026
b15de47
:sparkles: feat: JWT 토큰에 Provider 정보 추가, JWT 필터에서 Provider 를 올바르게 반환하…
ojy0903 Feb 14, 2026
c039cc4
:sparkles: feat: 마이페이지 Response DTO 에 Provider 추가 (String 으로 출력)
ojy0903 Feb 14, 2026
e30d91b
:sparkles: feat: UserService 마이페이지 조회 캐시 로직 수정, Converter DTO 에 맞게 추가
ojy0903 Feb 14, 2026
3fcd035
:sparkles: feat: 마이페이지 API Controller 에 추가
ojy0903 Feb 14, 2026
b88478b
:sparkles: feat: 미사용 import 문 제거
ojy0903 Feb 14, 2026
41f9655
:sparkles: feat: 디버그용 출력 코드 제거
ojy0903 Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.whereyouad.WhereYouAd.domains.user.application.dto.response;

public record MyPageResponse(
Long userId,
String email,
String name,
String profileImageUrl,
String phoneNumber,
boolean isEmailVerified,
String providerType
) {
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.whereyouad.WhereYouAd.domains.user.application.mapper;

import com.whereyouad.WhereYouAd.domains.user.application.dto.response.MyPageResponse;
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse;
import com.whereyouad.WhereYouAd.domains.user.domain.constant.UserStatus;
import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User;
Expand Down Expand Up @@ -32,4 +33,16 @@ public static OAuth2UserInfo toOAuth2UserInfo(User user, OAuth2Response oAuth2Re
.provider(oAuth2Response.getProvider())
.build();
}

public static MyPageResponse toMyPageResponse(User user, String provider) {

return new MyPageResponse(user.getId(),
user.getEmail(),
user.getName(),
user.getProfileImageUrl(),
user.getPhoneNumber(),
user.isEmailVerified(),
provider
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
@Getter
@RequiredArgsConstructor
public enum Provider {
EMAIL("email", "이메일"),
GOOGLE("google", "구글"),
NAVER("naver", "네이버"),
KAKAO("kakao", "카카오");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.whereyouad.WhereYouAd.domains.user.domain.service;

import com.whereyouad.WhereYouAd.domains.user.application.dto.response.MyPageResponse;
import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler;
import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode;
import com.whereyouad.WhereYouAd.domains.user.domain.constant.UserStatus;
Expand All @@ -10,6 +11,7 @@
import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository;
import com.whereyouad.WhereYouAd.global.utils.RedisUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -85,4 +87,21 @@ public void passwordReset(String email, String password) {

redisUtil.deleteData("VERIFIED:" + email);
}

/**
* 마이페이지 조회
* 파라미터 추가: userId 외에 'provider'(로그인 유형) 도 받기
* 캐시 키 수정: key = "#userId + ':' + #provider"
* 같은 유저(userId=1)라도 '구글'로 로그인했을 때와 '이메일'로 로그인했을 때
* 응답 데이터(MyPageResponse의 provider 필드)가 다르므로 캐시를 구분해야 합니다.
* 예) user:profile::1:GOOGLE / user:profile::1:EMAIL 로 따로 저장됨.
*/
@Cacheable(value = "user:profile", key = "#userId + ':' + #provider", unless = "#result == null")
@Transactional(readOnly = true)
public MyPageResponse getMyPage(Long userId, String provider) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND));

return UserConverter.toMyPageResponse(user, provider);
}
Comment on lines +91 to +106
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

같은 유저인데 provider별로 캐시를 분리하는 것이 정말 필요한지 검토해 주세요.

현재 캐시 키가 userId + ':' + provider이므로 같은 유저(userId=1)가 이메일 로그인, 구글 로그인할 때마다 별도 캐시 엔티리가 생깁니다. 하지만 MyPageResponse에서 provider 외의 필드(email, name, phoneNumber 등)는 모두 DB의 동일한 User 엔티티에서 가져오므로, 프로필 업데이트 시 모든 provider 변형의 캐시를 각각 evict해야 데이터 불일치를 방지할 수 있습니다.

예를 들어 유저가 이름을 변경하면 user:profile::1:EMAIL, user:profile::1:GOOGLE 등을 모두 무효화해야 하는데, 이는 관리가 복잡해집니다.

대안으로, provider 정보는 캐시에 포함하지 않고 캐시 키를 userId로만 설정한 뒤, 응답 조립 시 provider를 동적으로 추가하는 방식을 고려해 보세요:

💡 캐시 키 단순화 예시
// UserService
`@Cacheable`(value = "user:profile", key = "#userId", unless = "#result == null")
`@Transactional`(readOnly = true)
public MyPageResponse getMyPage(Long userId, Provider provider) {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND));
    return UserConverter.toMyPageResponse(user, provider.name());
}

단, 이 경우에도 provider가 응답에 포함되면 캐시된 값과 현재 로그인 provider가 다를 수 있으므로, provider를 캐시 대상에서 분리하는 구조가 더 적합합니다:

// 캐시는 User 정보만, provider는 컨트롤러에서 조립
`@Cacheable`(value = "user:profile", key = "#userId")
public UserProfileCache getUserProfile(Long userId) { ... }

// 컨트롤러에서
UserProfileCache cached = userService.getUserProfile(userId);
MyPageResponse response = MyPageResponse.of(cached, provider);
🤖 Prompt for AI Agents
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java`
around lines 91 - 106, The cache key currently includes provider in getMyPage
(Cacheable key = "#userId + ':' + `#provider`"), which creates separate cache
entries per provider and forces complex invalidation; either stop including
provider in the cache key and cache only by userId (change `@Cacheable` key to use
only userId and return/stash only user fields, then add provider via
UserConverter.toMyPageResponse or assemble in the controller), or keep provider
in the key but implement eviction that removes all provider variants when user
data changes; locate getMyPage and UserConverter.toMyPageResponse to implement
the chosen approach and ensure the cached value does not embed provider-specific
state so profile updates only require a single eviction.

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SmsRequest;
import com.whereyouad.WhereYouAd.domains.user.application.dto.request.PwdResetRequest;
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse;
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.MyPageResponse;
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SmsResponse;
import com.whereyouad.WhereYouAd.domains.user.domain.service.EmailService;
import com.whereyouad.WhereYouAd.domains.user.domain.service.SmsService;
Expand All @@ -12,13 +13,12 @@
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse;
import com.whereyouad.WhereYouAd.domains.user.presentation.docs.UserControllerDocs;
import com.whereyouad.WhereYouAd.global.response.DataResponse;
import com.whereyouad.WhereYouAd.global.security.jwt.CustomUserDetails;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
Expand Down Expand Up @@ -84,4 +84,17 @@ public ResponseEntity<DataResponse<String>> resetPassword(@RequestBody @Valid Pw
DataResponse.from("비밀번호 변경이 완료되었습니다.")
);
}

@GetMapping("/my")
public ResponseEntity<DataResponse<MyPageResponse>> getMyPage(@AuthenticationPrincipal CustomUserDetails userDetails) {

MyPageResponse response = userService.getMyPage(
userDetails.getUserId(),
userDetails.getProvider().name()
);

return ResponseEntity.ok(
DataResponse.from(response)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
import com.whereyouad.WhereYouAd.domains.user.application.dto.request.PwdResetRequest;
import com.whereyouad.WhereYouAd.domains.user.application.dto.request.SignUpRequest;
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.EmailSentResponse;
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.MyPageResponse;
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SmsResponse;
import com.whereyouad.WhereYouAd.domains.user.application.dto.response.SignUpResponse;
import com.whereyouad.WhereYouAd.global.response.DataResponse;
import com.whereyouad.WhereYouAd.global.security.jwt.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.RequestBody;

public interface UserControllerDocs {
Expand Down Expand Up @@ -85,4 +88,15 @@ public ResponseEntity<DataResponse<SmsResponse.SmsSentResponse>> sendSms(
})
public ResponseEntity<DataResponse<SmsResponse.SmsVerifiedResponse>> verifySms(
@RequestBody @Valid SmsRequest.SmsVerifyRequest request);

@Operation(
summary = "마이페이지 API",
description = "Authorization : Bearer \\<AccessToken\\> 을 헤더로 받아 현재 로그인한 회원의 정보를 조회합니다.\n\n" +
"회원 DB id,이메일, 이름, 프로필 이미지 URL, 전화번호, 이메일 인증 여부(true / false), 로그인 Provider(EMAIL, KAKAO, NAVER, GOOGLE) 를 반환합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "404_1", description = "해당 사용자 존재하지 않음")
})
public ResponseEntity<DataResponse<MyPageResponse>> getMyPage(@AuthenticationPrincipal CustomUserDetails userDetails);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.whereyouad.WhereYouAd.global.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration
@EnableCaching
public class RedisConfig {

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Redis 캐시 설정 정의
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// Key 직렬화: String ("user:profile::1" 처럼 보기 좋게 저장)
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
// Value 직렬화: JSON (자바 객체 -> JSON 변환 저장)
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
// TTL 설정: 데이터 유효 시간 (30분)
.entryTtl(Duration.ofMinutes(30))
// null 데이터는 캐싱하지 않음
.disableCachingNullValues();

return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() //swagger 접근 허용
.requestMatchers("/api/users/my").authenticated() //마이페이지는 인증 필요
.requestMatchers("/api/users/**", "/api/auth/**").permitAll() //로그인, 회원가입, 이메일 인증 접근 허용
.anyRequest().authenticated() //이외 접근은 인증 필요
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.whereyouad.WhereYouAd.global.security.jwt;

import com.whereyouad.WhereYouAd.domains.user.domain.constant.Provider;
import com.whereyouad.WhereYouAd.domains.user.domain.constant.UserStatus;
import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User;
import lombok.Getter;
Expand All @@ -10,13 +11,13 @@

import java.util.Collection;
import java.util.Collections;
import java.util.List;

@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

private final User user;
private final Provider provider;

// 일단 User 엔티티에 권한 구분(ADMIN / USER) 가 없기도 하고,
// 팀장, 멤버등의 역할 구분은 2차 MVP 에서 진행한다.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.whereyouad.WhereYouAd.global.security.jwt;

import com.whereyouad.WhereYouAd.domains.user.domain.constant.Provider;
import com.whereyouad.WhereYouAd.domains.user.exception.code.AuthErrorCode;
import com.whereyouad.WhereYouAd.domains.user.persistence.entity.User;
import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository;
Expand All @@ -22,6 +23,6 @@ public UserDetails loadUserByUsername(String email) {
User user = userRepository.findUserByEmail(email)
.orElseThrow(() -> new AppException(AuthErrorCode.USER_NOT_FOUND));

return new CustomUserDetails(user);
return new CustomUserDetails(user, Provider.EMAIL);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.whereyouad.WhereYouAd.global.security.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.whereyouad.WhereYouAd.domains.user.domain.constant.Provider;
import com.whereyouad.WhereYouAd.domains.user.exception.code.AuthErrorCode;
import com.whereyouad.WhereYouAd.global.response.ErrorResponse;
import io.jsonwebtoken.ExpiredJwtException;
Expand All @@ -12,7 +13,6 @@
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
Expand Down Expand Up @@ -44,13 +44,19 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse

//토큰에서 email 값 추출
String email = jwtTokenProvider.getSubject(token);
String providerStr = jwtTokenProvider.getProvider(token);
//& email 값으로 DB 내 해당 email 로 가입한 회원 존재하는지 확인
UserDetails userDetails = customUserDetailService.loadUserByUsername(email);
CustomUserDetails userDetails = (CustomUserDetails) customUserDetailService.loadUserByUsername(email);

CustomUserDetails finalUserDetails = new CustomUserDetails(
userDetails.getUser(),
Provider.valueOf(providerStr)
);

//Spring Security 가 인식 가능한 인증 객체(Authentication) 생성
//이미 인증된 상태에서 Security 가 인식 가능하게 만드는 것 임으로 비밀번호(credentials) 필드는 null
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
new UsernamePasswordAuthenticationToken(finalUserDetails, null, finalUserDetails.getAuthorities());

//SecurityContextHolder 에 인증 객체 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.whereyouad.WhereYouAd.global.security.jwt;

import com.whereyouad.WhereYouAd.domains.user.domain.constant.Provider;
import com.whereyouad.WhereYouAd.global.security.jwt.dto.TokenResponse;
import com.whereyouad.WhereYouAd.global.security.oauth2.dto.CustomOAuth2User;
import io.jsonwebtoken.*;
Expand All @@ -22,6 +23,7 @@ public class JwtTokenProvider {

//JWT 토큰 내 권한 정보를 담을 때 사용하는 key 값
private static final String AUTHORITIES_KEY = "auth";
private static final String PROVIDER_KEY = "provider";
//HTTP 헤더에 붙일 타입(Bearer {token})
private static final String BEARER_TYPE = "Bearer";
//AccessToken 만료 시간
Expand All @@ -43,17 +45,26 @@ public TokenResponse generateToken(Authentication authentication) {

// 로그인 종류에 따라 이메일(식별자) 추출
String email;
String provider;

Object principal = authentication.getPrincipal();

if (principal instanceof CustomOAuth2User) {
CustomOAuth2User oAuth2User = (CustomOAuth2User) principal;
// 소셜 로그인: CustomOAuth2User에서 이메일 추출
email = ((CustomOAuth2User) principal).getEmail();
email = oAuth2User.getEmail();

provider = oAuth2User.getProvider().name();
} else if (principal instanceof CustomUserDetails) {
CustomUserDetails userDetails = (CustomUserDetails) principal;
// 일반 로그인: UserDetails의 username(email) 추출
email = ((CustomUserDetails) principal).getUsername();
email = userDetails.getUsername();

provider = userDetails.getProvider().name();
} else {
// 그 외의 경우 (기본값)
email = authentication.getName();
provider = Provider.EMAIL.name();
}
//사용자 권한(ROLE_USER) 가져와서 문자열로 반환
String authorities = authentication.getAuthorities().stream()
Expand All @@ -67,6 +78,7 @@ public TokenResponse generateToken(Authentication authentication) {
String accessToken = Jwts.builder()
.setSubject(email) // Payload "sub": 유저의 이메일(ID)
.claim(AUTHORITIES_KEY, authorities) // Payload "auth": "ROLE_USER"
.claim(PROVIDER_KEY, provider)
.setExpiration(accessTokenExpireIn) // Payload "exp": 만료 시간
.signWith(key, SignatureAlgorithm.HS512) // Header "alg": HS512 알고리즘으로 서명
.compact();
Expand Down Expand Up @@ -118,4 +130,15 @@ private Claims parseClaims(String accessToken) {
return e.getClaims();
}
}

public String getProvider(String token) {
Claims claims = parseClaims(token);
String provider = claims.get(PROVIDER_KEY, String.class);

if (provider == null || provider.isEmpty()) {
return Provider.EMAIL.name();
}

return provider;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.whereyouad.WhereYouAd.global.security.oauth2.dto;

import com.whereyouad.WhereYouAd.domains.user.domain.constant.Provider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
Expand All @@ -12,6 +13,7 @@
public class CustomOAuth2User implements OAuth2User {

private final OAuth2UserInfo authUserDTO;
private final Provider provider;

@Override
public Map<String, Object> getAttributes() {
Expand Down Expand Up @@ -43,4 +45,8 @@ public String getProviderId() {
public String getEmail() {
return authUserDTO.getEmail();
}

public Provider getProvider() {
return provider;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,6 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic

// 공통: OAuth2UserInfo 생성 및 반환
OAuth2UserInfo authUserDTO = UserConverter.toOAuth2UserInfo(user, oAuth2Response);
return new CustomOAuth2User(authUserDTO);
return new CustomOAuth2User(authUserDTO, provider);
}
}