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
10 changes: 7 additions & 3 deletions src/main/java/com/carecode/core/aspect/RateLimitingAspect.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
Expand Down Expand Up @@ -43,9 +42,14 @@ public Object rateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) thro
if (count == 1L) {
redisTemplate.expire(key, Duration.ofSeconds(rateLimit.windowSeconds()));
}
} catch (DataAccessException e) {
} catch (RuntimeException e) {
// Redis 장애로 전체 API 가 막히지 않도록 fail-open 한다.
log.error("Rate limit 카운터 조회 실패 - Redis 장애로 제한을 건너뜁니다. key={}", key, e);
//
// 예전에는 DataAccessException 만 잡았다. 그 밖의 실패(연결 팩토리 상태에 따라
// opsForValue() 가 null 이거나, 풀·직렬화 단계에서 나는 오류)는 그대로 500 이 됐고,
// 이 어노테이션이 붙은 로그인·가입·인증코드·챗봇이 한꺼번에 멈췄다.
// RateLimitInterceptor 에서 같은 결함을 고쳤는데(#87) 여기는 남아 있었다.
log.error("Rate limit 카운터 조회 실패 - 제한을 건너뜁니다. key={}", key, e);
return joinPoint.proceed();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,72 @@ public ResponseEntity<ErrorResponse> handleNoResourceFound(NoResourceFoundExcept
.body(errorResponse);
}

/**
* 클라이언트가 잘못 보낸 요청.
*
* <p>이 클래스는 ResponseEntityExceptionHandler 를 상속하지 않고 아래에 Exception 최후 핸들러를
* 두고 있다. 그래서 스프링이 원래 4xx 로 바꿔주던 예외가 전부 최후 핸들러에 잡혀
* <b>500 + Slack 운영 알림</b>이 됐다. 실측으로 확인했다(ClientErrorStatusTest, 수정 전 5건 전부 500).
* 깨진 JSON 하나, 파라미터 하나 빠진 요청마다 알림이 울리면 진짜 장애가 그 소음에 묻힌다.
* 없는 URL(404)·권한 거부(403)를 걸러낸 것과 같은 이유다.
*
* <p>메시지에 예외 원문을 넣지 않는다. 역직렬화 오류 메시지에는 내부 클래스명과
* 필드 구조가 그대로 들어 있다.
*/
@ExceptionHandler({
org.springframework.http.converter.HttpMessageNotReadableException.class,
org.springframework.web.bind.MissingServletRequestParameterException.class,
org.springframework.web.method.annotation.MethodArgumentTypeMismatchException.class,
org.springframework.web.multipart.support.MissingServletRequestPartException.class,
org.springframework.web.bind.MissingPathVariableException.class
})
public ResponseEntity<ErrorResponse> handleBadRequest(Exception ex, WebRequest request) {
log.warn("잘못된 요청: {} - {}", ex.getClass().getSimpleName(), request.getDescription(false));

// Java 17 이라 타입 패턴 switch 대신 instanceof 로 가른다.
String message = "잘못된 요청입니다";
if (ex instanceof org.springframework.http.converter.HttpMessageNotReadableException) {
message = "요청 본문을 읽을 수 없습니다";
} else if (ex instanceof org.springframework.web.bind.MissingServletRequestParameterException e) {
message = "필수 파라미터가 없습니다: " + e.getParameterName();
} else if (ex instanceof org.springframework.web.method.annotation.MethodArgumentTypeMismatchException e) {
message = "파라미터 형식이 올바르지 않습니다: " + e.getName();
} else if (ex instanceof org.springframework.web.multipart.support.MissingServletRequestPartException e) {
message = "필수 파일이 없습니다: " + e.getRequestPartName();
}

return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of(ErrorCode.INVALID_INPUT, message, request.getDescription(false)));
}

@ExceptionHandler(org.springframework.web.HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotSupported(
org.springframework.web.HttpRequestMethodNotSupportedException ex, WebRequest request) {
log.warn("지원하지 않는 메서드: {} {}", ex.getMethod(), request.getDescription(false));
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ErrorResponse.of(ErrorCode.INVALID_INPUT,
"지원하지 않는 메서드입니다: " + ex.getMethod(), request.getDescription(false)));
}

