런닝 코스 공유 서비스

[런닝 코스 공유 서비스] - 20. User 도메인 PR 후 리팩토링

sson-coding 2026. 1. 15. 20:45

🏅오늘의 목표

  • 코드래빗의 PR 을 참고해 수정

진행한 작업

  • CustomUserDetails 수정

📃 개발내용

CustomUserDetails - getAuthorities()

기존

Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new GrantedAuthority() {
    @Override
    public @Nullable String getAuthority() {
        return user.getRole().toString();
    }
});
return authorities;
  • 문제점
    1. 불필요한 익명 클래스
      1. GrantedAuthority 를 구현하는 익명 클래스를 매번 생성
    2. 가독성 저하
      1. 단순한 기능을 복잡하게 표현
    3. 메모리 비효율
      1. 매번 새로운 ArrayList 와 익명 클래스 객체를 생성
    4. @Nullable 반환
      1. getAuthority 가 null 을 반환할 수 있어 불안정

수정

@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return Collections.singletonList(
			new SimpleGrantedAuthority(user.getRole().toString())
		);
	}

  • 수정 이유
    1. SpringSecurity 표준 사용
      1. SimpleGrantedAuthority 표준 구현체
    2. 불변 리스트
      1. Collections.singletonList() : 불변 리스트를 반환하여 안전성 보장
    3. 메모리 효율
      1. 불변 싱글톤 리스트로 메모리 사용 최소화
    4. 가독성 향상
  • Collections.singletonList()를 선택한 이유
    1. 불변성 보장
      1. 반환된 리스트는 수정 불가능
    2. 메모리 효율
    3. 의도 명확성
      1. 단 하나의 권한만 존재함을 명시

CustomUserDetails - getName()

기존

public @Nullable String getName() {
    return user.getProfile().getName();
}
  • 문제점
    1. NullPointException 위험
      1. user.getProfile 이 null 일 경우 NPE 발생
    2. OAuth2 재로그인
      1. 기존 사용자가 Profile 없이 재로그인하면 에러
    3. 데이터 불일치
      1. 신규 사용자는 Profile 이 있지만 기존 사용자는 없을 수 있음

수정

public String getName(){
		// Profile 자체가 null 인지 체크
		if (user.getProfile() == null) {
			return user.getEmail();
		}
		// Profile 은 있지만 name 은 null 인 경우
		return user.getProfile().getName() != null ?  user.getProfile().getName() : user.getEmail();
	}
  • 수정 이유
    1. NPE 방어
      1. 2단계 null 체크로 대응
    2. 안전한 fallback
      1. email 은 필수 이므로 항상 존재 보장
    3. 디버깅 용이
      1. 예외 발생 대신 email 반환으로 문제 파악 가능

CustomUserDetails - @Nullable 어노테이션

기존

public @Nullable UUID getUserUuid() {
    return user.getUuid();
}
public @Nullable String getEmail() {
    return user.getEmail();
}
public @Nullable String getName() {
    return user.getProfile().getName();
}
public @Nullable Role getRole() {
    return user.getRole();
}

수정

public UUID getUserUuid(){
		return user.getUuid();
	}
public String getEmail(){
		return user.getEmail();
	}
public String getName(){
		// Profile 자체가 null 인지 체크
		if (user.getProfile() == null) {
			return user.getEmail();
		}
		// Profile 은 있지만 name 은 null 인 경우
		return user.getProfile().getName() != null ?  user.getProfile().getName() : user.getEmail();
	}
public Role getRole(){
		return user.getRole();
	}

쿠키 설정 - 외부화

application.yml

  jwt:
    cookie:
      secure: false
      same-site: Lax
      http-only: true
      max-age-days: 7

JwtCookieProperties

@Getter
@ConfigurationProperties(prefix = "jwt.cookie")
public class JwtCookieProperties {

	private final boolean secure;
	private final String sameSite;
	private final boolean httpOnly;
	private final long maxAgeDays;

	public JwtCookieProperties(
		@DefaultValue("true") boolean secure,
		@DefaultValue("Strict") String sameSite,
		@DefaultValue("true") boolean httpOnly,
		@DefaultValue("7") long maxAgeDays) {
		this.secure = secure;
		this.sameSite = sameSite;
		this.httpOnly = httpOnly;
		this.maxAgeDays = maxAgeDays;
	}

	public ResponseCookie createCookie(String name, String value) {
		return ResponseCookie.from(name, value)
			.httpOnly(this.httpOnly)
			.secure(this.secure)
			.path("/")
			.maxAge(TimeUnit.DAYS.toSeconds(this.maxAgeDays))
			.sameSite(this.sameSite)
			.build();
	}
}

JwtConfig

@Configuration
@EnableConfigurationProperties(JwtCookieProperties.class)
public class JwtConfig {
}

OAuth2SuccessHandler

ResponseCookie refreshCookie = jwtCookieProperties.createCookie("refreshToken", refreshToken);

AuthController

// HttpOnly 쿠키로 새 Refresh Token 설정
ResponseCookie responseCookie = jwtCookieProperties.createCookie("refreshToken", newRefreshToken);

