-
Notifications
You must be signed in to change notification settings - Fork 0
✨ Feat: 사용자 정보 수정, 조회 기능 구현 + 사용자 정보 캐시 무효화 #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.be.sportizebe.domain.user.dto.request; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| @Schema(title = "UpdateProfileRequest DTO", description = "프로필 수정 요청") | ||
| public record UpdateProfileRequest( | ||
| @Schema(description = "닉네임", example = "새로운닉네임") | ||
| @NotBlank(message = "닉네임은 필수입니다") | ||
| @Size(min = 2, max = 20, message = "닉네임은 2~20자 사이여야 합니다") | ||
| String nickname, | ||
|
|
||
| @Schema(description = "한줄 소개", example = "안녕하세요! 축구를 좋아합니다.") | ||
| @Size(max = 100, message = "한줄 소개는 100자 이내여야 합니다") | ||
| String introduce | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.be.sportizebe.domain.user.dto.response; | ||
|
|
||
| import com.be.sportizebe.domain.user.entity.User; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import lombok.Builder; | ||
|
|
||
| @Builder | ||
| @Schema(title = "UpdateProfileResponse DTO", description = "프로필 수정 응답") | ||
| public record UpdateProfileResponse( | ||
| @Schema(description = "사용자 ID", example = "1") | ||
| Long userId, | ||
|
|
||
| @Schema(description = "닉네임", example = "새로운닉네임") | ||
| String nickname, | ||
|
|
||
| @Schema(description = "한줄 소개", example = "안녕하세요! 축구를 좋아합니다.") | ||
| String introduce | ||
| ) { | ||
| public static UpdateProfileResponse from(User user) { | ||
| return UpdateProfileResponse.builder() | ||
| .userId(user.getId()) | ||
| .nickname(user.getNickname()) | ||
| .introduce(user.getIntroduce()) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| package com.be.sportizebe.domain.user.dto.response; | ||
|
|
||
| import com.be.sportizebe.domain.user.entity.Gender; | ||
| import com.be.sportizebe.domain.user.entity.SportType; | ||
| import com.be.sportizebe.domain.user.entity.User; | ||
| import com.be.sportizebe.global.cache.dto.UserAuthInfo; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import lombok.Builder; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Builder | ||
| @Schema(title = "UserInfoResponse DTO", description = "사용자 정보 조회 응답") | ||
| public record UserInfoResponse( | ||
| @Schema(description = "사용자 ID", example = "1") | ||
| Long userId, | ||
|
|
||
| @Schema(description = "이메일(아이디)", example = "test@example.com") | ||
| String username, | ||
|
|
||
| @Schema(description = "닉네임", example = "축구왕") | ||
| String nickname, | ||
|
|
||
| @Schema(description = "한줄 소개", example = "안녕하세요!") | ||
| String introduce, | ||
|
|
||
| @Schema(description = "프로필 이미지 URL") | ||
| String profileImage, | ||
|
|
||
| @Schema(description = "성별", example = "MALE") | ||
| Gender gender, | ||
|
|
||
| @Schema(description = "전화번호", example = "010-1234-5678") | ||
| String phoneNumber, | ||
|
|
||
| @Schema(description = "관심 종목") | ||
| List<SportType> interestType | ||
| ) { | ||
| public static UserInfoResponse from(User user) { // DB 조회 | ||
| return UserInfoResponse.builder() | ||
| .userId(user.getId()) | ||
| .username(user.getUsername()) | ||
| .nickname(user.getNickname()) | ||
| .introduce(user.getIntroduce()) | ||
| .profileImage(user.getProfileImage()) | ||
| .gender(user.getGender()) | ||
| .phoneNumber(user.getPhoneNumber()) | ||
| .interestType(user.getInterestType()) | ||
| .build(); | ||
| } | ||
|
|
||
| public static UserInfoResponse from(UserAuthInfo userAuthInfo) { // 캐시메모리 조회 | ||
| return UserInfoResponse.builder() | ||
| .userId(userAuthInfo.getId()) | ||
| .username(userAuthInfo.getUsername()) | ||
| .nickname(userAuthInfo.getNickname()) | ||
| .introduce(userAuthInfo.getIntroduce()) | ||
| .profileImage(userAuthInfo.getProfileImage()) | ||
| .gender(userAuthInfo.getGender()) | ||
| .phoneNumber(userAuthInfo.getPhoneNumber()) | ||
| .interestType(userAuthInfo.getInterestType()) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package com.be.sportizebe.domain.user.entity; | ||
|
|
||
| public enum Gender { | ||
| MALE, | ||
| FEMALE | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,16 @@ | ||
| package com.be.sportizebe.domain.user.service; | ||
|
|
||
| import com.be.sportizebe.domain.user.dto.request.SignUpRequest; | ||
| import com.be.sportizebe.domain.user.dto.request.UpdateProfileRequest; | ||
| import com.be.sportizebe.domain.user.dto.response.ProfileImageResponse; | ||
| import com.be.sportizebe.domain.user.dto.response.SignUpResponse; | ||
| import com.be.sportizebe.domain.user.dto.response.UpdateProfileResponse; | ||
| import com.be.sportizebe.domain.user.dto.response.UserInfoResponse; | ||
| import com.be.sportizebe.domain.user.entity.Role; | ||
| import com.be.sportizebe.domain.user.entity.User; | ||
| import com.be.sportizebe.domain.user.exception.UserErrorCode; | ||
| import com.be.sportizebe.domain.user.repository.UserRepository; | ||
| import com.be.sportizebe.global.cache.service.UserCacheService; | ||
| import com.be.sportizebe.global.exception.CustomException; | ||
| import com.be.sportizebe.global.s3.enums.PathName; | ||
| import com.be.sportizebe.global.s3.service.S3Service; | ||
|
|
@@ -24,6 +28,7 @@ public class UserServiceImpl implements UserService { | |
|
|
||
| private final UserRepository userRepository; | ||
| private final PasswordEncoder passwordEncoder; | ||
| private final UserCacheService userCacheService; | ||
| private final S3Service s3Service; | ||
|
|
||
| @Override | ||
|
|
@@ -46,6 +51,8 @@ public SignUpResponse signUp(SignUpRequest request) { | |
| .username(request.username()) | ||
| .password(encodedPassword) | ||
| .nickname(request.nickName()) | ||
| .phoneNumber(request.phoneNumber()) | ||
| .gender(request.gender()) | ||
| .role(Role.USER) | ||
| .build(); | ||
|
|
||
|
|
@@ -72,8 +79,44 @@ public ProfileImageResponse uploadProfileImage(Long userId, MultipartFile file) | |
| // 사용자 프로필 이미지 URL 업데이트 | ||
| user.updateProfileImage(profileImageUrl); | ||
|
|
||
| // 프로필 이미지가 캐시에 포함되어 있으므로 캐시 무효화 | ||
| userCacheService.evictUserAuthInfo(userId); | ||
|
|
||
| log.info("사용자 프로필 이미지 업로드 완료: userId={}, url={}", userId, profileImageUrl); | ||
|
|
||
| return ProfileImageResponse.from(profileImageUrl); | ||
| } | ||
|
|
||
| @Override | ||
| @Transactional | ||
| public UpdateProfileResponse updateProfile(Long userId, UpdateProfileRequest request) { | ||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| // 닉네임 중복 체크 (자신의 닉네임이 아닌 경우만) | ||
| if (!user.getNickname().equals(request.nickname()) | ||
| && userRepository.existsByNickname(request.nickname())) { | ||
| throw new CustomException(UserErrorCode.DUPLICATE_NICKNAME); | ||
| } | ||
|
Comment on lines
+96
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 닉네임 비교 시 NPE 발생 가능성
🛡️ null 검증 추가 제안+ // 닉네임 필수 검증
+ if (request.nickname() == null || request.nickname().isBlank()) {
+ throw new CustomException(UserErrorCode.INVALID_NICKNAME);
+ }
+
// 닉네임 중복 체크 (자신의 닉네임이 아닌 경우만)
if (!user.getNickname().equals(request.nickname())
&& userRepository.existsByNickname(request.nickname())) {
throw new CustomException(UserErrorCode.DUPLICATE_NICKNAME);
}🤖 Prompt for AI Agents |
||
|
|
||
| user.updateProfile(request.nickname(), request.introduce()); | ||
|
|
||
| // 닉네임이 UserAuthInfo에 포함되어 있으므로 캐시 무효화 | ||
| userCacheService.evictUserAuthInfo(userId); | ||
|
|
||
| log.info("사용자 프로필 수정 완료: userId={}", userId); | ||
|
|
||
| return UpdateProfileResponse.from(user); | ||
| } | ||
|
|
||
| @Override | ||
| public UserInfoResponse getUserInfo(Long userId) { | ||
| // 캐시에서 사용자 정보 조회 (캐시 미스 시 DB 조회 후 캐싱) | ||
| var userAuthInfo = userCacheService.findUserAuthInfoById(userId); | ||
| if (userAuthInfo == null) { | ||
| throw new CustomException(UserErrorCode.USER_NOT_FOUND); | ||
| } | ||
|
|
||
| return UserInfoResponse.from(userAuthInfo); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,32 +1,48 @@ | ||
| package com.be.sportizebe.global.cache.dto; | ||
|
|
||
| import com.be.sportizebe.domain.user.entity.Gender; | ||
| import com.be.sportizebe.domain.user.entity.Role; | ||
| import com.be.sportizebe.domain.user.entity.SportType; | ||
| import com.be.sportizebe.domain.user.entity.User; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.io.Serializable; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * JWT 인증 필터에서 사용할 캐시용 사용자 정보 DTO | ||
| * User 엔티티의 연관관계(posts 등)로 인한 직렬화 문제를 방지 | ||
| * 민감정보(password, refreshToken) 제외 | ||
| */ | ||
| @Getter | ||
| @Builder | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class UserAuthInfo implements Serializable { // Serializable : 직렬화 가능하다라는 마커 표시 | ||
| public class UserAuthInfo implements Serializable { | ||
| private Long id; | ||
| private String username; | ||
| private String nickname; | ||
| private Role role; | ||
| private String profileImage; | ||
| private String introduce; | ||
| private Gender gender; | ||
| private String phoneNumber; | ||
| private List<SportType> interestType; | ||
|
Comment on lines
+29
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find the UserAuthInfo.java file and examine it
fd -name "UserAuthInfo.java" -type fRepository: Sportize/be Length of output: 285 🏁 Script executed: # Find cache and redis configuration files
fd -e yml -e yaml -e properties -type f | head -20Repository: Sportize/be Length of output: 225 🏁 Script executed: # Search for CacheConfig, RedisConfig, or cache-related configuration classes
rg -l 'CacheConfig|RedisConfig|@EnableCaching|@Configuration.*Cache' --type javaRepository: Sportize/be Length of output: 197 🏁 Script executed: # Search for how UserAuthInfo is used in caching context
rg 'UserAuthInfo' --type java -B 2 -A 2 | head -50Repository: Sportize/be Length of output: 5291 🏁 Script executed: # Find SportType class to verify if it's Serializable
fd -name "SportType.java" -type fRepository: Sportize/be Length of output: 285 🏁 Script executed: # Find and read UserAuthInfo.java
fd "UserAuthInfo.java"Repository: Sportize/be Length of output: 121 🏁 Script executed: # Find RedisCacheConfig.java and read it
cat src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.javaRepository: Sportize/be Length of output: 6172 🏁 Script executed: # Find UserCacheService.java and read it
cat src/main/java/com/be/sportizebe/global/cache/service/UserCacheService.javaRepository: Sportize/be Length of output: 1339 🏁 Script executed: # Find SportType.java
fd "SportType.java"Repository: Sportize/be Length of output: 120 🏁 Script executed: # Search for application configuration files
fd "\.(yml|yaml|properties)$" | head -10Repository: Sportize/be Length of output: 153 🏁 Script executed: # Search for any encryption or security configuration related to cache/redis
rg -i "serializer|encryption|password|ssl|secure" --type java | grep -i "redis\|cache"Repository: Sportize/be Length of output: 4104 🏁 Script executed: # Read UserAuthInfo.java
cat src/main/java/com/be/sportizebe/global/cache/dto/UserAuthInfo.javaRepository: Sportize/be Length of output: 1501 🏁 Script executed: # Read SportType.java
cat src/main/java/com/be/sportizebe/domain/user/entity/SportType.javaRepository: Sportize/be Length of output: 154 🏁 Script executed: # Check application configuration files for Redis security settings
cat docker/docker-compose.yml | grep -A 20 redisRepository: Sportize/be Length of output: 37 🏁 Script executed: # Search for any Spring Security or encryption configuration
rg "CommandLineEventPublisher|sslEnabled|requirepass|masterauth" --type javaRepository: Sportize/be Length of output: 37 🏁 Script executed: # Check for any mention of encryption in cache/redis context
rg -i "encrypt|cipher|secret.*redis|redis.*secret" --type javaRepository: Sportize/be Length of output: 37
현재 TTL은 5분으로 적절히 설정되어 있습니다. 🤖 Prompt for AI Agents |
||
|
|
||
| public static UserAuthInfo from(User user) { | ||
| return new UserAuthInfo( | ||
| user.getId(), | ||
| user.getUsername(), | ||
| user.getNickname(), | ||
| user.getRole() | ||
| ); | ||
| return UserAuthInfo.builder() | ||
| .id(user.getId()) | ||
| .username(user.getUsername()) | ||
| .nickname(user.getNickname()) | ||
| .role(user.getRole()) | ||
| .profileImage(user.getProfileImage()) | ||
| .introduce(user.getIntroduce()) | ||
| .gender(user.getGender()) | ||
| .phoneNumber(user.getPhoneNumber()) | ||
| .interestType(user.getInterestType()) | ||
| .build(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
성별 필드에 null 검증이 필요합니다.
gender가 null이면 DB의nullable=false제약과 충돌해 런타임 예외가 발생할 수 있습니다. 입력 단계에서 차단해 주세요.✅ 수정 제안
🤖 Prompt for AI Agents