🏅오늘의 목표
✅ 진행한 작업
📃 개발내용
CustomUserDetails - getAuthorities()
기존
Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new GrantedAuthority() {
@Override
public @Nullable String getAuthority() {
return user.getRole().toString();
}
});
return authorities;
- 문제점
- 불필요한 익명 클래스
- GrantedAuthority 를 구현하는 익명 클래스를 매번 생성
- 가독성 저하
- 단순한 기능을 복잡하게 표현
- 메모리 비효율
- 매번 새로운 ArrayList 와 익명 클래스 객체를 생성
- @Nullable 반환
- getAuthority 가 null 을 반환할 수 있어 불안정
수정
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(
new SimpleGrantedAuthority(user.getRole().toString())
);
}
- 수정 이유
- SpringSecurity 표준 사용
- SimpleGrantedAuthority 표준 구현체
- 불변 리스트
- Collections.singletonList() : 불변 리스트를 반환하여 안전성 보장
- 메모리 효율
- 불변 싱글톤 리스트로 메모리 사용 최소화
- 가독성 향상
- Collections.singletonList()를 선택한 이유
- 불변성 보장
- 반환된 리스트는 수정 불가능
- 메모리 효율
- 의도 명확성
- 단 하나의 권한만 존재함을 명시
CustomUserDetails - getName()
기존
public @Nullable String getName() {
return user.getProfile().getName();
}
- 문제점
- NullPointException 위험
- user.getProfile 이 null 일 경우 NPE 발생
- OAuth2 재로그인
- 기존 사용자가 Profile 없이 재로그인하면 에러
- 데이터 불일치
- 신규 사용자는 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();
}
- 수정 이유
- NPE 방어
- 2단계 null 체크로 대응
- 안전한 fallback
- email 은 필수 이므로 항상 존재 보장
- 디버깅 용이
- 예외 발생 대신 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);
}
예외 발생 시 응답 추가
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);
}
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);
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={}", ...);