팀원이 작성한 회원가입, 로그인 코드를 통해
JWT인가 요청을 어떻게 처리하는지 공부하고 나머지 API에 인가 로직을 포함시켜 작성하기로 했다.
JWT 인증은 Stateless(무상태) 방식으로, 클라이언트는 요청 마다 인증 전보를 전달하게 된다.
서버는 로그인 하면 이 때 사용하게 될 토큰을 발급해 준다.
사용자가 UserAuthController.signIn으로
이메일과 비밀번호를 보낸다.
@PostMapping("/auth")
public ResponseEntity<TokenResponse> signIn(@RequestBody UserLoginRequest request) {
return ResponseEntity.status(HttpStatus.OK)
.body(userAuthService.login(request));
}
UserAuthService.login에서 비밀번호를 검증하고, 성공하면 JwtUtil을 통해 AccessToken과 RefreshToken을 생성한다.
public TokenResponse login(UserLoginRequest request) {
User user = userRepository.findByEmailAndIsDeletedFalse(request.email()).orElseThrow(
() -> new ApiException(ErrorCode.USER_NOT_FOUND)
);
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
throw new ApiException(ErrorCode.INVALID_PASSWORD);
}
return jwtUtil.generateToken(user.getId(), user.getEmail(), user.getRole().name());
}
이때, JWT의 subject 필드에 사용자의 ID(UUID)를 넣고, roles 클레임에 권한 정보를 담는다.
// JwtUtil
public String generateAccessToken(UUID userId, String email, String role) {
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("email", email)
.claim("roles", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION))
.signWith(signingKey)
.compact();
}
subject 필드를 사용하는것은 JWT의 표준 규약을 따른 것이다.
JWT에는 Registered Claims(등록된 클레임)라는 표준 규약(RFC 7519)이 있다.
sub (Subject): 토큰의 주인공(누구인지)을 식별하는 식별자iss (Issuer): 토큰 발급자iat (Issued At): 발급 시간또, 스프링 시큐리티의 많은 내부 로직이 principal 정보를 추출할 때 기본적으로 sub를 먼저 확인하도록 설계되어 있다.
클라이언트는 로그인으로 획득한 토큰을 이후의 모든 API 요청 헤더에 Authorization: Bearer {AccessToken} 형태로 포함하여 보낸다.
JwtFilter가 모든 요청을 가로채
JwtUtil.validateToken으로 토큰의 유효성(만료 여부, 서명 등)을 확인한다.
유효하다면 토큰 내부의 subject(userId)와 role을 꺼내 UsernamePasswordAuthenticationToken을 만들고,
이 인증 객체를 SecurityContextHolder에 저장한다.
사용자 정보는 본인의 정보만을 열람할 수 있어 인증이 필요하다.
@GetMapping
public ResponseEntity<UserInfo> getUserInfo(Principal principal) {
return ResponseEntity.ok()
.body(UserInfo.from(userReadService.getUserInfo(principal.getName())));
}
@AuthenticationPrincipal 어노테이션을 사용하면,Principal 객체보다 더 구체적인 정보를 바로 주입받을 수 있다.
@GetMapping("/me")
public ResponseEntity<UserInfo> getMyInfo(@AuthenticationPrincipal String userId) {
// principal.getName()을 호출할 필요 없이 바로 userId(UUID 문자열)가 들어옵니다.
return ResponseEntity.ok(UserInfo.from(userReadService.getUserInfo(userId)));
}
시큐리티 설정 파일에 @EnableMethodSecurity를 추가하면
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 이 어노테이션을 추가해야 @PreAuthorize가 작동
@RequiredArgsConstructor
public class SecurityConfig { ... }
URL 패턴(requestMatchers)으로 권한을 관리하는 대신 메서드 단위로 세밀하게 권한을 제어할 수 있다.
@PreAuthorize("hasRole('CUSTOMER')")
@PreAuthorize("hasAnyRole('MANAGER', 'MASTER')")
@PreAuthorize("#userId == principal") // principal은 JwtFilter에서 넣은 userId(UUID) 만약 객체를 넣었다면 principal.username 처럼 접근
@PutMapping("/{userId}")
public ResponseEntity<UserInfo> updateInfo(@PathVariable String userId, @RequestBody UserInfoEditRequest request) {
...
}
메서드 파라미터가 지저분해지는 것을 방지하거나
수십 개의 컨트롤러와 서비스 메서드 파라미터에 userId를 추가하는 대신 공통 로그 서비스에서 처리하기 위해
SecurityContextHolder를 서비스 코드에서 호출하는것도 가끔 있다고 한다.
그러나 테스트 코드 작성이 어려워지고, 배치/스케줄러 작업에 서비스 코드를 가져다 사용하기 어렵다는 단점이 있어 좋은 방법은 아닌 것 같다.
+
만약 서비스에서 @Async 등을 사용하여 별도의 쓰레드에서 작업을 수행하게 되면, SecurityContext는 기본적으로 공유되지 않아 null이 발생한다. 컨트롤러에서 파라미터로 넘기는 방식을 유지하는게 좋다.
이러한 인증 방식이 MSA로 확장 됐을 때 고려할 점은 다음과 같다.
MSA에서는 API Gateway에서 토큰을 검증하고, 내부 서비스들에는 헤더로 X-User-Id 같은 값만 넘겨주는 방식을 주로 사용한다.
MSA 환경에서는 여러 서비스의 DB가 분리되는데,UUID는 전역적으로 유일하기 때문에 서비스 간 데이터 결합 시 충돌 걱정이 없다.
어제 코드 리뷰를 받을 때 나는 엔티티의 UUID에 Postgres의 전략을 따르는 @Id, @GeneratedValue 를 사용했는데,
팀원분은 @UuidGenerator를 추가로 사용하여 스프링(Hibernate) 레이어에서 생성하는 것을 사용했다.
그래서 스프링에서 생성하는 UUID와 DB에서 생성하는 UUID가 충돌이 날 수도 있을 것 같다는 의견을 들었는데 MSA확장과 연결해 생각해보니 어떻게 되는지 궁금해 UUID에 대해 더 알아보았다.
UUID(Universally Unique Identifier) 버전 4는 무작위성을 기반으로 생성된다.
충돌 확률은 복잡한 수식으로 표현됐는데,
지구상의 모든 사람이 매초 수십억 개의 UUID를 생성해도 충돌할 확률은 로또 1등에 수천 번 연속 당첨될 확률보다 낮다고 한다.
이런 시스템은 어떻게 만드는지 지구상엔 정말 천재들이 넘쳐나는 것 같다. (이런 UUID를 일부러 충돌 시켜보는 사람도 있을까?)
결론은 "어디서 생성하든 사실상 충돌 가능성은 0에 수렴한다"
그래서 MSA로 인해 DB가 분리되어도 UUID를 통해 전역에서 데이터를 구분할 수 있다.
결론은 팀원의 코드처럼 @UuidGenerator 를 사용하여 스프링(Hibernate) 레이어에서 생성하는 것이 좋다.
DB에 저장하기 전(INSERT 쿼리 전)에 이미 ID 값을 애플리케이션이 알고 있기 때문에, 로그를 남기거나 연관된 객체를 다룰 때 훨씬 편하다.
우리 팀의 요구사항으로는 사장님(OWNER)의 기능은 손님(CUSTOMER)의 기능을 포함하는 경우가 많다.
OWNER(List.of("ROLE_OWNER", "ROLE_CUSTOMER"))
와 같이 이중 리스트 구조(getRoles())를 사용하면, 나중에 권한이 배달 전용 권한(ROLE_DELIVERY) 등이 추가되는 등 권한이 복잡해져도
DB를 건드리는 게 아니라 Enum 리스트에 ROLE_DELIVERY만 추가하는 등 유연하게 대처할 수 있다.
문제를 읽고 생각나는대로 구현해보니 이중 for문으로 정렬하는 방법이 생각났다.
// 문자열 내림차순으로 배치하기
class Solution {
public String solution(String s) {
// 대문자 < 소문자 : ASCII의 순서와 동일.
// ASCII 코드로 문자 비교하며 큰 것부터 쓰면서 내림차순 정렬
StringBuilder sb = new StringBuilder();
boolean[] visited = new boolean[s.length()]; // 제외 여부
for(int i = 0; i < s.length(); i++) {
char max = 0;
int maxIdx = -1;
for(int j = 0; j < s.length(); j++){
char compare = s.charAt(j);
if (!visited[j] && max < compare) {
max = compare;
maxIdx = j;
}
}
sb.append(max);
visited[maxIdx] = true;
}
return sb.toString();
}
}
swap 정렬을 사용하면 방문 여부를 저장하지 않아도 된다.
public String solution(String s) {
char[] arr = s.toCharArray();
for (int i = 0; i < arr.length - 1; i++) {
for (int j = i + 1; j < arr.length; j++) {
// 내림차순: 뒤에 있는게 더 크면 앞으로 당기기
if (arr[i] < arr[j]) {
char temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
}
return new String(arr);
}
라이브러리 정렬을 사용하면 문제가 아주 간단해진다.
import java.util.Arrays;
class Solution {
public String solution(String s) {
// 1. 문자열을 char 배열로 변환
char[] chars = s.toCharArray();
// 2. 오름차순 정렬 (예: "AdBc" -> "ABCD...abcd" 순서대로)
Arrays.sort(chars);
// 3. 내림차순을 위해 뒤집기 (StringBuilder의 reverse 활용)
// new String(chars)로 배열을 다시 문자열로 만든 뒤 뒤집습니다.
return new StringBuilder(new String(chars)).reverse().toString();
}
}
reverse()를 사용하지 않고 뒤에서부터 읽어들이는 방법도 있다.
import java.util.Arrays;
class Solution {
public String solution(String s) {
char[] chars = s.toCharArray();
Arrays.sort(chars);
StringBuilder sb = new StringBuilder();
for (int i = chars.length - 1; i >= 0; i--) {
sb.append(chars[i]);
}
return sb.toString();
}
}
근데 왜 항상 코테 할 때면 이런 라이브러리들이 기억나지 않는지 모르겠다 ㅜ
위 방법보단 느리지만 stream을 사용해 간단하게 표현할 수 있다.
import java.util.stream.Collectors;
import java.util.Stream;
return Stream.of(s.split(""))
.sorted(java.util.Comparator.reverseOrder())
.collect(Collectors.joining());
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class ReviewController {
private final ReviewService reviewService;
@PostMapping("/reviews")
public ResponseEntity<ReviewResponse> create(@AuthenticationPrincipal UserDetails userDetails, @Valid @RequestBody ReviewCreateRequest request) {
ReviewResponse response =
reviewService.createReview(userDetails.getUsername(), request);
URI uri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.reviewId())
.toUri();
return ResponseEntity.created(uri).body(response);
}
}
@WebMvcTest는 기본적으로 사용자가 만든 SecurityConfig를 읽으려 하지만, JwtFilter 같은 커스텀 필터가 있으면 의존성 주입이 안 되어 시큐리티 필터가 요청을 막고 로그인 페이지로 보내버려서 200이 뜬다.

그래서 @WithMockUser 를 테스트 코드에 추가해 Scecurity Context에 추가해줘야 한다.
@WebMvcTest(ReviewController.class)
@WithMockUser // 가짜 사용자를 SecurityContext에 주입해줍니다. (기본 username: "user")
@Import({GlobalExceptionHandler.class, SecurityConfig.class})
class ReviewControllerTest {
multipart/form-data 방식으로 이미지 파일 부분과 기존의 json 부분을 나눠 요청을 받을 수 있다.
@RequestPart를 통해서 데이터를 받을 변수를 구분해주면 된다.
@PostMapping("/reviews")
public ResponseEntity<ReviewResponse> create(@AuthenticationPrincipal UserDetails userDetails,
@Valid @RequestPart ReviewCreateRequest request,
@RequestPart(value = "images", required = false) List<MultipartFile> images) {
ReviewResponse response =
reviewService.createReview(userDetails.getUsername(), request, images);
URI uri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.reviewId())
.toUri();
return ResponseEntity.created(uri).body(response);
}
}
나는 처음에 @RequestPart가 헤더같은 곳에 저장되는 거라 생각해 @RequestBody와 동시에 사용할 수 있을 줄 알았는데,
요청의 Content-Type이 application/json 대신 multipart/form-data 형식으로 정해지는 거라 동시에 사용할 수 없다고 한다.
postman에서 파일을 업로드 할 땐, 지정된 디렉토리에서 파일을 업로드 하거나, 3rd Party 디렉토리에서 업로드하는것을 허용해줘야 한다.
그렇지 않으면 form-data에서 아무리 파일 업로드를 선택해도 아무 반응 없이 무시된다.





(참고) 보낼 때 같은 key값으로 여러 곳에 나눠 넣어도 합쳐서 들어간다.
