Spring 숙련 (세션)

KimGwangmin·2026년 9월 15일

인증, 인가

  • 인증: 누구인지 확인하는 절차
  • 인가: 특정 행위를 할 수 있는지 확인하는 절차

인증을 먼저 하고, 그 다음에 인가 (누구인지 알아야 권한이 있는지를 확인 가능)

  • 인증 실패 시 401 Unauthorized
  • 인가 실패 시 403 Forbidden

세션

사용자의 로그인 정보를 서버가 보관하는 공간 혹은 방식
서버가 사용자를 기억해야 함 -> Stateful

자바에서는 JSESSIONID라는 키에 세션값(쿠키)을 담아 보낸다.
아래에 헷갈렸던 포인트를 정리했다.

  • HTTP 헤더는 key-value로 구성된 값이라고 배웠다. [참고]
  • 헤더에서의 키는 Set-Cookie(서버 응답) 혹은 Cookie(클라 요청)이다.
  • 헤더에서의 값이 JSESSIONID=random_value이다.
    • 쿠키는 항상 이름=값;옵션...의 문자열 형태로 전송된다.
  • 서블릿 컨테이너(톰캣 등)에서 헤더 문자열을 파싱하여 이름이 JSESSIONID인 쿠키를 찾고 그 값을 추출한다.
  • 서버의 세션 저장소(커다란 Map<SessionID, HttpSession> 형태)에서 키를 통해 해당 브라우저 전용 세션 객체를 찾아온다.

예제 실습

기본 코드 세팅 (3 layer architecture)

1. 엔티티

@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    public Member(String email, String password) {
        this.email = email;
        this.password = password;
    }
}

2. DTO

@Getter
public class SignupRequest {
    private String email;
    private String password;
}

@Getter
public class LoginRequest {
    private String email;
    private String password;
}

@Getter
@RequiredArgsConstructor
public class MemberResponse {
    private final Long id;
    private final String email;
}

3. 레포지토리

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
}

4. 서비스

@Service
@RequiredArgsConstructor
public class AuthService {
    private final MemberRepository memberRepository;

	// 가입
    @Transactional
    public MemberResponse signup(SignupRequest request) {
        Member member = memberRepository.save(new Member(request.getEmail(), request.getPassword()));
        return new MemberResponse(member.getId(), member.getEmail());
    }
	
    // 로그인
    @Transactional(readOnly = true)
    public MemberResponse authenticate(LoginRequest request) {
        Member member = memberRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
        // 비밀번호 일치 확인
        if (!member.getPassword().equals(request.getPassword())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        return new MemberResponse(member.getId(), member.getEmail());
    }
	
    // 회원 정보 조회
    @Transactional(readOnly = true)
    public MemberResponse requireMember(Long memberId) {
        if (memberId == null) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
        return new MemberResponse(member.getId(), member.getEmail());
    }
}

로그인, 로그아웃

세션 관리용 상수

  • 여러 곳에서 사용할 때 오타로 인한 버그를 줄여줌
  • const는 패키지명으로 쓸 수 없다.(예약어) consts 등의 패키지명을 사용해 그 안에 정의
  • 인스턴스화를 금지하기 위해 추상 클래스로 정의
public abstract class SessionConst {
    public static final String LOGIN_MEMBER_ID = "LOGIN_MEMBER_ID";
}

컨트롤러

@RestController
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;

	// 가입
    // 새 멤버를 등록하는 일
    // 기존에 다루던 POST와 동일
    @PostMapping("/signup")
    public ResponseEntity<MemberResponse> signup(
            @RequestBody SignupRequest body
    ) {
        return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(body));
    }

	// 로그인
    // DB를 바꾸지 않는데 POST?
    // DB는 그대로지만, 서버 내부 세션 저장소가 변경됨 (세션 생성 혹은 변경)
    // 서버의 상태가 변경되는 요청이기 때문에 POST
    // 또한 GET 방식은 아이디와 비밀번호가 URL 쿼리 파라미터로 노출되어야 함(평문)
    // POST는 body에 담아 비문으로 전송될 수 있음(HTTPS에서)
    @PostMapping("/login")
    public ResponseEntity<MemberResponse> login(
            @RequestBody LoginRequest body,
            HttpServletRequest request
    ) {
    	// 서비스 레이어에서 인증 (실패시 401 Unauthorized)
        MemberResponse member = authService.authenticate(body);
        // 세션 획득 혹은 새로 생성
        // 요청에 JSESSIONID 값(쿠키)이 실려왔다면 해당 키와 일치하는 세션 탐색
        // 요청에 쿠키가 없다면 새로 생성
        HttpSession session = request.getSession(); // 기본 인자 true: 세션이 없으면 생성
        // 세션 아이디 재발급 (하이재킹 방지)
        // 세션은 유지되지만, 세션을 조회할 쿠키 값이 바뀌는 것
        request.changeSessionId();
        // 해당 세션 공간에 LOGIN_MEMBER_ID를 키로 멤버 Id 저장
        session.setAttribute(SessionConst.LOGIN_MEMBER_ID, member.getId());
        return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(member);
    }
	
    // 내 정보 가져오기
    @GetMapping("/me")
    public ResponseEntity<MemberResponse> me(
            @SessionAttribute(name = SessionConst.LOGIN_MEMBER_ID, required = false) Long memberId
    ) {
        return ResponseEntity.ok().cacheControl(CacheControl.noStore())
                .body(authService.requireMember(memberId));
    }

	// 로그아웃
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(
            HttpServletRequest request
    ) {
    	// 세션이 있다면 가져옴
        HttpSession session = request.getSession(false);
        // 세션이 있다면
        if (session != null) {
        	// 세션을 무효화 (없앰)
            session.invalidate();
        }
        return ResponseEntity.noContent().build();
    }
}

API 호출 결과

가입

로그인

응답 헤더에 Set-Cookie 키와 JSESSIONID=...값을 확인할 수 있다.

정보 조회

  • 로그인 된 상태에서
  • logout API 날린 뒤

세션 만료(타임아웃) 설정

환경 변수에 아래와 같은 값을 추가한다.

server.servlet.session.timeout=30m // 분 단위 세션 만료
server.servlet.session.timeout=30s // 초 단위 세션 만료

0개의 댓글