[아이티센 부트캠프] 미니 풀스택 프로젝트 9 (Spring Security 적용)

이언덕·2026년 5월 25일

아이티센 부트캠프

목록 보기
108/115
post-thumbnail

Spring Security 인증 적용 - 기존 세션 로그인과 SecurityContext 연결하기

이번 글에서는 기존 로그인 기능에 Spring Security 인증 흐름을 연결한다.
기존 프로젝트에서는 로그인 성공 시 Member.id를 세션의 loginUserId에 저장했다.
하지만 현재 공구 기능 일부는 Authentication.getName()으로 현재 로그인한 사용자 ID를 꺼내고 있다.


따라서 이번 작업에서는 기존 로그인 검증 로직은 유지하면서, 로그인 성공 후 Member.id를 SecurityContext에도 저장하도록 수정한다.
이렇게 해야 팀원이 먼저 구현한 @PreAuthorize 기반 인가 코드가 더미 사용자가 아니라 실제 로그인 사용자 기준으로 동작한다.




1. 기존 프로젝트 인증 구조 확인하기

이번 작업은 처음부터 로그인 기능을 새로 만드는 작업이 아니다.
이미 프로젝트에는 로그인 기능이 구현되어 있다.
기존 로그인 기능은 Spring Security의 인증 객체를 사용하는 방식이 아니라, HttpSession에 로그인 사용자 ID를 저장해두고 그 값을 다시 꺼내 현재 사용자를 구분하는 방식이었다.


즉, 사용자가 로그인하면 서버는 loginId와 password를 확인한다.
로그인에 성공하면 서버는 Member.id를 세션의 loginUserId라는 이름으로 저장한다.
이후 마이페이지 같은 기능에서는 세션에서 loginUserId를 꺼내 현재 로그인한 사용자가 누구인지 판단한다.


이번 단계에서는 아직 코드를 수정하지 않는다.
먼저 기존 로그인 방식이 어떤 흐름으로 동작했는지 정확히 확인한다.
기존 방식의 핵심은 로그인 성공 결과를 SecurityContext가 아니라 HttpSession의 loginUserId로 관리했다는 점이다.


1-1. 기존 로그인 요청 흐름 확인하기

기존 로그인 요청은 사용자가 로그인 화면에서 아이디와 비밀번호를 입력하면서 시작된다.
화면에서는 fetch()를 사용해 서버의 로그인 API로 요청을 보낸다.


이때 요청 주소는 아래와 같다.

POST /api/auth/login

로그인 요청에 들어가는 값은 크게 두 가지다.

  • loginId
  • password

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.java
  • AuthService.java
  • LoginSessionManager.java
  • SessionConst.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()을 이미 사용하고 있기 때문에, 기존 세션 로그인 구조와 인가 코드 사이에 어떤 차이가 있는지 확인해야 한다.




2. 현재 프로젝트 인가 구조 확인하기

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를 저장해야 한다.




3. SecurityConfig와 DummyAuthenticationFilter 문제 확인하기

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가 들어가도록 연결할 것이다.




4. 실제 인증 적용 방향 정리하기

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.java
  • AuthController.java
  • SecurityConfig.java

AuthService.java는 기존 로그인 검증과 세션 저장은 유지하면서, 로그인 성공 후 SecurityContextLoginManager를 호출하도록 수정한다.


AuthController.java는 기존 HttpSession만 넘기던 구조에서 HttpServletRequest와 HttpServletResponse도 함께 넘길 수 있도록 수정한다.
이유는 SecurityContext를 세션에 저장하거나 정리할 때 요청/응답 객체가 필요하기 때문이다.


SecurityConfig.java는 더미 필터 등록을 제거하고, 실제 인증 기준에 맞게 접근 경로를 정리한다.


제거하거나 미사용 처리할 파일

이번 작업에서 제거하거나 미사용 처리할 파일은 아래와 같다.

  • DummyAuthenticationFilter.java

이 파일은 팀원이 인가 기능을 먼저 테스트하기 위해 만든 임시 인증 필터이다.
실제 인증을 적용하면 더 이상 고정된 더미 Member.id를 넣으면 안 된다.
따라서 SecurityConfig.java에서 등록을 제거하고, 파일도 삭제 대상으로 분리한다.


이번 단계에서 유지할 파일

이번 작업에서 당장 유지할 파일은 아래와 같다.

  • LoginSessionManager.java
  • SessionConst.java
  • MemberController.java
  • GroupBuyingController.java
  • GroupBuyingPagingController.java
  • GroupBuyingSecurityEvaluator.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에 저장하는 코드를 분리해서 작성한다.




5. SecurityContextLoginManager.java 추가하기

이번 단계에서는 로그인 성공 후 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.id
  • credentials: 이미 비밀번호 검증이 끝났으므로 null
  • authorities: 현재 역할 기반 권한을 사용하지 않으므로 빈 목록

이렇게 만들면 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를 호출하도록 연결한다.




6. AuthService.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를 함께 넘겨야 한다.




7. AuthController.java 수정하기

이번 단계에서는 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에 저장하는 흐름을 만들었으므로, 이제 더미 인증 필터 등록을 제거하고 실제 인증 기준에 맞게 접근 경로를 정리해야 한다.




8. SecurityConfig.java 수정하기

이번 단계에서는 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 등록 코드 제거
  • 더미 필터 등록에 필요했던 UsernamePasswordAuthenticationFilter import 제거

반대로 이번 단계에서 유지할 설정도 있다.

  • 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 인증 정보가 정리되는지 확인한다.




9. DummyAuthenticationFilter.java 제거하기

이번 단계에서는 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();
    }
}

이 코드에는 더미 필터 등록 코드가 없다.
그리고 UsernamePasswordAuthenticationFilter import도 없다.


즉, 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() 코드가 제거됐는가?
  • UsernamePasswordAuthenticationFilter import가 제거됐는가?
  • 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 인증 정보가 정리되는지 확인한다.




10. SecurityContextLoginManager 테스트 코드 작성하기

이번 단계에서는 수동 테스트를 길게 작성하지 않고, 인증 연결의 핵심 파일인 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 인증 정보도 비어 있어야 한다.

이제 다음 단계에서는 코드 테스트로 확인한 인증 흐름을 실제 요청 흐름 기준으로 다시 정리한다.




11. 인증 적용 후 전체 요청 흐름 정리하기

이번 단계에서는 지금까지 적용한 인증 흐름을 전체 요청 흐름 기준으로 정리한다.
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가 빠진 곳이 없는지 확인하면서 세부 인가 테스트를 이어가면 된다.

0개의 댓글