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/pw 로 로그인하면 서버가 세션을 만들어 저장소에 보관하고 세션ID 를 쿠키로 내려준다. 이후 요청마다 브라우저가 쿠키를 자동으로 실어 보내고, 서버는 세션ID 로 저장소를 조회해 사용자를 알아본다

핵심은 서버가 세션 정보를 기억한다는 것이다. 세션 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