@ExceptionHandler(org.springframework.web.HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMediaTypeNotSupported(
org.springframework.web.HttpMediaTypeNotSupportedException ex, WebRequest request) {
log.warn("지원하지 않는 Content-Type: {} {}", ex.getContentType(), request.getDescription(false));
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(ErrorResponse.of(ErrorCode.INVALID_INPUT,
"지원하지 않는 Content-Type 입니다", request.getDescription(false)));
}

/** 업로드 크기 초과. spring.servlet.multipart.max-file-size(10MB) 를 넘으면 여기로 온다. */
@ExceptionHandler(org.springframework.web.multipart.MaxUploadSizeExceededException.class)
public ResponseEntity<ErrorResponse> handleMaxUploadSize(
org.springframework.web.multipart.MaxUploadSizeExceededException ex, WebRequest request) {
log.warn("업로드 크기 초과: {}", request.getDescription(false));
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(ErrorResponse.of(ErrorCode.INVALID_INPUT,
"파일이 너무 큽니다", request.getDescription(false)));
}

// 모든 예외 처리 (최후의 수단)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAllExceptions(Exception ex, WebRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.carecode.core.exception.UserNotFoundException;
import com.carecode.domain.user.dto.request.LoginRequestDto;
import com.carecode.domain.user.dto.request.RefreshTokenRequest;
import com.carecode.domain.user.dto.request.SignUpRequest;
import com.carecode.domain.user.dto.response.TokenDto;
import com.carecode.domain.user.dto.response.UserDto;
import com.carecode.domain.user.entity.User;
Expand Down Expand Up @@ -84,7 +85,7 @@ public ResponseEntity<TokenDto> login(@Parameter(description = "로그인 정보
@LogExecutionTime
@RateLimit(requests = 5, windowSeconds = 3600, message = "가입 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.")
@Operation(summary = "회원가입", description = "새로운 사용자 등록")
public ResponseEntity<TokenDto> register(@Parameter(description = "회원가입 정보", required = true) @Valid @RequestBody UserDto request) {
public ResponseEntity<TokenDto> register(@Parameter(description = "회원가입 정보", required = true) @Valid @RequestBody SignUpRequest request) {
UserDto createdUser = userService.createUser(request);
User user = userService.getUserEntityByEmail(createdUser.getEmail());
TokenDto tokenDto = authService.issueTokenForUser(user, "회원가입 성공!");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.carecode.domain.user.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDate;

/**
* 이메일 회원가입 요청 DTO.
*
* <p>예전에는 응답 DTO 인 {@code UserDto} 를 그대로 요청 본문으로 받았다. 그래서
* {@code role} / {@code provider} / {@code providerId} / {@code isActive} / {@code emailVerified} 처럼
* <b>서버가 정해야 할 필드가 전부 요청 계약에 노출</b>돼 있었고, 실제로 {@code role} 을 그대로
* 저장해 누구나 관리자가 될 수 있었다(#82). 서비스에서 값을 무시하도록 막았지만, Swagger 에는
* 여전히 설정 가능한 것처럼 보였고 "무시되니까 괜찮다" 는 전제는 언젠가 깨진다.
*
* <p>여기에는 가입자가 정할 수 있는 값만 둔다. 요청에 {@code role} 등을 넣어 보내도
* 받을 필드가 없어 버려진다(스프링 기본값: 모르는 필드 무시).
*
* <p>또 {@code UserDto} 에는 검증 어노테이션이 하나도 없어서 {@code @Valid} 가 아무 일도 하지 않았다.
* 빈 이메일·형식이 틀린 이메일·한 글자 비밀번호로도 가입됐고, 이름이 없으면 DB NOT NULL 제약에서 500 이 났다.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SignUpRequest {

@NotBlank(message = "이메일은 필수입니다")
@Email(message = "이메일 형식이 올바르지 않습니다")
@Size(max = 255, message = "이메일이 너무 깁니다")
private String email;

/**
* 상한을 두는 건 BCrypt 가 72바이트 이후를 무시하기 때문이다(그 뒤는 검사되지 않는다).
* 영문 기준 64자면 안쪽이다. 한글은 글자당 3바이트라 24자를 넘으면 뒷부분이 잘린다.
*/
@NotBlank(message = "비밀번호는 필수입니다")
@Size(min = 8, max = 64, message = "비밀번호는 8~64자여야 합니다")
private String password;

@NotBlank(message = "이름은 필수입니다")
@Size(max = 50, message = "이름은 50자를 넘을 수 없습니다")
private String name;

@Pattern(regexp = "^[0-9+\\-() ]{0,20}$", message = "전화번호 형식이 올바르지 않습니다")
private String phoneNumber;

@Past(message = "생년월일은 과거 날짜여야 합니다")
private LocalDate birthDate;

/** Gender.valueOf 에 그대로 넘기므로 enum 이름만 받는다. 다른 값이면 예전에는 500 이었다. */
@Pattern(regexp = "^(MALE|FEMALE|OTHER)$", message = "성별은 MALE, FEMALE, OTHER 중 하나여야 합니다")
private String gender;

@Size(max = 255, message = "주소가 너무 깁니다")
private String address;
}
53 changes: 23 additions & 30 deletions src/main/java/com/carecode/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.carecode.core.annotation.RequireAuthentication;
import com.carecode.core.exception.UserNotFoundException;
import com.carecode.domain.user.dto.request.PasswordChangeRequestDto;
import com.carecode.domain.user.dto.request.SignUpRequest;
import com.carecode.domain.user.dto.response.UserDto;
import com.carecode.domain.user.dto.response.UserStatsResponse;
import com.carecode.domain.user.entity.User;
Expand Down Expand Up @@ -245,46 +246,38 @@ public UserDto completeKakaoRegistration(String email, String name, String role)
return convertToDto(updatedUser);
}

// 사용자 생성
/**
* 이메일 회원가입.
*
* <p>요청은 {@link SignUpRequest} 다. 예전에는 응답 DTO 인 UserDto 를 그대로 받아서
* role/provider/emailVerified 같은 서버 결정 값이 요청 계약에 노출돼 있었고, 실제로 role 을
* 그대로 저장해 누구나 관리자가 될 수 있었다(#82). 이제 요청 타입에 그 필드가 아예 없다.
* 그래도 아래에서 값을 명시적으로 고정해, 요청 타입이 다시 넓어져도 결과가 바뀌지 않게 한다.
*/
@Transactional
public UserDto createUser(UserDto userDto) {
log.info("사용자 생성: 이메일={}, provider={}", userDto.getEmail(), userDto.getProvider());
public UserDto createUser(SignUpRequest request) {
log.info("사용자 생성: 이메일={}", request.getEmail());

// 이메일 중복 확인
if (userRepository.findByEmail(userDto.getEmail()).isPresent()) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다: " + userDto.getEmail());
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다: " + request.getEmail());
}

// 이메일 회원가입이므로 비밀번호는 항상 필수다.
if (userDto.getPassword() == null || userDto.getPassword().trim().isEmpty()) {
// 컨트롤러의 @Valid 가 먼저 막지만, 서비스를 직접 부르는 경로에서도 비밀번호 없는 계정이 생기면 안 된다.
if (request.getPassword() == null || request.getPassword().trim().isEmpty()) {
throw new IllegalArgumentException("비밀번호는 필수입니다.");
}
String encodedPassword = passwordEncoder.encode(userDto.getPassword());

// 클라이언트가 보낸 role 은 신뢰하지 않는다.
//
// 예전에는 요청 본문의 role 을 그대로 썼다. 이 엔드포인트(POST /auth/register)는
// permitAll 이므로, 로그인조차 없이 {"role":"ADMIN"} 으로 가입하면 그 자리에서
// 관리자가 됐다. 가입은 언제나 일반 사용자로 끝나야 하고, 승격은 관리자만 할 수 있는
// 별도 경로(PUT /api/admin/users/{id}/role)로만 가능해야 한다.
if (userDto.getRole() != null && !SELF_SIGNUP_ROLE.name().equals(userDto.getRole())) {
log.warn("회원가입 요청의 role 을 무시합니다 - 요청값={}, 적용값={}",
userDto.getRole(), SELF_SIGNUP_ROLE);
}
String encodedPassword = passwordEncoder.encode(request.getPassword());

// provider/providerId 도 마찬가지다. 소셜 가입은 AuthServiceImpl 의 별도 경로가 처리하며
// 그쪽에서 provider 를 직접 지정한다. 여기서 클라이언트가 provider 를 붙일 수 있게 두면
// 비밀번호 없이(로그인 불가하지만) 임의 이메일·임의 providerId 로 계정을 미리 만들어 둘 수 있고,
// 이메일 인증도 건너뛴 것으로 표시됐다.
User user = User.builder()
.email(userDto.getEmail())
.email(request.getEmail())
.password(encodedPassword)
.name(userDto.getName())
.phoneNumber(userDto.getPhoneNumber())
.birthDate(userDto.getBirthDate())
.gender(userDto.getGender() != null ? Gender.valueOf(userDto.getGender()) : null)
.address(userDto.getAddress())
.profileImageUrl(userDto.getProfileImageUrl())
.name(request.getName())
.phoneNumber(request.getPhoneNumber())
.birthDate(request.getBirthDate())
.gender(request.getGender() != null ? Gender.valueOf(request.getGender()) : null)
.address(request.getAddress())
// 서버가 정하는 값. 요청으로는 바꿀 수 없다.
.role(SELF_SIGNUP_ROLE)
.provider(null)
.providerId(null)
Expand Down
Loading
Loading