Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,8 +1,11 @@
package com.be.sportizebe.domain.user.controller;

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.service.UserServiceImpl;
import com.be.sportizebe.global.response.BaseResponse;
import com.be.sportizebe.global.cache.dto.UserAuthInfo;
Expand Down Expand Up @@ -42,4 +45,23 @@ public ResponseEntity<BaseResponse<ProfileImageResponse>> uploadProfileImage(
ProfileImageResponse response = userService.uploadProfileImage(userAuthInfo.getId(), file);
return ResponseEntity.ok(BaseResponse.success("프로필 사진 업로드 성공", response));
}

@PutMapping("/profile")
@Operation(summary = "프로필 수정", description = "닉네임, 한줄소개를 수정합니다.")
public ResponseEntity<BaseResponse<UpdateProfileResponse>> updateProfile(
@AuthenticationPrincipal UserAuthInfo userAuthInfo,
@RequestBody @Valid UpdateProfileRequest request
) {
UpdateProfileResponse response = userService.updateProfile(userAuthInfo.getId(), request);
return ResponseEntity.ok(BaseResponse.success("프로필 수정 성공", response));
}

@GetMapping("/me")
@Operation(summary = "내 정보 조회", description = "현재 로그인한 사용자의 정보를 조회합니다.")
public ResponseEntity<BaseResponse<UserInfoResponse>> getMyInfo(
@AuthenticationPrincipal UserAuthInfo userAuthInfo
) {
UserInfoResponse response = userService.getUserInfo(userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("사용자 정보 조회 성공", response));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.be.sportizebe.domain.user.dto.request;

import com.be.sportizebe.domain.user.entity.Gender;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
Expand All @@ -18,6 +19,13 @@ public record SignUpRequest(

@Schema(description = "닉네임", example = "스포티")
@NotBlank(message = "사용할 닉네임을 입력해주세요.")
String nickName
String nickName,

@Schema(description = "성별", example = "MALE")
Gender gender,

@Schema(description = "전화번호", example = "010-xxxx-xxxx")
@NotBlank(message = "전화번호를 입력해주세요")
String phoneNumber
Comment on lines +24 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

성별 필드에 null 검증이 필요합니다.

gender가 null이면 DB의 nullable=false 제약과 충돌해 런타임 예외가 발생할 수 있습니다. 입력 단계에서 차단해 주세요.

✅ 수정 제안
 import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Size;
@@
     `@Schema`(description = "성별", example = "MALE")
+    `@NotNull`(message = "성별을 선택해주세요")
     Gender gender,
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/user/dto/request/SignUpRequest.java`
around lines 24 - 29, The SignUpRequest DTO's Gender gender field lacks null
validation which can violate the DB nullable=false constraint; add a validation
annotation (e.g., `@NotNull`) to the gender field in SignUpRequest and provide a
clear message (e.g., "성별을 입력해주세요"), and ensure the corresponding import for the
validation annotation is added so incoming requests with null gender are
rejected before persisting.

) {
}
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
}
17 changes: 17 additions & 0 deletions src/main/java/com/be/sportizebe/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ public class User extends BaseTimeEntity {

private String profileImage; // 프로필 사진 URL

private String introduce; // 한줄 소개

@Column(nullable = false)
@Enumerated(EnumType.STRING)
private Gender gender; // 성별

@Column(nullable = false, unique = true)
private String phoneNumber; // 전화번호

/*@Column(nullable = false)
private String address; // 주소*/

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<Post> posts = new ArrayList<>(); // 작성한 게시글 목록
Expand All @@ -57,4 +69,9 @@ public void updateRefreshToken(String refreshToken) {
public void updateProfileImage(String profileImage) {
this.profileImage = profileImage;
}

public void updateProfile(String nickname, String introduce) {
this.nickname = nickname;
this.introduce = introduce;
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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 org.springframework.web.multipart.MultipartFile;

public interface UserService {
Expand All @@ -12,4 +15,10 @@ public interface UserService {

// 프로필 사진 업로드
ProfileImageResponse uploadProfileImage(Long userId, MultipartFile file);

// 프로필 수정 (닉네임, 한줄소개)
UpdateProfileResponse updateProfile(Long userId, UpdateProfileRequest request);

// 사용자 정보 조회
UserInfoResponse getUserInfo(Long userId);
}
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;
Expand All @@ -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
Expand All @@ -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();

Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

닉네임 비교 시 NPE 발생 가능성

request.nickname()이 null인 경우 user.getNickname().equals(request.nickname())에서 문제가 발생하지 않지만, 순서를 바꾸면 request.nickname()이 null일 때 NPE가 발생합니다. 또한 현재 로직은 null 닉네임을 허용하는데, 닉네임이 @Column(nullable = false)로 정의되어 있으므로 null 검증이 필요합니다.

🛡️ 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
In `@src/main/java/com/be/sportizebe/domain/user/service/UserServiceImpl.java`
around lines 96 - 100, The nickname-comparison can throw NPE and allows null
nicknames despite the DB column being non-nullable; in UserServiceImpl validate
request.nickname() is not null/blank before comparing, throw an appropriate
exception if it is null, and use a null-safe comparison (e.g., Objects.equals or
compare user.getNickname() to request.nickname() only after null-check) prior to
calling userRepository.existsByNickname(request.nickname()) to check duplicates
and potentially throw CustomException(UserErrorCode.DUPLICATE_NICKNAME.


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);
}
}
30 changes: 23 additions & 7 deletions src/main/java/com/be/sportizebe/global/cache/dto/UserAuthInfo.java
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find the UserAuthInfo.java file and examine it
fd -name "UserAuthInfo.java" -type f

Repository: Sportize/be

Length of output: 285


🏁 Script executed:

# Find cache and redis configuration files
fd -e yml -e yaml -e properties -type f | head -20

Repository: 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 java

Repository: 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 -50

Repository: Sportize/be

Length of output: 5291


🏁 Script executed:

# Find SportType class to verify if it's Serializable
fd -name "SportType.java" -type f

Repository: 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.java

Repository: 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.java

Repository: 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 -10

Repository: 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.java

Repository: Sportize/be

Length of output: 1501


🏁 Script executed:

# Read SportType.java
cat src/main/java/com/be/sportizebe/domain/user/entity/SportType.java

Repository: Sportize/be

Length of output: 154


🏁 Script executed:

# Check application configuration files for Redis security settings
cat docker/docker-compose.yml | grep -A 20 redis

Repository: Sportize/be

Length of output: 37


🏁 Script executed:

# Search for any Spring Security or encryption configuration
rg "CommandLineEventPublisher|sslEnabled|requirepass|masterauth" --type java

Repository: 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 java

Repository: Sportize/be

Length of output: 37


phoneNumber PII 캐싱 시 암호화 전략 필요

phoneNumber는 민감정보로 현재 Redis에 평문(JSON)으로 저장되고 있습니다. RedisCacheConfig에서 Jackson2JsonRedisSerializer를 사용하면서 암호화가 적용되지 않고 있습니다. 다음 중 하나의 조치가 필요합니다:

  1. UserAuthInfo에서 phoneNumber 필드 제거 (필수 아닌 경우)
  2. Redis 저장소에 대한 암호화 전략 구현 (예: 필드 레벨 암호화)
  3. Redis 접근 제어 강화 (requirepass 설정, 네트워크 격리)

현재 TTL은 5분으로 적절히 설정되어 있습니다.

🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/global/cache/dto/UserAuthInfo.java` around
lines 29 - 33, UserAuthInfo currently stores phoneNumber in plaintext and is
serialized by Jackson2JsonRedisSerializer; either remove the phoneNumber field
from the DTO (UserAuthInfo) if it's not required in cache, or implement
encryption: add field-level encryption/decryption by creating a custom Redis
serializer or Jackson (de)serializer for the phoneNumber property and wire it
into RedisCacheConfig (replace Jackson2JsonRedisSerializer for cached
UserAuthInfo or add `@JsonSerialize/`@JsonDeserialize on phoneNumber) so values
are encrypted before persisting and decrypted on read; as an
alternative/complement, ensure Redis server access is hardened (requirepass,
network isolation) in deployment.


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();
}
}