CustomUsernamePasswordAuthenticationFilter

// RefreshToken 응답
ResponseCookie cookie = jwtCookieProperties.createCookie("refreshToken", refreshToken);

CustomUsernamePasswordAuthenticationFilter

로그 수정

// 로그인 요청 시 사용자 인증 처리
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws
		AuthenticationException {
		// 로그인 시도
		String username = obtainUsername(request);
		log.debug("로그인 시도 - username: {}", username);
		String password = obtainPassword(request);

		UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password);

		return authenticationManager.authenticate(authToken);
	}

  • info → debug
    • 개인정보를 위해 debug

예외 발생 시 응답 추가

public class sendErrorResponse {

	public static void sendResponse(HttpServletResponse response, String message) throws IOException {
		response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
		response.setContentType("application/json");
		response.setCharacterEncoding("UTF-8");
		response.getWriter().write("{\\"error\\": \\"" + message + "\\"}");
	}
}

CustomUserDetailsService

기존

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
       log.debug("사용자 조회 시도 : {}",username);

       // 이메일로 사용자 조회
       User user = userRepository.findByEmail(username)
          .orElseThrow(() -> {
             log.warn("사용자를 찾을 수 없습니다 - email: {}", username);
             return new UsernameNotFoundException("사용자를 찾을 수 없습니다. - email : " +username);
          });
       log.debug("사용자 로드 성공 - email: {}", username);

       return new CustomUserDetails(user);
    }
}

수정

@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		log.debug("사용자 조회 시도 : {}",username);

		// 이메일로 사용자 조회
		User user = userRepository.findByEmailAndStatus(username, UserStatus.ACTIVE)
			.orElseThrow(() -> {
				log.warn("ACTIVE 상태의 사용자를 찾을 수 없습니다 - email: {}", username);
				return new UsernameNotFoundException("사용자를 찾을 수 없습니다. - email : " +username);
			});
		log.debug("사용자 로드 성공 - email: {}, status: {}", username,user.getStatus());

		return new CustomUserDetails(user);
	}
  • 이메일과 상태(ACTIVE) 조회

Google,NaverOAuth2UserService

@Service
@RequiredArgsConstructor
@Transactional
public class NaverOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
- @Transactional 추가
- loginExistingUser 에서 user.updateLoginAt() 을 호출하지만 명시적으로 저장하지 않음
- JPA dirty checking 에 의존하려면 추가해야 함

## EmailVerificationService

