Skip to content

[케이] Chapter 8. Spring Security - Security 구조, 폼 로그인#114

Open
harim789 wants to merge 14 commits into
kei/mainfrom
kei/#110
Open

[케이] Chapter 8. Spring Security - Security 구조, 폼 로그인#114
harim789 wants to merge 14 commits into
kei/mainfrom
kei/#110

Conversation

@harim789
Copy link
Copy Markdown

✏️ 작업 내용

Spring Security 도입

  • build.gradle에 Spring Security 의존성 추가
  • SecurityConfig 신규 작성 (Public/Private 분리, formLogin, logout, exceptionHandling)
  • PasswordEncoder Bean으로 BCryptPasswordEncoder 등록

회원가입 API 구현

  • MemberReqDTO.SignUp에 이미 정의된 email, password 활용
  • Member 엔티티에 password 필드 추가
  • 폼 회원가입을 위해 socialUid는 nullable, socialTypeLOCAL 기본값 처리
  • profileUrl@Builder.Default로 기본 URL 박아둠
  • SocialType enum에 LOCAL 추가
  • MemberRepositoryexistsByEmail, findByEmail 쿼리 메서드 추가
  • FoodRepository, TermRepository 신규 작성 (findByName)
  • MemberConvertertoMember, toSignUpResponse 추가
  • MemberService.signUp 구현 (이메일 중복 체크 -> BCrypt 인코딩 -> Member 저장 -> MemberFood/MemberTerm 매핑 저장)
  • MemberController의 Mock 응답 제거 후 실제 서비스 호출로 교체
  • MemberErrorCodeFOOD_NOT_FOUND, TERM_NOT_FOUND 추가

시드 데이터 처리

  • InitDataLoader (CommandLineRunner 구현) 추가
  • Food/Term enum 값을 DB에 멱등하게 INSERT (이미 있는 값은 건너뜀)

인증/인가 처리 및 응답 통일

  • AuthMember (UserDetails 구현체): Member를 감싸는 어댑터
  • CustomUserDetailsService (UserDetailsService 구현): 이메일로 회원 조회
  • SecurityResponseWriter: 401/403 JSON 응답 공통 유틸
  • CustomEntryPoint (AuthenticationEntryPoint): 인증 실패(401) 시 통일된 JSON 응답
  • CustomAccessDenied (AccessDeniedHandler): 인가 실패(403) 시 통일된 JSON 응답

#️⃣ 연관된 이슈

closes #110


💡 함께 공유하고 싶은 부분

1. @RestControllerAdvice가 잡지 못하는 영역이 있다

CustomUserDetailsService는 Spring Security 필터 체인 안에서 호출되기 때문에 DispatcherServlet에 도달하지 못한다. 즉, 여기서 발생한 도메인 예외(MemberException 등)는 기존 GeneralExceptionAdvice가 잡지 못한다.

[잡히는 경로]   컨트롤러 → @RestControllerAdvice
[잡히지 않는 경로]   필터 체인 안 → 500 에러로 새어 나감

그래서 CustomUserDetailsService.loadUserByUsername에서는 도메인 예외 대신 Spring Security 표준 예외인 UsernameNotFoundException을 던지도록 구현했다. Spring Security의 DaoAuthenticationProvider가 이 예외를 잡아서 인증 실패로 변환해주고, 그러면 CustomEntryPoint가 동작해 통일된 401 JSON 응답이 나간다.

2. 시드 데이터 처리 필요성

회원가입 시 preferredFoods, agreedTerms를 받아서 Food/Term 테이블에서 조회한 뒤 MemberFood/MemberTerm으로 매핑한다. 그런데 food/term 테이블이 비어있으면 findByName()이 실패해서 회원가입 자체가 안 된다.

이걸 해결하려고 CommandLineRunnerInitDataLoader를 만들어서 서버 부팅 시 enum 값들을 DB에 자동 시드하도록 처리했다. filter(... isEmpty())로 DB에 없는 값만 INSERT하게 해서, 재시작할 때마다 중복 삽입되지 않도록 멱등성을 확보했다.

3. 어댑터 패턴으로 도메인과 프레임워크 분리

Member 엔티티에 직접 implements UserDetails를 박는 방식도 가능하지만, 그러면 도메인 엔티티가 Spring Security에 의존하게 된다. 그래서 AuthMember라는 어댑터 클래스를 따로 만들어 Member를 감싸는 방식으로 구현했다. 이렇게 하면 Member는 순수한 도메인 객체로 남고, Spring Security 관련 처리는 AuthMember가 전담한다.


