🟣 서버는 들어온 요청을 믿지 않는다
앱에서 API를 호출할 때는 "정상적인 사용자가 정상적인 값을 보낸다"고 가정하기 쉽다. 서버는 그 반대로 선다. 네트워크 너머에서 온 요청은 형식이 깨졌을 수도, 남의 요청일 수도 있다고 보고 두 겹으로 검증한다. 먼저 값의 형식이 맞는지 보고(형식 검증), 그다음 이 요청을 보낸 게 누구인지 확인한다(신원 검증).
형식 검증은 스프링에서 @Valid로 선언한다. 요청 객체 필드에 제약을 붙여 두면, 컨트롤러로 들어오는 순간 프레임워크가 검사하고 어긋나면 처리 로직에 닿기도 전에 400으로 막는다.
public record SignupRequest(
@Email String email,
@Size(min = 8) String password
) {}
@PostMapping("/signup")
public void signup(@Valid @RequestBody SignupRequest req) { ... }앱에서 입력값을 한 번 검사해 보냈더라도 서버는 다시 검사한다. 클라이언트 검증은 사용자 편의고, 서버 검증이 실제 방어선이기 때문이다. 형식을 통과한 요청에 대해 이제 "너는 누구인가"를 물어야 하는데, 여기서 인증이 시작된다.
🟣 세션에서 토큰으로
전통적인 방식은 세션이다. 로그인에 성공하면 서버가 세션을 하나 만들어 메모리에 들고, 그 열쇠(세션 ID)를 쿠키로 내려 준다. 이후 요청마다 쿠키가 따라오고, 서버는 자기가 들고 있는 세션 저장소에서 그 ID를 찾아 사용자를 알아본다.
이 방식은 서버가 상태를 들고 있어야 한다는 부담이 있다. 서버를 여러 대로 늘리면 세션이 어느 서버에 있는지 문제가 생기고, 앱처럼 쿠키를 다루기 번거로운 클라이언트에도 잘 안 맞는다. 그래서 요즘 앱 API는 대개 토큰을 쓴다.
토큰 방식은 상태를 서버가 아니라 토큰 자체에 담는다. 로그인에 성공하면 서버는 "이 사람은 id 42, 권한은 일반 사용자"라는 정보를 담고 서버만 아는 열쇠로 서명한 문자열을 만들어 앱에 준다. 앱은 이걸 저장해 두고 요청마다 헤더에 실어 보낸다. 서버는 저장소를 뒤질 필요 없이, 토큰의 서명만 검증하면 내용을 믿을 수 있다.
세션이 나쁜 게 아니라, 서버를 무상태로 두고 여러 대로 쉽게 늘리려는 쪽에서 토큰이 맞는다. 어느 쪽이든 트레이드오프가 있다. 토큰은 저장소를 안 뒤지는 대신, 한번 발급하면 만료 전까지 무효화하기가 까다롭다.
🟣 JWT의 구조
앱 API에서 쓰는 토큰은 대개 JWT다. JWT는 점으로 나뉜 세 부분으로 되어 있고, 각각 헤더, 페이로드, 서명이다.
여기서 처음에 오해했던 지점이 있다. 페이로드는 암호화가 아니라 인코딩일 뿐이라, 누구나 열어서 내용을 읽을 수 있다. JWT가 지키는 건 비밀이 아니라 위조 불가다. 서명은 헤더와 페이로드를 서버만 아는 키로 계산한 값이라, 내용을 한 글자라도 바꾸면 서명이 안 맞는다. 그래서 페이로드에 비밀번호 같은 민감 정보를 넣으면 안 되고, id나 권한처럼 노출돼도 되는 최소한만 담는다.
서버가 요청을 받으면 토큰의 서명을 다시 계산해 붙어 온 서명과 비교한다. 같으면 페이로드를 신뢰하고, 만료 시각이 지났으면 거부한다. 저장소 조회 없이 이 계산만으로 사용자를 알아보는 것이 토큰의 값어치다.
🚀 액세스 토큰과 리프레시 토큰
토큰을 무효화하기 어렵다는 약점 때문에, 실무에서는 수명이 짧은 액세스 토큰과 긴 리프레시 토큰을 나눈다. 액세스 토큰은 몇십 분이면 만료돼서, 탈취돼도 오래 못 쓴다. 만료되면 앱이 리프레시 토큰으로 새 액세스 토큰을 받아 온다. 앱에서 토큰이 만료됐을 때 자동으로 갱신 요청을 한 번 끼워 넣고 원래 요청을 재시도하던 그 처리가, 서버의 이 이중 토큰 설계와 짝을 이룬다.
🟣 소셜 로그인: 비밀번호를 대신 받는 구조
애플·카카오·구글 로그인 버튼을 누르면 우리 서버는 비밀번호를 직접 받지 않는다. 대신 인증을 그 제공자에게 위임하고, 제공자가 "이 사람 맞다"고 보증한 결과만 받는다. 이 위임 규약이 OAuth이고, 앱 API에서는 보통 인가 코드 방식을 쓴다.
흐름을 앱, 인증 제공자, 우리 서버 세 쪽으로 나눠 시간순으로 보면 이렇다.
핵심은 앱이 사용자의 카카오 비밀번호를 절대 보지 않는다는 데 있다. 비밀번호는 앱과 제공자 사이에서만 오가고, 우리 서버가 받는 건 한 번 쓰고 버리는 인가 코드뿐이다. 서버는 그 코드를 제공자에게 되물어 진짜인지 확인한 뒤에야 사용자 정보를 얻는다. 코드를 가로채도 서버의 비밀 키가 없으면 사용자 정보로 바꿀 수 없어서, 중간에 노출되는 값의 위험을 줄인 설계다.
제공자에게서 이메일이나 고유 식별자를 받고 나면, 서버는 그 사람을 우리 회원 테이블에서 찾거나 없으면 새로 만들고, 앞서 본 우리 JWT를 발급한다. 여기서부터는 소셜이든 일반 로그인이든 똑같이 우리 토큰으로 인증된다. 로그인 수단은 입구에서만 다르고, 그 뒤의 흐름은 하나로 합쳐진다.
🚀 제공자마다 다른 부분
큰 흐름은 같아도 세부는 제공자마다 갈린다. 애플 로그인은 이메일 대신 매번 같은 고유 식별자를 주고, 사용자가 이메일 가리기를 고르면 실제 주소 대신 중계 주소를 넘긴다. 그래서 회원을 이메일로만 식별하면 애플 사용자가 어긋난다. 제공자가 주는 고유 식별자를 회원의 기준 키로 잡고 이메일은 부가 정보로 두는 판단이, 여러 제공자를 한 회원 모델에 담을 때의 관건이었다.
🟣 정리
- 서버는 요청을 믿지 않고 두 겹으로 검증한다.
@Valid로 형식을 막고, 인증으로 신원을 확인한다. 클라이언트 검증은 편의고 서버 검증이 실제 방어선이다. - 세션은 서버가 상태를 들고, 토큰은 상태를 토큰에 담아 서버를 무상태로 둔다. 무상태 확장에는 토큰이 맞지만 무효화가 어렵다는 대가가 있다.
- JWT는 헤더·페이로드·서명 세 부분이다. 페이로드는 암호화가 아니라 인코딩이라 누구나 읽을 수 있고, 서명이 위조를 막는다. 민감 정보를 넣지 않는다.
- 소셜 로그인은 인증을 제공자에게 위임하고, 서버는 한 번 쓰는 인가 코드만 받는다. 입구만 다르고 그 뒤는 우리 JWT로 합쳐진다.
- 면접에서 "JWT를 왜 쓰나"를 물으면 무상태와 무효화의 트레이드오프, 액세스·리프레시 분리까지 답하는 편이 얕은 지식과 갈린다.
인증까지 붙으면 서버는 기능적으로 완성된다. 그런데 이 서버가 요청을 많이 받기 시작하면, 매 요청이 DB에 접근하는 지점에서 새로운 문제가 생긴다. 연결을 요청마다 새로 여는 비용, 그리고 그 연결을 나눠 쓰는 방법이 마지막 장의 주제다.