```java
// 이메일 인증시 호출
	public void sendVerificationEmail(String email) {
		log.debug("이메일 인증 요청: email={}", email);
  • info → debug

application.yml

app:
  base-url : <http://localhost:8080>
  email:
    verification:
      expiration-minutes: 10
      rate-limit-minutes: 1

AppProperties

@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app")
public class AppProperties {
	private String baseUrl;
	private Email email = new Email();

	@Getter
	@Setter
	public static class Email {
		private Verification verification = new Verification();

		@Getter
		@Setter
		public static class Verification {
			private int expirationMinutes = 10;
			private int rateLimitMinutes = 1;
		}
	}
}

EmailVerificationService

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailVerificationService {
	private final RedisTemplate<String, String> redisTemplate;
	private final JavaMailSender mailSender;
	private final EmailVerificationRepository emailVerificationRepository;
	private final AppProperties appProperties;

	// 이메일 인증시 호출
	public void sendVerificationEmail(String email) {
		log.debug("이메일 인증 요청: email={}", email);

		// 이미 인증된 이메일인지 체크
		emailVerificationRepository.findByEmail(email)
			.filter(EmailVerification::isVerified)
			.ifPresent(emailVerification -> {
				throw new BaseException(UserErrorCode.EMAIL_ALREADY_VERIFIED);
			});

		// 호출 제한 토큰 생성
		String limitKey = "email:limit:"+email;

		// 호출 제한 체크
		if(Boolean.TRUE.equals(redisTemplate.hasKey(limitKey))) {
			throw new BaseException(UserErrorCode.EMAIL_SEND_TOO_FREQUENT);
		}

		// 인증 토큰 생성
		String token = UUID.randomUUID().toString();
		String redisKey = "email:verify:"+token;

		// 설정값 사용
		int expirationMinutes = appProperties.getEmail().getVerification().getExpirationMinutes();
		int rateLimitMinutes = appProperties.getEmail().getVerification().getRateLimitMinutes();

		// Redis 저장
		redisTemplate.opsForValue().set(redisKey, email, expirationMinutes, TimeUnit.MINUTES);

		// 인증 링크 생성
		String link = String.format("%s/email/verify?token=%s", appProperties.getBaseUrl(), token);

		SimpleMailMessage message = new SimpleMailMessage();
		message.setTo(email);
		message.setSubject("[Run-ing] 이메일 인증");
		message.setText(
			"안녕하세요,\\n\\n" +
				"Run-ing 이메일 인증을 위해 아래 링크를 클릭해주세요.\\n\\n" +
				link + "\\n\\n" +
				"이 링크는 " + expirationMinutes + "분간 유효합니다.\\n" +
				"본인이 요청하지 않았다면 이 메일을 무시해주세요."
		);

		try{
			mailSender.send(message);
			// 호출 제한 토큰 저장 -> 이 키가 존재하는 동안 재발송 불가 (1분)
			redisTemplate.opsForValue().set(limitKey, "1", rateLimitMinutes, TimeUnit.MINUTES);
			log.info("이메일 발송 성공: email={}", email);
		}catch (RuntimeException e) {
			redisTemplate.delete(redisKey); // 발송 실패시 토큰 제거
			log.error("메일 발송 실패 : email={}, error={}", email, e.getMessage());
			throw new BaseException(UserErrorCode.EMAIL_SEND_FAIL);
		}
	}

	// 사용자가 인증 링크를 클릭했을 때 호출
	@Transactional
	public boolean verifyEmail(String token) {
		log.info("이메일 인증 시도: token={}", token);

		// Redis 에서 토큰 조회
		String redisKey = "email:verify:"+token;
		String email = redisTemplate.opsForValue().getAndDelete(redisKey);

		if (email == null) {
			log.warn("유효하지 않은 토큰: token={}", token);
			return false;
		}

		// 이미 인증된 이메일인지 체크
		EmailVerification emailVerification = emailVerificationRepository
			.findByEmail(email)
			.orElseGet(() -> new EmailVerification(email));

		if (emailVerification.isVerified()) {
			log.info("이미 인증된 이메일: email={}", email);
			return true;
		}

		emailVerification.markVerified();
		emailVerificationRepository.save(emailVerification);

		log.info("이메일 인증 완료: email={}", email);

		return true;
	}
}

UserService - 프로필 이미지 업로드

기존

// 프로필 이미지 업로드
	@Transactional
	public String updateProfileImage(UUID userUuid, MultipartFile file){
		User user = userRepository.findByUuid(userUuid)
			.orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND));

		String oldImageUrl = user.getProfile().getProfileUrl();
		String newImageUrl = profileImageService.store(file,userUuid,oldImageUrl);

		user.getProfile().updateProfileUrl(newImageUrl);

		return newImageUrl;
	}

수정

// 프로필 이미지 업로드
	@Transactional
	public String updateProfileImage(UUID userUuid, MultipartFile file){
		User user = userRepository.findByUuid(userUuid)
			.orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND));

		Profile profile = user.getProfile();
		if (profile == null) {
			throw new BaseException(UserErrorCode.PROFILE_NOT_FOUND);
		}

		String oldImageUrl = profile.getProfileUrl();
		String newImageUrl = profileImageService.store(file,userUuid,oldImageUrl);

		profile.updateProfileUrl(newImageUrl);

		return newImageUrl;
	}
  • user.getProfile() 이 null 일 경우 NPE 발생할 수 있음

AuthController - logout()

기존

// 로그아웃
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(
    @AuthenticationPrincipal CustomUserDetails customUserDetails,
    HttpServletRequest request
) {
    // 리프레시 토큰 삭제
    jwtRefreshTokenService.delete(customUserDetails.getUserUuid());

    // 액세스 토큰 추출
    String header = request.getHeader("Authorization");

    if (header != null && header.startsWith("Bearer ")) {
       String accessToken = header.substring(7);
       try {
          long remainingTime = jwtUtil.getRemainingExpiration(accessToken);
          if (remainingTime > 0) {
             jwtBlacklistService.blacklist(accessToken, remainingTime);
          }
       } catch (Exception e) {
          log.debug("logout accessToken 처리 중 예외: {}", e.getMessage());
       }
    }

    return ResponseEntity.ok(new ApiResponse<>("로그아웃 성공"));
}

수정

// 로그아웃
	@PostMapping("/logout")
	public ResponseEntity<ApiResponse<Void>> logout(
		@AuthenticationPrincipal CustomUserDetails customUserDetails,
		HttpServletRequest request
	) {
		// customUserDetails null 체크
		if(customUserDetails == null){
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ApiResponse<>("인증되지 않은 사용자입니다."));
		}

		// 리프레시 토큰 삭제
		jwtRefreshTokenService.delete(customUserDetails.getUserUuid());

		// 액세스 토큰 추출
		String header = request.getHeader("Authorization");

		if (header != null && header.startsWith("Bearer ")) {
			String accessToken = header.substring(7);
			try {
				long remainingTime = jwtUtil.getRemainingExpiration(accessToken);
				if (remainingTime > 0) {
					jwtBlacklistService.blacklist(accessToken, remainingTime);
				}
			} catch (Exception e) {
				log.debug("logout accessToken 처리 중 예외: {}", e.getMessage());
			}
		}

		return ResponseEntity.ok(new ApiResponse<>("로그아웃 성공"));
	}
  • customUserDetails 가 인증 실패 , 익명 접근시 null 일 수 있음

UserService - createUser 로그

기존

log.info("사용자 생성 완료 : id={}, email={}, username={}", ...);

수정

log.info("사용자 생성 완료 : id={}", savedUser.getId());
log.debug("생성된 사용자 상세 : email={}, username={}", ...);
  • 민감 정보 노출 최소화