🤔 질문

Q1. SocialType enum에 LOCAL을 추가하는 설계가 맞을까요?

폼 회원가입 회원을 구분하기 위해 SocialType enum에 LOCAL을 추가하고, social_uid를 nullable로 변경했습니다. 가장 단순한 방식이라 학습 단계에 맞다고 판단했지만, 의미적으로는 어색한 부분이 있는 것 같습니다.

  • LOCAL은 사실 소셜이 아닌데 SocialType에 포함시켜도 되는지 궁금합니다.
  • 별도 테이블 분리(LocalAuth/SocialAuth)나 AuthType enum 분리 같은 다른 방식과 비교했을 때, 실무에서는 어떤 기준으로 선택하나요?

Q2. CustomUserDetailsService에서 UsernameNotFoundException을 던지는 게 맞을까요?

워크북에서는 loadUserByUsername에서 도메인 예외를 던지는 예시도 나왔는데, 위에서 설명한 이유로 UsernameNotFoundException을 던지는 방식으로 구현했습니다. 워크북에서 도메인 예외를 사용한 의도가 따로 있었는지, 실무에서는 어떤 선택이 일반적인지 궁금합니다.

Q3. Food/Term 시드 데이터는 다른 분들은 어떻게 처리하셨나요?

회원가입에서 preferredFoods, agreedTerms 매핑을 구현하면서 시드 데이터의 필요성을 알게 됐습니다. CommandLineRunner로 처리했는데, 다른 분들은 어떻게 했는지 궁금합니다.

  • DB에 직접 INSERT 해두었는지, 저처럼 CommandLineRunner로 처리했는지 궁금합니다.

✅ 워크북 체크리스트

  • 모든 핵심 키워드 정리를 마쳤나요?
  • 핵심 키워드에 대해 완벽히 이해하셨나요?
  • 이론 학습 이후 직접 실습을 해보는 시간을 가졌나요?
  • 미션을 수행하셨나요?
  • 미션을 기록하셨나요?

✅ 컨벤션 체크리스트

  • 디렉토리 구조 컨벤션을 잘 지켰나요?
  • pr 제목을 컨벤션에 맞게 작성하였나요?
  • pr에 해당되는 이슈를 연결하였나요?
  • 적절한 라벨을 설정하였나요?
  • 스터디원들에게 code review를 요청하기 위해 reviewer를 등록하였나요?
  • 닉네임/main 브랜치의 최신 상태를 반영하고 있는지 확인했나요?

📌 주안점

  • CustomUserDetailsService에서 UsernameNotFoundException 사용
  • Member 엔티티의 변경 사항: password 필드 추가, socialUid nullable 처리, socialType/profileUrl 기본값 처리가 적절한지

@harim789 harim789 requested a review from a team May 23, 2026 07:25
@harim789 harim789 self-assigned this May 23, 2026
@harim789 harim789 added the 🚀Week 8 8주차 워크북 미션 label May 23, 2026
@harim789 harim789 linked an issue May 23, 2026 that may be closed by this pull request
@harim789 harim789 requested a review from Copilot May 23, 2026 07:25
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Spring Security를 도입하고(폼 로그인/로그아웃, 인증·인가 예외 응답 통일), 폼 기반 회원가입 API 및 회원 선호음식/약관 시드 데이터를 추가해 회원가입 플로우가 동작하도록 구성한 PR입니다.

