안녕방가뿡 재영입니다
오늘은 스프링 시큐리티의 Principal 객체를 활용해서 인증된 사용자의 상세 정보에 접근하는 방식에 대해 알아보겠습니다
이걸 왜 알아보게되었냐면... 저도알고싶지않았어요

농담이고, Principal 객체에 대해 정리하는 이유는
기존의 자체 회원가입 폼을 이용해서 가입한 사용자 (이하 일반회원, Member) 가 로그인을 한 후 이용할 수 있는 기능에
구글, 네이버 로그인 API 를 사용해서 로그인한 사용자 (이하 소셜회원, SocialMember) 가 쇼핑몰의 모든 기능을 사용할 수 있도록 수정하는 과정을 진행하던 와중에, 일반회원과 소셜회원의 principal.getName() 메소드의 반환값 속성이 다른 문제를 발견하고
이걸 뜯어고치던 과정이 있었습니다.
그 과정을 정리하고, 스프링 시큐리티에서 사용하는 Principal 객체의 기능, Principal을 사용하는 이유 등을 정리해보겠습니다
시~~~~작
1. 문제 상황 - 회원 type 별로 principal.getName() 의 반환값이 다른 오류 발생
제가 지금은 오류를 다 해결한 상태라 정확히 어떤 부분이 문제가 생겼는지 디버그를 찍어볼 수는 없는데,
최대한 기억을 더듬더덤... 거리면서 작성해보겠습니다
문제는 회원의 장바구니 기능을 Member 만 사용하게 먼저 구현해뒀던 것을
AbstractUser 로 변경하고, CartService 에 MemberRepository 를 사용해서 Member 의 email을 조회하던것을
UserRepository 를 사용해서 AbstractUser 의 email을 조회하는걸로 변경하여 회원의 타입의 상관 없이 로그인 후 이용할 수 있는 장바구니 기능을 이용할 수 있도록 코드를 수정하고 있었습니다.
기능 부분에서는 컨트롤러 계층이 딱히 하는 일이 없어서, 서비스 계층만 수정하고 프로젝트를 돌렸는데
왜인지 userId 값이 비어있다는 500 에러가 발생한 것입니다
처음에는 서비스 계층에서 코드를 잘못 수정했을거라고 생각하고 디버그모드에서 코드마다 디버그포인트를 다 찍어서 확인했는데...
email 에 왜인지 소셜회원은 이름을 조회하는 것입니다
Spring Data JPA 가 잘못되었을리는 없고...
서비스계층만 건드렸지 혹시 컨트롤러 계층에서 GetMapping, PostMapping 을 진행할 때 principal 객체의 반환값이 잘못된 것이 아닐까? 하고 컨트롤러쪽에도 디버깅을 진행했습니다.
결론은 컨트롤러 계층에서 사용한 principal.getName() 메소드가 반환하는 값이 일반회원은 이메일, 소셜회원은 사용자가 설정한 이름이라 이 반환값이 서비스계층에 잘못 전달되어 최종적으로는 userId 값이 null 로 들어가는 오류가 발생한 것이었습니다.
2. 문제가 발생한 원인
사용자가 SNS 로그인 API 를 이용해서 소셜 로그인을 진행할 때, CustomOAuth2User 에서 설정한 반환 형식에 맞게 로그인 API에서 가져온 사용자의 정보를 반환하는데, 저는 getName() 메소드가 사용자의 email 값이 아닌 실제 이름 (ex. 홍길동) 을 반환하도록 설계했습니다.
이게 언뜻 들으면 당연히 'getName' 이니까 사용자의 이름을 가져오는 것이 맞는 것 아니야? 라고 생각할 수 있습니다. 물론 저도 처음에 이렇게 생각했습니다...
이건 스프링 시큐리티의 기본 동작을 잘 파악하고 있어야 하는 부분인데, Principal.getName() 은 사용자를 식별할 수 있는 고유 값 (일반적으로 이메일 또는 사용자 ID) 를 반환하도록 설계되어 있기 때문에, 메소드명에 맞춰서 냅다 실제 이름을 return 하도록 만든게 문제였습니다.
실제 이름을 반환하는 것은 로그인 과정에는 문제가 되지 않았습니다. 왜냐면 getName() 에서는 사용자의 실제 이름을 반환하고, 따로 getEmail() 메소드를 만들어서 로그인 API에서 받는 이메일을 반환하도록 설계했기 때문입니다.
그러나 이제 Principal 객체를 사용해서 사용자의 인증 정보 중 고유한 값인 이메일 값을 반환하려고 할 때, Member 는 email 을 정상적으로 반환하지만, SocialMember 의 경우 CustomOAuth2User 에서 getName() 메소드가 사용자의 실제 이름을 반환하도록 설계했기 때문에, Member 와 다르게 사용자의 실제 이름이 반환되는 문제가 발생한 것입니다.
3. 문제 해결 과정
3-1. 디버깅 (CartController, CustomOAuth2UserService, CustomOAuth2User)
디버깅 모드를 통해 CartController 에서 사용한 Principal.getName() 메소드의 반환값이 일반회원과 소셜회원이 다른 것을 확인했습니다. Principal.getName() 메소드를 사용하는 부분이 상당히 많아서, 아래는 일부분만 코드를 가져와봤습니다.
@Controller
@RequiredArgsConstructor
public class CartController {
private final CartService cartService;
@PostMapping("/cart")
public @ResponseBody ResponseEntity order(@RequestBody @Valid CartItemDto cartItemDto,
BindingResult bindingResult,
Principal principal) {
if (bindingResult.hasErrors()) {
StringBuilder sb = new StringBuilder();
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
for (FieldError fieldError : fieldErrors) {
sb.append(fieldError.getDefaultMessage());
}
return new ResponseEntity<String>(sb.toString(), HttpStatus.BAD_REQUEST);
}
String email = principal.getName(); // 회원 타입마다 반환 값이 다름을 확인한 디버그 포인트
Long cartItemId;
try {
cartItemId = cartService.addCart(cartItemDto, email);
} catch (Exception e) {
return new ResponseEntity<String>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
return new ResponseEntity<Long>(cartItemId, HttpStatus.OK);
}
반환 값이 다른 문제를 해결하기 위해, CustomOAuth2UserService 에서 반환되는 사용자 정보와 CustomOAuth2User 애서 구현한 메소드를 검토했고, getName() 메소드가 반환해야 할 값이 사용자의 실제 이름이 아닌, 사용자의 이메일이라는 점을 파악했습니다.
public class CustomOAuth2User implements OAuth2User {
@Override
public String getName() {
return socialMemberDto.getName(); // 코드를 변경해야 할 부분 확인 (getName() -> getEmail())
}
}
3-2. 코드 수정
Principal.getName() 메소드가 사용자의 이메일을 반환하도록 하기 위해서, CustomOAuth2User 에서 구현한 getName() 메소드의 코드를 변경했습니다.
만약 사용자의 이메일을 반환하는 과정에서, 이메일이 비어있어 이메일을 반환하지 못 하는 경우, 로그인 API 서버에서 발급받은 Provider (ex. google, naver...) 와 ProviderId 를 조합한 고유한 값을 반환하도록 분기처리도 함께 진행했습니다.
그리고, CustomOAuth2User 의 getName() 메소드가 username 을 반환하여 Principal.getName() 이 우리가 기대하던 사용자의 이메일 값이 반환되는 것이 아닌, username 이 반환되는 경우를 막기 위해, CustomOAuth2User 에 getEmail() 메소드를 추가했습니다.
public class CustomOAuth2User implements OAuth2User {
@Override
public String getName() {
if (socialMemberDto.getEmail() != null && !socialMemberDto.getEmail().isEmpty()) {
return socialMemberDto.getEmail();
} else {
return socialMemberDto.getUsername(); // 이메일이 없는 경우 username 반환
}
}
public String getEmail() {
return socialMemberDto.getEmail();
}
}
그리고 이제 CartController 에서 Principal.getName() 을 사용자의 이메일로 반환하기 위해서, getUserName 이라는 CartController 에서만 사용할 메소드를 하나 만들고, Member 의 경우 기존처럼 Principal.getName() 을 반환하도록 하고,
SocialMember 의 경우 Principal 의 형변환을 통해 CustomOAuth2User 에서 설정한 getEmail() 메소드에서 받은 사용자의 이메일값을 반환하도록 분기 처리를 진행했습니다.
(방금 다른 기능을 수정하면서 확인했는데, 컨트롤러쪽에서 분기처리를 하지 않아도, CustomOAuth2User 에서 getName() 이 이메일을 반환하도록 수정해줬기 때문에, 다른 기능에서 principal.getName() 을 사용하는 부분은 수정하지 않아도 됩니다!)
@Controller
@RequiredArgsConstructor
public class CartController {
private final CartService cartService;
// 사용자의 유형에 맞게 principal.getName() 값을 다르게 반환하는 메소드
private String getUserEmail(Principal principal) {
if (principal instanceof CustomOAuth2User) {
return ((CustomOAuth2User) principal).getEmail(); // 소셜 로그인 사용자의 경우 getName() 을 사용자 이름이 아닌 email 로 반환
} else {
return principal.getName(); // 일반 사용자인 경우 getName()은 이메일을 반환
}
}
@PostMapping("/cart")
public @ResponseBody ResponseEntity order(@RequestBody @Valid CartItemDto cartItemDto,
BindingResult bindingResult,
Principal principal) {
if (bindingResult.hasErrors()) {
StringBuilder sb = new StringBuilder();
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
for (FieldError fieldError : fieldErrors) {
sb.append(fieldError.getDefaultMessage());
}
return new ResponseEntity<String>(sb.toString(), HttpStatus.BAD_REQUEST);
}
String email = getUserEmail(principal);
Long cartItemId;
try {
cartItemId = cartService.addCart(cartItemDto, email);
} catch (Exception e) {
return new ResponseEntity<String>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
return new ResponseEntity<Long>(cartItemId, HttpStatus.OK);
}
}
3-3. 테스트 및 검증
코드 변경 후에는 Logger 를 활용하여 소셜회원의 경우, 반환하는 사용자의 이메일과 사용자의 이름을 확인했고, 로컬 환경에서 테스트를 진행하면서 일반 회원과 소셜 회원 모두 장바구니 기능에 접근하고, 이용할 수 있는 점을 확인했습니다.
(이전에는 그냥 출력문으로 콘솔창에 정보를 찍었는데, 제발 로그는 sout 말고 Slf4j Logger 를 씁시다... )
log.info("Email from OAuth2Response: {}", oAuth2Response.getEmail());
log.info("Name from OAuth2Response: {}", oAuth2Response.getName());

4. Principal 에 대해서
4-1. Principal 의 기능
프로젝트에서 문제가 되는 오류는 다 해결했고, 이제는 스프링 시큐리티의 Principal 객체에 대해 정리해보겠습니다. Principal 은 보안 관련 용어에서 중요한 개념 중 하나로, 현재 작업을 수행하고 있는 사용자, 디바이스, 시스템 등을 지칭합니다.
스프링 시큐리티는 자체적으로 인증과 인가 과정을 관리하면서 Principal 객체를 사용하여 현재 인증된 사용자의 세부 정보에 접근할 수 있도록 합니다. 스프링 시큐리티의 인증 메커니즘은 Authentication 객체를 통해 작동하는데, 이 객체는 Principal 정보뿐만 아니라 권한 정보(Granted Authorities), 인증 상세 정보 등을 포함합니다.
Principal 은 사용자의 인증 정보를 나타냅니다. 이 정보에는 사용자의 이름, 권한, 비밀번호 등이 포함될 수 있습니다. 스프링 시큐리티에서는 Principal 객체를 통해 현재 인증된 사용자의 상세 정보에 접근하거나, 해당 사용자의 권한을 검사하는데 사용됩니다.
정리하자면, 스프링 시큐리티를 프로젝트에 사용함으로써, Principal 객체를 사용할 수 있고, Principal을 사용하여 현재 인증된 사용자의 세부 정보에 접근할 수 있습니다.
(스프링 시큐리티를 도입하지 않은 프로젝트에서 Principal 객체를 직접 사용하는 것은 일반적이지 않기 때문에, 스프링 시큐리티나 다른 보안 프레임워크를 프로젝트에 도입 후 Principal 객체를 사용하는 것이 좋습니다.)
4-2. Principal 을 사용하는 이유
Principal 을 사용하는 이유는 크게 세 가지가 있습니다.
1) 보안 강화
현재 세션에서 인증된 사용자만이 특정 작업을 수행할 수 있도록 함으로써 시스템의 보안을 강화합니다.
2) 사용자 맞춤형 서비스 제공
사용자의 인증 정보를 바탕으로 사용자 맞춤형 서비스를 제공할 수 있습니다. 예를 들어, 사용자별로 허용된 데이터만 보여주거나 사용자의 역할에 따라 접근 가능한 기능을 제한할 수 있습니다.
3) 감사 및 로깅
Principal 정보를 사용하여 누가 어떤 작업을 수행했는지 추적할 수 있어, 시스템의 투명성을 높이고 이상 징후를 조기에 감지할 수 있습니다.
근데, 정말 단순하게 생각해서 로그인을 CRUD 중 Read 에 해당하는 행위란 말이야~ 하고 스프링 시큐리티를 사용하지 않고 단순히 데이터베이스에서 사용자 정보를 조회하는 것만으로는 보안상의 취약점이 발생할 수 있습니다. (ex. 사용자 인증 없이 누구나 사용자 정보에 접근하는것... )
그래서 스프링 시큐리티는 아래와 같은 기능을 제공하여, 단순히 데이터를 읽는 것 이상의 보안 조치를 요구합니다.
1) 인증(Authentication)
사용자가 누구인지 확인하는 과정입니다. 이 과정에서 사용자의 신원을 확인하기 위해 사용자 이름과 비밀번호를 검증하고, 필요한 경우 더 강력한 인증 수단(예: 이중 인증)을 요구할 수 있습니다.
2) 인가(Authorization)
인증된 사용자가 특정 자원에 접근할 수 있는 권한이 있는지 검사합니다. 예를 들어, 관리자만 접근할 수 있는 페이지나 기능이 있을 경우, 스프링 시큐리티는 해당 사용자가 관리자 권한을 가지고 있는지 확인합니다.
3) 보안 강화
세션 관리, 요청 검증, CSRF(Cross-Site Request Forgery) 보호 등 다양한 보안 기능을 통해 애플리케이션의 보안을 강화합니다.
4) 개인화 및 감사
사용자별로 서비스를 개인화하고, 사용자의 활동을 로깅하여 시스템의 보안 감사에 활용할 수 있습니다.
따라서, 스프링 시큐리티의 Principal 객체는 이 모든 과정에서 중요한 역할을 하며, 애플리케이션에서 보안을 유지하는 데 필수적인 도구입니다. (따봉시큐리티야고마워~
4-3. principal.getName() 이 사용자의 이름이 아닌 이메일을 반환하는 이유
principal.getName() 메소드는 Principal 인터페이스에서 가장 기본적인 메소드 중 하나로, 일반적으로 사용자의 "이름"을 반환합니다.
하지만, 웹 애플리케이션에서 사용자의 이름보다는 이메일 주소가 더 유일하고 식별하기 쉬운 정보로 사용되는 경우가 많습니다. 특히, 로그인 기능에서는 이메일 주소를 사용자 ID로 사용하는 것이 일반적입니다.
(보통 동명이인이 있을 수 있기 때문에, 이름에는 unique = true 체크를 하지 않습니다. 이메일 중복 체크를 하며, unique = true 옵션을 설정하기 때문에 이메일이 이름보다 더 유일한 값이라고 볼 수 있습니다.)
따라서, 많은 시스템에서 principal.getName()은 사용자의 유니크한 식별자인 이메일을 반환하도록 설정됩니다. 이렇게 이메일을 반환하도록 설정하는 것은 다음과 같은 이점이 있습니다:
1) 식별 용이성
이메일은 대부분의 시스템에서 유저를 식별할 수 있는 유니크한 값입니다.
2) 통합 로그인 처리
소셜 로그인과 같이 다양한 방식의 인증을 동일한 방식으로 처리할 수 있어 시스템의 일관성을 유지할 수 있습니다.
principal.getName() 메소드를 이메일을 반환하도록 구현하는 것은 보안과 효율성, 그리고 시스템 관리의 편의성을 높이는 중요한 방법입니다. 따라서, 소셜 로그인에서도 이 메소드가 이메일을 반환하도록 구성하는 것은 사용자 관리를 통일하고 간소화하는 데 큰 도움이 됩니다.
4-4. Principal 기능을 확장해서 사용하는 방법
사용자의 유니크한 이름 값을 반환하는 principal.getName() 메소드는 java.security.Principal 인터페이스에서 제공하고 있습니다.
근데 여기서는 정말 getName() 메소드만 정의하고 있기 때문에, 스프링 시큐리티에서는 Principal 을 확장하여 더 많은 정보와 기능을 제공하는 커스텀 구현체를 사용합니다.
스프링 시큐리티의 Authentication 인터페이스는 Principal 을 확장하고 있으며, 아래와 같은 추가 메소드를 제공하여 인증된 사용자의 다양한 정보를 반환할 수 있습니다.
1) getAuthorities()
현재 사용자에게 부여된 권한이나 역할을 GrantedAuthority 객체의 컬렉션으로 반환합니다. 이를 통해 사용자가 수행할 수 있는 작업을 결정할 수 있습니다.
2) getCredentials()
사용자 인증에 사용된 자격증명(예: 비밀번호, 토큰 등)을 반환합니다. 보안을 위해 자격증명 정보는 인증 후 종종 지워지거나 숨겨집니다.
3) getDetails()
인증 과정에서 수집된 추가 사용자 정보(예: IP 주소, 세션 ID 등)를 반환합니다. 이 정보는 인증의 컨텍스트를 이해하는 데 도움을 줄 수 있습니다.
4) getPrincipal()
실제 사용자 객체 또는 주 식별 정보를 반환합니다. getName()과 유사하지만, 보다 복잡한 객체를 반환할 수 있습니다.
5) isAuthenticated()
현재 사용자의 인증 여부를 나타내는 boolean 값을 반환합니다. 이는 특정 작업을 수행하기 전에 사용자의 인증 상태를 체크하는 데 사용됩니다.
스프링 시큐리티의 Authentication 인터페이스를 통해 사용자의 인증 상태를 관리하고 확인할 수 있으며, 각 애플리케이션의 보안 요구에 맞게 이 인터페이스를 활용해서 세밀한 접근 제어와 보안 로직을 구현할 수 있습니다.
5. 정리
이렇게 principal.getName() 메소드가 반환하는 정보가 다른 문제점을 파악하고, 코드를 수정하면서 Principal 객체의 기능과 이점, 그리고 스프링 시큐리티를 활용하여 Principal 기능을 확장하여 이용하는 방법까지 알 수 있었습니다.
그리고, 기존에는 단위테스트 진행 후 바로 로컬 환경에서 기능을 직접 테스트하는 과정으로 검증을 진행했는데, 디버깅 모드를 활용해서 어느 부분에서 문제가 발생하는지 정확하게 파악할 수 있는 연습을 할 수 있는 시간이었습니다...
라고 마무리를 해보겠습니다... 고생했다 나자신!
그럼 20000~
🍀
좋아하는 것을 계속 좋아하세요!
반드시 행복해집니다
[Github] https://github.com/chujaeyeong
[E-mail] chujy1224@gmail.com
'Project' 카테고리의 다른 글
| Swagger 도구를 사용한 API 문서 작성 방법과 사용 시 이점 - 쇼핑몰 프로젝트 06 (1) | 2024.06.09 |
|---|---|
| JPA CascadeType 설정으로 인한 리뷰 삭제 문제 해결하기 - 쇼핑몰 프로젝트 05 (2) | 2024.06.08 |
| 여러 유형의 회원을 하나의 엔티티로 관리하기 - 쇼핑몰 개인 프로젝트 03 (0) | 2024.05.27 |
| Spring Boot 3.x + Spring Security 6 + OAuth2 소셜 로그인의 흐름 - 쇼핑몰 개인 프로젝트 02 (1) | 2024.05.16 |
| Spring Boot 3.x + Spring Security 6 + OAuth2 활용한 소셜 로그인 기능 구현하기 - 쇼핑몰 개인 프로젝트 01 (2) | 2024.05.15 |