
이번 글에서는 기존 로그인 기능에
Spring Security인증 흐름을 연결한다.
기존 프로젝트에서는 로그인 성공 시Member.id를 세션의loginUserId에 저장했다.
하지만 현재 공구 기능 일부는Authentication.getName()으로 현재 로그인한 사용자ID를 꺼내고 있다.
따라서 이번 작업에서는 기존 로그인 검증 로직은 유지하면서, 로그인 성공 후Member.id를SecurityContext에도 저장하도록 수정한다.
이렇게 해야 팀원이 먼저 구현한@PreAuthorize기반 인가 코드가 더미 사용자가 아니라 실제 로그인 사용자 기준으로 동작한다.
이번 작업은 처음부터 로그인 기능을 새로 만드는 작업이 아니다.
이미 프로젝트에는 로그인 기능이 구현되어 있다.
기존 로그인 기능은Spring Security의 인증 객체를 사용하는 방식이 아니라,HttpSession에 로그인 사용자ID를 저장해두고 그 값을 다시 꺼내 현재 사용자를 구분하는 방식이었다.
즉, 사용자가 로그인하면 서버는loginId와password를 확인한다.
로그인에 성공하면 서버는Member.id를 세션의loginUserId라는 이름으로 저장한다.
이후 마이페이지 같은 기능에서는 세션에서loginUserId를 꺼내 현재 로그인한 사용자가 누구인지 판단한다.
이번 단계에서는 아직 코드를 수정하지 않는다.
먼저 기존 로그인 방식이 어떤 흐름으로 동작했는지 정확히 확인한다.
기존 방식의 핵심은 로그인 성공 결과를SecurityContext가 아니라HttpSession의loginUserId로 관리했다는 점이다.
1-1. 기존 로그인 요청 흐름 확인하기
기존 로그인 요청은 사용자가 로그인 화면에서 아이디와 비밀번호를 입력하면서 시작된다.
화면에서는fetch()를 사용해 서버의 로그인API로 요청을 보낸다.
이때 요청 주소는 아래와 같다.POST /api/auth/login로그인 요청에 들어가는 값은 크게 두 가지다.
loginIdpassword
loginId는 사용자가 로그인 화면에 입력한 아이디이다.
서버는 이 값으로member테이블에서 회원을 찾는다.
password는 사용자가 로그인 화면에 입력한 원문 비밀번호이다.
서버는 이 값을 그대로 저장하지 않고, 이미DB에 저장된 해시 비밀번호와 비교할 때만 사용한다.
기존 로그인 흐름은 아래처럼 이어진다.사용자가 loginId, password 입력 → POST /api/auth/login 요청 → AuthController.login() 호출 → AuthService.login() 호출 → loginId로 member 테이블에서 회원 조회 → 입력 password와 저장된 해시 password 비교 → 로그인 성공 → 세션에 loginUserId = Member.id 저장여기서 가장 중요한 부분은 마지막 흐름이다.
기존 프로젝트는 로그인에 성공했을 때Spring Security의Authentication객체에 사용자를 저장하지 않았다.
대신 서버 세션에loginUserId라는 이름으로Member.id를 저장했다.
예를 들어 사용자가 아래처럼 로그인했다고 생각하면 된다.loginId = testuser1 password = Test1234!서버는 먼저
testuser1이라는 로그인 아이디로member테이블에서 회원을 찾는다.
회원이 존재하고 비밀번호까지 맞으면, 서버는 그 회원의Member.id를 꺼낸다.
예를 들어 조회된 회원의Member.id가 아래 값이라고 하자.70698642-f42d-4aba-841e-0f4cac4a1eb1그러면 기존 로그인 성공 후 세션에는 아래 값이 저장된다.
loginUserId = 70698642-f42d-4aba-841e-0f4cac4a1eb1이후 요청에서는 사용자가 매번
loginId와password를 다시 보내지 않아도 된다.
브라우저가 같은 세션을 유지하고 있으면, 서버는 세션에서loginUserId를 꺼내 현재 로그인한 사용자를 구분할 수 있다.
정리하면 기존 로그인 방식은 아래처럼 이해하면 된다.로그인할 때 → loginId, password로 회원 확인 로그인 성공 후 → Member.id를 세션에 저장 이후 요청에서 → 세션의 loginUserId로 현재 사용자 구분따라서 1번에서 확인해야 하는 핵심은 단순히 “로그인 요청이 있다”가 아니다.
기존 프로젝트가 세션에 저장된loginUserId를 기준으로 로그인 사용자를 구분했다는 점이다.
1-2. AuthController와 AuthService가 로그인 요청을 처리하는 구조 확인하기
기존 프로젝트에서는 로그인 요청을
Spring Security의 기본 로그인 필터가 처리하지 않는다.
직접 만든 로그인 화면에서fetch()로JSON요청을 보내고, 그 요청을AuthController가 받는다.
즉, 현재 프로젝트의 기존 로그인 흐름은 아래처럼 나누어 볼 수 있다.
AuthController.java는 로그인 요청을 받는다.AuthService.java는 회원 조회, 비밀번호 검증, 세션 저장을 처리한다.이렇게 나누어 보면 로그인 요청을 받는 코드와 실제 로그인 검증을 처리하는 코드가 구분된다.
이제 두 파일을 따로 확인한다.
AuthController.java 확인
먼저
AuthController.java는 로그인 요청을 받는 파일이다.
현재 로그인 요청 주소는POST /api/auth/login이다.
아래 코드는AuthController.java의 전체 흐름이다.
import부분은 흐름 확인에 꼭 필요하지 않으므로 생략하고, 기존 로그인 구조에서 확인해야 할 부분만 주석으로 표시한다.@Controller @RequiredArgsConstructor @Validated @RequestMapping("/api/auth") public class AuthController { private final AuthService authService; @ResponseBody @GetMapping("/login-ids/check") public ApiResponse<AuthResponse.DuplicateCheck> checkLoginId( @RequestParam("loginId") @NotBlank(message = "로그인 아이디를 입력해주세요.") @Size(min = 4, max = 20, message = "로그인 아이디는 4자 이상 20자 이하로 입력해주세요.") @Pattern(regexp = "^[a-z0-9]+$", message = "로그인 아이디는 영문 소문자와 숫자만 사용할 수 있습니다.") String loginId ) { AuthResponse.DuplicateCheck response = authService.checkLoginId(loginId); return ApiResponse.success(response); } @ResponseBody @GetMapping("/nicknames/check") public ApiResponse<AuthResponse.DuplicateCheck> checkNickname( @RequestParam("nickname") @NotBlank(message = "닉네임을 입력해주세요.") @Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하로 입력해주세요.") @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") String nickname ) { AuthResponse.DuplicateCheck response = authService.checkNickname(nickname); return ApiResponse.success(response); } @ResponseBody @PostMapping("/signup") public ApiResponse<AuthResponse.SignupResult> signup( @Valid @RequestBody AuthRequest.Signup request ) { AuthResponse.SignupResult response = authService.signup(request); return ApiResponse.success(response); } @ResponseBody @PostMapping("/login") // 확인: 기존 로그인 요청은 POST /api/auth/login으로 들어온다. public ApiResponse<AuthResponse.LoginResult> login( @Valid @RequestBody AuthRequest.Login request, // 확인: loginId, password를 JSON 요청 본문으로 받는다. HttpSession session // 확인: 로그인 성공 후 세션에 Member.id를 저장하기 위해 AuthService로 전달한다. ) { AuthResponse.LoginResult response = authService.login(request, session); // 확인: 실제 로그인 검증과 세션 저장은 AuthService에서 처리한다. return ApiResponse.success(response); // 확인: 로그인 성공 응답을 공통 응답 구조로 반환한다. } @ResponseBody @PostMapping("/logout") public ApiResponse<Void> logout( HttpSession session ) { authService.logout(session); return ApiResponse.success(null); } }위 전체 코드에서 기존 로그인 요청에 직접 해당하는 부분만 다시 뽑으면 아래와 같다.
@ResponseBody @PostMapping("/login") // 확인: 기존 로그인 요청은 POST /api/auth/login으로 들어온다. public ApiResponse<AuthResponse.LoginResult> login( @Valid @RequestBody AuthRequest.Login request, // 확인: loginId, password를 JSON 요청 본문으로 받는다. HttpSession session // 확인: 로그인 성공 후 세션에 Member.id를 저장하기 위해 AuthService로 전달한다. ) { AuthResponse.LoginResult response = authService.login(request, session); // 확인: 실제 로그인 검증과 세션 저장은 AuthService에서 처리한다. return ApiResponse.success(response); // 확인: 로그인 성공 응답을 공통 응답 구조로 반환한다. }이 코드에서
AuthController의 역할은 로그인 요청을 받는 것이다.
@RequestBody는 화면에서 보낸JSON요청 본문을AuthRequest.Login객체로 바꿔준다.
@Valid는loginId와password가 비어 있는지 확인한다.
HttpSession은 로그인 성공 후 세션에 값을 저장하기 위해AuthService로 전달된다.
즉,AuthController는 현재 사용자가 맞는지 직접 판단하지 않는다.
AuthController는 요청을 받고, 실제 로그인 검증은AuthService.login()에 맡긴다.
AuthService.java 확인
다음으로
AuthService.java는 실제 로그인 검증을 처리하는 파일이다.
AuthController가 로그인 요청을 받아 넘기면,AuthService.login()이loginId로 회원을 찾고 비밀번호를 검증한다.
검증에 성공하면 세션에Member.id를 저장한다.
아래 코드는AuthService.java의 전체 흐름이다.
import부분은 생략하고, 기존 로그인 구조에서 확인해야 할 부분만 주석으로 표시한다.@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AuthService { private final MemberRepository memberRepository; // 확인: loginId로 회원을 조회할 때 사용한다. private final UserNearbyAddressRepository userNearbyAddressRepository; private final AddressService addressService; private final PasswordEncoder passwordEncoder; // 확인: 입력 비밀번호와 저장된 해시 비밀번호를 비교할 때 사용한다. private final LoginSessionManager loginSessionManager; // 확인: 로그인 성공 후 세션에 Member.id를 저장할 때 사용한다. public AuthResponse.DuplicateCheck checkLoginId(String loginId) { boolean exists = memberRepository.existsByLoginId(loginId); return new AuthResponse.DuplicateCheck(!exists); } public AuthResponse.DuplicateCheck checkNickname(String nickname) { boolean exists = memberRepository.existsByNickname(nickname); return new AuthResponse.DuplicateCheck(!exists); } @Transactional public AuthResponse.SignupResult signup(AuthRequest.Signup request) { validatePasswordConfirm(request.password(), request.passwordConfirm()); validateTermsAgreed(request.termsAgreed()); validateDuplicateLoginId(request.loginId()); validateDuplicateNickname(request.nickname()); Member member = Member.builder() .loginId(request.loginId()) .password(passwordEncoder.encode(request.password())) .nickname(request.nickname()) .address(request.address()) .entX(request.entX()) .entY(request.entY()) .build(); Member savedMember = memberRepository.save(member); List<UserNearbyAddress> nearbyAddresses = addressService.createNearbyAddresses( savedMember, request.entX(), request.entY() ); userNearbyAddressRepository.saveAll(nearbyAddresses); return new AuthResponse.SignupResult(savedMember.getId()); } private void validatePasswordConfirm( String password, String passwordConfirm ) { if (!Objects.equals(password, passwordConfirm)) { throw new BusinessException(ErrorCode.PASSWORD_NOT_MATCH); } } private void validateTermsAgreed(Boolean termsAgreed) { if (!Boolean.TRUE.equals(termsAgreed)) { throw new BusinessException(ErrorCode.TERMS_NOT_AGREED); } } private void validateDuplicateLoginId(String loginId) { if (memberRepository.existsByLoginId(loginId)) { throw new BusinessException(ErrorCode.DUPLICATED_LOGIN_ID); } } private void validateDuplicateNickname(String nickname) { if (memberRepository.existsByNickname(nickname)) { throw new BusinessException(ErrorCode.DUPLICATED_NICKNAME); } } public AuthResponse.LoginResult login( AuthRequest.Login request, HttpSession session ) { // 확인: 사용자가 입력한 loginId로 member 테이블에서 회원을 조회한다. Member member = memberRepository.findByLoginId(request.loginId()) .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED)); // 확인: 사용자가 입력한 원문 비밀번호와 DB에 저장된 해시 비밀번호를 비교한다. if (!passwordEncoder.matches(request.password(), member.getPassword())) { throw new BusinessException(ErrorCode.LOGIN_FAILED); } // 확인: 로그인 성공 시 세션에 loginId가 아니라 Member.id를 저장한다. loginSessionManager.login(session, member.getId()); // 확인: 로그인 성공 응답에는 비밀번호를 제외한 회원 정보를 담아 반환한다. return new AuthResponse.LoginResult( member.getId(), member.getLoginId(), member.getNickname(), member.getAddress(), member.getRadius(), member.getEntX(), member.getEntY() ); } public void logout(HttpSession session) { loginSessionManager.requireLoginUserId(session); loginSessionManager.logout(session); } }위 전체 코드에서 기존 로그인 처리에 직접 해당하는 부분만 다시 뽑으면 아래와 같다.
public AuthResponse.LoginResult login( AuthRequest.Login request, HttpSession session ) { // 확인: 사용자가 입력한 loginId로 member 테이블에서 회원을 조회한다. Member member = memberRepository.findByLoginId(request.loginId()) .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED)); // 확인: 사용자가 입력한 원문 비밀번호와 DB에 저장된 해시 비밀번호를 비교한다. if (!passwordEncoder.matches(request.password(), member.getPassword())) { throw new BusinessException(ErrorCode.LOGIN_FAILED); } // 확인: 로그인 성공 시 세션에 loginId가 아니라 Member.id를 저장한다. loginSessionManager.login(session, member.getId()); // 확인: 로그인 성공 응답에는 비밀번호를 제외한 회원 정보를 담아 반환한다. return new AuthResponse.LoginResult( member.getId(), member.getLoginId(), member.getNickname(), member.getAddress(), member.getRadius(), member.getEntX(), member.getEntY() ); }이 코드에서 기존 로그인 방식의 핵심은 세션 저장이다.
회원 조회와 비밀번호 검증이 끝난 뒤 아래 코드가 실행된다.loginSessionManager.login(session, member.getId());이 코드는 로그인 성공 사용자의
Member.id를 세션에 저장한다.
그래서 이후 기능에서는 다시 비밀번호를 확인하지 않고, 세션에 저장된loginUserId를 기준으로 현재 사용자를 구분할 수 있다.
정리하면AuthController와AuthService는 기존 로그인 구조에서 아래 역할을 나눠 가진다.
AuthController는POST /api/auth/login요청을 받는다.AuthController는 요청값과HttpSession을AuthService로 넘긴다.AuthService는loginId로 회원을 조회한다.AuthService는passwordEncoder.matches()로 비밀번호를 검증한다.AuthService는 로그인 성공 시LoginSessionManager를 통해 세션에Member.id를 저장한다.따라서 기존 프로젝트는 이미 로그인 검증 자체는 처리하고 있었다.
문제는 이 결과가Spring Security의SecurityContext에 저장되지 않았다는 점이다.
이 부분은 뒤에서 실제 인증 적용 단계에서 수정한다.
1-3. loginId와 password가 기존 로그인 검증값으로 사용되는 이유 설명하기
기존 로그인 방식에서는 사용자가 입력한
loginId와password를 기준으로 회원을 확인했다.
여기서 중요한 점은loginId와password를 그대로 로그인 상태로 저장하지 않았다는 것이다.
이 두 값은 로그인 순간에만 사용되는 검증값이다.
이제 이 흐름을AuthService.java기준으로 다시 확인한다.
아래 코드는AuthService.java의login()메서드 안에 있는 회원 조회 코드이다.// AuthService.java의 login() 메서드 내부 Member member = memberRepository.findByLoginId(request.loginId()) .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED));이 코드는 사용자가 입력한
loginId로member테이블에서 회원을 조회한다.
즉,loginId는 어떤 회원을 찾을지 결정하는 값이다.
예를 들어 사용자가 아래처럼 입력했다고 하자.loginId = minju password = abc1234!서버는 먼저
minju라는 로그인 아이디를 가진 회원이member테이블에 있는지 찾는다.
회원이 없으면 아래 코드의orElseThrow()가 실행되고, 로그인 실패 예외가 발생한다.// AuthService.java의 login() 메서드 내부 .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED));이때 실패 메시지는 “해당 아이디가 없습니다.”처럼 구체적으로 나누지 않는다.
회원이 없는 경우와 비밀번호가 틀린 경우를 모두LOGIN_FAILED로 처리한다.
그래야 사용자가 존재하는 아이디를 추측하기 어렵다.
그 다음password는 조회된 회원이 실제 사용자가 맞는지 확인하는 값이다.
아래 코드는AuthService.java의login()메서드 안에 있는 비밀번호 검증 코드이다.// AuthService.java의 login() 메서드 내부 if (!passwordEncoder.matches(request.password(), member.getPassword())) { throw new BusinessException(ErrorCode.LOGIN_FAILED); }여기서
request.password()는 사용자가 입력한 원문 비밀번호이다.
member.getPassword()는 회원가입 때DB에 저장된 해시 비밀번호이다.
두 값은 모양이 다르기 때문에 단순 문자열 비교를 하면 안 된다.
예를 들어 사용자가 회원가입 때Test1234!를 입력했다고 해도,DB에는 보통 아래처럼 해시 처리된 값이 저장된다.$2a$10$u0X...생략...그래서 로그인할 때 아래처럼 비교하면 안 된다.
// 잘못된 비교 방식 request.password().equals(member.getPassword());사용자가 입력한 값은 원문 비밀번호이고,
DB에 저장된 값은 해시 비밀번호이기 때문이다.
그래서 기존 프로젝트에서는 아래처럼PasswordEncoder.matches()를 사용했다.// AuthService.java의 login() 메서드 내부 // 기존 프로젝트의 올바른 비교 방식 passwordEncoder.matches(request.password(), member.getPassword());이 방식은 사용자가 입력한 원문 비밀번호가 저장된 해시 비밀번호와 같은 의미인지 확인한다.
정리하면 기존 로그인 방식에서loginId와password의 역할은 아래와 같다.loginId → member 테이블에서 회원을 찾기 위한 값 password → 조회된 회원이 실제 사용자가 맞는지 확인하기 위한 값 Member.id → 로그인 성공 후 세션에 저장되는 실제 사용자 구분값즉, 기존 프로젝트는
loginId와password를 이용해 회원을 검증하고, 검증이 끝나면Member.id를 세션에 저장해 이후 요청에서 사용자를 구분했다.
1-4. 로그인 성공 후 HttpSession의 loginUserId에 Member.id가 저장되는 흐름 확인하기
기존 프로젝트에서 로그인 성공 후 가장 중요한 흐름은 세션 저장이다.
로그인에 성공하면 서버는 사용자가 입력한loginId를 세션에 저장하지 않는다.
대신 서버가 조회한 회원의 고유 식별자인Member.id를 저장한다.
이 흐름은AuthService.login()안의 아래 코드에서 시작된다.// AuthService.java의 login() 메서드 내부 loginSessionManager.login(session, member.getId());이 코드는
LoginSessionManager에게 현재 세션과 로그인한 회원의id를 전달한다.
그러면LoginSessionManager는 세션에 정해진 이름으로 값을 저장한다.
먼저 현재LoginSessionManager.java의 흐름을 보면 아래와 같다.@Component public class LoginSessionManager { public void login( HttpSession session, String userId ) { session.setAttribute(SessionConst.LOGIN_USER_ID, userId); // 확인: 세션에 loginUserId라는 이름으로 Member.id를 저장한다. } public void logout(HttpSession session) { session.invalidate(); // 확인: 로그아웃 시 현재 세션을 무효화한다. } public String getLoginUserId(HttpSession session) { Object value = session.getAttribute(SessionConst.LOGIN_USER_ID); // 확인: 세션에서 loginUserId 값을 꺼낸다. if (value == null) { return null; } return (String) value; // 확인: 세션에 저장된 Member.id를 String으로 반환한다. } public String requireLoginUserId(HttpSession session) { String loginUserId = getLoginUserId(session); if (loginUserId == null) { throw new BusinessException(ErrorCode.LOGIN_REQUIRED); // 확인: 세션에 loginUserId가 없으면 로그인 필요 예외를 발생시킨다. } return loginUserId; } }이 전체 코드에서 세션 저장에 직접 해당하는 부분만 다시 뽑아보면 아래와 같다.
public void login( HttpSession session, String userId ) { session.setAttribute(SessionConst.LOGIN_USER_ID, userId); // 확인: 세션에 loginUserId라는 이름으로 Member.id를 저장한다. }
SessionConst.LOGIN_USER_ID의 실제 값은 아래와 같다.public static final String LOGIN_USER_ID = "loginUserId";따라서 로그인 성공 후 세션에는 아래와 같은 형태로 값이 저장된다.
세션 키: loginUserId 세션 값: Member.id예를 들어 로그인한 회원의
Member.id가 아래 값이라고 생각해보자.70698642-f42d-4aba-841e-0f4cac4a1eb1그러면 로그인 성공 후 세션에는 아래처럼 저장된다.
loginUserId = 70698642-f42d-4aba-841e-0f4cac4a1eb1이후 마이페이지처럼 로그인이 필요한 기능에서는 세션에서 이 값을 꺼내 사용한다.
대표 흐름은 아래와 같다.마이페이지 요청 → HttpSession 전달 → LoginSessionManager.requireLoginUserId(session) → 세션에서 loginUserId 조회 → loginUserId 값으로 현재 회원 판단이 구조에서는 브라우저가 같은 세션을 유지하고 있어야 한다.
로그인 성공 후 같은 브라우저에서 요청을 보내면 서버는 세션에 저장된loginUserId를 찾을 수 있다.
반대로 세션이 없거나 만료되면 서버는 현재 사용자를 알 수 없기 때문에 로그인 필요 상태로 처리한다.
정리하면 기존 프로젝트는 아래 기준으로 사용자를 구분했다.
- 로그인 순간에는
loginId와password로 회원을 검증한다.- 로그인 성공 후에는
Member.id를 세션에 저장한다.- 이후 요청에서는 세션의
loginUserId로 현재 사용자를 판단한다.기존 로그인 방식은 세션의
loginUserId를 기준으로 현재 사용자를 구분하는 구조였다.
1-5. 공부한 개념 연결: 인증은 사용자가 누구인지 확인하는 과정이다
공부했던
Spring Security개념에서 인증은 사용자가 누구인지 확인하는 과정이라고 정리했다.
다만 이 문장을 그냥 개념으로만 보면 현재 프로젝트와 연결이 잘 안 된다.
이번 프로젝트 기준으로 보면, 기존 인증 확인은Spring Security가 아니라 직접 만든 로그인 코드에서 처리했다.
기존 프로젝트의 인증 확인 흐름은 아래처럼 볼 수 있다.사용자가 loginId, password 입력 → 서버가 loginId로 회원 조회 → 서버가 password를 해시 비밀번호와 비교 → 맞으면 로그인 성공 → Member.id를 세션에 저장즉, 기존 프로젝트에서 사용자가 누구인지 확인하는 작업은
AuthService.login()이 담당했다.
그리고 확인이 끝난 뒤 서버는 그 결과를HttpSession에 저장했다.
이 부분이 공부했던Spring Security기본 흐름과 다른 점이다.
공부했던 기본 흐름에서는 인증 성공 정보가SecurityContextHolder에 저장된다고 했다.
하지만 기존 프로젝트에서는 인증 성공 정보가SecurityContextHolder가 아니라 세션의loginUserId에 저장되었다.
그래서 현재 상태를 비교하면 아래와 같다.공부했던 Spring Security 인증 성공 흐름 → 인증 성공 정보가 SecurityContext에 저장됨 기존 프로젝트 로그인 성공 흐름 → Member.id가 HttpSession의 loginUserId에 저장됨이 차이 때문에 이후 문제가 생긴다.
공구 기능 일부는 이미Authentication.getName()으로 현재 로그인한 사용자ID를 꺼내고 있다.
그런데 기존 로그인 방식은Authentication을 만들지 않고 세션에만 값을 저장했다.
따라서 기존 로그인 성공만으로는Authentication.getName()을 사용하는 코드와 자연스럽게 연결되지 않는다.
이 문제를 해결하려면 기존 세션 저장은 유지하면서, 로그인 성공 시SecurityContext에도 같은Member.id를 저장해야 한다.
정리하면 이번 작업의 연결 기준은 아래와 같다.기존 세션 방식 유지 → loginUserId = Member.id Spring Security 인증 정보 추가 → Authentication.getName() = Member.id이렇게 맞추면 기존 세션 기반 코드와
Authentication기반 코드가 같은 사용자를 바라볼 수 있다.
이번 인증 적용의 핵심은 세션의loginUserId와Authentication.getName()이 같은Member.id를 기준으로 동작하게 만드는 것이다.
1-6. 이번 단계에서 수정하지 않고 확인만 하는 파일 정리하기
이번 1번 단계에서는 코드를 수정하지 않는다.
현재 프로젝트의 기존 인증 구조를 먼저 확인하는 단계이다.
확인한 파일은 아래와 같다.
AuthController.javaAuthService.javaLoginSessionManager.javaSessionConst.java각 파일의 역할은 아래처럼 정리할 수 있다.
AuthController.java는POST /api/auth/login요청을 받는다.AuthController.java는 요청DTO와HttpSession을AuthService로 넘긴다.AuthService.java는loginId로 회원을 조회한다.AuthService.java는PasswordEncoder.matches()로 비밀번호를 검증한다.AuthService.java는 로그인 성공 시LoginSessionManager.login()을 호출한다.LoginSessionManager.java는 세션에loginUserId라는 이름으로Member.id를 저장한다.SessionConst.java는loginUserId세션 키를 상수로 관리한다.이번 단계에서 중요한 결론은 기존 프로젝트가 이미 로그인 검증과 세션 기반 사용자 구분을 처리하고 있었다는 점이다.
다만 이 결과가 아직Spring Security의SecurityContext와 연결되어 있지 않았다.
그래서 다음 단계에서는 현재 프로젝트의 인가 구조를 확인한다.
특히 공구 기능에서Authentication.getName()을 이미 사용하고 있기 때문에, 기존 세션 로그인 구조와 인가 코드 사이에 어떤 차이가 있는지 확인해야 한다.
1번에서는 기존 프로젝트의 로그인 방식이 어떻게 동작했는지 확인했다.
기존 방식에서는 사용자가loginId와password로 로그인에 성공하면, 서버가Member.id를 세션의loginUserId에 저장했다.
이후 마이페이지 같은 기능은 세션에서loginUserId를 꺼내 현재 로그인한 사용자를 구분했다.
그런데 현재 공구 기능 쪽 코드는 세션에서loginUserId를 꺼내는 방식이 아니라,Spring Security의Authentication객체를 사용하는 방식으로 작성되어 있다.
즉, 기존 로그인 흐름은HttpSession기준이고, 공구 인가 흐름은Authentication기준이다.
그래서 이번 단계에서는 바로 코드부터 보지 않고, 먼저 인가가 무엇인지부터 정리한다.
인가 개념을 이해해야@PreAuthorize,Authentication.getName(),GroupBuyingSecurityEvaluator.java가 왜 필요한지 자연스럽게 연결된다.
인가의 핵심은 로그인한 사용자가 이 기능을 사용해도 되는지 확인하는 것이다.
2-0. 인가가 무엇인지 먼저 이해하기
인가는 로그인한 사용자가 특정 기능을 사용할 수 있는지 확인하는 과정이다.
쉽게 말하면 “너 이거 해도 돼?”를 검사하는 단계이다.
여기서 먼저 인증과 인가를 구분해야 한다.
인증은 사용자가 누구인지 확인하는 과정이다.
예를 들어loginId와password를 입력해서 실제 회원인지 확인하는 것이 인증이다.
반면 인가는 인증된 사용자가 어떤 기능까지 사용할 수 있는지 확인하는 과정이다.
로그인에 성공했다고 해서 모든 기능을 사용할 수 있는 것은 아니다.
예를 들어 공구 서비스에서는 로그인한 사용자라도 아무 공구나 수정하거나 삭제하면 안 된다.
예를 들어 아래 상황을 생각하면 된다.사용자 A → 공구 1번의 주최자 사용자 B → 공구 1번의 일반 참여자 사용자 C → 공구 1번과 아무 관계 없는 로그인 사용자세 사용자는 모두 로그인한 사용자일 수 있다.
즉, 인증은 모두 성공한 상태이다.
하지만 공구 1번을 수정할 수 있는 사용자는 다르다.
공구 1번의 주최자인 사용자 A만 수정할 수 있어야 한다.
사용자 B와 사용자 C는 로그인은 했지만, 공구 1번을 수정할 권한은 없다.
이 차이를 검사하는 것이 인가이다.인증 → 로그인한 사용자인가? 인가 → 이 공구를 수정해도 되는 사용자인가?공구 기능에서는 인가가 특히 중요하다.
공구 생성은 로그인한 사용자라면 가능할 수 있다.
하지만 공구 수정, 삭제, 참여, 참여 취소 같은 기능은 단순 로그인 여부만으로 판단하면 안 된다.
예를 들어 공구 수정은 아래 조건을 확인해야 한다.
- 현재 사용자가 해당 공구의 주최자인가?
- 공구 상태가 아직 모집 중인가?
- 이미 일반 참여자가 생기지는 않았는가?
공구 참여도 마찬가지다.
로그인한 사용자라고 해서 무조건 참여할 수 있는 것이 아니다.
- 현재 사용자가 공구 주최자는 아닌가?
- 이미 참여한 사용자는 아닌가?
- 공구 상태가 모집 중인가?
이런 조건을 확인하지 않으면 문제가 생긴다.
로그인만 한 사용자가 남의 공구를 수정하거나, 주최자가 자기 공구에 참여하거나, 이미 참여한 사용자가 중복 참여하는 상황이 생길 수 있다.
그래서 현재 프로젝트에서는 공구 기능에@PreAuthorize와GroupBuyingSecurityEvaluator.java를 사용한다.
@PreAuthorize는Controller메서드가 실행되기 전에 접근 가능 여부를 먼저 검사한다.
GroupBuyingSecurityEvaluator.java는 공구 주최자 확인, 참여 가능 여부 확인 같은 세부 인가 조건을 검사하는 파일이다.
정리하면 이번 2번에서 확인할 흐름은 아래와 같다.기존 인증 결과 → 로그인 성공 사용자 존재 공구 기능 접근 → @PreAuthorize가 먼저 검사 세부 인가 조건 → GroupBuyingSecurityEvaluator.java가 검사 현재 사용자 확인 → Authentication.getName() 사용여기서 가장 중요한 값은
Authentication.getName()이다.
현재 프로젝트의 공구 인가 코드는Authentication.getName()이 실제 로그인한 사용자의Member.id를 반환한다고 가정하고 작성되어 있다.
따라서 2번에서는Authentication.getName()이 어느 파일의 어느 메서드에서 쓰이고, 왜 이 값이 실제Member.id여야 하는지 확인한다.
2-1. 공구 기능에서 Authentication.getName()을 사용하는 코드 확인하기
공구 기능에서는 현재 로그인한 사용자
ID를 가져올 때 세션의loginUserId를 직접 꺼내지 않는다.
대신Controller메서드의 매개변수로Authentication을 받고, 그 안에서getName()을 호출한다.
다만authentication.getName()은 여러 메서드에서 반복해서 사용된다.
그래서 모든 사용 위치를 한 줄씩 따로 보여주면 오히려 흐름이 잘 보이지 않는다.
이번 구간에서는 대표 메서드 하나를 기준으로Authentication을 받고, 사용자ID를 꺼내고,Service로 넘기는 흐름을 확인한다.
대표로 볼 파일과 메서드는 아래와 같다.
- 파일:
GroupBuyingController.java- 메서드:
addGroupBuying()- 역할: 공구 생성 요청 처리
GroupBuyingController.java의addGroupBuying()메서드는 공구 생성 요청을 처리한다.
공구를 생성하려면 현재 로그인한 사용자가 누구인지 알아야 한다.
그래야 생성된 공구의 주최자를 현재 사용자로 저장할 수 있기 때문이다.
아래 코드는GroupBuyingController.java의addGroupBuying()메서드이다.// GroupBuyingController.java의 addGroupBuying() 메서드 @PostMapping() @PreAuthorize("isAuthenticated()") // 확인: 로그인한 사용자만 공구 생성 요청을 보낼 수 있다. public String addGroupBuying( @Valid @ModelAttribute GroupBuyingRequest.Create request, @RequestParam(value = "images", required = false) List<MultipartFile> images, Authentication authentication // 확인: Spring Security 인증 객체를 매개변수로 받는다. ) { String loggedInUserId = authentication.getName(); // 확인: 현재 로그인한 사용자 ID를 Authentication에서 꺼낸다. GroupBuyingResponse.Create res = groupBuyingService.addGroupBuying(request, images, loggedInUserId); // 확인: 현재 사용자 ID를 Service로 넘긴다. return "redirect:/group-buyings/" + res.groupBuyingId(); }이 메서드에서 먼저 봐야 할 부분은 매개변수의
Authentication authentication이다.Authentication authentication이 코드는
Spring Security가 현재 요청의 인증 정보를Controller메서드에 전달해주는 부분이다.
기존 세션 방식처럼HttpSession session을 직접 받는 것이 아니라,Spring Security가 관리하는 인증 객체를 받는다.
그 다음 현재 사용자ID를 아래 코드로 꺼낸다.String loggedInUserId = authentication.getName();여기서
loggedInUserId에는 현재 로그인한 사용자의Member.id가 들어와야 한다.
그래야 공구 생성 로직에서 이 사용자를 공구 주최자로 저장할 수 있다.
마지막으로 꺼낸 사용자ID를Service로 넘긴다.GroupBuyingResponse.Create res = groupBuyingService.addGroupBuying(request, images, loggedInUserId);즉,
addGroupBuying()메서드의 흐름은 아래처럼 볼 수 있다.공구 생성 요청 → @PreAuthorize("isAuthenticated()")로 로그인 여부 확인 → Authentication authentication 매개변수로 현재 인증 객체 받기 → authentication.getName()으로 현재 사용자 ID 꺼내기 → groupBuyingService.addGroupBuying()으로 현재 사용자 ID 전달 → Service에서 이 사용자를 공구 주최자로 사용예를 들어 실제 로그인한 회원의
Member.id가 아래 값이라고 하자.70698642-f42d-4aba-841e-0f4cac4a1eb1그러면
addGroupBuying()안의 값 흐름은 아래처럼 되어야 한다.Authentication.getName() → 70698642-f42d-4aba-841e-0f4cac4a1eb1 loggedInUserId → 70698642-f42d-4aba-841e-0f4cac4a1eb1 groupBuyingService.addGroupBuying(request, images, loggedInUserId) → 현재 사용자를 공구 주최자로 사용이 구조에서 중요한 점은
Authentication.getName()이 실제 로그인 사용자Member.id를 반환해야 한다는 것이다.
만약 이 값이 더미 사용자ID이거나 다른 사용자ID라면, 공구 생성 시 주최자가 실제 로그인 사용자가 아니라 다른 사용자로 들어갈 수 있다.
정리하면GroupBuyingController.java의 공구 생성 흐름은 기존 세션 방식이 아니라Authentication방식으로 현재 사용자를 구분한다.
따라서 로그인 성공 후Authentication.getName()에 실제Member.id가 들어가도록 만들어야 한다.
2-2. @PreAuthorize가 적용된 공구 생성, 수정, 참여 흐름 확인하기
현재 공구 기능에는
@PreAuthorize가 여러 곳에 붙어 있다.
@PreAuthorize는 메서드가 실행되기 전에 접근 가능 여부를 먼저 검사하는 어노테이션이다.
즉,Controller메서드 본문이 실행되기 전에 먼저 조건을 확인한다.
조건을 통과하면 메서드가 실행되고, 통과하지 못하면 요청이 막힌다.
현재 공구 기능에서 사용하는@PreAuthorize는 크게 두 종류로 나눌 수 있다.
- 단순히 로그인 여부만 확인하는 방식
- 별도 인가 객체를 호출해서 주최자, 참여 가능 여부 등을 확인하는 방식
이 두 방식은 목적이 다르다.
로그인 여부만 필요하면isAuthenticated()를 사용한다.
공구 수정이나 참여처럼 세부 조건이 필요하면GroupBuyingSecurityEvaluator.java의 메서드를 호출한다.
GroupBuyingController.java의 addGroupBuying() 메서드
먼저 로그인 여부만 확인하는 대표 코드는
GroupBuyingController.java의addGroupBuying()메서드이다.
이 메서드는 공구 생성 요청을 처리한다.
공구 생성은 로그인한 사용자만 가능하면 되기 때문에isAuthenticated()를 사용한다.// GroupBuyingController.java의 addGroupBuying() 메서드 @PostMapping() @PreAuthorize("isAuthenticated()") // 확인: 로그인한 사용자만 공구 생성 요청을 보낼 수 있다. public String addGroupBuying( @Valid @ModelAttribute GroupBuyingRequest.Create request, @RequestParam(value = "images", required = false) List<MultipartFile> images, Authentication authentication ) { String loggedInUserId = authentication.getName(); GroupBuyingResponse.Create res = groupBuyingService.addGroupBuying(request, images, loggedInUserId); return "redirect:/group-buyings/" + res.groupBuyingId(); }이 코드에서
@PreAuthorize("isAuthenticated()")는 현재 사용자가 인증된 사용자인지 먼저 확인한다.
조건을 통과해야addGroupBuying()메서드 본문이 실행된다.
즉, 흐름은 아래처럼 이어진다.공구 생성 요청 → @PreAuthorize("isAuthenticated()") 실행 → 로그인 상태이면 메서드 실행 → authentication.getName()으로 현재 사용자 ID 조회 → 공구 생성 Service 호출여기서는 세부 권한까지 확인하지 않는다.
공구 생성은 로그인한 사용자라면 가능하다는 기준이기 때문에 로그인 여부만 검사한다.
GroupBuyingController.java의 editGroupBuying() 메서드
다음으로 세부 인가 조건을 확인하는 대표 코드는
GroupBuyingController.java의editGroupBuying()메서드이다.
이 메서드는 공구 수정 요청을 처리한다.
공구 수정은 로그인 여부만으로 판단하면 안 된다.
해당 공구의 주최자이고, 공구가 모집 중이며, 일반 참여자가 아직 없어야 수정할 수 있다.
그래서editGroupBuying()메서드에서는GroupBuyingSecurityEvaluator.java의canModifyGroupBuying()메서드를 호출한다.// GroupBuyingController.java의 editGroupBuying() 메서드 @PostMapping("/{id}/edit") @PreAuthorize("@groupBuyingSecurity.canModifyGroupBuying(authentication, #groupBuyingId)") // 확인: GroupBuyingSecurityEvaluator.java의 canModifyGroupBuying()으로 수정 가능 여부를 검사한다. public String editGroupBuying( @PathVariable("id") Long groupBuyingId, @Valid @ModelAttribute GroupBuyingRequest.Create request, @RequestParam(value = "images", required = false) List<MultipartFile> images, @RequestParam(value = "deletedImageIds", required = false) List<Long> deletedImageIds, Authentication authentication ) { String loggedInUserId = authentication.getName(); groupBuyingService.updateGroupBuying(groupBuyingId, request, images, deletedImageIds, loggedInUserId); return "redirect:/group-buyings/" + groupBuyingId; }이 코드에서 핵심은 아래 부분이다.
@PreAuthorize("@groupBuyingSecurity.canModifyGroupBuying(authentication, #groupBuyingId)")
@groupBuyingSecurity는GroupBuyingSecurityEvaluator.java에 붙은@Component("groupBuyingSecurity")이름이다.
즉, 이 코드는GroupBuyingSecurityEvaluator.java의canModifyGroupBuying()메서드를 호출한다.
여기서 전달되는 값은 두 가지다.
authentication: 현재 로그인한 사용자 정보를 담은 인증 객체#groupBuyingId: 요청 경로에서 받은 공구ID즉, 수정 권한 확인 흐름은 아래처럼 볼 수 있다.
공구 수정 요청 → @PreAuthorize 실행 → GroupBuyingSecurityEvaluator.java의 canModifyGroupBuying(authentication, groupBuyingId) 호출 → 현재 사용자 ID 확인 → 공구 주최자 여부 확인 → 모집 중인지 확인 → 일반 참여자가 없는지 확인 → 모두 맞으면 editGroupBuying() 실행이 조건을 통과하지 못하면
editGroupBuying()메서드 본문은 실행되지 않는다.
그래서@PreAuthorize안에서 사용하는authentication값이 정확해야 한다.
GroupBuyingController.java의 participateGroupBuying() 메서드
공구 참여도 세부 인가 조건이 필요하다.
로그인한 사용자라고 해서 무조건 공구에 참여할 수 있는 것이 아니다.
공구 주최자는 자기 공구에 참여하면 안 되고, 이미 참여한 사용자도 다시 참여하면 안 되며, 공구 상태도 모집 중이어야 한다.
이 흐름은GroupBuyingController.java의participateGroupBuying()메서드에서 확인할 수 있다.// GroupBuyingController.java의 participateGroupBuying() 메서드 @PostMapping("/{id}/participants") @PreAuthorize("@groupBuyingSecurity.canParticipate(authentication, #groupBuyingId)") // 확인: GroupBuyingSecurityEvaluator.java의 canParticipate()로 참여 가능 여부를 검사한다. public String participateGroupBuying( @PathVariable("id") Long groupBuyingId, @Valid GroupBuyingRequest.Participate groupBuyingRequest, Authentication authentication, RedirectAttributes redirectAttributes ) { String loggedInUserId = authentication.getName(); GroupBuyingResponse.Participate res = groupBuyingService.participateGroupBuying( groupBuyingRequest.applyQuantity(), loggedInUserId, groupBuyingId ); redirectAttributes.addFlashAttribute("participateSuccess", true); return "redirect:/group-buyings/" + groupBuyingId; }이 코드에서 핵심은 아래 부분이다.
@PreAuthorize("@groupBuyingSecurity.canParticipate(authentication, #groupBuyingId)")이 조건은
GroupBuyingSecurityEvaluator.java의canParticipate()메서드를 호출한다.
그리고 현재 인증 객체인authentication과 요청 경로에서 받은groupBuyingId를 넘긴다.
공구 참여 인가 흐름은 아래처럼 볼 수 있다.공구 참여 요청 → @PreAuthorize 실행 → GroupBuyingSecurityEvaluator.java의 canParticipate(authentication, groupBuyingId) 호출 → 현재 사용자 ID 확인 → 주최자인지 확인 → 이미 참여했는지 확인 → 모집 중인지 확인 → 참여 가능하면 participateGroupBuying() 실행이 구조에서 중요한 점은
@PreAuthorize가 메서드 실행 전에 먼저 인가 조건을 확인한다는 것이다.
조건을 통과하지 못하면participateGroupBuying()내부 코드까지 들어가지 않는다.
따라서@PreAuthorize안에서 사용하는authentication.getName()이 실제 로그인 사용자의Member.id여야 한다.
그렇지 않으면 주최자 확인이나 참여자 확인이 잘못된 사용자 기준으로 실행된다.
2-3. GroupBuyingSecurityEvaluator.java가 Authentication에서 사용자 ID를 꺼내는 구조 확인하기
@PreAuthorize에서 복잡한 조건을 검사할 때 호출하는 파일이GroupBuyingSecurityEvaluator.java이다.
이 파일은@Component("groupBuyingSecurity")로 등록되어 있다.
그래서@PreAuthorize안에서@groupBuyingSecurity라는 이름으로 호출할 수 있다.
이름만 보면groupBuyingSecurity가 메서드처럼 보일 수 있지만, 실제로는Spring Bean이름이다.
그리고 그Bean의 실제 클래스 파일이GroupBuyingSecurityEvaluator.java이다.
먼저GroupBuyingSecurityEvaluator.java에서 공통으로 현재 사용자ID를 꺼내는 메서드를 확인한다.// GroupBuyingSecurityEvaluator.java의 getUserId() 메서드 private String getUserId(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) { return null; // 확인: 인증 정보가 없거나 인증되지 않았으면 사용자 ID를 null로 처리한다. } return authentication.getName(); // 확인: Authentication에서 현재 사용자 ID를 꺼낸다. }
GroupBuyingSecurityEvaluator.java의 권한 검사 메서드들은 먼저 이getUserId()메서드를 호출한다.
즉, 공구 인가 검사의 출발점은 세션의loginUserId가 아니라Authentication.getName()이다.
그 다음 공구 주최자인지 확인할 때는 아래 메서드를 사용한다.// GroupBuyingSecurityEvaluator.java의 checkOrganizer() 메서드 private boolean checkOrganizer(GroupBuying groupBuying, String userId) { return groupBuying.getMember().getId().equals(userId); // 확인: 공구 주최자의 Member.id와 현재 사용자 ID를 비교한다. }이 코드는 공구의 주최자
Member.id와Authentication.getName()에서 꺼낸userId를 비교한다.
이제 대표로canModifyGroupBuying()메서드를 확인한다.
이 메서드는 공구 수정과 삭제에서 사용하는 인가 검사 메서드이다.// GroupBuyingSecurityEvaluator.java의 canModifyGroupBuying() 메서드 public boolean canModifyGroupBuying(Authentication authentication, Long groupBuyingId) { String userId = getUserId(authentication); // 확인: Authentication 기준으로 현재 사용자 ID를 가져온다. if (userId == null) return false; return groupBuyingRepository.findById(groupBuyingId) .map(groupBuying -> { boolean isOrganizer = checkOrganizer(groupBuying, userId); // 확인: 현재 사용자가 해당 공구의 주최자인지 확인한다. boolean isRecruiting = GroupBuyingStatus.RECRUITING.equals(groupBuying.getStatus()); // 확인: 공구가 모집 중인지 확인한다. boolean hasParticipants = participationRepository.existsByGroupBuyingIdAndRole( groupBuyingId, UserRole.PARTICIPANT ); // 확인: 일반 참여자가 이미 있는지 확인한다. return isOrganizer && isRecruiting && !hasParticipants; }) .orElse(false); }이 메서드의 흐름을 풀어보면 아래와 같다.
canModifyGroupBuying(authentication, groupBuyingId) 호출 → getUserId(authentication) 호출 → authentication.getName()으로 현재 사용자 ID 조회 → groupBuyingRepository.findById(groupBuyingId)로 공구 조회 → checkOrganizer(groupBuying, userId)로 주최자 여부 확인 → 공구 상태가 RECRUITING인지 확인 → 일반 참여자가 있는지 확인 → 주최자이고, 모집 중이고, 일반 참여자가 없으면 true → 아니면 false예를 들어 공구 주최자의
Member.id가 아래 값이라고 하자.70698642-f42d-4aba-841e-0f4cac4a1eb1그리고
Authentication.getName()도 같은 값을 반환하면 주최자 확인은 성공한다.groupBuying.getMember().getId() → 70698642-f42d-4aba-841e-0f4cac4a1eb1 authentication.getName() → 70698642-f42d-4aba-841e-0f4cac4a1eb1 결과 → 같은 사용자이므로 주최자반대로
Authentication.getName()이 실제 로그인 사용자Member.id가 아니라 더미 값이거나 다른 값이면 권한 검사가 잘못된다.
정리하면GroupBuyingSecurityEvaluator.java의 핵심은 아래와 같다.
Authentication이 없으면 인가 실패로 처리한다.Authentication.getName()으로 현재 사용자ID를 꺼낸다.- 그 값을 공구 주최자
Member.id또는 참여자Member.id와 비교한다.- 조건이 맞으면 메서드 실행을 허용한다.
- 조건이 맞지 않으면 메서드 실행을 막는다.
따라서
GroupBuyingSecurityEvaluator.java가 제대로 동작하려면Authentication.getName()이 반드시 실제 로그인한 사용자의Member.id여야 한다.
2-4. 공부한 개념 연결: 인증이 먼저 끝나야 인가가 가능하다
공부했던
Spring Security개념에서 인증과 인가는 서로 다른 역할이라고 정리했다.
인증은 사용자가 누구인지 확인하는 과정이고, 인가는 인증된 사용자가 특정 기능을 사용할 수 있는지 확인하는 과정이다.
이번 프로젝트 기준으로 보면 기존 로그인 기능은 인증에 해당한다.
사용자가loginId와password를 입력하면 서버가 회원을 찾고 비밀번호를 검증한다.
기존 방식에서는 이 검증 결과를 세션의loginUserId에 저장했다.
반면 공구 수정, 공구 삭제, 공구 참여 가능 여부 확인은 인가에 해당한다.
로그인한 사용자라고 해서 모든 공구를 수정할 수 있는 것은 아니다.
현재 사용자가 해당 공구의 주최자인지, 공구가 모집 중인지, 이미 참여한 사용자인지 같은 조건을 확인해야 한다.
이 흐름을 프로젝트 코드 기준으로 정리하면 아래와 같다.인증 → 사용자가 누구인지 확인 → 기존 AuthService.login()에서 loginId, password 검증 → 기존 방식에서는 세션에 loginUserId = Member.id 저장 인가 → 인증된 사용자가 이 기능을 사용할 수 있는지 확인 → @PreAuthorize 실행 → GroupBuyingSecurityEvaluator.java 호출 → Authentication.getName()으로 현재 사용자 ID 확인 → 주최자, 참여자, 모집 상태 조건 검사여기서 문제가 되는 지점은 인증 결과를 저장하는 위치가 서로 다르다는 점이다.
기존 로그인은 세션에loginUserId를 저장한다.
그런데 공구 인가 코드는Authentication.getName()을 본다.
따라서 인증이 끝났다고 해도Authentication에 실제 사용자 정보가 들어 있지 않으면 인가 코드가 정상적으로 동작할 수 없다.
예를 들어 기존 세션에는 아래처럼 저장되어 있다고 하자.loginUserId = 1111-aaaa그런데
Authentication.getName()이 아래처럼 다른 값을 반환하면 문제가 생긴다.Authentication.getName() = 9999-dummy그러면 공구 인가 코드는 실제 로그인 사용자
1111-aaaa가 아니라9999-dummy를 현재 사용자로 판단한다.
결과적으로 주최자 확인, 참여자 확인, 참여 가능 여부 확인이 모두 잘못된 기준으로 실행될 수 있다.
그래서 이번 인증 적용에서 해야 할 일은 명확하다.
기존 세션 저장은 유지하되, 로그인 성공 시SecurityContext에도 같은Member.id를 저장해야 한다.기존 세션 → loginUserId = Member.id Spring Security 인증 객체 → Authentication.getName() = Member.id이렇게 맞춰야 인증과 인가가 같은 사용자를 기준으로 연결된다.
2-5. 현재 프로젝트에서 인증 기준과 인가 기준이 나뉘어 있는 문제 정리하기
현재 프로젝트의 문제는 로그인 기능이 없다는 것이 아니다.
기존 로그인 기능은 이미 있다.
AuthService.login()은loginId로 회원을 찾고,passwordEncoder.matches()로 비밀번호를 검증하고, 로그인 성공 시 세션에Member.id를 저장한다.
문제는 그 다음이다.
공구 기능은 세션의loginUserId를 사용하지 않는다.
공구 기능은Authentication.getName()을 사용한다.
현재 구조를 비교하면 아래와 같다.기존 로그인/마이페이지 기준 → HttpSession → loginUserId → Member.id 현재 공구 인가 기준 → Authentication → authentication.getName() → Member.id라고 가정즉, 두 흐름이 같은
Member.id를 바라봐야 하는데, 아직 실제 로그인 성공 결과가Authentication에 들어가지 않는 상태이다.
이 상태에서 팀원이 인가 기능을 먼저 구현하기 위해 임시로 더미 인증 필터를 만들었다.
더미 인증 필터는 실제 로그인 사용자가 아니라, 테스트용Member.id를Authentication에 강제로 넣는 방식이다.
이 덕분에@PreAuthorize와Authentication.getName()을 사용하는 공구 기능을 먼저 테스트할 수 있었다.
하지만 실제 인증을 적용한 뒤에도 더미 인증 필터가 남아 있으면 문제가 생긴다.
사용자가 실제로 누구로 로그인했는지와 상관없이,Authentication.getName()은 더미 사용자를 반환할 수 있기 때문이다.
따라서 다음 단계에서는SecurityConfig.java와DummyAuthenticationFilter.java를 확인해야 한다.
특히 더미 필터가 왜 들어갔고, 실제 인증 적용 후 왜 제거해야 하는지 확인해야 한다.
이번 2번 단계의 결론은 아래와 같다.
- 기존 로그인 기능은 세션의
loginUserId로 사용자를 구분한다.- 공구 인가 기능은
Authentication.getName()으로 사용자를 구분한다.GroupBuyingSecurityEvaluator.java는Authentication.getName()값을 공구 주최자 또는 참여자Member.id와 비교한다.- 따라서
Authentication.getName()은 반드시 실제 로그인 사용자의Member.id여야 한다.- 현재는 이 연결이 아직 완성되지 않았기 때문에 실제 인증 적용이 필요하다.
현재 프로젝트의 핵심 문제는 인증 결과는 세션에 있고, 인가 코드는
Authentication을 본다는 점이다.
이 문제를 해결하려면 로그인 성공 시 세션과SecurityContext에 같은Member.id를 저장해야 한다.
2번에서는 현재 공구 인가 코드가
Authentication.getName()을 기준으로 현재 로그인한 사용자ID를 꺼낸다는 것을 확인했다.
공구 생성, 수정, 참여, 삭제 같은 기능은 세션의loginUserId를 직접 보지 않고,Authentication안에 들어 있는 사용자 정보를 기준으로 동작한다.
그런데 아직 실제 로그인 성공 결과가SecurityContext에 저장되는 구조는 완성되지 않았다.
그래서 팀원이 인가 처리를 먼저 구현하고 테스트하기 위해 임시로DummyAuthenticationFilter.java를 만들어 두었다.
이 필터는 실제 로그인한 사용자가 아니라, 테스트용Member.id를Authentication에 강제로 넣어주는 역할을 한다.
이번 단계에서는SecurityConfig.java와DummyAuthenticationFilter.java를 확인한다.
여기서 중요한 점은 더미 필터를 잘못 만든 코드로 보는 것이 아니라, 인가 기능을 먼저 테스트하기 위해 잠시 넣어둔 임시 인증 코드로 이해하는 것이다.
3-1. 현재 SecurityConfig.java 구조 확인하기
먼저
SecurityConfig.java를 확인한다.
이 파일은Spring Security에서 어떤 요청을 허용하고, 어떤 요청을 막을지 정하는 설정 파일이다.
공부했던 내용에서SecurityFilterChain은 요청이 들어왔을 때 적용할 보안 규칙을 정하는 핵심 설정이라고 정리했다.
현재 프로젝트에서도SecurityConfig.java안에서SecurityFilterChain을Bean으로 등록하고 있다.
아래 코드는 현재SecurityConfig.java의 전체 흐름이다.
import부분은 흐름 확인에 꼭 필요하지 않으므로 생략하고, 현재 보안 설정에서 확인해야 할 부분만 주석으로 표시한다.@Configuration @EnableWebSecurity @EnableMethodSecurity // 확인: @PreAuthorize 어노테이션을 컨트롤러에서 사용하기 위해 필요하다. public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 확인: 현재는 CSRF 보호를 비활성화해둔 상태이다. // 확인: fetch 기반 API 요청과 Thymeleaf 폼 요청 테스트 과정에서 CSRF 토큰 처리까지 함께 넣으면 흐름이 복잡해지기 때문에 현재 단계에서는 꺼둔다. .csrf(csrf -> csrf.disable()) // 확인: Spring Security 기본 formLogin 기능을 사용하지 않는다. // 확인: 현재 프로젝트는 login.html에서 fetch()로 /api/auth/login에 JSON 요청을 보내고, AuthController와 AuthService가 직접 로그인 검증을 처리한다. .formLogin(form -> form.disable()) // 확인: HTTP Basic 인증도 사용하지 않는다. .httpBasic(basic -> basic.disable()) // 확인: 현재 모든 요청을 permitAll로 열어둔 상태이다. .authorizeHttpRequests(auth -> auth // 확인: 세부 권한은 컨트롤러의 @PreAuthorize로 제어할 예정이므로 여기서는 모두 허용해둔 상태이다. .anyRequest().permitAll() ) // 확인: 팀원이 로그인 구현 전까지 사용할 임시 필터를 등록해둔 상태이다. // 확인: Spring Security의 UsernamePasswordAuthenticationFilter보다 먼저 더미 인증 객체를 강제로 넣는다. .addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }이 전체 코드에서 현재 확인해야 할 핵심은 세 가지이다.
@EnableMethodSecurity가 켜져 있다.- 모든 요청이
permitAll()로 열려 있다.DummyAuthenticationFilter가UsernamePasswordAuthenticationFilter앞에 등록되어 있다.먼저
@EnableMethodSecurity는@PreAuthorize를 사용하기 위해 필요하다.
2번에서 확인한 것처럼 공구 기능에는@PreAuthorize("isAuthenticated()"),@PreAuthorize("@groupBuyingSecurity.canModifyGroupBuying(authentication, #groupBuyingId)")같은 코드가 들어가 있다.
이런 메서드 단위 인가 검사를 사용하려면@EnableMethodSecurity가 활성화되어 있어야 한다.
핵심 설정만 다시 뽑으면 아래와 같다.@EnableMethodSecurity // 확인: @PreAuthorize를 사용하기 위해 필요하다.이 설정이 있기 때문에
Controller메서드 위에 붙은@PreAuthorize가 동작할 수 있다.
다음으로 모든 요청을 허용하는 설정이 있다..authorizeHttpRequests(auth -> auth // 확인: 현재는 모든 요청을 허용한다. .anyRequest().permitAll() )이 설정은
SecurityFilterChain단계에서 모든 요청을 막지 않겠다는 의미이다.
즉, 현재 전역 보안 설정만 보면 로그인하지 않은 사용자도 모든URL요청을 통과할 수 있다.
마지막으로 더미 인증 필터 등록 코드가 있다..addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);이 코드는
DummyAuthenticationFilter를UsernamePasswordAuthenticationFilter보다 앞에 넣는 설정이다.
즉, 요청이Controller에 도착하기 전에 더미 필터가 먼저 실행되고, 그 안에서 임시 인증 객체를SecurityContext에 넣는다.
정리하면 현재SecurityConfig.java는 아래처럼 이해할 수 있다.SecurityConfig.java 현재 구조 → @PreAuthorize 사용 가능하게 설정 → CSRF 비활성화 → formLogin 비활성화 → httpBasic 비활성화 → 모든 요청 permitAll → DummyAuthenticationFilter를 보안 필터 체인 앞쪽에 등록이 구조는 실제 인증이 완성된 최종 구조라기보다, 공구 인가 기능을 먼저 테스트하기 위한 임시 구조에 가깝다.
3-2. 모든 요청이 permitAll로 열려 있는 이유와 한계 확인하기
현재
SecurityConfig.java에는 아래 설정이 들어가 있다..authorizeHttpRequests(auth -> auth // 확인: 현재는 모든 요청을 허용한다. .anyRequest().permitAll() )
permitAll()은 해당 요청을 인증 없이 허용한다는 뜻이다.
그리고anyRequest().permitAll()은 모든 요청을 인증 없이 허용한다는 뜻이다.
이렇게 열어둔 이유는 인가 기능을 먼저 붙이는 과정에서 전체 요청을 막아버리면 개발과 테스트가 어려워지기 때문이다.
특히 로그인 인증 연결이 아직 완성되지 않은 상태에서 전역 설정을authenticated()로 바꾸면, 많은 화면과API가 먼저 막힐 수 있다.
그래서 현재 단계에서는 전역 보안 설정에서 모든 요청을 일단 허용하고, 세부 기능 접근 여부는Controller의@PreAuthorize로 확인하는 방식이 들어가 있다.
즉, 현재 구조는URL단계에서 막는 방식이 아니라 메서드 실행 직전에 권한을 검사하는 방식이다.
하지만 이 방식에는 한계가 있다.
전역 설정이 모두 열려 있으면,@PreAuthorize가 붙지 않은 요청은 그대로 접근 가능해질 수 있다.
즉, 보호가 필요한 기능은 반드시@PreAuthorize가 붙어 있어야 한다.
예를 들어 아래처럼 생각하면 된다.SecurityConfig.java → anyRequest().permitAll() → 전역 URL 접근은 모두 허용 Controller 메서드 → @PreAuthorize가 붙어 있으면 메서드 실행 전 검사 → @PreAuthorize가 없으면 별도 검사 없이 실행될 수 있음따라서 이 구조에서는
SecurityConfig.java에서 모든 요청을 막는 것이 아니라, 보호가 필요한 메서드에@PreAuthorize가 빠지지 않았는지 확인하는 것이 중요하다.
팀원이 잡은 구조도 이 방향이므로, 뒤에서 실제 수정할 때는permitAll()을 유지하고 더미 인증 필터만 제거하는 방향으로 정리한다.
현재 구조의 한계를 정리하면 아래와 같다.
- 모든 요청이 전역적으로 허용되어 있다.
- 보호가 필요한 요청도
SecurityConfig.java단계에서는 막히지 않는다.@PreAuthorize가 붙은 메서드는 별도로 검사되지만, 안 붙은 메서드는 놓칠 수 있다.- 따라서 보호가 필요한 공구 생성, 수정, 참여, 삭제 기능에는
@PreAuthorize적용 여부를 반드시 확인해야 한다.현재 프로젝트의 최종 방향은
permitAll()을 유지하되, 실제 보호 책임을 각 메서드의@PreAuthorize에서 처리하는 구조이다.
3-3. 팀원이 인가 처리를 먼저 하기 위해 DummyAuthenticationFilter를 만든 이유 설명하기
현재 프로젝트에서는 팀원이 공구 쪽 인가 처리를 먼저 진행했다.
2번에서 확인한 것처럼 공구 기능은 이미Authentication.getName()을 기준으로 현재 로그인한 사용자ID를 꺼내고 있다.
문제는 그 시점에 실제 로그인 성공 결과가SecurityContext에 저장되는 구조가 아직 없었다는 점이다.
기존 로그인 기능은 세션에loginUserId를 저장했지만, 공구 인가 코드는 세션을 보지 않고Authentication을 봤다.
즉, 구조가 아래처럼 나뉘어 있었다.기존 로그인 성공 결과 → HttpSession → loginUserId = Member.id 공구 인가 코드 → Authentication → authentication.getName()이 상태에서
Authentication이 비어 있으면 공구 인가 코드를 테스트하기 어렵다.
@PreAuthorize에서authentication을 넘겨도 실제 사용자 정보가 없기 때문에 주최자 확인이나 참여 가능 여부 확인이 제대로 이어지지 않는다.
그래서 팀원이 임시로DummyAuthenticationFilter.java를 만들어 두었다.
이 필터는 요청이 들어올 때마다 고정된 테스트용Member.id를 가진 더미 사용자를 만들고, 그 사용자를SecurityContext에 넣는다.
이렇게 하면 실제 로그인 인증 연결이 아직 없어도, 공구 인가 코드는 아래처럼 동작을 테스트할 수 있다.요청 들어옴 → DummyAuthenticationFilter 실행 → 테스트용 Member.id를 가진 Authentication 생성 → SecurityContext에 저장 → Controller의 @PreAuthorize 실행 → GroupBuyingSecurityEvaluator.java에서 authentication.getName() 사용 가능따라서
DummyAuthenticationFilter.java는 잘못 만든 코드가 아니다.
인가 기능을 먼저 구현하고 테스트하기 위해 필요한 임시 코드였다.
다만 이제 인증을 실제 로그인과 연결하는 단계로 넘어가면 이 더미 필터는 제거해야 한다.
계속 남아 있으면 실제 로그인한 사용자와 상관없이 고정된 더미 사용자가 현재 사용자처럼 처리될 수 있기 때문이다.
3-4. DummyAuthenticationFilter가 고정된 memberId를 Authentication에 넣는 흐름 확인하기
이제
DummyAuthenticationFilter.java를 확인한다.
이 파일은 요청마다 임시 인증 객체를 만들어SecurityContextHolder에 강제로 저장한다.
아래 코드는 현재DummyAuthenticationFilter.java의 전체 흐름이다.
import부분은 생략하고, 더미 인증 구조에서 확인해야 할 부분만 주석으로 표시한다.// 개발 테스트용 임시 필터 // 확인: 실제 로그인 인증이 완성되면 삭제하거나 SecurityConfig.java에서 등록을 제거해야 한다. public class DummyAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { // 확인: 테스트용 더미 사용자 객체를 만든다. UserDetails dummyUser = User.builder() // 확인: 임시 사용자로 사용할 고정 Member.id를 username 자리에 넣는다. .username("70698642-f42d-4aba-841e-0f4cac4a1eb1") // 확인: 실제 로그인 검증에 사용하는 비밀번호가 아니라, 더미 UserDetails 객체 생성을 위해 채운 임시값이다. .password("password") .authorities(Collections.emptyList()) .build(); // 확인: 더미 사용자 정보를 기반으로 Authentication 객체를 만든다. UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( dummyUser, null, dummyUser.getAuthorities() ); // 확인: SecurityContext에 더미 Authentication을 강제로 저장한다. SecurityContextHolder.getContext().setAuthentication(authentication); // 확인: 다음 필터 또는 Controller로 요청 흐름을 넘긴다. filterChain.doFilter(request, response); } }이 코드에서 가장 먼저 봐야 할 부분은 더미 사용자 생성 코드이다.
UserDetails dummyUser = User.builder() // 확인: 임시 사용자로 사용할 고정 Member.id를 username 자리에 넣는다. .username("70698642-f42d-4aba-841e-0f4cac4a1eb1") // 확인: 실제 로그인 검증에 사용하는 비밀번호가 아니라, 더미 UserDetails 객체 생성을 위해 채운 임시값이다. .password("password") .authorities(Collections.emptyList()) .build();여기서
username자리에 들어간 값이 중요하다.
현재는 임시 사용자로 사용할 고정Member.id가 들어가 있다.
password("password")는 실제 로그인 검증에 사용하는 비밀번호가 아니다.
이 필터는 사용자가 입력한 비밀번호를 검사하지 않는다.
단지UserDetails형태의 더미 사용자 객체를 만들기 위해 임시 값을 넣어둔 것이다.
이후UsernamePasswordAuthenticationToken을 만들 때 이dummyUser가principal로 들어간다.UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( dummyUser, null, dummyUser.getAuthorities() );
Authentication.getName()은 보통principal의 이름값을 반환한다.
현재principal은dummyUser이고,dummyUser의username에는 고정된Member.id가 들어 있다.
따라서 더미 필터가 실행되면Authentication.getName()은 아래 값을 반환할 수 있다.Authentication.getName() → 70698642-f42d-4aba-841e-0f4cac4a1eb1마지막으로 이 인증 객체를
SecurityContextHolder에 저장한다.SecurityContextHolder.getContext().setAuthentication(authentication);이 코드가 실행되면 이후
@PreAuthorize나Controller에서Authentication을 사용할 수 있다.
전체 흐름을 정리하면 아래와 같다.요청 들어옴 → DummyAuthenticationFilter 실행 → UserDetails dummyUser 생성 → dummyUser.username에 고정 Member.id 저장 → UsernamePasswordAuthenticationToken 생성 → SecurityContextHolder에 Authentication 저장 → 이후 @PreAuthorize와 Controller에서 authentication.getName() 사용 가능이 구조 덕분에 실제 인증 구현 전에도 공구 인가 코드를 테스트할 수 있었다.
하지만 실제 로그인 인증이 적용되면, 더미Member.id가 아니라 로그인 성공 사용자의 실제Member.id가 들어가야 한다.
3-5. 공부한 개념 연결: Filter는 Controller 앞에서 요청을 먼저 검사한다
공부했던
Spring Security개념에서Filter는Controller보다 먼저 요청을 처리한다고 정리했다.
사용자가 어떤URL로 요청을 보내면 그 요청이 바로Controller로 들어가는 것이 아니다.
먼저 여러Filter를 통과하고, 그 다음Controller메서드가 실행된다.
현재 프로젝트의DummyAuthenticationFilter.java도 이 흐름 안에 들어간다.
SecurityConfig.java에서 아래처럼 등록했기 때문이다..addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);이 코드는
DummyAuthenticationFilter를UsernamePasswordAuthenticationFilter보다 앞에서 실행하겠다는 뜻이다.
즉, 요청이 들어오면 더미 필터가 먼저 실행되고, 그 안에서 임시 인증 객체를SecurityContext에 넣는다.
흐름을 그림처럼 보면 아래와 같다.브라우저 요청 → Spring Security Filter Chain → DummyAuthenticationFilter 실행 → SecurityContextHolder에 더미 Authentication 저장 → @PreAuthorize 인가 검사 → Controller 메서드 실행이 흐름에서 중요한 점은
Controller가 실행되기 전에 이미SecurityContext에 인증 객체가 들어간다는 것이다.
그래서Controller메서드에서 아래처럼Authentication을 받을 수 있다.public String addGroupBuying( ..., Authentication authentication ) { String loggedInUserId = authentication.getName(); }만약 필터에서
SecurityContext에 아무것도 넣지 않으면,Authentication이 없거나 익명 사용자 기준으로 처리될 수 있다.
그러면@PreAuthorize나authentication.getName()을 사용하는 코드가 원하는 방식으로 동작하지 않는다.
즉, 더미 필터는 공구 인가 코드를 먼저 테스트하기 위해Controller앞단에서 임시 인증 정보를 준비해준 코드라고 볼 수 있다.
하지만 실제 인증 적용 후에는 이 역할을 더미 필터가 하면 안 된다.
실제 로그인 성공 시 만들어진 인증 정보가SecurityContext에 들어가야 한다.
3-6. 실제 인증 적용 후 더미 필터가 남아 있으면 생기는 문제 정리하기
더미 필터는 인가 기능을 먼저 테스트하기 위한 임시 코드였다.
하지만 실제 인증을 적용한 뒤에도 더미 필터가 남아 있으면 문제가 생긴다.
가장 큰 문제는 실제 로그인 사용자와Authentication.getName()의 사용자가 달라질 수 있다는 점이다.
예를 들어 사용자가 실제로 아래 회원으로 로그인했다고 하자.실제 로그인 사용자 Member.id → 1111-aaaa그런데
DummyAuthenticationFilter.java가 계속 실행되면Authentication에는 아래 더미 값이 들어갈 수 있다.DummyAuthenticationFilter가 넣는 Member.id → 70698642-f42d-4aba-841e-0f4cac4a1eb1그러면 현재 프로젝트 안에서 사용자 기준이 둘로 갈라진다.
HttpSession → loginUserId = 1111-aaaa Authentication → authentication.getName() = 70698642-f42d-4aba-841e-0f4cac4a1eb1이 상태에서는 마이페이지와 공구 기능이 서로 다른 사용자를 현재 사용자로 판단할 수 있다.
마이페이지는 세션의loginUserId를 보므로1111-aaaa를 현재 사용자로 본다.
반면 공구 기능은Authentication.getName()을 보므로 더미 사용자를 현재 사용자로 볼 수 있다.
이렇게 되면 아래 문제가 생길 수 있다.
- 실제 로그인한 사용자가 아닌 더미 사용자가 공구 주최자로 저장될 수 있다.
- 공구 수정 권한 검사가 더미 사용자 기준으로 실행될 수 있다.
- 공구 참여 가능 여부가 실제 사용자 기준이 아니라 더미 사용자 기준으로 판단될 수 있다.
- 정산 처리도 실제 로그인 사용자와 다른 사용자 기준으로 실행될 수 있다.
결국 실제 인증 적용 후에는 더미 필터가 남아 있으면 안 된다.
로그인 성공 사용자의Member.id가Authentication.getName()에 들어가야 하고, 더미Member.id가 들어가면 안 된다.
실제 인증 적용 후 더미 필터가 남아 있으면 세션 기준 사용자와 Authentication 기준 사용자가 달라질 수 있다.
이 문제를 막기 위해 더미 필터는 제거 대상으로 분리해야 한다.
3-7. 이번 단계에서 DummyAuthenticationFilter를 제거 대상으로 분리하기
이번 3번 단계에서는 아직 코드를 수정하지 않는다.
현재SecurityConfig.java와DummyAuthenticationFilter.java가 어떤 구조인지 확인하고, 어떤 부분을 제거해야 하는지 분리하는 단계이다.
제거 대상으로 볼 코드는 크게 두 가지다.
첫 번째는SecurityConfig.java의 더미 필터 등록 코드이다..addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);실제 프로젝트 적용 기준에서는 이 등록 코드를 먼저 제거해야 한다.
그래야 요청마다 더미 인증 객체가 강제로 들어가지 않는다.
두 번째는DummyAuthenticationFilter.java파일 자체이다.public class DummyAuthenticationFilter extends OncePerRequestFilter { ... }
SecurityConfig.java에서 더미 필터 등록을 제거하면 이 파일은 더 이상 보안 필터 체인에서 사용되지 않는다.
그래서 실제 인증 적용 후에는 삭제해도 된다.
중요한 것은 더미 필터가 보안 필터 체인에 더 이상 들어가지 않게 만드는 것이다.
파일이 남아 있어도 등록하지 않으면 실행되지 않지만, 헷갈리지 않게 삭제하는 것이 더 직관적이다.
이번 단계에서 확인한 내용을 정리하면 아래와 같다.
SecurityConfig.java는 현재 모든 요청을permitAll()로 열어두고 있다.SecurityConfig.java는DummyAuthenticationFilter를 보안 필터 체인에 등록하고 있다.DummyAuthenticationFilter.java는 고정된Member.id를 가진 더미 사용자를 만든다.DummyAuthenticationFilter.java는 그 더미 사용자를SecurityContextHolder에 강제로 저장한다.- 이 구조는 팀원이 인가 처리를 먼저 테스트하기 위해 만든 임시 구조이다.
- 실제 인증 적용 후에는 더미 필터 등록을 제거해야 한다.
다음 단계에서는 실제 인증 적용 방향을 정리한다.
기존AuthService.login()의 회원 조회와 비밀번호 검증은 유지하고, 로그인 성공 시 세션뿐 아니라SecurityContext에도 실제 로그인 사용자의Member.id가 들어가도록 연결할 것이다.
3번에서는 현재
SecurityConfig.java와DummyAuthenticationFilter.java구조를 확인했다.
현재 프로젝트는 공구 인가 기능을 먼저 테스트하기 위해DummyAuthenticationFilter.java에서 고정된Member.id를Authentication에 넣고 있었다.
하지만 이제 실제 인증을 적용해야 한다.
실제 인증을 적용한다는 것은 더미 사용자를 넣는 것이 아니라, 로그인에 성공한 실제 사용자의Member.id를Authentication.getName()으로 꺼낼 수 있게 만드는 것이다.
이번 단계에서는 바로 코드를 수정하지 않는다.
먼저 어떤 기존 코드는 유지하고, 어떤 코드는 추가하고, 어떤 코드는 제거해야 하는지 방향을 정리한다.
이번 작업의 핵심은 기존 세션 로그인 구조를 버리는 것이 아니라, 로그인 성공 결과를SecurityContext에도 함께 저장하도록 연결하는 것이다.
4-1. 기존 AuthService.login()의 회원 조회 로직은 유지하는 이유
기존 프로젝트에서는 로그인 요청이 들어오면
AuthService.java의login()메서드에서 회원을 조회했다.
사용자가 입력한loginId를 기준으로member테이블에서 회원을 찾는 방식이다.
이 흐름은 이미 정상적으로 구현되어 있으므로 제거하면 안 된다.
Spring Security를 적용한다고 해서 무조건 기존 로그인 검증 코드를 전부 버려야 하는 것은 아니다.
현재 프로젝트는 이미 직접 만든 로그인 화면과fetch()기반 로그인 요청 구조를 가지고 있기 때문이다.
기존 회원 조회 흐름은 아래와 같다.// AuthService.java의 login() 메서드 내부 // 확인: 사용자가 입력한 loginId로 member 테이블에서 회원을 조회한다. Member member = memberRepository.findByLoginId(request.loginId()) .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED));이 코드는 사용자가 입력한
loginId로 실제 회원이 존재하는지 확인한다.
회원이 없으면LOGIN_FAILED예외를 발생시킨다.
예를 들어 사용자가 아래 값으로 로그인했다고 하자.loginId = testuser1 password = Test1234!서버는 먼저
testuser1이라는 로그인 아이디를 가진 회원이member테이블에 있는지 확인한다.
회원이 없다면 비밀번호를 비교할 대상도 없으므로 로그인 실패로 처리한다.
이 회원 조회 로직을 유지해야 하는 이유는 명확하다.
현재 프로젝트의 로그인 기준은 여전히 사용자가 입력한loginId와password이다.
SecurityContext에 인증 정보를 저장하려면, 먼저 실제 회원인지 검증이 끝나야 한다.
정리하면 이 단계에서 회원 조회 로직은 아래 기준으로 유지한다.
- 사용자가 입력한
loginId로 회원을 찾는다.- 회원이 없으면 로그인 실패로 처리한다.
- 회원 조회에 성공해야 다음 비밀번호 검증으로 넘어간다.
- 이 로직은 기존 로그인 검증의 핵심이므로 유지한다.
즉,
Spring Security인증을 연결하더라도 회원 조회 자체는 기존AuthService.login()흐름을 그대로 사용한다.
4-2. 기존 PasswordEncoder.matches() 비밀번호 검증은 유지하는 이유
회원 조회가 끝났다고 바로 로그인 성공으로 보면 안 된다.
조회된 회원이 실제 사용자가 맞는지 확인하려면 비밀번호 검증이 필요하다.
기존 프로젝트에서는PasswordEncoder.matches()를 사용해 사용자가 입력한 원문 비밀번호와DB에 저장된 해시 비밀번호를 비교했다.
이 방식도 그대로 유지해야 한다.
기존 비밀번호 검증 흐름은 아래와 같다.// AuthService.java의 login() 메서드 내부 // 확인: 사용자가 입력한 원문 비밀번호와 DB에 저장된 해시 비밀번호를 비교한다. if (!passwordEncoder.matches(request.password(), member.getPassword())) { throw new BusinessException(ErrorCode.LOGIN_FAILED); }여기서
request.password()는 사용자가 로그인 화면에서 입력한 원문 비밀번호이다.
member.getPassword()는 회원가입 때PasswordEncoder로 암호화되어DB에 저장된 해시 비밀번호이다.
예를 들어 사용자가 로그인 화면에서 아래처럼 입력했다고 하자.password = Test1234!하지만
DB에는 아래처럼 원문이 아니라 해시 처리된 값이 저장되어 있다.$2a$10$u0X...생략...그래서 아래처럼 단순 문자열 비교를 하면 안 된다.
// 잘못된 비교 방식 request.password().equals(member.getPassword());원문 비밀번호와 해시 비밀번호는 문자열 모양이 다르기 때문이다.
따라서 기존처럼PasswordEncoder.matches()를 사용해야 한다.
이 로직을 유지해야 하는 이유는 보안과 직접 관련이 있다.
Spring Security를 적용하더라도 비밀번호를 안전하게 검증하는 책임은 그대로 필요하다.
현재 프로젝트에서는 그 검증을 이미AuthService.login()에서 처리하고 있으므로, 이 코드를 제거하지 않는다.
정리하면 비밀번호 검증 로직은 아래 기준으로 유지한다.
- 사용자가 입력한 원문 비밀번호를 그대로
DB값과 비교하지 않는다.PasswordEncoder.matches()로 원문 비밀번호와 해시 비밀번호를 비교한다.- 비밀번호가 틀리면
LOGIN_FAILED예외를 발생시킨다.- 비밀번호 검증에 성공해야 로그인 성공 처리로 넘어간다.
실제 인증 적용은 비밀번호 검증을 대체하는 작업이 아니라, 비밀번호 검증이 끝난 뒤 인증 성공 정보를
SecurityContext에도 저장하는 작업이다.
4-3. 기존 LoginSessionManager 세션 저장도 유지해야 하는 이유
기존 프로젝트에서는 로그인 성공 후
LoginSessionManager를 사용해 세션에Member.id를 저장했다.
이 구조도 당장 제거하면 안 된다.
이유는 아직 프로젝트 안에 세션 기반으로 로그인 사용자를 확인하는 코드가 남아 있기 때문이다.
대표적으로 기존 마이페이지 역할을 하는MemberController.java는 아직HttpSession과LoginSessionManager를 기준으로 로그인 사용자ID를 꺼내는 구조이다.
기존 세션 저장 코드는 아래와 같다.// AuthService.java의 login() 메서드 내부 // 확인: 로그인 성공 시 세션에 loginId가 아니라 Member.id를 저장한다. loginSessionManager.login(session, member.getId());이 코드는 로그인 성공한 회원의
Member.id를LoginSessionManager로 넘긴다.
그러면LoginSessionManager는 세션에loginUserId라는 이름으로 값을 저장한다.
세션 저장 흐름은 아래처럼 볼 수 있다.로그인 성공 → member.getId() 조회 → LoginSessionManager.login(session, member.getId()) 호출 → HttpSession에 loginUserId = Member.id 저장예를 들어 로그인한 회원의
Member.id가 아래 값이라고 하자.1111-aaaa그러면 세션에는 아래처럼 저장된다.
loginUserId = 1111-aaaa이 값은 기존 세션 기반 기능에서 현재 사용자를 구분하는 기준으로 사용된다.
따라서 이번 인증 적용에서 세션 저장을 바로 제거하면 기존 기능이 깨질 수 있다.
공구 기능은Authentication을 사용하지만, 기존 마이페이지 쪽은 아직 세션을 사용하고 있기 때문이다.
이번 단계에서는 아래 기준으로 간다.
- 기존
LoginSessionManager.login()호출은 유지한다.- 기존 세션 키
loginUserId도 유지한다.- 기존 세션 기반 기능이 깨지지 않게 한다.
- 추가로
SecurityContext에도 같은Member.id를 저장한다.즉, 이번 작업은 기존 세션 방식을 삭제하는 리팩토링이 아니다.
기존 세션 방식과Spring Security인증 방식을 같은Member.id기준으로 연결하는 작업이다.
4-4. 공부한 개념 연결: 인증 성공 정보는 SecurityContext에 저장된다
공부했던
Spring Security개념에서 인증에 성공하면 인증 정보는SecurityContext에 저장된다고 정리했다.
SecurityContext는 현재 인증된 사용자가 누구인지 담는 공간이다.
그리고 이SecurityContext는 보통SecurityContextHolder를 통해 접근한다.
즉,Spring Security는 현재 사용자 정보를 아래 흐름으로 관리한다.Authentication → 인증된 사용자 정보를 담는 객체 SecurityContext → Authentication을 담는 보안 컨텍스트 SecurityContextHolder → 현재 실행 흐름에서 SecurityContext를 꺼내는 저장소기존 프로젝트는 로그인 성공 후 이 흐름을 사용하지 않았다.
로그인 성공 결과를SecurityContext가 아니라HttpSession의loginUserId에 저장했다.
기존 흐름은 아래와 같았다.기존 로그인 성공 흐름 → AuthService.login()에서 회원 조회 → 비밀번호 검증 → LoginSessionManager.login() → HttpSession에 loginUserId = Member.id 저장하지만 공구 인가 코드는
HttpSession의loginUserId를 보지 않는다.
공구 인가 코드는Authentication.getName()을 본다.
따라서 실제 인증을 적용하려면 로그인 성공 시 아래 흐름도 추가해야 한다.추가해야 하는 Spring Security 인증 흐름 → 로그인 성공한 Member.id 확인 → Authentication 객체 생성 → SecurityContext에 Authentication 저장 → 이후 Authentication.getName()으로 Member.id 조회 가능여기서 중요한 점은
Authentication에 들어가는 사용자ID와 세션의loginUserId가 같아야 한다는 것이다.
예를 들어 로그인한 사용자의Member.id가 아래 값이라면,Member.id = 1111-aaaa기존 세션에는 아래처럼 저장된다.
HttpSession → loginUserId = 1111-aaaa그리고 새로 연결할
Spring Security인증 정보도 아래처럼 되어야 한다.SecurityContext → Authentication.getName() = 1111-aaaa그래야 기존 세션 기반 코드와 공구 인가 코드가 같은 사용자를 현재 사용자로 판단한다.
이번 인증 적용에서 가장 중요한 기준은HttpSession의loginUserId와Authentication.getName()이 같은Member.id를 바라보게 만드는 것이다.
4-5. 로그인 성공 후 SecurityContext에 Authentication을 저장하는 방향 정리하기
이제 실제 적용 방향을 정리한다.
기존AuthService.login()은 회원 조회, 비밀번호 검증, 세션 저장까지 처리하고 있다.
여기에 로그인 성공 후SecurityContext저장 흐름을 추가한다.
다만 이 코드를AuthService.java안에 길게 직접 작성하면 로그인 로직이 복잡해진다.
그래서 다음 단계에서는SecurityContextLoginManager.java라는 새 파일을 추가해SecurityContext저장 역할을 분리한다.
방향은 아래와 같다.AuthService.login() → loginId로 회원 조회 → password 검증 → 기존 LoginSessionManager.login(session, member.getId()) 유지 → 새 SecurityContextLoginManager.login(...) 호출 추가 → Authentication 생성 → SecurityContext에 Authentication 저장이렇게 하면
AuthService.java는 기존 로그인 검증 흐름을 유지하면서, 로그인 성공 후SecurityContext저장만 추가로 위임할 수 있다.
SecurityContext에 저장할 인증 객체는Authentication타입이어야 한다.
실제 구현에서는UsernamePasswordAuthenticationToken을 사용할 수 있다.
이 객체는 인증된 사용자 정보를 담는Authentication구현체이다.
적용 방향을 조금 더 자세히 보면 아래와 같다.로그인 성공 사용자 → member.getId() Authentication 생성 → UsernamePasswordAuthenticationToken 사용 현재 요청의 SecurityContext 저장 → SecurityContextHolder에 저장 이후 요청에서도 유지되도록 저장 → SecurityContextRepository를 통해 SecurityContext를 세션에 저장여기서 중요한 점은
SecurityContextHolder에만 넣고 끝내면 안 된다는 것이다.
SecurityContextHolder는 현재 실행 흐름에서 인증 정보를 사용할 수 있게 해준다.
하지만 로그인 이후 다음 요청에서도 인증 상태를 유지하려면,SecurityContextRepository를 통해SecurityContext가 세션에 저장되도록 해야 한다.
따라서SecurityContextLoginManager.java는 아래 역할을 맡는다.
- 로그인 성공 시
Member.id를 기준으로Authentication객체를 만든다.- 만든
Authentication을SecurityContext에 넣는다.- 현재 요청에서 사용할 수 있도록
SecurityContextHolder에 저장한다.- 이후 요청에서도 유지되도록
SecurityContextRepository를 통해 세션에 저장한다.- 로그아웃 시
SecurityContextHolder를 비우고 저장된 인증 정보도 정리한다.이번 프로젝트에서는 기존 세션 로그인 구조도 유지하므로, 최종적으로 아래 두 흐름이 동시에 만들어져야 한다.
기존 세션 로그인 흐름 → loginUserId = Member.id Spring Security 인증 흐름 → Authentication.getName() = Member.id이 방향으로 가면 기존 기능은 유지하면서 공구 인가 코드도 실제 로그인 사용자 기준으로 동작시킬 수 있다.
4-6. Authentication.getName()에 Member.id가 들어가야 하는 이유
Authentication.getName()에 어떤 값을 넣을지는 매우 중요하다.
여기에loginId를 넣을 수도 있고,nickname을 넣을 수도 있고,Member.id를 넣을 수도 있다.
하지만 현재 프로젝트에서는 반드시Member.id가 들어가야 한다.
이유는 공구 인가 코드가Member.id를 기준으로 비교하기 때문이다.
2번에서 확인한GroupBuyingSecurityEvaluator.java는 공구 주최자의Member.id와 현재 사용자ID를 비교한다.
대표 흐름은 아래와 같다.// GroupBuyingSecurityEvaluator.java의 checkOrganizer() 메서드 private boolean checkOrganizer(GroupBuying groupBuying, String userId) { return groupBuying.getMember().getId().equals(userId); }여기서
groupBuying.getMember().getId()는 공구 주최자의Member.id이다.
그리고userId는Authentication.getName()에서 꺼낸 값이다.
따라서 두 값이 같은 기준이어야 한다.groupBuying.getMember().getId() → Member.id authentication.getName() → Member.id만약
Authentication.getName()에loginId가 들어가면 어떻게 될까?
예를 들어 아래처럼 값이 달라진다.공구 주최자 Member.id → 1111-aaaa Authentication.getName() → testuser1이 경우 둘은 같은 사용자를 가리켜도 문자열이 다르다.
그래서checkOrganizer()는false를 반환할 수 있다.
즉, 실제 주최자임에도 주최자로 인정되지 않을 수 있다.
반대로 더미 사용자ID가 들어가도 문제가 생긴다.실제 로그인 사용자 Member.id → 1111-aaaa Authentication.getName() → 70698642-f42d-4aba-841e-0f4cac4a1eb1이 경우 공구 기능은 실제 로그인 사용자가 아니라 더미 사용자를 현재 사용자로 판단할 수 있다.
그래서 이번 적용에서는 아래 기준을 반드시 지켜야 한다.
- 세션의
loginUserId에는Member.id를 저장한다.Authentication.getName()도Member.id를 반환해야 한다.- 공구 인가 비교 기준도
Member.id로 통일한다.현재 프로젝트에서는
loginId가 아니라Member.id가 실제 사용자 구분 기준이다.
따라서Authentication.getName()에도 반드시Member.id가 들어가야 한다.
4-7. 이번 작업에서 추가, 수정, 제거, 유지할 파일 목록 정리하기
이제 실제 코드 수정에 들어가기 전에 파일 작업 범위를 정리한다.
이번 인증 적용은 여러 파일을 한꺼번에 섞어서 수정하면 흐름이 헷갈린다.
그래서 추가할 파일, 수정할 파일, 제거할 파일, 유지할 파일을 먼저 나눈다.
이번 작업의 기준은 아래와 같다.기존 로그인 검증 로직 → 유지 기존 세션 저장 → 유지 SecurityContext 저장 → 추가 DummyAuthenticationFilter → 제거 대상추가할 파일
이번 작업에서 새로 추가할 파일은 아래와 같다.
SecurityContextLoginManager.java이 파일은 로그인 성공 시
Authentication객체를 만들고, 그 객체를SecurityContext에 저장하는 역할을 맡는다.
AuthService.java안에SecurityContext저장 코드를 길게 넣지 않기 위해 별도 파일로 분리한다.
수정할 파일
이번 작업에서 수정할 파일은 아래와 같다.
AuthService.javaAuthController.javaSecurityConfig.java
AuthService.java는 기존 로그인 검증과 세션 저장은 유지하면서, 로그인 성공 후SecurityContextLoginManager를 호출하도록 수정한다.
AuthController.java는 기존HttpSession만 넘기던 구조에서HttpServletRequest와HttpServletResponse도 함께 넘길 수 있도록 수정한다.
이유는SecurityContext를 세션에 저장하거나 정리할 때 요청/응답 객체가 필요하기 때문이다.
SecurityConfig.java는 더미 필터 등록을 제거하고, 실제 인증 기준에 맞게 접근 경로를 정리한다.
제거하거나 미사용 처리할 파일
이번 작업에서 제거하거나 미사용 처리할 파일은 아래와 같다.
DummyAuthenticationFilter.java이 파일은 팀원이 인가 기능을 먼저 테스트하기 위해 만든 임시 인증 필터이다.
실제 인증을 적용하면 더 이상 고정된 더미Member.id를 넣으면 안 된다.
따라서SecurityConfig.java에서 등록을 제거하고, 파일도 삭제 대상으로 분리한다.
이번 단계에서 유지할 파일
이번 작업에서 당장 유지할 파일은 아래와 같다.
LoginSessionManager.javaSessionConst.javaMemberController.javaGroupBuyingController.javaGroupBuyingPagingController.javaGroupBuyingSecurityEvaluator.java
LoginSessionManager.java와SessionConst.java는 기존 세션 기반 기능이 아직 사용하고 있으므로 유지한다.
MemberController.java는 기존 마이페이지 역할을 하는 파일이고, 아직 세션 기준으로 로그인 사용자를 확인한다.
이번 작업에서는 기존 기능이 깨지지 않도록 바로 바꾸지 않는다.
GroupBuyingController.java,GroupBuyingPagingController.java,GroupBuyingSecurityEvaluator.java는 이미Authentication기준으로 작성되어 있다.
이번 인증 적용의 목적은 이 파일들을 고치는 것이 아니라, 이 파일들이 기대하는Authentication.getName()값이 실제 로그인 사용자의Member.id가 되도록 연결하는 것이다.
이번 4번 단계의 결론은 아래와 같다.
- 기존
AuthService.login()의 회원 조회 로직은 유지한다.- 기존
PasswordEncoder.matches()비밀번호 검증은 유지한다.- 기존
LoginSessionManager세션 저장도 유지한다.- 새로
SecurityContextLoginManager.java를 추가한다.- 로그인 성공 시
SecurityContext에 실제 로그인 사용자의Member.id를 저장한다.DummyAuthenticationFilter.java는 제거 대상으로 분리한다.다음 단계에서는 먼저
SecurityContextLoginManager.java를 추가한다.
이 파일을 통해 로그인 성공 후Authentication객체를 만들고,SecurityContext에 저장하는 코드를 분리해서 작성한다.
이번 단계에서는 로그인 성공 후
Spring Security의SecurityContext에 실제 로그인 사용자 정보를 저장하는 파일을 새로 추가한다.
기존AuthService.java안에 인증 객체 생성 코드와SecurityContext저장 코드를 전부 넣으면 로그인 검증 로직이 복잡해진다.
그래서SecurityContext저장 책임만 따로 분리한SecurityContextLoginManager.java를 만든다.
이 파일은 로그인 성공 시 실제 로그인 사용자의Member.id를 기준으로Authentication객체를 만든다.
그리고 이 인증 객체를SecurityContextHolder에 저장하고, 다음 요청에서도 인증 정보가 유지되도록 세션에도 저장한다.
이 파일의 핵심 역할은 기존 세션 로그인 결과와Spring Security인증 정보를 같은Member.id기준으로 연결하는 것이다.
5-1. 추가 파일 위치 확인하기
먼저 새로 추가할 파일 위치부터 확인한다.
이번에 추가할 파일은SecurityContextLoginManager.java이다.
추가 위치는 아래와 같다.src/main/java/com/example/groupbuyingweb/core/security/SecurityContextLoginManager.java이 파일을
core/security패키지에 두는 이유는 역할 때문이다.
SecurityContextLoginManager.java는 특정 도메인 기능을 처리하는 파일이 아니다.
공구, 회원, 포인트 같은 비즈니스 기능을 직접 처리하지 않는다.
이 파일은Spring Security의 인증 정보를 생성하고 저장하는 공통 보안 흐름을 담당한다.
그래서controller나service패키지가 아니라, 보안 공통 코드가 들어가는core/security위치에 두는 것이 자연스럽다.
이번 작업에서 새 파일을 추가한 뒤, 이후 단계에서AuthService.java가 이 파일을 호출하게 만들 것이다.
흐름을 간단히 보면 아래와 같다.AuthService.login() → 기존 회원 조회 → 기존 비밀번호 검증 → 기존 세션 저장 → SecurityContextLoginManager.login() 호출 → SecurityContext에 실제 로그인 사용자 인증 정보 저장즉,
SecurityContextLoginManager.java는 로그인 요청을 직접 받는 파일이 아니다.
로그인 성공 이후AuthService.java에서 호출되는 보안 저장 담당 파일이다.
5-2. SecurityContextLoginManager를 추가하는 이유 설명하기
기존 로그인 기능은 이미
AuthService.java에서 처리하고 있다.
loginId로 회원을 찾고,PasswordEncoder.matches()로 비밀번호를 검증하고, 로그인 성공 시 세션에Member.id를 저장한다.
기존 로그인 성공 흐름은 아래와 같다.AuthService.login() → loginId로 회원 조회 → password 검증 → LoginSessionManager.login(session, member.getId()) → HttpSession에 loginUserId = Member.id 저장이 구조는 기존 마이페이지 같은 세션 기반 기능에서는 동작한다.
하지만 공구 기능은 세션의loginUserId를 보지 않는다.
공구 기능은Authentication.getName()을 본다.
따라서 로그인 성공 후 아래 흐름이 추가로 필요하다.로그인 성공한 Member.id → Authentication 객체 생성 → SecurityContext에 저장 → Authentication.getName()으로 Member.id 조회 가능이 코드를
AuthService.java에 직접 넣을 수도 있다.
하지만 그렇게 하면AuthService.java가 너무 많은 일을 하게 된다.
AuthService.java가 맡아야 하는 핵심 역할은 회원가입, 로그인 검증, 로그아웃 같은 인증 서비스 흐름이다.
여기에SecurityContextHolder,SecurityContextRepository,UsernamePasswordAuthenticationToken같은 보안 저장 코드가 길게 들어가면 로그인 로직을 읽기 어려워진다.
그래서 역할을 분리한다.
AuthService.java: 회원 조회, 비밀번호 검증, 기존 세션 저장 흐름 담당SecurityContextLoginManager.java:Spring Security인증 객체 생성과SecurityContext저장 담당이렇게 나누면 각 파일의 책임이 분명해진다.
AuthService.java는 로그인 성공 시SecurityContextLoginManager.login()만 호출하면 된다.
실제Authentication객체를 어떻게 만들고 어디에 저장할지는SecurityContextLoginManager.java가 담당한다.
정리하면SecurityContextLoginManager.java를 추가하는 이유는 아래와 같다.
- 로그인 성공 사용자의
Member.id를Authentication.getName()으로 꺼낼 수 있게 만든다.SecurityContext저장 코드를AuthService.java에서 분리한다.- 기존 세션 로그인 구조를 유지하면서
Spring Security인증 흐름을 추가한다.- 공구 인가 코드가 실제 로그인 사용자 기준으로 동작하게 만든다.
즉, 이 파일은 기존 로그인 검증 로직을 대체하는 파일이 아니라, 로그인 성공 후
Spring Security가 이해할 수 있는 인증 정보를 저장하는 파일이다.
5-3. 공부한 개념 연결: Authentication은 인증 정보를 담는 객체이다
공부했던
Spring Security개념에서Authentication은 인증 정보를 담는 객체라고 정리했다.
쉽게 말하면 현재 사용자가 누구인지, 인증된 사용자인지, 어떤 권한을 가지고 있는지를 담는 객체이다.
현재 프로젝트에서 필요한 핵심은Authentication.getName()이다.
공구 인가 코드가 현재 로그인 사용자ID를 꺼낼 때 아래 코드를 사용하기 때문이다.authentication.getName()따라서 로그인 성공 후 만들어지는
Authentication객체의 이름값에는 반드시Member.id가 들어가야 한다.
그래야 공구 인가 코드가 실제 로그인한 사용자의Member.id를 기준으로 주최자 여부나 참여 가능 여부를 판단할 수 있다.
이 흐름을 기존 세션 구조와 비교하면 아래와 같다.기존 세션 로그인 구조 → HttpSession → loginUserId = Member.id Spring Security 인증 구조 → SecurityContext → Authentication.getName() = Member.id두 흐름 모두 같은
Member.id를 바라봐야 한다.
만약 세션에는 실제 로그인 사용자Member.id가 있고,Authentication에는 다른 값이 들어가면 문제가 생긴다.
예를 들어 아래처럼 값이 갈라지면 안 된다.HttpSession → loginUserId = 1111-aaaa Authentication → authentication.getName() = 9999-dummy이 경우 마이페이지는
1111-aaaa사용자를 현재 사용자로 보고, 공구 기능은9999-dummy사용자를 현재 사용자로 볼 수 있다.
그래서 이번 작업에서는 로그인 성공 시 같은Member.id를 세션과SecurityContext양쪽에 맞춰 넣어야 한다.
Authentication구현체는 여러 종류가 있지만, 이번 프로젝트에서는UsernamePasswordAuthenticationToken을 사용한다.
이 객체는 이름처럼 아이디와 비밀번호 기반 인증에서 자주 사용하는Authentication구현체이다.
다만 이번 코드에서는 비밀번호 검증을 이미AuthService.login()에서 끝낸 뒤에 인증 객체를 만든다.
그래서UsernamePasswordAuthenticationToken에 실제 비밀번호를 다시 넣을 필요는 없다.
이번 코드에서는 인증된 사용자 식별값으로Member.id를 넣고, 비밀번호 자리는null로 둔다.
정리하면 이번 파일에서 만들 인증 객체의 기준은 아래와 같다.
principal: 실제 로그인한 사용자의Member.idcredentials: 이미 비밀번호 검증이 끝났으므로nullauthorities: 현재 역할 기반 권한을 사용하지 않으므로 빈 목록이렇게 만들면
Authentication.getName()은 실제 로그인한 사용자의Member.id를 반환할 수 있다.
5-4. SecurityContextLoginManager.java 전체 코드 먼저 확인하기
이제 추가할
SecurityContextLoginManager.java의 전체 코드를 먼저 확인한다.
새 파일이므로 수정 전/후가 아니라, 전체 코드를 먼저 보고 그 다음 핵심 부분을 나눠 설명한다.
아래 코드를 새 파일로 추가하면 된다.package com.example.groupbuyingweb.core.security; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.stereotype.Component; import java.util.Collections; @Component public class SecurityContextLoginManager { private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); public void login( String memberId, HttpServletRequest request, HttpServletResponse response ) { // 추가: 로그인 성공한 사용자의 Member.id를 기준으로 Authentication 객체를 만든다. Authentication authentication = new UsernamePasswordAuthenticationToken( memberId, null, Collections.emptyList() ); // 추가: 빈 SecurityContext를 만들고 Authentication을 저장한다. SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); // 추가: 현재 요청 흐름에서 Authentication을 사용할 수 있도록 SecurityContextHolder에 저장한다. SecurityContextHolder.setContext(context); // 추가: 다음 요청에서도 인증 정보가 유지되도록 SecurityContext를 세션에 저장한다. securityContextRepository.saveContext(context, request, response); } public void logout( HttpServletRequest request, HttpServletResponse response ) { // 추가: 현재 요청 흐름에 남아 있는 SecurityContext를 비운다. SecurityContextHolder.clearContext(); // 추가: 세션에 저장된 Spring Security 인증 정보도 제거한다. HttpSession session = request.getSession(false); if (session != null) { session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); } // 추가: 비어 있는 SecurityContext를 저장해 이후 요청에서 인증 정보가 남지 않도록 한다. SecurityContext emptyContext = SecurityContextHolder.createEmptyContext(); securityContextRepository.saveContext(emptyContext, request, response); } }이 전체 코드는 크게 두 가지 메서드로 나눌 수 있다.
login(): 로그인 성공 시Authentication을 만들고SecurityContext에 저장한다.logout(): 로그아웃 시SecurityContext와 세션에 저장된 보안 인증 정보를 정리한다.이제 전체 코드에서 핵심 부분을 하나씩 나누어 확인한다.
Authentication 객체를 만드는 코드 흐름 설명하기
먼저 로그인 성공 후
Authentication객체를 만드는 부분을 확인한다.
이 코드는SecurityContextLoginManager.java의login()메서드 안에 있다.// SecurityContextLoginManager.java의 login() 메서드 내부 Authentication authentication = new UsernamePasswordAuthenticationToken( memberId, null, Collections.emptyList() );이 코드는 로그인 성공한 사용자를
Spring Security가 이해할 수 있는 인증 객체로 바꾸는 부분이다.
첫 번째 값인memberId는 실제 로그인한 사용자의Member.id이다.
이 값이principal역할을 한다.
즉, 현재 인증된 사용자가 누구인지 나타내는 값이다.
두 번째 값인null은 비밀번호 자리이다.
이번 프로젝트에서는 이미AuthService.login()에서PasswordEncoder.matches()로 비밀번호 검증을 끝냈다.
그래서 인증 객체를 만들 때 비밀번호를 다시 넣을 필요가 없다.
세 번째 값인Collections.emptyList()는 권한 목록이다.
현재 공구 인가 로직은ROLE_USER,ROLE_ADMIN같은 역할 기반 권한이 아니라, 현재 사용자Member.id를 기준으로 주최자 여부와 참여 가능 여부를 검사한다.
그래서 지금 단계에서는 빈 권한 목록을 넣는다.
이 코드의 핵심은memberId를 첫 번째 값으로 넣는 것이다.
그래야Authentication.getName()을 호출했을 때Member.id를 꺼낼 수 있다.
예를 들어 로그인 성공한 사용자의Member.id가 아래 값이라고 하자.memberId = 1111-aaaa그러면 인증 객체는 아래 기준으로 만들어진다.
principal → 1111-aaaa credentials → null authorities → []이후 공구 코드에서 아래처럼 호출하면,
authentication.getName()아래 값을 얻을 수 있어야 한다.
Authentication.getName() → 1111-aaaa즉, 이 코드는 공구 인가 코드가 실제 로그인 사용자의
Member.id를 볼 수 있게 만드는 출발점이다.
SecurityContextHolder에 인증 객체를 저장하는 코드 흐름 설명하기
Authentication객체를 만들었다고 끝이 아니다.
만든 인증 객체를SecurityContext에 넣고, 그SecurityContext를 현재 요청에서 사용할 수 있도록 저장해야 한다.
이 흐름은SecurityContextLoginManager.java의login()메서드 안에 아래 코드로 작성되어 있다.// SecurityContextLoginManager.java의 login() 메서드 내부 SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); SecurityContextHolder.setContext(context);첫 번째 줄은 빈
SecurityContext를 만든다.SecurityContext context = SecurityContextHolder.createEmptyContext();
SecurityContext는Authentication을 담는 공간이다.
기존 컨텍스트를 그대로 사용하는 것이 아니라, 새 빈 컨텍스트를 만들고 여기에 로그인 성공 인증 정보를 넣는다.
두 번째 줄은 방금 만든Authentication을SecurityContext에 저장한다.context.setAuthentication(authentication);이렇게 해야
SecurityContext안에 현재 인증된 사용자 정보가 들어간다.
세 번째 줄은 이SecurityContext를SecurityContextHolder에 저장한다.SecurityContextHolder.setContext(context);
SecurityContextHolder에 저장해야 현재 요청 흐름에서Authentication을 꺼낼 수 있다.
예를 들어 같은 요청 안에서SecurityContextHolder.getContext().getAuthentication()을 호출하면 방금 저장한 인증 객체를 얻을 수 있다.
정리하면 이 부분의 흐름은 아래와 같다.Authentication 생성 완료 → 빈 SecurityContext 생성 → SecurityContext에 Authentication 저장 → SecurityContextHolder에 SecurityContext 저장이 코드 덕분에 현재 요청 안에서는
Spring Security가 현재 사용자의 인증 정보를 알 수 있게 된다.
SecurityContextRepository로 SecurityContext를 세션에 저장하는 흐름 설명하기
SecurityContextHolder에 저장하는 것만으로는 부족하다.
SecurityContextHolder는 현재 요청 흐름에서 인증 정보를 사용할 수 있게 해준다.
하지만 로그인 이후 다음 요청에서도 인증 상태를 유지하려면SecurityContext를 세션에 저장해야 한다.
이 흐름은 아래 코드에서 처리한다.// SecurityContextLoginManager.java의 필드 private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();이 코드는
SecurityContext를 어디에 저장할지 정하는 객체를 준비하는 부분이다.
여기서는HttpSessionSecurityContextRepository를 사용한다.
이름 그대로SecurityContext를HttpSession기반으로 저장하고 꺼내는 역할을 한다.
실제 저장은login()메서드 안의 아래 코드에서 이루어진다.// SecurityContextLoginManager.java의 login() 메서드 내부 securityContextRepository.saveContext(context, request, response);이 코드는 현재 만들어둔
SecurityContext를 세션에 저장한다.
그래서 로그인 요청이 끝난 뒤 다음 요청이 들어와도,Spring Security가 세션에서SecurityContext를 다시 꺼내 사용할 수 있다.
흐름을 정리하면 아래와 같다.로그인 요청에서 Authentication 생성 → SecurityContext 생성 → SecurityContextHolder에 저장 → SecurityContextRepository.saveContext(context, request, response) → HttpSession에 Spring Security 인증 정보 저장 → 다음 요청에서도 Authentication 사용 가능이 부분이 없으면 로그인 요청 순간에는 인증 객체가 있는 것처럼 보여도, 다음 요청에서 인증 정보가 유지되지 않을 수 있다.
이번 프로젝트에서 이 저장 흐름이 중요한 이유는 공구 페이지가 로그인 이후 다른 요청에서 동작하기 때문이다.
예를 들어 로그인 성공 후/group-buyings로 이동하면 새로운 요청이 발생한다.
그 요청에서도Authentication.getName()으로 실제 로그인 사용자Member.id를 꺼낼 수 있어야 한다.
따라서SecurityContextRepository를 통해 세션에SecurityContext를 저장하는 흐름이 필요하다.
로그아웃 시 SecurityContext를 비우는 코드 흐름 설명하기
로그인 성공 시
SecurityContext에 인증 정보를 저장했다면, 로그아웃할 때는 이 정보를 반드시 비워야 한다.
그래야 로그아웃 이후 이전 인증 정보가 남아 있지 않는다.
이 흐름은SecurityContextLoginManager.java의logout()메서드에서 처리한다.// SecurityContextLoginManager.java의 logout() 메서드 public void logout( HttpServletRequest request, HttpServletResponse response ) { // 추가: 현재 요청 흐름에 남아 있는 SecurityContext를 비운다. SecurityContextHolder.clearContext(); // 추가: 세션에 저장된 Spring Security 인증 정보도 제거한다. HttpSession session = request.getSession(false); if (session != null) { session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); } // 추가: 비어 있는 SecurityContext를 저장해 이후 요청에서 인증 정보가 남지 않도록 한다. SecurityContext emptyContext = SecurityContextHolder.createEmptyContext(); securityContextRepository.saveContext(emptyContext, request, response); }먼저 아래 코드는 현재 요청 흐름에 남아 있는 인증 정보를 제거한다.
SecurityContextHolder.clearContext();이렇게 하면 현재 실행 흐름에서
SecurityContextHolder가 들고 있는 인증 정보가 비워진다.
하지만 이것만으로는 세션에 저장된 인증 정보까지 완전히 정리했다고 보기 어렵다.
그래서 세션에 저장된Spring Security인증 정보도 제거한다.HttpSession session = request.getSession(false); if (session != null) { session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); }
request.getSession(false)는 기존 세션이 있으면 가져오고, 없으면 새로 만들지 않는다.
로그아웃을 처리할 때 불필요하게 새 세션을 만들 필요가 없기 때문이다.
마지막으로 비어 있는SecurityContext를 저장한다.SecurityContext emptyContext = SecurityContextHolder.createEmptyContext(); securityContextRepository.saveContext(emptyContext, request, response);이 코드는 이후 요청에서 이전 인증 정보가 다시 살아나지 않도록 비어 있는 컨텍스트를 저장하는 흐름이다.
기존LoginSessionManager.logout(session)은 세션 자체를 무효화한다.
하지만 이번 작업에서는Spring Security인증 정보도 함께 관리하므로,SecurityContextLoginManager.logout()도 같이 호출해야 한다.
여기서 호출 순서가 중요하다.
실제AuthService.logout()에서 연결할 때는SecurityContextLoginManager.logout(request, response)를 먼저 호출하고, 그 다음LoginSessionManager.logout(session)을 호출하는 방향으로 간다.
이유는LoginSessionManager.logout(session)이 세션을 먼저 무효화하면, 이후SecurityContextLoginManager.logout()에서 세션에 저장된Spring Security인증 정보를 정리하는 흐름이 꼬일 수 있기 때문이다.
따라서 먼저Spring Security인증 정보를 정리하고, 마지막에 기존 세션을 무효화하는 흐름으로 가야 한다.
정리하면 로그아웃 시 정리할 대상은 두 가지다.Spring Security 인증 정보 → SecurityContextLoginManager.logout(request, response) → SecurityContextHolder 정리 → 세션에 저장된 SecurityContext 정리 기존 세션 로그인 정보 → LoginSessionManager.logout(session) → 세션 무효화이렇게 해야 기존 세션 기반 로그인 정보와
Spring Security인증 정보가 함께 정리된다.
5-5. SecurityContextLoginManager.java 추가 흐름 정리하기
이번 단계에서는
SecurityContextLoginManager.java를 새로 추가했다.
이 파일은 로그인 검증을 직접 처리하는 파일이 아니다.
로그인 성공 이후Spring Security가 이해할 수 있는 인증 정보를 만들어 저장하는 파일이다.
전체 흐름을 다시 정리하면 아래와 같다.로그인 성공 → AuthService.login()에서 Member 조회와 password 검증 완료 → Member.id 확보 → 기존 LoginSessionManager로 HttpSession에 loginUserId 저장 → SecurityContextLoginManager.login() 호출 → Member.id로 Authentication 생성 → SecurityContextHolder에 저장 → SecurityContextRepository를 통해 세션에 SecurityContext 저장로그아웃 흐름은 아래처럼 이어진다.
로그아웃 요청 → SecurityContextLoginManager.logout() 호출 → SecurityContextHolder 정리 → 세션에 저장된 SecurityContext 정리 → LoginSessionManager.logout(session) 호출 → 기존 세션 무효화로그아웃에서는 순서가 중요하다.
Spring Security인증 정보를 먼저 정리하고, 마지막에 기존 세션을 무효화해야 한다.
그래야 세션에 남아 있는SecurityContext를 정리하는 흐름이 안전하게 끝난다.
이번 파일을 추가하면 이후AuthService.java에서 할 일은 단순해진다.
기존 로그인 성공 흐름은 유지하고, 로그인 성공 후 아래 호출만 추가하면 된다.securityContextLoginManager.login(member.getId(), request, response);그리고 로그아웃 시에는 아래 호출을 먼저 실행한다.
securityContextLoginManager.logout(request, response);그 다음 기존 세션 로그아웃을 처리한다.
loginSessionManager.logout(session);이렇게 하면 기존 세션 기반 로그인 구조와
Spring Security인증 구조가 같은Member.id기준으로 연결된다.
다음 단계에서는AuthService.java를 수정한다.
기존 회원 조회, 비밀번호 검증, 세션 저장은 유지하고, 방금 추가한SecurityContextLoginManager.java를 호출하도록 연결한다.
이번 단계에서는 기존
AuthService.java에SecurityContextLoginManager.java를 연결한다.
기존AuthService.java는 이미loginId로 회원을 조회하고,PasswordEncoder.matches()로 비밀번호를 검증하고, 로그인 성공 시 세션에Member.id를 저장하고 있다.
이번 수정의 핵심은 이 기존 흐름을 지우는 것이 아니다.
기존 회원 조회, 비밀번호 검증, 세션 저장은 그대로 유지하고, 로그인 성공 후SecurityContext에도 같은Member.id를 저장하는 코드를 추가한다.
또한 로그아웃할 때도 기존 세션만 무효화하면 안 된다.
로그인 성공 시Spring Security인증 정보도 저장했기 때문에, 로그아웃 시에는SecurityContext에 저장된 인증 정보도 함께 정리해야 한다.
이번 수정의 핵심은 기존 세션 로그인 구조를 유지하면서SecurityContextLoginManager.java호출만 추가하는 것이다.
6-1. 수정 파일과 수정 위치 확인하기
이번에 수정할 파일은
AuthService.java이다.
수정 파일 위치는 아래와 같다.src/main/java/com/example/groupbuyingweb/service/AuthService.java이번 단계에서 수정할 위치는 크게 다섯 곳이다.
import추가- 필드 주입부에
SecurityContextLoginManager추가login()메서드 매개변수 수정login()메서드 내부에SecurityContext저장 호출 추가logout()메서드 매개변수와 내부 흐름 수정기존
AuthService.java의 역할은 그대로 유지한다.
즉, 회원가입, 아이디 중복확인, 닉네임 중복확인, 비밀번호 확인, 약관 확인, 주소 저장 흐름은 이번 단계에서 건드리지 않는다.
이번 작업은 로그인 성공과 로그아웃 흐름에Spring Security인증 정보 저장과 정리를 추가하는 작업이다.
따라서 수정 범위를 정확히 좁혀서 봐야 한다.
6-2. AuthService.java 수정 방향 먼저 정리하기
수정 방향은 아래처럼 잡는다.
로그인 성공 시 → 기존 회원 조회 유지 → 기존 비밀번호 검증 유지 → 기존 세션 저장 유지 → SecurityContextLoginManager.login() 추가 호출 로그아웃 시 → 기존 로그인 여부 확인 유지 → SecurityContextLoginManager.logout() 먼저 호출 → LoginSessionManager.logout() 나중에 호출여기서 중요한 점은 기존 세션 저장을 제거하지 않는다는 것이다.
기존 프로젝트에는 아직 세션의loginUserId를 기준으로 현재 사용자를 찾는 코드가 남아 있다.
그래서LoginSessionManager.login(session, member.getId())는 그대로 유지한다.
대신 로그인 성공 후 아래 코드가 추가된다.securityContextLoginManager.login(member.getId(), httpRequest, httpResponse);이 코드는 5번에서 추가한
SecurityContextLoginManager.java의login()메서드를 호출한다.
로그인 성공한 사용자의Member.id를Authentication객체에 넣고, 그 인증 정보를SecurityContext에 저장하는 역할을 한다.
로그아웃에서는 순서가 중요하다.
기존 세션을 먼저 무효화하면,SecurityContext정리 과정에서 세션에 저장된 보안 인증 정보를 정리하는 흐름이 꼬일 수 있다.
그래서 아래 순서로 간다.1. 로그인 여부 확인 2. SecurityContextLoginManager.logout(httpRequest, httpResponse) 호출 3. LoginSessionManager.logout(session) 호출즉,
Spring Security인증 정보를 먼저 정리하고, 마지막에 기존 세션을 무효화한다.
6-3. 필드 주입부 수정하기
먼저
AuthService.java에SecurityContextLoginManager를 주입해야 한다.
AuthService.java는@RequiredArgsConstructor를 사용하고 있으므로,final필드로 선언하면 생성자 주입이 자동으로 적용된다.
수정 전 필드 주입 코드
현재
AuthService.java의 필드 주입부는 아래와 같다.private final MemberRepository memberRepository; private final UserNearbyAddressRepository userNearbyAddressRepository; private final AddressService addressService; private final PasswordEncoder passwordEncoder; private final LoginSessionManager loginSessionManager;현재 필드에는
LoginSessionManager는 있지만,SecurityContextLoginManager는 없다.
그래서 기존 세션 저장은 가능하지만,SecurityContext저장 역할을 맡는 파일을 호출할 수 없다.
수정 후 필드 주입 코드
수정 후에는 아래처럼
SecurityContextLoginManager필드를 추가한다.private final MemberRepository memberRepository; private final UserNearbyAddressRepository userNearbyAddressRepository; private final AddressService addressService; private final PasswordEncoder passwordEncoder; private final LoginSessionManager loginSessionManager; private final SecurityContextLoginManager securityContextLoginManager; // 추가: 로그인 성공 정보를 SecurityContext에 저장하고 로그아웃 시 정리하기 위해 필요하다.새로 추가된 필드는 아래 코드이다.
private final SecurityContextLoginManager securityContextLoginManager; // 추가: 로그인 성공 정보를 SecurityContext에 저장하고 로그아웃 시 정리하기 위해 필요하다.이 필드가 있어야
AuthService.java에서 아래 메서드들을 호출할 수 있다.securityContextLoginManager.login(member.getId(), httpRequest, httpResponse); securityContextLoginManager.logout(httpRequest, httpResponse);즉,
AuthService.java는 기존 로그인 검증을 처리하고,SecurityContextLoginManager.java는Spring Security인증 정보 저장과 정리를 담당하게 된다.
6-4. login() 메서드 시그니처 수정하기
다음은
login()메서드 선언부를 수정한다.
기존에는AuthRequest.Login request와HttpSession session만 받았다.
하지만 이제SecurityContextLoginManager.login()을 호출하려면HttpServletRequest와HttpServletResponse도 필요하다.
수정 전 login() 메서드 선언
기존
login()메서드 선언은 아래와 같다.public AuthResponse.LoginResult login( AuthRequest.Login request, HttpSession session )이 구조에서는 기존 세션 저장은 가능하다.
하지만SecurityContextRepository.saveContext(context, request, response)를 호출할 때 필요한 요청 객체와 응답 객체가 없다.
수정 후 login() 메서드 선언
수정 후에는 아래처럼
HttpServletRequest와HttpServletResponse를 추가한다.public AuthResponse.LoginResult login( AuthRequest.Login request, HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext를 세션에 저장할 때 필요한 요청 객체이다. HttpServletResponse httpResponse // 추가: SecurityContext를 세션에 저장할 때 필요한 응답 객체이다. )여기서 이름을
httpRequest,httpResponse로 잡은 이유는AuthRequest.Login request와 이름이 겹치지 않게 하기 위해서다.
AuthRequest.Login request는 로그인 요청DTO이다.
사용자가 입력한loginId,password를 담고 있다.
반면HttpServletRequest httpRequest는 실제 서블릿 요청 객체이다.
SecurityContextRepository가SecurityContext를 세션에 저장할 때 필요하다.
HttpServletResponse httpResponse도 마찬가지다.
SecurityContextRepository.saveContext()는 요청과 응답 객체를 함께 받아 현재 인증 정보를 저장한다.
정리하면 두request의 역할은 다르다.AuthRequest.Login request → 사용자가 보낸 로그인 요청값 DTO → loginId, password 포함 HttpServletRequest httpRequest → 실제 HTTP 요청 객체 → SecurityContext 저장에 필요 HttpServletResponse httpResponse → 실제 HTTP 응답 객체 → SecurityContext 저장에 필요이렇게 구분하면 왜 매개변수가 늘어나는지 이해할 수 있다.
6-5. login() 메서드 내부 수정하기
이제
login()메서드 내부를 수정한다.
기존 로그인 검증 흐름은 그대로 유지하고, 로그인 성공 후SecurityContextLoginManager.login()호출만 추가한다.
수정 전 login() 메서드 전체 흐름
현재
AuthService.java의login()메서드는 아래와 같다.public AuthResponse.LoginResult login( AuthRequest.Login request, HttpSession session ) { // 1. 로그인 아이디로 회원 조회 Member member = memberRepository.findByLoginId(request.loginId()) .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED)); // 2. 입력 비밀번호와 저장된 해시 비밀번호 비교 if (!passwordEncoder.matches(request.password(), member.getPassword())) { throw new BusinessException(ErrorCode.LOGIN_FAILED); } // 3. 로그인 성공 시 세션에 Member.id 저장 loginSessionManager.login(session, member.getId()); // 4. 로그인 성공 응답 DTO 반환 return new AuthResponse.LoginResult( member.getId(), member.getLoginId(), member.getNickname(), member.getAddress(), member.getRadius(), member.getEntX(), member.getEntY() ); }이 코드는 기존 세션 로그인 흐름만 처리한다.
로그인 성공 후 세션에는loginUserId = Member.id가 저장되지만,SecurityContext에는 인증 정보가 저장되지 않는다.
그래서 공구 기능에서 사용하는Authentication.getName()과 자연스럽게 연결되지 않는다.
수정 후 login() 메서드 전체 흐름
수정 후에는 기존 세션 저장 코드 아래에
SecurityContextLoginManager.login()호출을 추가한다.public AuthResponse.LoginResult login( AuthRequest.Login request, HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext 저장에 필요한 요청 객체이다. HttpServletResponse httpResponse // 추가: SecurityContext 저장에 필요한 응답 객체이다. ) { // 1. 로그인 아이디로 회원 조회 Member member = memberRepository.findByLoginId(request.loginId()) .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED)); // 2. 입력 비밀번호와 저장된 해시 비밀번호 비교 if (!passwordEncoder.matches(request.password(), member.getPassword())) { throw new BusinessException(ErrorCode.LOGIN_FAILED); } // 3. 로그인 성공 시 세션에 Member.id 저장 loginSessionManager.login(session, member.getId()); // 추가: 로그인 성공 정보를 Spring Security의 SecurityContext에도 저장한다. securityContextLoginManager.login(member.getId(), httpRequest, httpResponse); // 4. 로그인 성공 응답 DTO 반환 return new AuthResponse.LoginResult( member.getId(), member.getLoginId(), member.getNickname(), member.getAddress(), member.getRadius(), member.getEntX(), member.getEntY() ); }이 코드에서 기존 흐름은 그대로 유지된다.
회원 조회도 그대로이고, 비밀번호 검증도 그대로이고, 세션 저장도 그대로이다.
추가된 부분은 아래 한 줄이다.securityContextLoginManager.login(member.getId(), httpRequest, httpResponse);이 코드는 로그인 성공한 사용자의
Member.id를SecurityContextLoginManager.java로 넘긴다.
그러면SecurityContextLoginManager.java가Member.id를 기준으로Authentication객체를 만들고,SecurityContext에 저장한다.
로그인 성공 후 만들어지는 기준은 아래처럼 된다.기존 세션 → loginUserId = member.getId() Spring Security 인증 정보 → Authentication.getName() = member.getId()즉, 기존 세션 기반 코드와 공구 인가 코드가 같은 사용자를 바라보게 된다.
6-6. logout() 메서드 시그니처 수정하기
다음은
logout()메서드 선언부를 수정한다.
기존에는HttpSession session만 받았다.
하지만 이제 로그아웃 시SecurityContext도 정리해야 하므로HttpServletRequest와HttpServletResponse도 필요하다.
수정 전 logout() 메서드 선언
기존
logout()메서드 선언은 아래와 같다.public void logout(HttpSession session)이 구조에서는 기존 세션 무효화만 처리할 수 있다.
하지만SecurityContextLoginManager.logout()을 호출하려면HttpServletRequest,HttpServletResponse가 필요하다.
수정 후 logout() 메서드 선언
수정 후에는 아래처럼 변경한다.
public void logout( HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext 정리에 필요한 요청 객체이다. HttpServletResponse httpResponse // 추가: SecurityContext 정리에 필요한 응답 객체이다. )이렇게 수정해야
AuthService.logout()안에서 아래 코드를 호출할 수 있다.securityContextLoginManager.logout(httpRequest, httpResponse);즉, 로그아웃에서는 기존 세션 로그아웃과
Spring Security인증 정보 정리가 함께 필요하다.
6-7. logout() 메서드 내부 수정하기
이제
logout()메서드 내부를 수정한다.
여기서 가장 중요한 것은 호출 순서이다.
기존 세션을 먼저 무효화하면, 이후SecurityContext정리 과정에서 세션에 저장된 보안 인증 정보를 정리하는 흐름이 꼬일 수 있다.
그래서SecurityContextLoginManager.logout()을 먼저 호출하고, 그 다음LoginSessionManager.logout()을 호출한다.
수정 전 logout() 메서드 전체 흐름
현재
logout()메서드는 아래와 같다.public void logout(HttpSession session) { loginSessionManager.requireLoginUserId(session); loginSessionManager.logout(session); }이 코드는 먼저 세션에
loginUserId가 있는지 확인한다.
그 다음 기존 세션을 무효화한다.
하지만 현재 구조에서는Spring Security의SecurityContext까지 정리하지 않는다.
로그인 성공 시SecurityContext에도 인증 정보를 저장하게 되면, 로그아웃 시 이 정보도 함께 정리해야 한다.
수정 후 logout() 메서드 전체 흐름
수정 후에는 아래처럼 변경한다.
public void logout( HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext 정리에 필요한 요청 객체이다. HttpServletResponse httpResponse // 추가: SecurityContext 정리에 필요한 응답 객체이다. ) { loginSessionManager.requireLoginUserId(session); // 추가: Spring Security 인증 정보를 먼저 정리한다. securityContextLoginManager.logout(httpRequest, httpResponse); // 수정: Spring Security 인증 정보 정리 후 기존 세션을 무효화한다. loginSessionManager.logout(session); }기존 로그인 여부 확인 코드는 유지한다.
loginSessionManager.requireLoginUserId(session);이 코드는 세션에
loginUserId가 없는 상태에서 로그아웃 요청이 들어오면 로그인 필요 예외를 발생시킨다.
기존 로그아웃 정책을 유지하기 위해 그대로 둔다.
그 다음 새로 추가한SecurityContextLoginManager.logout()을 먼저 호출한다.securityContextLoginManager.logout(httpRequest, httpResponse);이 코드는
SecurityContextHolder를 비우고, 세션에 저장된Spring Security인증 정보도 정리한다.
마지막으로 기존 세션을 무효화한다.loginSessionManager.logout(session);이 순서로 가야 안전하다.
기존 세션을 먼저 무효화하면SecurityContextLoginManager.logout()이 세션에 저장된 인증 정보를 정리하기 어려워질 수 있기 때문이다.
정리하면 로그아웃 최종 흐름은 아래와 같다.로그아웃 요청 → 세션에 loginUserId가 있는지 확인 → SecurityContextLoginManager.logout(httpRequest, httpResponse) → Spring Security 인증 정보 정리 → LoginSessionManager.logout(session) → 기존 세션 무효화이렇게 해야 기존 세션 로그인 정보와
Spring Security인증 정보가 함께 정리된다.
6-8. AuthService.java 수정 반영 전체 코드 작성하기
이제 수정사항을 모두 반영한
AuthService.java전체 코드를 확인한다.
전체 코드에서는 이번 단계에서 새로 추가하거나 수정한 부분에만// 추가:또는// 수정:주석을 표시한다.
package com.example.groupbuyingweb.service; import com.example.groupbuyingweb.core.error.BusinessException; import com.example.groupbuyingweb.core.security.SecurityContextLoginManager; // 추가: SecurityContext 저장과 정리를 담당하는 파일을 사용하기 위해 필요하다. import com.example.groupbuyingweb.core.session.LoginSessionManager; import com.example.groupbuyingweb.domain.dto.request.AuthRequest; import com.example.groupbuyingweb.domain.dto.response.AuthResponse; import com.example.groupbuyingweb.domain.entity.h2.UserNearbyAddress; import com.example.groupbuyingweb.domain.entity.mysql.Member; import com.example.groupbuyingweb.domain.enums.ErrorCode; import com.example.groupbuyingweb.repository.h2.UserNearbyAddressRepository; import com.example.groupbuyingweb.repository.mysql.MemberRepository; import jakarta.servlet.http.HttpServletRequest; // 추가: SecurityContext를 세션에 저장하거나 정리할 때 필요하다. import jakarta.servlet.http.HttpServletResponse; // 추가: SecurityContext를 세션에 저장하거나 정리할 때 필요하다. import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Objects; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AuthService { private final MemberRepository memberRepository; private final UserNearbyAddressRepository userNearbyAddressRepository; private final AddressService addressService; private final PasswordEncoder passwordEncoder; private final LoginSessionManager loginSessionManager; private final SecurityContextLoginManager securityContextLoginManager; // 추가: 로그인 성공 정보를 SecurityContext에 저장하고 로그아웃 시 정리한다. public AuthResponse.DuplicateCheck checkLoginId(String loginId) { boolean exists = memberRepository.existsByLoginId(loginId); return new AuthResponse.DuplicateCheck(!exists); } public AuthResponse.DuplicateCheck checkNickname(String nickname) { boolean exists = memberRepository.existsByNickname(nickname); return new AuthResponse.DuplicateCheck(!exists); } @Transactional public AuthResponse.SignupResult signup(AuthRequest.Signup request) { validatePasswordConfirm(request.password(), request.passwordConfirm()); validateTermsAgreed(request.termsAgreed()); validateDuplicateLoginId(request.loginId()); validateDuplicateNickname(request.nickname()); Member member = Member.builder() .loginId(request.loginId()) .password(passwordEncoder.encode(request.password())) .nickname(request.nickname()) .address(request.address()) .entX(request.entX()) .entY(request.entY()) .build(); Member savedMember = memberRepository.save(member); List<UserNearbyAddress> nearbyAddresses = addressService.createNearbyAddresses( savedMember, request.entX(), request.entY() ); userNearbyAddressRepository.saveAll(nearbyAddresses); return new AuthResponse.SignupResult(savedMember.getId()); } private void validatePasswordConfirm( String password, String passwordConfirm ) { if (!Objects.equals(password, passwordConfirm)) { throw new BusinessException(ErrorCode.PASSWORD_NOT_MATCH); } } private void validateTermsAgreed(Boolean termsAgreed) { if (!Boolean.TRUE.equals(termsAgreed)) { throw new BusinessException(ErrorCode.TERMS_NOT_AGREED); } } private void validateDuplicateLoginId(String loginId) { if (memberRepository.existsByLoginId(loginId)) { throw new BusinessException(ErrorCode.DUPLICATED_LOGIN_ID); } } private void validateDuplicateNickname(String nickname) { if (memberRepository.existsByNickname(nickname)) { throw new BusinessException(ErrorCode.DUPLICATED_NICKNAME); } } public AuthResponse.LoginResult login( AuthRequest.Login request, HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext 저장에 필요한 요청 객체이다. HttpServletResponse httpResponse // 추가: SecurityContext 저장에 필요한 응답 객체이다. ) { // 1. 로그인 아이디로 회원 조회 Member member = memberRepository.findByLoginId(request.loginId()) .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED)); // 2. 입력 비밀번호와 저장된 해시 비밀번호 비교 if (!passwordEncoder.matches(request.password(), member.getPassword())) { throw new BusinessException(ErrorCode.LOGIN_FAILED); } // 3. 로그인 성공 시 세션에 Member.id 저장 loginSessionManager.login(session, member.getId()); // 추가: 로그인 성공 정보를 Spring Security의 SecurityContext에도 저장한다. securityContextLoginManager.login(member.getId(), httpRequest, httpResponse); // 4. 로그인 성공 응답 DTO 반환 return new AuthResponse.LoginResult( member.getId(), member.getLoginId(), member.getNickname(), member.getAddress(), member.getRadius(), member.getEntX(), member.getEntY() ); } public void logout( HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext 정리에 필요한 요청 객체이다. HttpServletResponse httpResponse // 추가: SecurityContext 정리에 필요한 응답 객체이다. ) { loginSessionManager.requireLoginUserId(session); // 추가: Spring Security 인증 정보를 먼저 정리한다. securityContextLoginManager.logout(httpRequest, httpResponse); // 수정: Spring Security 인증 정보 정리 후 기존 세션을 무효화한다. loginSessionManager.logout(session); } }
6-9. AuthService.java 수정 흐름 정리하기
이번 단계에서는
AuthService.java에SecurityContextLoginManager.java를 연결했다.
기존 로그인 검증 로직은 삭제하지 않았다.
로그인 흐름은 아래처럼 바뀐다.수정 전 로그인 흐름 → loginId로 회원 조회 → password 검증 → 세션에 loginUserId = Member.id 저장 → 로그인 성공 응답 반환 수정 후 로그인 흐름 → loginId로 회원 조회 → password 검증 → 세션에 loginUserId = Member.id 저장 → SecurityContext에 Authentication.getName() = Member.id 저장 → 로그인 성공 응답 반환로그아웃 흐름은 아래처럼 바뀐다.
수정 전 로그아웃 흐름 → loginUserId 존재 여부 확인 → 세션 무효화 수정 후 로그아웃 흐름 → loginUserId 존재 여부 확인 → SecurityContext 정리 → 세션 무효화이번 수정으로 기존 세션 기반 로그인 정보와
Spring Security인증 정보가 같은Member.id를 기준으로 연결된다.
즉, 기존 마이페이지 같은 세션 기반 기능은 계속loginUserId를 사용할 수 있다.
그리고 공구 기능처럼Authentication.getName()을 사용하는 코드도 실제 로그인한 사용자의Member.id를 기준으로 동작할 수 있다.
다음 단계에서는AuthController.java를 수정한다.
현재AuthController.java는AuthService.login(request, session)과AuthService.logout(session)만 호출하고 있다.
하지만 이제AuthService.java의 메서드 시그니처가 바뀌었으므로,AuthController.java에서도HttpServletRequest와HttpServletResponse를 함께 넘겨야 한다.
이번 단계에서는
AuthController.java를 수정한다.
6번에서AuthService.java의login()메서드와logout()메서드가 바뀌었다.
기존에는HttpSession만 넘겼지만, 이제는SecurityContext를 저장하고 정리하기 위해HttpServletRequest와HttpServletResponse도 함께 넘겨야 한다.
즉, 이번 수정은Controller가 로그인 검증을 직접 하도록 바꾸는 작업이 아니다.
Controller는 여전히 요청을 받고Service로 넘기는 역할만 한다.
다만AuthService.java가SecurityContextLoginManager.java를 호출할 수 있도록 요청 객체와 응답 객체를 함께 전달하는 구조로 바꾼다.
이번 수정의 핵심은AuthController.java가 변경된AuthService.java메서드 시그니처에 맞게HttpServletRequest와HttpServletResponse를 전달하도록 바꾸는 것이다.
7-1. 수정 파일과 수정 위치 확인하기
이번에 수정할 파일은
AuthController.java이다.
수정 파일 위치는 아래와 같다.src/main/java/com/example/groupbuyingweb/controller/AuthController.java이번 단계에서 수정할 위치는 크게 세 곳이다.
import추가login()메서드 매개변수와AuthService.login()호출 코드 수정logout()메서드 매개변수와AuthService.logout()호출 코드 수정기존 중복확인, 회원가입 요청 메서드는 이번 단계에서 수정하지 않는다.
이번 작업은 로그인 성공 후SecurityContext저장 흐름과 로그아웃 시SecurityContext정리 흐름을AuthService.java로 전달하기 위한 수정이다.
7-2. AuthController.java 수정 방향 먼저 정리하기
수정 방향은 아래처럼 잡는다.
수정 전 login() → AuthService.login(request, session) 수정 후 login() → AuthService.login(request, session, httpRequest, httpResponse) 수정 전 logout() → AuthService.logout(session) 수정 후 logout() → AuthService.logout(session, httpRequest, httpResponse)여기서 중요한 점은
AuthController.java가 실제SecurityContext를 직접 저장하거나 정리하지 않는다는 것이다.
SecurityContext저장과 정리는 5번에서 추가한SecurityContextLoginManager.java가 담당한다.
그리고 그 파일을 호출하는 위치는 6번에서 수정한AuthService.java이다.
따라서AuthController.java의 역할은 아래처럼 유지된다.
- 로그인 요청을 받는다.
- 요청
DTO, 세션, 요청 객체, 응답 객체를AuthService.java로 넘긴다.AuthService.java가 처리한 결과를ApiResponse.success()로 감싸서 반환한다.이렇게 하면 역할이 섞이지 않는다.
Controller는 요청과 응답 연결 역할을 하고, 실제 로그인 검증과 인증 정보 저장은Service와 보안 매니저가 처리한다.
7-3. login() 메서드 수정하기
먼저
login()메서드를 수정한다.
기존에는 로그인 요청DTO와HttpSession만 받았다.
하지만 6번에서 수정한AuthService.login()은HttpServletRequest와HttpServletResponse까지 필요로 한다.
수정 전 login() 메서드 코드
현재
AuthController.java의login()메서드는 아래와 같다.@ResponseBody @PostMapping("/login") public ApiResponse<AuthResponse.LoginResult> login( @Valid @RequestBody AuthRequest.Login request, HttpSession session ) { AuthResponse.LoginResult response = authService.login(request, session); return ApiResponse.success(response); }이 코드는 기존 세션 로그인 구조에서는 충분했다.
AuthService.login()이HttpSession을 이용해 세션에loginUserId = Member.id를 저장했기 때문이다.
하지만 이제는 로그인 성공 후SecurityContext에도 인증 정보를 저장해야 한다.
SecurityContextLoginManager.login()은SecurityContextRepository.saveContext(context, request, response)를 사용한다.
그래서 실제HttpServletRequest와HttpServletResponse가 필요하다.
수정 후 login() 메서드 코드
수정 후에는 아래처럼
HttpServletRequest와HttpServletResponse를 매개변수로 추가한다.@ResponseBody @PostMapping("/login") public ApiResponse<AuthResponse.LoginResult> login( @Valid @RequestBody AuthRequest.Login request, HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext를 세션에 저장하기 위해 Service로 전달한다. HttpServletResponse httpResponse // 추가: SecurityContext를 세션에 저장하기 위해 Service로 전달한다. ) { AuthResponse.LoginResult response = authService.login(request, session, httpRequest, httpResponse); // 수정: 변경된 AuthService.login() 시그니처에 맞춰 요청/응답 객체를 함께 전달한다. return ApiResponse.success(response); }새로 추가된 매개변수는 아래 두 개이다.
HttpServletRequest httpRequest, HttpServletResponse httpResponse
HttpServletRequest는 현재 로그인 요청 자체를 나타내는 객체이다.
HttpServletResponse는 현재 로그인 응답을 나타내는 객체이다.
이 두 객체는AuthController.java가 직접 사용하는 것이 아니다.
AuthController.java는 이 객체들을AuthService.java로 넘긴다.
그 다음AuthService.java가SecurityContextLoginManager.login(member.getId(), httpRequest, httpResponse)를 호출할 때 사용한다.
흐름을 정리하면 아래와 같다.로그인 요청 → AuthController.login() → AuthRequest.Login request 받기 → HttpSession session 받기 → HttpServletRequest httpRequest 받기 → HttpServletResponse httpResponse 받기 → AuthService.login(request, session, httpRequest, httpResponse) 호출 → AuthService에서 기존 세션 저장과 SecurityContext 저장 처리즉,
AuthController.java는 로그인 성공 정보를 직접 저장하지 않는다.
로그인 성공 처리에 필요한 객체들을AuthService.java로 전달하는 입구 역할만 한다.
7-4. logout() 메서드 수정하기
다음은
logout()메서드를 수정한다.
기존 로그아웃은HttpSession만 받아서AuthService.logout(session)으로 넘겼다.
하지만 이제 로그아웃할 때SecurityContext도 함께 정리해야 하므로HttpServletRequest와HttpServletResponse를 함께 넘겨야 한다.
수정 전 logout() 메서드 코드
현재
AuthController.java의logout()메서드는 아래와 같다.@ResponseBody @PostMapping("/logout") public ApiResponse<Void> logout( HttpSession session ) { authService.logout(session); return ApiResponse.success(null); }이 코드는 기존 세션 로그아웃만 처리하던 구조이다.
AuthService.logout(session)이LoginSessionManager.logout(session)을 호출하고, 최종적으로session.invalidate()가 실행되는 흐름이었다.
하지만 이제 로그인 성공 시SecurityContext에도 인증 정보를 저장한다.
따라서 로그아웃 시에도 세션만 무효화하면 안 된다.
SecurityContextHolder와 세션에 저장된Spring Security인증 정보도 함께 정리해야 한다.
수정 후 logout() 메서드 코드
수정 후에는 아래처럼
HttpServletRequest와HttpServletResponse를 추가로 받는다.@ResponseBody @PostMapping("/logout") public ApiResponse<Void> logout( HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext 정리를 위해 Service로 전달한다. HttpServletResponse httpResponse // 추가: SecurityContext 정리를 위해 Service로 전달한다. ) { authService.logout(session, httpRequest, httpResponse); // 수정: 변경된 AuthService.logout() 시그니처에 맞춰 요청/응답 객체를 함께 전달한다. return ApiResponse.success(null); }새로 추가된 매개변수는 로그인과 동일하게 아래 두 개이다.
HttpServletRequest httpRequest, HttpServletResponse httpResponse로그아웃에서는 이 객체들이
SecurityContextLoginManager.logout(httpRequest, httpResponse)를 호출할 때 필요하다.
흐름을 정리하면 아래와 같다.로그아웃 요청 → AuthController.logout() → HttpSession session 받기 → HttpServletRequest httpRequest 받기 → HttpServletResponse httpResponse 받기 → AuthService.logout(session, httpRequest, httpResponse) 호출 → AuthService에서 SecurityContext 먼저 정리 → AuthService에서 기존 세션 무효화여기서도
AuthController.java는 직접SecurityContext를 정리하지 않는다.
실제 정리 순서는AuthService.java가 담당한다.
중요한 점은 로그아웃에서도 요청 본문을 받지 않는다는 것이다.
로그아웃 대상은 사용자가 요청 본문으로 보내는 사용자ID가 아니라, 현재 요청에 연결된 세션과 인증 정보이다.
그래서@RequestBody가 필요하지 않다.
7-5. AuthController.java 수정 반영 전체 코드 작성하기
이제 수정사항을 모두 반영한
AuthController.java전체 코드를 확인한다.
전체 코드에서는 이번 단계에서 새로 추가하거나 수정한 부분에만// 추가:또는// 수정:주석을 표시한다.
package com.example.groupbuyingweb.controller; import com.example.groupbuyingweb.core.api.ApiResponse; import com.example.groupbuyingweb.domain.dto.request.AuthRequest; import com.example.groupbuyingweb.domain.dto.response.AuthResponse; import com.example.groupbuyingweb.service.AuthService; import jakarta.servlet.http.HttpServletRequest; // 추가: SecurityContext 저장과 정리에 필요한 요청 객체를 Service로 전달하기 위해 필요하다. import jakarta.servlet.http.HttpServletResponse; // 추가: SecurityContext 저장과 정리에 필요한 응답 객체를 Service로 전달하기 위해 필요하다. import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller @RequiredArgsConstructor @Validated @RequestMapping("/api/auth") public class AuthController { private final AuthService authService; @ResponseBody @GetMapping("/login-ids/check") public ApiResponse<AuthResponse.DuplicateCheck> checkLoginId( @RequestParam("loginId") @NotBlank(message = "로그인 아이디를 입력해주세요.") @Size(min = 4, max = 20, message = "로그인 아이디는 4자 이상 20자 이하로 입력해주세요.") @Pattern(regexp = "^[a-z0-9]+$", message = "로그인 아이디는 영문 소문자와 숫자만 사용할 수 있습니다.") String loginId ) { AuthResponse.DuplicateCheck response = authService.checkLoginId(loginId); return ApiResponse.success(response); } @ResponseBody @GetMapping("/nicknames/check") public ApiResponse<AuthResponse.DuplicateCheck> checkNickname( @RequestParam("nickname") @NotBlank(message = "닉네임을 입력해주세요.") @Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하로 입력해주세요.") @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") String nickname ) { AuthResponse.DuplicateCheck response = authService.checkNickname(nickname); return ApiResponse.success(response); } @ResponseBody @PostMapping("/signup") public ApiResponse<AuthResponse.SignupResult> signup( @Valid @RequestBody AuthRequest.Signup request ) { AuthResponse.SignupResult response = authService.signup(request); return ApiResponse.success(response); } @ResponseBody @PostMapping("/login") public ApiResponse<AuthResponse.LoginResult> login( @Valid @RequestBody AuthRequest.Login request, HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext 저장에 필요한 요청 객체이다. HttpServletResponse httpResponse // 추가: SecurityContext 저장에 필요한 응답 객체이다. ) { AuthResponse.LoginResult response = authService.login(request, session, httpRequest, httpResponse); // 수정: SecurityContext 저장에 필요한 요청/응답 객체도 함께 전달한다. return ApiResponse.success(response); } @ResponseBody @PostMapping("/logout") public ApiResponse<Void> logout( HttpSession session, HttpServletRequest httpRequest, // 추가: SecurityContext 정리에 필요한 요청 객체이다. HttpServletResponse httpResponse // 추가: SecurityContext 정리에 필요한 응답 객체이다. ) { authService.logout(session, httpRequest, httpResponse); // 수정: SecurityContext 정리에 필요한 요청/응답 객체도 함께 전달한다. return ApiResponse.success(null); } }
7-6. AuthController.java 수정 흐름 정리하기
이번 단계에서는
AuthController.java를 수정했다.
수정 이유는 6번에서AuthService.java의login()메서드와logout()메서드의 매개변수가 바뀌었기 때문이다.
로그인 요청 흐름은 아래처럼 바뀐다.수정 전 로그인 요청 흐름 → AuthController.login() → AuthService.login(request, session) → 세션에 loginUserId = Member.id 저장 수정 후 로그인 요청 흐름 → AuthController.login() → AuthService.login(request, session, httpRequest, httpResponse) → 세션에 loginUserId = Member.id 저장 → SecurityContext에 Authentication.getName() = Member.id 저장로그아웃 요청 흐름은 아래처럼 바뀐다.
수정 전 로그아웃 요청 흐름 → AuthController.logout() → AuthService.logout(session) → 세션 무효화 수정 후 로그아웃 요청 흐름 → AuthController.logout() → AuthService.logout(session, httpRequest, httpResponse) → SecurityContext 정리 → 세션 무효화이번 수정으로
AuthController.java는SecurityContext저장과 정리에 필요한 요청 객체와 응답 객체를AuthService.java로 전달할 수 있게 되었다.
즉,AuthController.java의 역할은 여전히 요청을 받는 입구이다.
실제 로그인 검증, 세션 저장,SecurityContext저장, 로그아웃 정리는AuthService.java와SecurityContextLoginManager.java에서 처리한다.
다음 단계에서는SecurityConfig.java를 수정한다.
지금까지 실제 로그인 성공 정보를SecurityContext에 저장하는 흐름을 만들었으므로, 이제 더미 인증 필터 등록을 제거하고 실제 인증 기준에 맞게 접근 경로를 정리해야 한다.
이번 단계에서는
SecurityConfig.java를 수정한다.
3번에서 확인했듯이 현재SecurityConfig.java에는 팀원이 인가 처리를 먼저 테스트하기 위해DummyAuthenticationFilter가 등록되어 있었다.
이 필터는 실제 로그인한 사용자가 아니라, 고정된 더미Member.id를Authentication에 넣는 임시 인증 필터였다.
하지만 5번, 6번, 7번을 거치면서 실제 로그인 성공 사용자의Member.id를SecurityContext에 저장하는 흐름을 만들었다.
이제Authentication.getName()은 더미 필터가 넣는 값이 아니라, 실제 로그인 성공 시 저장된Member.id를 바라봐야 한다.
다만 팀원은 전역URL접근 제어를SecurityConfig.java에서 강하게 막는 방식이 아니라, 컨트롤러의@PreAuthorize로 세부 인가를 처리하는 구조로 잡고 있다.
따라서 이번 단계에서는anyRequest().permitAll()을authenticated()로 바꾸지 않는다.
이번 수정의 핵심은 팀원이 잡은@PreAuthorize중심 인가 구조는 유지하면서, 더미 인증 필터만 제거하는 것이다.
8-1. 수정 파일과 수정 위치 확인하기
이번에 수정할 파일은
SecurityConfig.java이다.
수정 파일 위치는 아래와 같다.src/main/java/com/example/groupbuyingweb/core/security/SecurityConfig.java이번 단계에서 수정할 위치는 크게 두 곳이다.
DummyAuthenticationFilter등록 코드 제거- 더미 필터 등록에 필요했던
UsernamePasswordAuthenticationFilterimport 제거반대로 이번 단계에서 유지할 설정도 있다.
anyRequest().permitAll()유지@EnableMethodSecurity유지formLogin()비활성화 유지httpBasic()비활성화 유지csrf()비활성화 유지여기서 가장 헷갈리기 쉬운 부분은
anyRequest().permitAll()이다.
일반적으로는 실제 인증 적용 후anyRequest().authenticated()로 바꾸는 경우가 많다.
하지만 현재 팀원은 세부 권한을 컨트롤러의@PreAuthorize로 제어하는 방향을 잡았다.
따라서 이번 글에서는 팀원 구조를 기준으로 간다.
즉,SecurityConfig.java에서는URL단계의 전체 요청은 열어두고, 실제 기능 접근 가능 여부는 각 컨트롤러 메서드의@PreAuthorize에서 판단한다.
8-2. SecurityConfig.java 수정 방향 먼저 정리하기
이번 수정 방향은 아래처럼 잡는다.
수정 전 → 모든 요청 permitAll → DummyAuthenticationFilter 등록 → 요청마다 고정 Member.id가 Authentication에 들어감 → @PreAuthorize는 더미 Authentication을 기준으로 인가 검사 수정 후 → 모든 요청 permitAll 유지 → DummyAuthenticationFilter 등록 제거 → 실제 로그인 성공 시 저장된 SecurityContext 기준으로 Authentication 사용 → @PreAuthorize는 실제 로그인 사용자 Member.id를 기준으로 인가 검사이렇게 가는 이유는 팀원 인가 구조 때문이다.
현재 공구 기능에는 이미@PreAuthorize가 붙어 있다.
예를 들어 공구 생성은 로그인 여부를 확인하고, 공구 수정이나 참여는GroupBuyingSecurityEvaluator.java를 통해 세부 조건을 검사한다.
즉, 팀원 구조에서SecurityConfig.java의 역할은 전체URL을 강하게 막는 것이 아니라, 메서드 단위 인가가 동작할 수 있는 기반을 열어두는 쪽에 가깝다.
그래서@EnableMethodSecurity가 중요하다.
현재 필요한 가장 중요한 수정은 더미 필터 제거이다.
더미 필터가 남아 있으면 실제 로그인 성공으로 만든SecurityContext가 있어도, 더미 사용자가Authentication에 들어갈 수 있다.
정리하면 이번 수정의 기준은 아래와 같다.
@PreAuthorize중심 인가 구조는 유지한다.anyRequest().permitAll()도 팀원 구조에 맞춰 유지한다.- 실제 로그인 인증을 적용했으므로 더미 인증 필터는 제거한다.
Authentication.getName()은 실제 로그인 사용자의Member.id를 반환해야 한다.이 흐름을 알아야
SecurityConfig.java를 무조건authenticated()로 바꾸지 않는 이유를 이해할 수 있다.
8-3. anyRequest().permitAll() 구조 유지하기
이번 단계에서는
anyRequest().permitAll()을 유지한다.
이유는 팀원이 세부 권한을 컨트롤러의@PreAuthorize로 제어하는 구조를 잡았기 때문이다.
유지할 전역 요청 허용 코드
SecurityConfig.java의 요청 허용 구조는 아래처럼 유지한다..authorizeHttpRequests(auth -> auth // 유지: 세부 권한은 컨트롤러의 @PreAuthorize로 제어할 예정이므로 URL 단계에서는 모든 요청을 허용한다. .anyRequest().permitAll() )이 코드는 모든 요청을
SecurityFilterChain의URL단계에서는 허용한다는 뜻이다.
즉,/group-buyings,/api/group-buyings,/member관련 요청도URL단계에서는 막지 않는다.
하지만 이 말이 아무 권한 검사도 하지 않는다는 뜻은 아니다.
보호가 필요한 컨트롤러 메서드 위에@PreAuthorize가 붙어 있다면, 메서드 실행 직전에 다시 인가 검사가 일어난다.
예를 들어 공구 생성 메서드가 아래처럼 되어 있다고 하자.@PreAuthorize("isAuthenticated()")이 경우
SecurityConfig.java에서permitAll()로 요청이 통과되더라도, 실제 메서드 실행 전에는isAuthenticated()조건을 검사한다.
인증 정보가 없으면 메서드가 실행되지 않는다.
공구 수정이나 참여처럼 더 복잡한 조건은 아래처럼 검사한다.@PreAuthorize("@groupBuyingSecurity.canModifyGroupBuying(authentication, #groupBuyingId)")이 경우
GroupBuyingSecurityEvaluator.java가 현재 사용자가 공구 주최자인지, 공구 상태가 모집 중인지, 참여자가 있는지 같은 세부 조건을 검사한다.
즉, 이번 프로젝트의 인가 흐름은 아래처럼 이해하면 된다.SecurityConfig.java → URL 단계에서는 전체 요청 permitAll Controller 메서드 → @PreAuthorize로 실제 접근 가능 여부 검사 GroupBuyingSecurityEvaluator.java → 공구 주최자, 참여자, 상태 조건 같은 세부 인가 검사
### 이 구조에서 주의할 점 이 구조는 가능하지만 주의할 점이 있다. `SecurityConfig.java`에서 전체 요청을 `permitAll()`로 열어두기 때문에, 보호가 필요한 메서드에 `@PreAuthorize`가 빠지면 그 메서드는 그대로 실행될 수 있다.
즉, 팀원 구조에서는 아래 기준이 중요하다. * 보호가 필요한 컨트롤러 메서드에는 반드시 `@PreAuthorize`를 붙여야 한다. * 공구 생성, 수정, 참여, 삭제처럼 사용자 권한이 필요한 기능은 `@PreAuthorize` 누락 여부를 확인해야 한다. * `SecurityConfig.java`는 전역적으로 막지 않으므로, 메서드 단위 인가 누락이 곧 보안 누락으로 이어질 수 있다.그래서 이 글에서는
permitAll()을 유지하되, 이것이 “아무 보안도 필요 없다”는 뜻이 아니라고 정리한다.
팀원 구조에서 실제 보호 책임은SecurityConfig.java의authenticated()가 아니라 각 메서드의@PreAuthorize에 있다.
8-4. DummyAuthenticationFilter 등록 코드 제거하기
이제 더미 인증 필터 등록 코드를 제거한다.
DummyAuthenticationFilter.java는 팀원이 공구 인가 기능을 먼저 테스트하기 위해 임시로 넣어둔 필터였다.
하지만 이제 실제 로그인 성공 정보를SecurityContext에 저장하는 흐름이 만들어졌다.
따라서 더미 필터는 더 이상 사용하면 안 된다.
수정 전 더미 필터 등록 코드
기존
SecurityConfig.java에는 아래 코드가 있었다..addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);이 코드는
DummyAuthenticationFilter를UsernamePasswordAuthenticationFilter보다 먼저 실행하도록 등록한다.
이렇게 등록되어 있으면 요청이 컨트롤러에 도착하기 전에DummyAuthenticationFilter가 먼저 실행된다.
그리고 더미 필터는 고정된Member.id를 가진Authentication객체를 만들어SecurityContextHolder에 저장한다.
흐름을 보면 아래와 같다.요청 들어옴 → DummyAuthenticationFilter 실행 → 고정 Member.id로 Authentication 생성 → SecurityContextHolder에 저장 → Controller 또는 @PreAuthorize에서 Authentication 사용이 구조는 실제 로그인 구현 전에는 인가 기능 테스트에 도움이 됐다.
하지만 실제 인증을 적용한 지금은 위험하다.
실제 로그인한 사용자가 누구인지와 상관없이 더미 사용자가 현재 사용자처럼 들어갈 수 있기 때문이다.
제거할 더미 필터 등록 코드
수정 후 코드에 새로 작성할 내용은 없다.
아래 코드를SecurityConfig.java에서 삭제한다..addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);또한 더미 필터 등록 코드가 사라지면 아래
import도 필요 없다.// 제거 대상 import import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
UsernamePasswordAuthenticationFilter는 더미 필터를 앞에 끼워 넣을 때 사용하던 클래스이다.
등록 코드를 삭제하면 더 이상 사용하지 않으므로import도 제거한다.
정리하면 이번 단계에서 제거되는 것은 아래 두 가지다.
addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)등록 코드UsernamePasswordAuthenticationFilter관련import
DummyAuthenticationFilter.java파일 자체도 더 이상 사용하지 않는 파일이 되므로, 실제 프로젝트에서는 삭제 대상으로 분리한다.
더미 필터를 제거해야 하는 이유
더미 필터를 제거해야 하는 이유는 실제 로그인 사용자와 더미 사용자가 충돌할 수 있기 때문이다.
예를 들어 실제 로그인 사용자의Member.id가 아래 값이라고 하자.실제 로그인 사용자 → 1111-aaaa그런데 더미 필터가 계속 실행되면
Authentication.getName()에는 아래 값이 들어갈 수 있다.DummyAuthenticationFilter의 고정 사용자 → 70698642-f42d-4aba-841e-0f4cac4a1eb1그러면 현재 사용자 기준이 둘로 갈라진다.
HttpSession → loginUserId = 1111-aaaa SecurityContext → Authentication.getName() = 70698642-f42d-4aba-841e-0f4cac4a1eb1이 상태에서는 기존 세션 기반 기능과 공구 인가 기능이 서로 다른 사용자를 현재 사용자로 판단할 수 있다.
그래서 실제 인증 적용 후에는 더미 필터를 반드시 제거해야 한다.
이제Authentication은 더미 필터가 만드는 것이 아니라, 로그인 성공 시SecurityContextLoginManager.java가 실제 로그인 사용자Member.id로 만들어야 한다.
8-5. formLogin과 httpBasic 비활성화 유지하기
이번 수정에서도
formLogin()과httpBasic()비활성화 설정은 유지한다.
이 두 설정은 더미 필터 제거와 별개의 문제이다.
formLogin을 계속 비활성화하는 이유
현재 프로젝트는
Spring Security기본 로그인 화면과 기본 로그인 처리 방식을 사용하지 않는다.
직접 만든 로그인 화면에서fetch()로/api/auth/login에JSON요청을 보내고,AuthController.java와AuthService.java가 로그인 검증을 처리한다.
따라서 아래 설정은 유지한다..formLogin(form -> form.disable())이 설정을 유지하는 이유는
Spring Security의 기본formLogin흐름과 현재 프로젝트의 직접 구현 로그인 흐름이 섞이지 않게 하기 위해서다.
현재 로그인 흐름은 아래와 같다.직접 만든 로그인 화면 → fetch()로 /api/auth/login 요청 → AuthController.login() → AuthService.login() → 세션 저장 → SecurityContext 저장따라서
formLogin()을 켜서Spring Security기본 로그인 처리로 넘길 필요가 없다.
httpBasic을 계속 비활성화하는 이유
httpBasic()도 계속 비활성화한다..httpBasic(basic -> basic.disable())
HTTP Basic인증은 브라우저나 클라이언트가 요청 헤더에 아이디와 비밀번호를 실어 보내는 방식이다.
현재 프로젝트는 그런 방식으로 로그인하지 않는다.
현재 프로젝트는 로그인 화면에서loginId,password를 입력하고, 그 값을/api/auth/login으로 보내서 직접 검증한다.
따라서httpBasic()을 켜둘 필요가 없다.
정리하면 아래와 같다.formLogin → Spring Security 기본 로그인 폼 방식 → 현재 프로젝트에서는 사용하지 않음 httpBasic → 요청 헤더 기반 기본 인증 방식 → 현재 프로젝트에서는 사용하지 않음그래서 두 설정은 기존처럼 비활성화 상태로 유지한다.
8-6. csrf 비활성화 유지 여부 정리하기
이번 단계에서는
csrf()비활성화 설정도 일단 유지한다.
다만 이 설정은 나중에 다시 검토해야 한다.
현재 단계에서 csrf를 비활성화해두는 이유
현재 프로젝트는 직접 만든 로그인 화면,
fetch()요청,Thymeleaf화면 요청이 섞여 있다.
이 상태에서CSRF보호를 바로 켜면, 로그인, 회원가입, 공구 생성, 수정, 삭제 같은 요청마다CSRF Token을 함께 전달해야 한다.
아직 인증과 인가 연결을 정리하는 단계에서는 이 부분까지 함께 넣으면 흐름이 복잡해진다.
그래서 현재 단계에서는 아래 설정을 유지한다..csrf(csrf -> csrf.disable())이 설정은
CSRF보호를 끄는 설정이다.
즉, 현재 단계에서는POST,PATCH,DELETE요청을 보낼 때CSRF Token검증을 하지 않는다.
현재 글의 목적은CSRF까지 완성하는 것이 아니라, 기존 세션 로그인과SecurityContext인증 흐름을 연결하는 것이다.
그래서 이번 단계에서는 일단 유지한다.
추후 csrf를 다시 검토해야 하는 이유
다만 실제 서비스 기준에서는
CSRF를 계속 꺼두는 것이 안전한 최종 상태라고 말하기 어렵다.
특히 세션 기반 인증을 사용하는 웹 서비스에서는CSRF보호가 중요할 수 있다.
현재 프로젝트도 세션을 사용하고 있다.
따라서 인증 적용이 끝난 뒤에는CSRF Token을 로그인 화면, 회원가입 화면, 공구 생성/수정/삭제 요청에 어떻게 포함할지 다시 검토해야 한다.
세션 기반 인증을 사용하는 구조에서는CSRF를 계속 비활성화한 상태로 두는 것이 최종 보안 설정이라고 보기 어렵다.
이번 단계에서는 인증 흐름 연결을 우선하기 위해 잠시 유지하고, 이후CSRF Token전달 방식을 별도로 정리해야 한다.
정리하면 아래와 같다.현재 단계 → 인증과 인가 연결이 우선 → csrf 비활성화 유지 추후 보완 단계 → CSRF Token 적용 방식 검토 → form 요청과 fetch 요청에 token 전달 방식 추가 검토즉, 이번 글에서는
csrf()를 계속 비활성화하지만, 이것이 최종 보안 완성이라는 뜻은 아니다.
8-7. SecurityConfig.java 수정 반영 전체 코드 작성하기
이제 수정사항을 모두 반영한
SecurityConfig.java전체 코드를 확인한다.
전체 코드에서는 최종적으로 남길 코드만 보여준다.
더미 필터 등록 코드는 제거되었으므로 전체 코드에 포함하지 않는다.
package com.example.groupbuyingweb.core.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .authorizeHttpRequests(auth -> auth // 유지: 세부 권한은 컨트롤러의 @PreAuthorize로 제어할 예정이므로 URL 단계에서는 모든 요청을 허용한다. .anyRequest().permitAll() ); return http.build(); } }이 전체 코드에서 핵심은 두 가지다.
anyRequest().permitAll()은 팀원 인가 구조에 맞춰 유지한다.DummyAuthenticationFilter등록 코드는 제거한다.최종적으로
SecurityConfig.java는 더미 인증 필터 없이@PreAuthorize중심 인가 구조를 유지한다.
따라서 실제 권한 검사는SecurityConfig.java의authenticated()가 아니라 각 컨트롤러 메서드의@PreAuthorize에서 수행된다.
8-8. SecurityConfig.java 수정 흐름 정리하기
이번 단계에서는
SecurityConfig.java를 수정했다.
수정 전 구조는 아래와 같았다.수정 전 → 모든 요청 permitAll → DummyAuthenticationFilter 등록 → 더미 Member.id를 Authentication에 강제로 저장 → @PreAuthorize는 더미 Authentication 기준으로 인가 검사수정 후 구조는 아래와 같다.
수정 후 → 모든 요청 permitAll 유지 → DummyAuthenticationFilter 등록 제거 → 실제 로그인 성공 시 저장된 SecurityContext 기준으로 Authentication 사용 → @PreAuthorize는 실제 로그인 사용자 Member.id 기준으로 인가 검사이번 수정으로 더미 인증 구조를 제거하고, 팀원이 잡은
@PreAuthorize중심 인가 구조에 실제 로그인 인증 정보를 연결할 수 있게 되었다.
이제 로그인 성공 시에는 아래 흐름이 사용된다.로그인 성공 → AuthService.login() → LoginSessionManager.login(session, member.getId()) → SecurityContextLoginManager.login(member.getId(), request, response) → SecurityContext에 Authentication.getName() = Member.id 저장그리고 공구 기능처럼
@PreAuthorize가 붙은 요청은 아래 흐름으로 동작한다.공구 요청 → SecurityConfig에서는 URL 단계 permitAll → Controller 메서드 진입 전 @PreAuthorize 실행 → Authentication.getName()으로 현재 사용자 Member.id 확인 → GroupBuyingSecurityEvaluator.java에서 세부 인가 조건 검사이렇게 되면 공구 기능에서 사용하는
Authentication.getName()은 더미 사용자가 아니라 실제 로그인한 사용자의Member.id를 바라보게 된다.
단, 이 구조에서는 보호가 필요한 메서드에@PreAuthorize가 빠지면URL단계에서 막아주지 않는다.
따라서 공구 생성, 수정, 참여, 삭제처럼 사용자 권한이 필요한 메서드는@PreAuthorize적용 여부를 반드시 확인해야 한다.
다음 단계에서는DummyAuthenticationFilter.java를 실제로 제거한다.
더미 필터 등록을 제거한 뒤에는 더 이상 고정된 더미Member.id가Authentication에 들어가지 않아야 한다.
그 다음 단계에서는 수동 테스트를 길게 반복하지 않고,SecurityContextLoginManager.java의 핵심 동작을 테스트 코드로 검증한다.
특히login()호출 시Authentication.getName()에Member.id가 저장되는지,logout()호출 시SecurityContext인증 정보가 정리되는지 확인한다.
이번 단계에서는
DummyAuthenticationFilter.java를 제거한다.
8번에서SecurityConfig.java에 등록되어 있던 더미 인증 필터 등록 코드를 제거했다.
따라서 이제DummyAuthenticationFilter.java는 보안 필터 체인에서 실행되지 않는다.
이 파일은 팀원이 인가 기능을 먼저 테스트하기 위해 임시로 만든 파일이었다.
하지만 지금은 실제 로그인 성공 시SecurityContextLoginManager.java가 로그인 사용자의Member.id를SecurityContext에 저장한다.
그래서 더 이상 고정된 더미Member.id를 넣는 필터가 필요하지 않다.
이번 단계의 핵심은 실제 인증 흐름이 연결되었으므로 임시 더미 인증 파일을 프로젝트에서 제거하는 것이다.
9-1. 제거 대상 파일 위치 확인하기
먼저 제거할 파일 위치를 확인한다.
이번에 제거할 파일은DummyAuthenticationFilter.java이다.
삭제 대상 파일 위치는 아래와 같다.src/main/java/com/example/groupbuyingweb/core/security/DummyAuthenticationFilter.java이 파일은
core/security패키지에 있었다.
처음에는SecurityConfig.java에 등록되어 요청이 들어올 때마다 더미 인증 객체를 만들어주는 역할을 했다.
하지만 8번에서 더미 필터 등록 코드를 제거했으므로, 이제 이 파일은 더 이상 실행 흐름에 필요하지 않다.
즉, 파일이 프로젝트에 남아 있어도 실행되지는 않을 수 있지만, 헷갈리지 않게 삭제하는 것이 좋다.
이번 단계에서 제거할 대상은 아래 하나다.DummyAuthenticationFilter.java주의할 점은
SecurityContextLoginManager.java는 삭제하면 안 된다는 것이다.
SecurityContextLoginManager.java는 5번에서 새로 추가한 실제 인증 연결 파일이다.
로그인 성공 시 실제 로그인 사용자의Member.id를Authentication에 넣고SecurityContext에 저장하는 역할을 한다.
정리하면 아래처럼 구분하면 된다.삭제할 파일 → DummyAuthenticationFilter.java → 임시 더미 인증 필터 유지할 파일 → SecurityContextLoginManager.java → 실제 로그인 성공 정보를 SecurityContext에 저장하는 파일
9-2. DummyAuthenticationFilter.java가 임시 파일이었던 이유 다시 정리하기
DummyAuthenticationFilter.java는 처음부터 최종 인증 구조를 위해 만든 파일이 아니었다.
팀원이 공구 인가 기능을 먼저 구현하고 테스트하기 위해 임시로 만든 파일이었다.
당시에는 기존 로그인 성공 정보가SecurityContext에 저장되지 않았다.
기존 로그인 기능은 세션의loginUserId에Member.id를 저장하는 구조였다.
하지만 공구 인가 코드는Authentication.getName()을 사용하고 있었다.
즉, 당시 구조는 아래처럼 나뉘어 있었다.기존 로그인 성공 결과 → HttpSession → loginUserId = Member.id 공구 인가 코드 → Authentication → authentication.getName()이 상태에서는 공구 인가 코드를 테스트하기 어렵다.
Authentication안에 현재 사용자 정보가 없으면@PreAuthorize나GroupBuyingSecurityEvaluator.java에서 현재 사용자ID를 확인할 수 없기 때문이다.
그래서 팀원이DummyAuthenticationFilter.java를 만들어 고정된 더미Member.id를Authentication에 넣어둔 것이다.
흐름은 아래와 같았다.요청 들어옴 → DummyAuthenticationFilter 실행 → 고정 Member.id로 Authentication 생성 → SecurityContextHolder에 저장 → @PreAuthorize에서 authentication.getName() 사용 가능이 구조 덕분에 실제 인증 연결 전에도 공구 인가 코드를 먼저 테스트할 수 있었다.
하지만 이제 상황이 바뀌었다.
5번에서SecurityContextLoginManager.java를 추가했고, 6번에서AuthService.java가 로그인 성공 시 이 파일을 호출하도록 수정했다.
7번에서는AuthController.java가HttpServletRequest와HttpServletResponse를AuthService.java로 넘기도록 수정했다.
현재 로그인 성공 흐름은 아래처럼 바뀌었다.로그인 성공 → AuthService.login() → loginSessionManager.login(session, member.getId()) → securityContextLoginManager.login(member.getId(), httpRequest, httpResponse) → SecurityContext에 Authentication.getName() = Member.id 저장이제 실제 로그인 사용자의
Member.id가Authentication.getName()으로 들어간다.
따라서 더미 필터는 더 이상 필요하지 않다.
더미 필터가 계속 남아 있으면 실제 로그인 사용자 대신 고정된 더미 사용자가 현재 사용자처럼 처리될 수 있다.
그래서 실제 인증 연결 이후에는 반드시 제거해야 한다.
9-3. SecurityConfig.java에서 등록 코드가 제거됐는지 확인하기
파일을 삭제하기 전에 먼저
SecurityConfig.java에서 더미 필터 등록 코드가 제거됐는지 확인해야 한다.
파일만 삭제하고 등록 코드가 남아 있으면 컴파일 오류가 발생한다.
제거 전 등록 코드 확인
기존
SecurityConfig.java에는 아래 코드가 있었다..addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);이 코드는
DummyAuthenticationFilter를Spring Security필터 체인에 등록하는 코드이다.
이 코드가 남아 있으면 서버가 실행될 때DummyAuthenticationFilter클래스를 찾으려고 한다.
그런데DummyAuthenticationFilter.java파일을 삭제한 상태에서 이 등록 코드가 남아 있으면 문제가 생긴다.
프로젝트는 삭제된 클래스를 참조하려고 하기 때문에 컴파일 오류가 날 수 있다.
따라서 삭제 전에 반드시 아래 코드가 제거됐는지 확인한다.확인할 것 → SecurityConfig.java에 addFilterBefore(new DummyAuthenticationFilter(), ...) 코드가 남아 있지 않은가?
제거 후 SecurityConfig.java 최종 구조 확인
8번에서 정리한 최종
SecurityConfig.java구조는 아래와 같다.package com.example.groupbuyingweb.core.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .authorizeHttpRequests(auth -> auth // 유지: 세부 권한은 컨트롤러의 @PreAuthorize로 제어할 예정이므로 URL 단계에서는 모든 요청을 허용한다. .anyRequest().permitAll() ); return http.build(); } }이 코드에는 더미 필터 등록 코드가 없다.
그리고UsernamePasswordAuthenticationFilterimport도 없다.
즉,SecurityConfig.java는 이제 더미 필터에 의존하지 않는다.
URL단계에서는 전체 요청을 허용하고, 실제 세부 권한은 컨트롤러의@PreAuthorize에서 처리하는 구조만 남아 있다.
필터 체인에 등록되지 않으면 실행되지 않는다는 점 설명하기
DummyAuthenticationFilter.java파일이 프로젝트 안에 남아 있더라도,SecurityConfig.java에 등록되어 있지 않으면 보안 필터 체인에서 실행되지 않는다.
즉, 실행 여부는 파일 존재 자체보다 필터 체인 등록 여부가 더 중요하다.
하지만 사용하지 않는 파일이 계속 남아 있으면 나중에 헷갈릴 수 있다.
팀원이 볼 때 “이 필터 아직 쓰는 건가?”라고 오해할 수 있고, 나중에 누군가 다시 등록할 가능성도 있다.
그래서 이번 단계에서는 등록 코드를 제거한 뒤 파일까지 삭제한다.
흐름은 아래와 같다.1. SecurityConfig.java에서 더미 필터 등록 코드 제거 2. 더미 필터 관련 import 제거 3. DummyAuthenticationFilter.java 파일 삭제 4. 프로젝트에서 남은 참조가 없는지 확인이 순서로 가야 안전하다.
9-4. DummyAuthenticationFilter.java 파일 삭제하기
이제
DummyAuthenticationFilter.java파일을 삭제한다.
삭제 대상은 아래 파일이다.src/main/java/com/example/groupbuyingweb/core/security/DummyAuthenticationFilter.java
### 삭제 대상 파일 확인 삭제할 파일 이름은 정확히 `DummyAuthenticationFilter.java`이다. `SecurityContextLoginManager.java`와 헷갈리면 안 된다.
두 파일의 역할은 완전히 다르다. ```text DummyAuthenticationFilter.java → 삭제 대상 → 임시로 고정 Member.id를 Authentication에 넣던 더미 필터SecurityContextLoginManager.java
→ 유지 대상
→ 실제 로그인 성공 사용자의 Member.id를 SecurityContext에 저장하는 파일삭제할 때는 `IntelliJ` 프로젝트 트리에서 아래 경로로 이동하면 된다. ```text src → main → java → com → example → groupbuyingweb → core → security → DummyAuthenticationFilter.java해당 파일을 선택한 뒤 삭제하면 된다.
삭제 후 프로젝트 구조 확인
삭제 후
core/security패키지에는 최소한 아래 파일들이 남아 있어야 한다.core/security → SecurityConfig.java → SecurityContextLoginManager.java
SecurityConfig.java는 보안 설정 파일이다.
SecurityContextLoginManager.java는 로그인 성공 정보를SecurityContext에 저장하고, 로그아웃 시 정리하는 파일이다.
반대로 아래 파일은 없어야 한다.core/security → DummyAuthenticationFilter.java삭제 후에도 이 파일이 남아 있으면 아직 제거가 완료된 것이 아니다.
import 오류가 남는지 확인하기
파일을 삭제한 뒤에는
SecurityConfig.java에 더미 필터 관련 import가 남아 있지 않은지 확인한다.
특히 아래 import가 남아 있으면 제거해야 한다.import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;이 import는 더미 필터를 등록할 때만 필요했다.
이제addFilterBefore()코드를 제거했으므로 필요하지 않다.
만약DummyAuthenticationFilter를 import하는 코드가 따로 있었다면 그것도 제거해야 한다.
예를 들어 아래와 같은 import가 있으면 삭제 대상이다.import com.example.groupbuyingweb.core.security.DummyAuthenticationFilter;현재
SecurityConfig.java와DummyAuthenticationFilter.java가 같은 패키지에 있었다면 별도 import가 없었을 수도 있다.
그래도 프로젝트 전체 검색으로DummyAuthenticationFilter라는 이름이 남아 있는지 확인하는 것이 좋다.
9-5. 삭제 후 컴파일 기준 점검하기
파일을 삭제한 뒤에는 프로젝트가 정상적으로 컴파일되는지 확인해야 한다.
삭제 작업은 단순히 파일을 지우는 것으로 끝나지 않는다.
남아 있는 참조가 없는지 확인해야 한다.
SecurityConfig.java import 오류 확인
먼저
SecurityConfig.java를 열어 import 오류가 없는지 확인한다.
삭제 후 최종SecurityConfig.java에는 아래 import가 없어야 한다.import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;또한 아래 등록 코드도 없어야 한다.
.addFilterBefore(new DummyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);이 둘 중 하나라도 남아 있으면 삭제가 완전하지 않은 것이다.
DummyAuthenticationFilter 참조 검색하기
다음으로 프로젝트 전체에서
DummyAuthenticationFilter를 검색한다.
IntelliJ에서는 전체 검색으로 아래 단어를 찾으면 된다.DummyAuthenticationFilter검색 결과가 나오지 않으면 정상이다.
만약 검색 결과가 나온다면 해당 위치를 확인해야 한다.
남아 있는 참조가 아래처럼 코드에서 사용 중이라면 삭제하거나 수정해야 한다.남아 있으면 안 되는 예 → new DummyAuthenticationFilter() → DummyAuthenticationFilter.class → import ...DummyAuthenticationFilter반대로 블로그 글이나 주석 파일 같은 설명 문서에 남아 있는 것은 실행 코드가 아니므로 문제는 아니다.
하지만 실제src/main/java안의 코드에는 남아 있으면 안 된다.
프로젝트 실행 전 확인할 체크리스트 정리하기
삭제 후 실행 전에는 아래 내용을 확인한다.
SecurityConfig.java에서addFilterBefore()코드가 제거됐는가?UsernamePasswordAuthenticationFilterimport가 제거됐는가?DummyAuthenticationFilter.java파일이 삭제됐는가?- 프로젝트 전체 검색에서 실행 코드 기준
DummyAuthenticationFilter참조가 남아 있지 않은가?SecurityContextLoginManager.java는 삭제하지 않고 유지했는가?@EnableMethodSecurity는 유지되어 있는가?anyRequest().permitAll()은 팀원 인가 구조에 맞게 유지되어 있는가?이 체크가 끝나야 서버 실행으로 넘어갈 수 있다.
9-6. DummyAuthenticationFilter.java 제거 흐름 정리하기
이번 단계에서는
DummyAuthenticationFilter.java를 제거했다.
이 파일은 팀원이 인가 기능을 먼저 테스트하기 위해 사용한 임시 인증 필터였다.
수정 전 구조는 아래와 같았다.수정 전 → SecurityConfig.java에서 DummyAuthenticationFilter 등록 → 요청마다 더미 Member.id를 Authentication에 저장 → @PreAuthorize가 더미 사용자 기준으로 인가 검사수정 후 구조는 아래와 같다.
수정 후 → SecurityConfig.java에서 DummyAuthenticationFilter 등록 제거 → DummyAuthenticationFilter.java 파일 삭제 → 로그인 성공 시 SecurityContextLoginManager.java가 실제 Member.id 저장 → @PreAuthorize가 실제 로그인 사용자 기준으로 인가 검사이제 더미 인증 구조는 제거되었다.
인증 정보는 더 이상 고정된 더미Member.id가 아니라, 로그인 성공한 사용자의 실제Member.id를 기준으로 만들어진다.
최종 흐름은 아래와 같다.로그인 성공 → AuthService.login() → LoginSessionManager.login(session, member.getId()) → SecurityContextLoginManager.login(member.getId(), request, response) → SecurityContext에 Authentication.getName() = Member.id 저장 공구 요청 → SecurityConfig에서는 URL 단계 permitAll → Controller 메서드 진입 전 @PreAuthorize 실행 → Authentication.getName()으로 실제 로그인 사용자 Member.id 확인 → GroupBuyingSecurityEvaluator.java에서 세부 인가 조건 검사이 흐름에서 중요한 점은
Authentication을 더 이상 더미 필터가 만들지 않는다는 것이다.
이제Authentication은 로그인 성공 후SecurityContextLoginManager.java가 실제 로그인 사용자의Member.id를 기준으로 만든다.
따라서 공구 생성, 수정, 참여 같은 기능에서Authentication.getName()을 호출하면 더미 사용자가 아니라 실제 로그인 사용자의Member.id를 기준으로 인가 검사가 진행되어야 한다.
이번 단계까지 끝나면 실제 인증 정보와 팀원이 구현한 인가 구조가 연결된다.
다음 단계에서는 수동 테스트를 길게 반복하지 않고,SecurityContextLoginManager.java의 핵심 동작을 테스트 코드로 검증한다.
특히login()호출 시Authentication.getName()에Member.id가 저장되는지,logout()호출 시SecurityContext인증 정보가 정리되는지 확인한다.
이번 단계에서는 수동 테스트를 길게 작성하지 않고, 인증 연결의 핵심 파일인
SecurityContextLoginManager.java를 테스트 코드로 검증한다.
회원가입과 로그인은 이미 정상적으로 동작하는 것을 확인했다.
따라서 이번 글에서는 회원가입 화면 입력 과정이나 로그인 성공 여부를 다시 길게 다루지 않는다.
이번 테스트 코드의 목적은 로그인 성공 후Member.id가Authentication.getName()에 저장되는지 확인하는 것이다.
공구 인가 기능은Authentication.getName()으로 현재 로그인 사용자를 확인하므로, 이 값이loginId가 아니라 실제Member.id여야 한다.
이번 테스트의 핵심은SecurityContextLoginManager가 실제 로그인 사용자의Member.id를SecurityContext에 제대로 저장하고, 로그아웃 시 정리하는지 확인하는 것이다.
10-1. 수동 테스트 대신 테스트 코드로 확인하는 이유
회원가입과 로그인은 이미 화면에서 정상 동작을 확인했다.
따라서 같은 내용을 다시 길게 반복해서 테스트하면 글의 중심이 흐려진다.
이번 인증 적용에서 정말 중요한 부분은 로그인 성공 이후다.
기존 프로젝트는 세션의loginUserId로 로그인 사용자를 구분했다.
하지만 공구 인가 기능은Authentication.getName()으로 현재 사용자를 확인한다.
그래서 이번 테스트에서는 아래 흐름만 정확히 검증한다.SecurityContextLoginManager.login() → Member.id를 Authentication에 저장 → Authentication.getName()으로 Member.id 확인 가능 SecurityContextLoginManager.logout() → SecurityContextHolder 정리 → 세션에 저장된 SecurityContext 정리이 테스트가 통과하면
AuthService.login()에서SecurityContextLoginManager.login(member.getId(), httpRequest, httpResponse)를 호출했을 때 실제 로그인 사용자의Member.id가Authentication.getName()으로 들어간다는 것을 확인할 수 있다.
즉, 10번은 전체 회원가입/로그인 수동 테스트가 아니라, 인증 연결의 핵심 코드가 제대로 동작하는지 검증하는 테스트 코드 작성 구간이다.
10-2. 테스트 파일 위치 확인하기
이번에 추가할 테스트 파일은
SecurityContextLoginManagerTest.java이다.
테스트 파일 위치는 아래와 같다.src/test/java/com/example/groupbuyingweb/core/security/SecurityContextLoginManagerTest.java실제 테스트 대상 파일은 아래 위치에 있다.
src/main/java/com/example/groupbuyingweb/core/security/SecurityContextLoginManager.java테스트 파일도 같은 패키지 구조에 맞춰 작성한다.
이렇게 하면 테스트 대상 파일과 테스트 파일의 관계를 바로 확인할 수 있다.
이번 테스트는DB를 직접 조회하지 않는다.
회원가입과 로그인 성공 여부는 이미 확인했기 때문이다.
대신MockHttpServletRequest와MockHttpServletResponse를 사용해서 실제 요청과 응답 객체가 있는 것처럼 테스트한다.
이 테스트에서 사용하는 주요 객체는 아래와 같다.MockHttpServletRequest → 테스트용 요청 객체 MockHttpServletResponse → 테스트용 응답 객체 SecurityContextHolder → 현재 요청에서 사용할 SecurityContext를 보관하는 객체 HttpSessionSecurityContextRepository → SecurityContext를 세션에 저장할 때 사용하는 기본 저장소이 테스트는 전체 애플리케이션을 띄우는 테스트가 아니다.
SecurityContextLoginManager의 동작만 확인하는 단위 테스트에 가깝다.
그래서@SpringBootTest를 사용하지 않고, 테스트 코드에서SecurityContextLoginManager객체를 직접 생성한다.
10-3. 테스트 코드에서 확인할 핵심 기준 정리하기
테스트 코드에서 확인할 기준은 세 가지다.
첫 번째는 로그인 저장 테스트다.
SecurityContextLoginManager.login()을 호출했을 때SecurityContextHolder에Authentication이 저장되어야 한다.
그리고Authentication.getName()은 테스트에서 넘긴Member.id와 같아야 한다.입력값 → memberId = "1111-aaaa" 기대 결과 → SecurityContextHolder.getContext().getAuthentication().getName() → "1111-aaaa"두 번째는 세션 저장 테스트다.
SecurityContextLoginManager.login()은 현재 요청의 세션에도SecurityContext를 저장해야 한다.
그래야 로그인 이후 다른 요청에서도 같은 인증 정보를 사용할 수 있다.요청 세션 → SPRING_SECURITY_CONTEXT 저장 저장된 SecurityContext → Authentication 존재 → Authentication.getName() = Member.id세 번째는 로그아웃 정리 테스트다.
SecurityContextLoginManager.logout()을 호출하면SecurityContextHolder의 인증 정보가 사라져야 한다.
또 세션에 저장된SecurityContext도 비어 있거나 제거되어야 한다.logout() 호출 후 → SecurityContextHolder.getContext().getAuthentication() = null → 세션의 SPRING_SECURITY_CONTEXT 인증 정보도 정리됨이 세 가지를 확인하면
SecurityContextLoginManager의 핵심 역할을 테스트 코드로 검증할 수 있다.
10-4. 로그인 성공 시 Authentication 저장 테스트 작성하기
먼저 로그인 성공 시
Member.id가Authentication.getName()에 저장되는지 확인한다.
테스트 흐름은 아래와 같다.1. 테스트용 memberId 준비 2. MockHttpServletRequest 생성 3. MockHttpServletResponse 생성 4. securityContextLoginManager.login(memberId, request, response) 호출 5. SecurityContextHolder에서 Authentication 확인 6. Authentication.getName()이 memberId와 같은지 확인 7. 세션에 저장된 SecurityContext도 확인테스트에서 사용할 Member.id 준비하기
테스트에서는 실제
DB에 있는 회원을 조회하지 않는다.
이 테스트의 목적은SecurityContextLoginManager가 넘겨받은memberId를Authentication에 제대로 넣는지 확인하는 것이다.
그래서 테스트용 값은 아래처럼 문자열로 준비한다.String memberId = "1111-aaaa";이 값은 실제 회원가입 테스트에서 확인한
Member.id역할을 한다.
중요한 것은loginId가 아니라Member.id를 넣는다는 점이다.
MockHttpServletRequest와 MockHttpServletResponse를 사용하는 이유
SecurityContextLoginManager.login()은SecurityContextRepository.saveContext(context, request, response)를 사용해 인증 정보를 세션에 저장한다.
그래서 테스트에서도 요청 객체와 응답 객체가 필요하다.
실제 서버 요청을 보내지 않고 테스트하기 위해 아래 객체를 사용한다.MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse();이 두 객체는 테스트 환경에서 사용할 수 있는 가짜 요청과 가짜 응답이다.
이 객체를 사용하면 실제 브라우저 요청 없이도 세션 저장 흐름을 확인할 수 있다.
SecurityContextHolder에 Authentication이 저장되는지 확인하기
login()을 호출한 뒤에는SecurityContextHolder에서 인증 정보를 꺼낸다.Authentication authentication = SecurityContextHolder.getContext().getAuthentication();그리고 아래 두 가지를 확인한다.
assertThat(authentication).isNotNull(); assertThat(authentication.getName()).isEqualTo(memberId);첫 번째 검증은
Authentication객체가 실제로 만들어졌는지 확인한다.
두 번째 검증은Authentication.getName()이 테스트에서 넣은memberId와 같은지 확인한다.
이 테스트가 통과하면SecurityContextHolder기준으로는 인증 정보가 정상 저장된 것이다.
세션에 SPRING_SECURITY_CONTEXT가 저장되는지 확인하기
SecurityContextHolder에만 인증 정보가 있으면 현재 요청 안에서는 확인할 수 있다.
하지만 로그인 이후 다른 요청에서도 인증 정보를 사용하려면 세션에도SecurityContext가 저장되어야 한다.
그래서 테스트에서는 요청 객체에서 세션을 꺼낸다.HttpSession session = request.getSession(false);그리고 세션 안에
SPRING_SECURITY_CONTEXT가 있는지 확인한다.SecurityContext savedContext = (SecurityContext) session.getAttribute( HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY );여기서
SPRING_SECURITY_CONTEXT_KEY는Spring Security가 세션에SecurityContext를 저장할 때 사용하는 기본 키다.
저장된SecurityContext안에도Authentication이 있어야 하고, 그 이름값도memberId와 같아야 한다.assertThat(savedContext).isNotNull(); assertThat(savedContext.getAuthentication()).isNotNull(); assertThat(savedContext.getAuthentication().getName()).isEqualTo(memberId);이 검증까지 통과하면 세션 저장까지 정상적으로 된 것이다.
10-5. 로그아웃 시 SecurityContext 정리 테스트 작성하기
다음은 로그아웃 테스트를 작성한다.
로그아웃 테스트는 처음부터 비어 있는 상태에서 바로logout()을 호출하지 않는다.
먼저login()을 호출해서 인증 정보가 저장된 상태를 만든 뒤,logout()을 호출해야 한다.
테스트 흐름은 아래와 같다.1. 테스트용 memberId 준비 2. MockHttpServletRequest 생성 3. MockHttpServletResponse 생성 4. login() 호출로 인증 상태 만들기 5. Authentication이 존재하는지 먼저 확인 6. logout() 호출 7. SecurityContextHolder의 Authentication이 null인지 확인 8. 세션에 저장된 SecurityContext 인증 정보가 정리되었는지 확인먼저 로그인 상태를 테스트 코드로 만들기
로그아웃을 테스트하려면 먼저 로그인 상태가 있어야 한다.
그래서 테스트 안에서securityContextLoginManager.login()을 먼저 호출한다.securityContextLoginManager.login(memberId, request, response); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();이렇게 하면
SecurityContextHolder와 세션에 인증 정보가 들어간 상태를 만들 수 있다.
logout() 호출 후 SecurityContextHolder가 비워지는지 확인하기
로그아웃은 아래처럼 호출한다.
securityContextLoginManager.logout(request, response);호출 후에는
SecurityContextHolder안의Authentication이 없어야 한다.Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); assertThat(authentication).isNull();이 검증이 통과하면 현재 쓰레드 기준 인증 정보는 정리된 것이다.
세션에 저장된 SecurityContext가 정리되는지 확인하기
로그아웃 후에는 세션에 저장된
SecurityContext도 확인한다.
HttpSessionSecurityContextRepository구현 방식에 따라 세션의SPRING_SECURITY_CONTEXT가 제거되거나, 비어 있는SecurityContext가 저장될 수 있다.
그래서 테스트에서는 두 상황을 모두 안전하게 확인한다.HttpSession session = request.getSession(false); if (session != null) { Object savedContext = session.getAttribute( HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY ); if (savedContext != null) { SecurityContext securityContext = (SecurityContext) savedContext; assertThat(securityContext.getAuthentication()).isNull(); } }이 코드는 세션이 없으면 그대로 통과한다.
세션이 있고SecurityContext가 남아 있더라도, 그 안의Authentication이null이면 정상으로 본다.
10-6. SecurityContextLoginManagerTest.java 전체 코드 작성하기
이제 전체 테스트 코드를 작성한다.
추가할 파일은 아래 위치에 만든다.src/test/java/com/example/groupbuyingweb/core/security/SecurityContextLoginManagerTest.java테스트 전체 코드는 아래와 같다.
package com.example.groupbuyingweb.core.security; import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import static org.assertj.core.api.Assertions.assertThat; class SecurityContextLoginManagerTest { private SecurityContextLoginManager securityContextLoginManager; @BeforeEach void setUp() { securityContextLoginManager = new SecurityContextLoginManager(); } @AfterEach void tearDown() { SecurityContextHolder.clearContext(); } @Test @DisplayName("로그인 성공 시 Member.id가 Authentication.getName()에 저장된다") void loginSaveMemberIdToAuthenticationName() { // given: 실제 로그인 성공 후 전달될 Member.id 역할의 테스트 값이다. String memberId = "1111-aaaa"; MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); // when: SecurityContextLoginManager가 Member.id를 인증 정보로 저장한다. securityContextLoginManager.login(memberId, request, response); // then: 현재 요청에서 사용하는 SecurityContextHolder에 Authentication이 저장되어야 한다. Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); assertThat(authentication).isNotNull(); assertThat(authentication.getName()).isEqualTo(memberId); // then: 세션에도 SPRING_SECURITY_CONTEXT가 저장되어야 다음 요청에서 인증 정보를 사용할 수 있다. HttpSession session = request.getSession(false); assertThat(session).isNotNull(); SecurityContext savedContext = (SecurityContext) session.getAttribute( HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY ); assertThat(savedContext).isNotNull(); assertThat(savedContext.getAuthentication()).isNotNull(); assertThat(savedContext.getAuthentication().getName()).isEqualTo(memberId); } @Test @DisplayName("로그아웃 시 SecurityContext 인증 정보가 정리된다") void logoutClearSecurityContext() { // given: 먼저 로그인 상태를 만들어 SecurityContext에 인증 정보를 저장한다. String memberId = "1111-aaaa"; MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); securityContextLoginManager.login(memberId, request, response); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); // when: 로그아웃을 실행한다. securityContextLoginManager.logout(request, response); // then: SecurityContextHolder의 Authentication이 비워져야 한다. Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); assertThat(authentication).isNull(); // then: 세션에 SecurityContext가 남아 있더라도 내부 Authentication은 없어야 한다. HttpSession session = request.getSession(false); if (session != null) { Object savedContext = session.getAttribute( HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY ); if (savedContext != null) { SecurityContext securityContext = (SecurityContext) savedContext; assertThat(securityContext.getAuthentication()).isNull(); } } } }이 테스트 코드는
DB를 사용하지 않는다.
실제 회원가입과 로그인 성공 여부는 이미 확인했기 때문이다.
대신 이 테스트는SecurityContextLoginManager가 넘겨받은Member.id를Authentication에 제대로 저장하는지, 그리고 로그아웃 시 인증 정보를 정리하는지에 집중한다.
10-7. 테스트 실행 결과 확인하기
테스트 파일을 작성한 뒤
SecurityContextLoginManagerTest를 실행한다.
IntelliJ에서는 테스트 클래스 왼쪽의 실행 버튼을 눌러 실행할 수 있다.
테스트가 성공하면 아래 두 테스트가 모두 통과해야 한다.로그인 성공 시 Member.id가 Authentication.getName()에 저장된다 → 통과 로그아웃 시 SecurityContext 인증 정보가 정리된다 → 통과첫 번째 테스트가 통과하면 아래 흐름이 정상이라는 뜻이다.
memberId → SecurityContextLoginManager.login() → Authentication 생성 → Authentication.getName() = memberId → 세션에 SPRING_SECURITY_CONTEXT 저장두 번째 테스트가 통과하면 아래 흐름이 정상이라는 뜻이다.
로그인 상태 생성 → SecurityContextLoginManager.logout() → SecurityContextHolder 인증 정보 제거 → 세션에 저장된 인증 정보 정리
테스트를 실행한 결과
SecurityContextLoginManagerTest의 테스트 2개가 모두 통과했다.2 tests passed BUILD SUCCESSFUL첫 번째 테스트는 로그인 성공 시
Member.id가Authentication.getName()에 저장되는지 확인하는 테스트다.
두 번째 테스트는 로그아웃 시SecurityContext인증 정보가 정리되는지 확인하는 테스트다.
따라서SecurityContextLoginManager.java는 로그인 성공 시 실제 로그인 사용자의Member.id를SecurityContext에 저장하고, 로그아웃 시 인증 정보를 정리하는 역할을 정상적으로 수행한다고 볼 수 있다.
10-8. 테스트 실패 시 확인할 부분 정리하기
테스트가 실패하면 어느 부분에서 실패했는지에 따라 확인할 파일이 달라진다.
Authentication이 null인 경우
첫 번째 테스트에서
authentication이null이라면SecurityContextHolder에 인증 정보가 저장되지 않은 것이다.
이 경우SecurityContextLoginManager.login()안에서 아래 코드가 있는지 확인한다.SecurityContextHolder.setContext(context);이 코드가 빠져 있으면 현재 요청에서 사용할
SecurityContext가 설정되지 않는다.
Authentication.getName()이 memberId와 다른 경우
Authentication.getName()이 테스트에서 넣은memberId와 다르다면Authentication을 만들 때 첫 번째 값에 무엇을 넣었는지 확인해야 한다.
정상 기준은 아래와 같다.Authentication authentication = new UsernamePasswordAuthenticationToken( memberId, null, Collections.emptyList() );첫 번째 값은
loginId가 아니라Member.id여야 한다.
현재 공구 인가 기능은Authentication.getName()으로Member.id를 꺼내 비교하기 때문이다.
세션에 SPRING_SECURITY_CONTEXT가 저장되지 않는 경우
첫 번째 테스트에서 세션의
SPRING_SECURITY_CONTEXT가null이라면SecurityContextRepository저장 흐름을 확인해야 한다.
정상 기준은 아래와 같다.securityContextRepository.saveContext(context, request, response);이 코드가 있어야 세션에
SecurityContext가 저장된다.
그래야 로그인 이후 다른 요청에서도 인증 정보를 사용할 수 있다.
로그아웃 후 Authentication이 남아 있는 경우
두 번째 테스트에서 로그아웃 후에도
Authentication이 남아 있다면logout()메서드에서 아래 코드가 실행되는지 확인한다.SecurityContextHolder.clearContext();이 코드가 있어야 현재 요청 기준 인증 정보가 정리된다.
또 세션에 저장된 인증 정보까지 정리하려면SecurityContextRepository를 통해 비어 있는SecurityContext를 저장하거나 기존 인증 정보를 제거하는 흐름이 필요하다.
10-9. 테스트 코드로 확인한 내용 정리하기
이번 단계에서는
SecurityContextLoginManagerTest.java를 작성해 인증 연결의 핵심 동작을 검증했다.
수동으로 화면을 눌러 확인하는 대신, 테스트 코드로SecurityContextLoginManager의 역할을 직접 확인했다.
첫 번째 테스트에서는 로그인 성공 시Member.id가Authentication.getName()에 저장되는지 확인했다.memberId = 1111-aaaa → securityContextLoginManager.login(memberId, request, response) → Authentication.getName() = 1111-aaaa → 세션에 SPRING_SECURITY_CONTEXT 저장두 번째 테스트에서는 로그아웃 시 인증 정보가 정리되는지 확인했다.
로그인 상태 생성 → securityContextLoginManager.logout(request, response) → SecurityContextHolder의 Authentication 제거 → 세션의 SecurityContext 인증 정보 정리이 테스트가 통과하면
AuthService.login()에서securityContextLoginManager.login(member.getId(), httpRequest, httpResponse)를 호출했을 때, 공구 인가 기능에서 사용하는Authentication.getName()이 실제 로그인 사용자의Member.id를 바라볼 수 있다.
즉, 이번 테스트 코드로 확인한 핵심은 아래와 같다.
Authentication.getName()에는loginId가 아니라Member.id가 저장된다.SecurityContextHolder에 인증 정보가 저장된다.- 세션에도
SPRING_SECURITY_CONTEXT가 저장된다.- 로그아웃 시
SecurityContextHolder인증 정보가 정리된다.- 세션에 남은
SecurityContext인증 정보도 비어 있어야 한다.이제 다음 단계에서는 코드 테스트로 확인한 인증 흐름을 실제 요청 흐름 기준으로 다시 정리한다.
이번 단계에서는 지금까지 적용한 인증 흐름을 전체 요청 흐름 기준으로 정리한다.
10번에서는SecurityContextLoginManagerTest.java를 작성해서SecurityContextLoginManager.java가Member.id를Authentication.getName()에 저장하고, 로그아웃 시 인증 정보를 정리하는지 테스트 코드로 확인했다.
이제 마지막으로 실제 프로젝트 요청 흐름에서 회원가입, 로그인, 세션,SecurityContext,@PreAuthorize, 로그아웃이 어떻게 연결되는지 정리한다.
이번 단계의 핵심은 기존 세션 로그인 구조와Spring Security인증 구조가 따로 노는 것이 아니라, 같은Member.id를 기준으로 연결되도록 만든 흐름을 정리하는 것이다.
11-1. 회원가입과 로그인은 기존 흐름을 유지한다
이번 인증 적용 작업은 기존 회원가입과 로그인 기능을 새로 갈아엎는 작업이 아니었다.
이미 프로젝트에는 회원가입과 로그인 기능이 구현되어 있었고, 화면에서도 정상 동작을 확인했다.
회원가입은 사용자가 입력한 회원 정보를member테이블에 저장하는 흐름이다.
이때 비밀번호는 원문 그대로 저장되지 않고 암호화되어 저장되어야 한다.
또 주소 선택 흐름에서는 기준 주소와 기준 좌표가 저장되고, 기준 좌표 주변 주소 데이터도 함께 만들어진다.
로그인은 사용자가 입력한loginId와password를 이용해 실제 회원인지 확인하는 흐름이다.
이 흐름에서 중요한 점은 사용자가 로그인 화면에 입력하는 값과 서버 내부에서 사용자를 구분하는 값이 다르다는 것이다.로그인 화면 입력값 → loginId → password 서버 내부 사용자 구분값 → Member.id로그인할 때는
loginId와password를 입력하지만, 로그인 성공 후 실제 사용자 기준으로 저장되어야 하는 값은Member.id이다.
기존 로그인 기능에서는 로그인 성공 후 세션에loginUserId라는 이름으로Member.id를 저장했다.
이 구조는 그대로 유지한다.로그인 성공 → Member 조회 → password 검증 → session에 loginUserId = Member.id 저장즉, 기존 세션 로그인 구조를 버리지 않는다.
기존 마이페이지나 회원 정보 조회처럼 세션의loginUserId를 사용하던 기능들이 이미 있기 때문이다.
11-2. 로그인 성공 후 세션과 SecurityContext가 함께 저장되는 흐름 정리하기
이번 작업에서 새로 추가한 부분은 로그인 성공 후
SecurityContext에도 인증 정보를 저장하는 흐름이다.
기존에는 세션에만loginUserId가 저장되었다.
하지만 팀원이 구현한 공구 인가 기능은Authentication.getName()을 사용하고 있었다.
그래서 로그인 성공 후 아래 두 저장 흐름이 함께 실행되어야 한다.기존 저장 흐름 → LoginSessionManager.login(session, member.getId()) → HttpSession에 loginUserId 저장 추가 저장 흐름 → SecurityContextLoginManager.login(member.getId(), request, response) → SecurityContext에 Authentication 저장여기서 두 흐름 모두 같은 값을 사용해야 한다.
그 값은member.getId()이다.
정리하면 로그인 성공 후 저장 기준은 아래처럼 맞아야 한다.HttpSession → loginUserId = Member.id SecurityContext → Authentication.getName() = Member.id이렇게 해야 기존 세션 기반 기능과
Spring Security기반 인가 기능이 같은 사용자를 바라본다.
만약 세션에는 실제 로그인 사용자의Member.id가 들어가고,Authentication.getName()에는 더미 사용자ID나loginId가 들어가면 문제가 생긴다.
그 경우 기존 기능과 공구 인가 기능이 서로 다른 사용자를 현재 사용자로 판단할 수 있다.잘못된 상태 예시 → session loginUserId = 실제 Member.id → Authentication.getName() = 더미 Member.id 또는 loginId그래서 이번 작업에서는 로그인 성공 시
SecurityContextLoginManager.java를 통해Authentication.getName()에도 실제Member.id가 들어가도록 연결했다.
11-3. 기존 세션 기반 기능이 loginUserId를 사용하는 흐름 정리하기
기존 프로젝트의 일부 기능은 세션의
loginUserId를 기준으로 현재 로그인 사용자를 확인한다.
대표적으로 마이페이지나 내 회원 정보 조회 같은 기능이 여기에 해당한다.
이 기능들은Spring Security의Authentication을 직접 사용하지 않는다.
대신HttpSession에서loginUserId를 꺼내 현재 사용자를 찾는다.마이페이지 요청 → HttpSession에서 loginUserId 조회 → loginUserId로 Member 조회 → 현재 로그인 사용자 정보 반환이 흐름을 코드 기준으로 보면 아래와 같다.
session.getAttribute("loginUserId") → Member.id 확인 → MemberRepository로 회원 조회즉, 기존 세션 기반 기능에서 중요한 값은
loginUserId이다.
그리고 이 값은 반드시Member.id여야 한다.
이번 작업에서 기존 세션 저장 흐름을 유지한 이유가 여기에 있다.
이미 세션 기반으로 동작하는 기능이 있으므로, 기존 구조를 없애면 마이페이지 같은 기능이 깨질 수 있다.
따라서 최종 구조는 아래처럼 가져간다.기존 세션 기반 기능 → loginUserId 사용 → Member.id 기준으로 현재 사용자 조회 공구 인가 기능 → Authentication.getName() 사용 → Member.id 기준으로 현재 사용자 조회두 흐름은 사용하는 객체는 다르지만, 최종 기준값은 같다.
바로Member.id이다.
11-4. 공구 인가 기능이 Authentication.getName()을 사용하는 흐름 정리하기
공구 기능에서는 팀원이
@PreAuthorize기반 인가를 먼저 구현해두었다.
이 구조에서는 컨트롤러 메서드가 실행되기 전에 현재 사용자가 해당 기능을 실행할 권한이 있는지 검사한다.
공구 인가 기능에서 현재 로그인 사용자를 확인할 때 사용하는 값은Authentication.getName()이다.공구 요청 → @PreAuthorize 실행 → Authentication.getName()으로 현재 사용자 ID 확인 → 공구 데이터의 작성자 또는 참여자 정보와 비교예를 들어 공구 수정은 아무나 할 수 있으면 안 된다.
공구를 만든 주최자만 수정할 수 있어야 한다.
이때 인가 로직은 현재 사용자와 공구 주최자를 비교한다.현재 로그인 사용자 → Authentication.getName() 공구 주최자 → groupBuying.getMember().getId()두 값이 같으면 현재 사용자가 해당 공구의 주최자라고 판단할 수 있다.
반대로 두 값이 다르면 수정 권한이 없는 사용자로 볼 수 있다.
그래서Authentication.getName()에 반드시Member.id가 들어가야 한다.
만약 여기에loginId가 들어가면 공구 주최자의Member.id와 비교할 수 없다.잘못된 비교 → Authentication.getName() = test389919 → groupBuying.getMember().getId() = 1111-aaaa → 값이 다르므로 권한 판단 실패 가능정상 흐름은 아래처럼 되어야 한다.
정상 비교 → Authentication.getName() = 1111-aaaa → groupBuying.getMember().getId() = 1111-aaaa → 같은 사용자로 판단 가능이번 인증 적용의 핵심은 바로 이 지점을 맞추는 것이었다.
공구 인가 기능이 실제 로그인 사용자의Member.id를 기준으로 동작하도록SecurityContext에 인증 정보를 저장한 것이다.
11-5. SecurityConfig.java에서 permitAll을 유지하는 이유 다시 정리하기
이번 작업에서 헷갈렸던 부분은
SecurityConfig.java의anyRequest().permitAll()이다.
일반적으로 인증을 적용한다고 하면 모든 보호 요청을authenticated()로 막는 흐름을 떠올릴 수 있다.
하지만 현재 팀원 구조에서는SecurityConfig.java에서URL단계 접근을 강하게 막는 방식이 아니다.
전체 요청은permitAll()로 열어두고, 실제 세부 권한은 컨트롤러 메서드의@PreAuthorize에서 검사하는 구조다..authorizeHttpRequests(auth -> auth // 유지: 세부 권한은 컨트롤러의 @PreAuthorize로 제어할 예정이므로 URL 단계에서는 모든 요청을 허용한다. .anyRequest().permitAll() )이 구조에서는
SecurityConfig.java가 요청을 막는 최종 지점이 아니다.
요청은URL단계에서 통과하지만, 보호가 필요한 메서드에 도달하기 전에@PreAuthorize가 실행된다.
흐름은 아래와 같다.요청 들어옴 → SecurityConfig.java에서 URL 단계 permitAll → Controller 메서드 진입 전 @PreAuthorize 실행 → 조건이 맞으면 메서드 실행 → 조건이 맞지 않으면 메서드 실행 차단따라서 이번 작업에서는
anyRequest().permitAll()을authenticated()로 바꾸지 않았다.
팀원이 잡은@PreAuthorize중심 인가 구조를 유지해야 했기 때문이다.
다만 이 구조에는 주의할 점이 있다.
SecurityConfig.java가 전역으로 요청을 막아주지 않으므로, 보호가 필요한 메서드에@PreAuthorize가 빠지면 해당 기능이 그대로 열릴 수 있다.주의할 점 → SecurityConfig.java는 URL 단계에서 모두 허용 → 보호 책임은 @PreAuthorize에 있음 → 보호 메서드에 @PreAuthorize가 빠지면 보안 누락 가능그래서 이후 공구 생성, 수정, 참여, 삭제처럼 권한이 필요한 기능은
@PreAuthorize적용 여부를 반드시 확인해야 한다.
11-6. @PreAuthorize 중심 인가 구조와 연결하기
@PreAuthorize는 메서드가 실행되기 전에 권한 조건을 검사하는 역할을 한다.
현재 프로젝트에서는 공구 기능의 세부 인가를 이 방식으로 처리한다.
단순히 로그인 여부만 확인하는 기능이라면 아래처럼 작성할 수 있다.@PreAuthorize("isAuthenticated()")이 조건은 현재 사용자가 인증된 사용자인지 확인한다.
즉,SecurityContext에Authentication이 있어야 통과할 수 있다.
공구 수정처럼 세부 조건이 필요한 경우에는GroupBuyingSecurityEvaluator.java같은 인가 판단 클래스를 사용할 수 있다.@PreAuthorize("@groupBuyingSecurity.canModifyGroupBuying(authentication, #groupBuyingId)")이 구조에서는
@PreAuthorize가 실행될 때authentication객체를 인가 판단 메서드로 넘긴다.
그리고 인가 판단 메서드는authentication.getName()으로 현재 사용자Member.id를 꺼낸다.
전체 흐름은 아래와 같다.공구 수정 요청 → @PreAuthorize 실행 → GroupBuyingSecurityEvaluator.java 호출 → authentication.getName()으로 현재 사용자 Member.id 확인 → groupBuyingId로 공구 조회 → 공구 주최자 Member.id와 현재 사용자 Member.id 비교 → 조건이 맞으면 허용 → 조건이 맞지 않으면 차단이 구조가 정상으로 동작하려면 전제가 있다.
Authentication.getName()이 실제 로그인 사용자의Member.id를 반환해야 한다.
10번 테스트 코드에서 확인한 내용이 바로 이 전제를 뒷받침한다.SecurityContextLoginManager.login(memberId, request, response) → Authentication.getName() = memberId따라서
AuthService.login()에서 실제 로그인 사용자의member.getId()를SecurityContextLoginManager.login()에 넘기면, 공구 인가 기능도 실제 로그인 사용자 기준으로 동작할 수 있다.
11-7. 로그아웃 시 세션과 SecurityContext가 함께 정리되는 흐름 정리하기
로그아웃에서는 기존 세션 정보와
SecurityContext인증 정보가 함께 정리되어야 한다.
둘 중 하나만 정리되면 문제가 생길 수 있다.
예를 들어 세션의loginUserId만 지우고SecurityContext가 남아 있으면, 공구 인가 기능에서는 아직 인증된 사용자처럼 보일 수 있다.
반대로SecurityContext만 정리하고 세션이 남아 있으면, 기존 세션 기반 기능에서는 아직 로그인한 사용자처럼 보일 수 있다.
그래서 로그아웃에서는 두 정보를 모두 정리해야 한다.로그아웃 처리 기준 → SecurityContext 정리 → 기존 세션 무효화현재 로그아웃 흐름은 아래 순서로 잡는다.
1. loginSessionManager.requireLoginUserId(session) 2. securityContextLoginManager.logout(request, response) 3. loginSessionManager.logout(session)먼저 로그인 사용자인지 확인한다.
그 다음SecurityContextLoginManager.logout()으로SecurityContext인증 정보를 정리한다.
마지막으로 기존 세션을 무효화한다.
이 순서가 중요한 이유는 세션을 먼저 무효화하면 세션에 저장된SecurityContext를 정리하는 흐름이 꼬일 수 있기 때문이다.
그래서SecurityContext를 먼저 정리하고, 마지막에 기존 세션을 무효화하는 방식으로 가져간다.
로그아웃 후 최종 상태는 아래와 같아야 한다.HttpSession → loginUserId 사용 불가 SecurityContext → Authentication 없음이 상태가 되어야 기존 세션 기반 기능과
@PreAuthorize기반 공구 인가 기능 모두에서 로그아웃된 사용자로 판단할 수 있다.
11-8. 전체 인증 흐름 최종 정리하기
이번 인증 적용 작업의 전체 흐름을 정리하면 아래와 같다.
먼저 회원가입과 로그인은 기존 기능을 유지한다.회원가입 → member 테이블에 회원 저장 → 비밀번호 암호화 저장 → 기준 주소와 주변 주소 데이터 저장 로그인 → loginId와 password 검증 → 실제 Member 조회로그인 성공 후에는 기존 세션 저장과 새
SecurityContext저장이 함께 실행된다.로그인 성공 → LoginSessionManager.login(session, member.getId()) → HttpSession에 loginUserId = Member.id 저장 로그인 성공 → SecurityContextLoginManager.login(member.getId(), request, response) → SecurityContext에 Authentication.getName() = Member.id 저장기존 마이페이지 같은 기능은 세션의
loginUserId를 사용한다.기존 세션 기반 기능 → session에서 loginUserId 조회 → Member.id 기준으로 현재 사용자 확인공구 인가 기능은
Authentication.getName()을 사용한다.공구 인가 기능 → @PreAuthorize 실행 → Authentication.getName()으로 현재 사용자 Member.id 확인 → GroupBuyingSecurityEvaluator.java에서 세부 인가 조건 검사
SecurityConfig.java에서는 팀원 구조에 맞춰permitAll()을 유지한다.SecurityConfig.java → URL 단계에서는 permitAll → 실제 권한 검사는 @PreAuthorize에서 처리더미 인증 필터는 제거한다.
DummyAuthenticationFilter.java → 임시 더미 인증 필터 → 실제 인증 연결 후 제거로그아웃 시에는
SecurityContext와 세션을 함께 정리한다.로그아웃 → SecurityContextLoginManager.logout(request, response) → SecurityContext 인증 정보 정리 → LoginSessionManager.logout(session) → 기존 세션 무효화정리하면 이번 작업의 핵심은 아래와 같다.
- 기존 세션 로그인 구조는 유지한다.
- 로그인 성공 후
SecurityContext에도 인증 정보를 저장한다.- 세션의
loginUserId와Authentication.getName()은 같은Member.id를 바라본다.DummyAuthenticationFilter는 제거한다.SecurityConfig.java의permitAll()은 팀원 인가 구조에 맞춰 유지한다.- 실제 세부 권한은
@PreAuthorize에서 검사한다.- 로그아웃 시 세션과
SecurityContext를 함께 정리한다.이렇게 정리하면 기존 로그인 기능과 팀원이 구현한 공구 인가 기능이 같은 사용자 기준으로 연결된다.
이후에는 공구 생성, 수정, 참여, 삭제 기능별로@PreAuthorize가 빠진 곳이 없는지 확인하면서 세부 인가 테스트를 이어가면 된다.