Changes:

  • Spring Security 설정 추가 및 401/403 응답을 ApiResponse 포맷으로 통일(EntryPoint/AccessDenied/Writer, UserDetails 어댑터)
  • 폼 회원가입 구현: 이메일 중복 체크, BCrypt 인코딩, Member 저장 및 Food/Term 매핑 저장
  • Food/Term enum 기반 시드 데이터 자동 적재(부팅 시)

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
build.gradle Spring Security 의존성 추가
src/main/java/com/example/umc10th/global/config/SecurityConfig.java 보안 정책(permitAll/authenticated), formLogin/logout, 예외 처리 핸들러 연결
src/main/java/com/example/umc10th/global/config/InitDataLoader.java Food/Term 시드 데이터 적재 CommandLineRunner
src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java 401/403 공통 JSON(ApiResponse) 작성 유틸
src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java 인증 실패(401) 응답 통일
src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java 인가 실패(403) 응답 통일
src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java 이메일 기반 사용자 조회(UserDetailsService)
src/main/java/com/example/umc10th/global/security/AuthMember.java Member → UserDetails 어댑터
src/main/java/com/example/umc10th/domain/member/controller/MemberController.java 회원가입 Mock 제거 후 실제 서비스 호출
src/main/java/com/example/umc10th/domain/member/service/MemberService.java 회원가입(signUp) 구현 및 매핑 저장
src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java SignUp DTO↔Entity/Response 변환 추가
src/main/java/com/example/umc10th/domain/member/entity/Member.java password 필드 추가, socialUid nullable, 기본값 처리(socialType/profileUrl)
src/main/java/com/example/umc10th/domain/member/enums/SocialType.java LOCAL 타입 추가
src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java existsByEmail/findByEmail 추가
src/main/java/com/example/umc10th/domain/member/repository/FoodRepository.java Food 조회(findByName) 리포지토리 추가
src/main/java/com/example/umc10th/domain/member/repository/TermRepository.java Term 조회(findByName) 리포지토리 추가
src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java 회원가입 매핑 관련 에러 코드 추가(FOOD_NOT_FOUND/TERM_NOT_FOUND)

@RequiredArgsConstructor
public class SecurityResponseWriter {

private ObjectMapper objectMapper;
Comment on lines +23 to +33
private final String[] allowUris = {
// Swagger 허용
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",
"/auth/**",

// 폼 로그인 페이지 자체
"/login",
"/login/**"
};
Comment on lines +36 to +40
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> requests
.requestMatchers(allowUris).permitAll()
Comment on lines +21 to +27
.email(request.email())
.password(encodedPassword)
.phoneNumber(request.phoneNumber())
.gender(Gender.valueOf(request.gender()))
.birth(request.birth())
.address(Address.valueOf(request.address()))
.detailAddress(request.detailAddress())
Comment on lines +18 to +22
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(
"해당 이메일의 회원을 찾을 수 없습니다: " + email
));
Comment on lines +60 to +78
// 4. 선호 음식 매핑 저장
for (String foodName: request.preferredFoods()) {
com.example.umc10th.domain.member.enums.Food foodEnum;
try {
foodEnum = com.example.umc10th.domain.member.enums.Food.valueOf(foodName);
} catch (IllegalArgumentException e) {
throw new MemberException(MemberErrorCode.FOOD_NOT_FOUND);
}

Food food = foodRepository.findByName(foodEnum)
.orElseThrow(() -> new MemberException(MemberErrorCode.FOOD_NOT_FOUND));

MemberFood memberFood = MemberFood.builder()
.member(savedMember)
.food(food)
.build();

em.persist(memberFood);
}
Comment on lines +32 to +41
private void seedFoods() {
// enum의 모든 값을 순회하면서, DB에 없는 것만 INSERT
Arrays.stream(com.example.umc10th.domain.member.enums.Food.values())
.filter(foodEnum -> foodRepository.findByName(foodEnum).isEmpty())
.forEach(foodEnum -> {
foodRepository.save(Food.builder()
.name(foodEnum)
.build());
log.info("[InitDataLoader] Food 시드: {}", foodEnum);
});
Comment on lines +15 to +20
@Slf4j
@Component
@RequiredArgsConstructor
// CommandLineRunner: Spring이 제공하는 인터페이스
// 이걸 구현하면 애플리케이션이 시작된 직후에 run() 메서드가 자동으로 호출된다.
public class InitDataLoader implements CommandLineRunner {
@Eugene-Shin
Copy link
Copy Markdown

깊게 고민하신 흔적이 보여 좋은 것 같습니다!

Copy link
Copy Markdown

@CokaNuri CokaNuri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RestControllerAdvice로 잡는 도메인 예외와 시큐리티 필터 단 예외는 처리 경로가 완전히 분리되어 있다는 점에 대해 짚어주신 부분 훌륭합니다.

SecurityConfig에서 로그인,회원가입 관련 api를 public으로 열어두실 때
경로를 /api/v1/auth/** 와 같은 방식으로 수정만 하시면 될 거 같아요.

고생하셨습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🚀Week 8 8주차 워크북 미션

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chapter 8. Spring Security - Security 구조, 폼 로그인

4 participants