Skip to content
Park Hyoin PARKHYO.IN
Go back

기능별 공부 #1 — 세션 기반 인증: HTTP는 왜 나를 기억 못 하나

Edit page

이제 기능별로 공부하기로 했다. 로그인/인증, 예약/재고, 결제, 좋아요/조회수 같은 것들을 하나씩 파볼 계획이다. 그 첫 편으로, 로그인/인증에서 세션 기반 인증을 다룬다.

Table of contents

Open Table of contents

인증(authN) vs 인가(authZ)

먼저 헷갈리기 쉬운 두 단어를 갈랐다.

  • 인증(Authentication)누구인지 확인. 로그인이 여기 해당한다. 아이디/비밀번호 같은 신원으로 “이 사람이 그 사람이 맞는지”를 판정한다.
  • 인가(Authorization)권한을 확인. 로그인은 됐는데 관리자 페이지에 들어갈 수 있는지, 남의 글을 지울 수 있는지 같은 걸 판정한다.

당연히 인증 먼저, 그다음 인가다. 영어 약자로는 각각 authN, authZ로 쓴다. (앞의 N, Z로 구분하는 게 재밌다.)

근본 문제: HTTP는 기억을 못 한다

HTTP는 stateless다. 쉽게 말하면 이전 상황을 기억하지 못한다. 요청 하나하나가 서로를 모르는 상태라, 방금 로그인한 사람이 다음 요청을 보내도 서버 입장에선 낯선 사람이 보낸 요청으로 인식한다.

그런데 우리는 웹에서 로그인한 뒤 계속 내 정보를 받아본다. 서버는 어떻게 내가 로그인했다는 걸 기억하는 걸까? 이 문제를 푸는 방식이 세션 방식토큰(JWT) 방식이다. 오늘은 세션 쪽이다.

세션 기반 인증은 어떻게 동작하나

세션 방식은 로그인에 성공하면 서버가 출입증을 하나 발급해서 주고, 그 사본을 서버도 보관하는 식이다.

  1. 로그인: 사용자가 id/pw 전송
  2. 서버: id/pw 확인 → 맞으면 세션 생성
    • 서버 메모리(또는 DB/Redis)에 세션ID: abc123 → 김철수 저장
    • 사용자에겐 세션ID(abc123)를 쿠키로 내려줌
  3. 이후 요청: 브라우저가 자동으로 쿠키(abc123)를 함께 보냄
  4. 서버: 쿠키의 abc123을 보고 “아, 김철수구나” 하고 알아봄

핵심은 서버가 세션 정보를 기억한다는 것이다. 세션 ID는 그냥 사물함 번호표 같은 거고, 누구인지는 서버가 들고 있다. 사용자는 번호표(세션 ID)만 들고 다니며 요청하고, 서버는 그 번호로 일치하는 사물함(사용자 정보)을 열어본다.

여기서 중요한 건 브라우저가 매 요청마다 쿠키를 자동으로 실어 보낸다는 점이다. 그래서 로그인 후 모든 요청에 세션 ID가 자동으로 따라가고, 서버는 매번 누가 보냈는지 알 수 있다.

스프링에서는 — Spring Security

실무에서는 인증을 처음부터 다 직접 구현하지는 않는다고 한다. Spring Security라는 라이브러리를 쓴다. 인증/인가의 사실상 표준이다.

build.gradle에 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-security'

이것만 넣어도 스프링이 모든 요청에 로그인을 요구하기 시작한다. 핵심 설정 클래스는 이런 모양이다.

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/signup").permitAll()  // 이건 누구나
                .anyRequest().authenticated()                       // 나머진 로그인 필요
            )
            .formLogin(form -> form.permitAll());  // 기본 로그인 폼 사용

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();  // 비밀번호 해싱용 — 아래에서 설명
    }
}

SecurityFilterChain이 있으면, 요청이 컨트롤러에 닿기 전에 인증됐는지 먼저 검사한다. 검사를 통과 못 하면 컨트롤러까지 가지도 못하고 로그인 페이지로 튕긴다. authorizeHttpRequests가 인가를 관리하는데, 로그인·회원가입은 “로그인해야 들어갈 수 있게” 만들면 모순이니 permitAll로 누구나 열어둔다. Spring Security를 쓰면 쿠키 발급·세션 저장·검증 같은 세션 관리를 전부 자동으로 해준다.

비밀번호는 절대 그냥 저장하지 않는다

비밀번호를 DB에 평문 그대로 저장하면 절대 안 된다. 그대로 저장했다가 DB가 털리면 모든 사용자의 비밀번호가 그대로 노출된다. 같은 비밀번호를 여러 사이트에 재사용하는 사람이 많아서, 피해가 그 사이트 하나로 끝나지 않는다.

그래서 비밀번호는 **해싱(hashing)**해서 저장한다. 해싱은 원래 값으로 되돌릴 수 없는 단방향 변환이다.

여기서 내가 막혔던 지점: “암호화했으면 복호화를 해야 비교할 수 있는 거 아닌가?” 아니다. 로그인할 때 입력받은 비밀번호도 똑같이 해싱한 뒤, 저장된 해시값과 비교하면 된다. 그러면 서버가 원본 비밀번호를 몰라도 신원을 확인할 수 있다.

이걸 BCryptPasswordEncoder가 해준다. BCrypt는 비밀번호 해싱의 표준 알고리즘이다.

// 회원가입 시: 비밀번호 해싱해서 저장
String hashed = passwordEncoder.encode("password123");
// → "$2a$10$..." 같은 해시가 저장됨

// 로그인 시: 입력값과 저장된 해시 비교
boolean matches = passwordEncoder.matches("password123", hashed);
// → true (원본을 몰라도 일치 여부 확인 가능)

단순 해싱인 SHA-256도 있는데, 굳이 BCrypt를 쓰는 이유는 BCrypt가 일부러 느리게 설계됐기 때문이다. 일반 해시는 너무 빨라서 해커가 무차별 대입(brute-force)을 하기 쉽다. 느리면 대입 한 번에 시간이 걸려 공격 비용이 확 올라간다.

이 사실(비밀번호가 해시로 저장된다는 것)은 이번에 처음 알았다. 그동안 의심스러운 사이트에는 평소 안 쓰는 비밀번호로 가입하곤 했는데 — 알고 보니 그 습관은 계속 유지하는 게 맞다. 해싱은 사이트가 제대로 구현했을 때만 나를 지켜준다. 정작 의심스러운 곳은 평문으로 저장하거나 허술한 해시를 쓸 가능성이 큰 곳이라, 거기일수록 고유한 비밀번호를 써야 한다. “요즘은 다 해싱하니까 괜찮겠지”가 위험한 이유다.

세션 방식의 약점

세션은 잘 동작하지만 약점이 있다.

약점 1 — 서버가 세션을 다 기억해야 한다. 사용자가 적을 땐 괜찮지만, 많아지면 서버가 그 세션들을 전부 들고 있어야 해서 메모리 부담이 커진다.

약점 2 — 서버가 여러 대면? 요즘은 서버를 여러 대 두고 부하를 나누는 경우가 많다. 그런데 로그인 세션이 A 서버에만 있는데 다음 요청이 B 서버로 가면, B는 이 사람을 못 알아본다. 해결하려면 세션을 Redis 같은 공유 저장소에 두어야 하는데 그만큼 복잡해진다.

그래서 나온 게 JWT 방식이다. 서버가 아무것도 기억하지 않아도 되게, 출입증 자체에 정보를 담아 서버가 기억할 필요를 없앤다. (세션과 정반대 접근이라, 이건 따로 파볼 주제로 남겨둔다.)

회고

오늘 배운 걸 한마디로 하면 — 그냥 “로그인 기능 구현해줘” 라고 했으면 클로드가 세션이든 JWT든 알아서 짜줬을 것이다. 그리고 나는 그걸 평생 이해하지 못한 채 썼을 것이다.

이렇게 “이 기능을 만들려면 무엇을 고민해야 하고, 어떤 라이브러리로 실제 구현하는가”를 뜯어보는 공부를 계속 이어갈 생각이다. AI가 대신 짜주는 시대일수록, 짜준 걸 이해하는 쪽에 값어치가 있다고 믿는다.

더 공부해볼 것

  • JWT 방식 — 서버가 상태를 기억 안 하는(stateless) 인증. 출입증(토큰)에 정보를 담는 방식과 세션 방식의 trade-off(서버 부담 vs 토큰 무효화의 어려움 등).
  • salt — BCrypt 해시($2a$10$...)에 섞인 무작위 값. 같은 비밀번호라도 해시가 달라지게 해서 레인보우 테이블 공격을 막는 장치. $10$이 뭘 뜻하는지(작업 강도)도 같이.
  • 세션을 공유 저장소에 두기 — 서버 여러 대일 때 Redis 세션 스토어를 실제로 어떻게 붙이는지.
  • Spring Security 필터 체인 — “컨트롤러 전에 검사한다”의 실제 순서. 어떤 필터들이 어떤 순서로 도는지.
  • 인가(authZ) 실제 구현 — 오늘은 “로그인 여부”까지만 봤다. 역할(Role) 기반으로 “관리자만 접근” 같은 걸 어떻게 거는지.

Edit page