HTTP 요청은 기본적으로 무상태(Stateless)구조다.
즉, 서버는 요청 하나가 끝나면 이전 요청의 사용자가 누구였는지 자동으로 기억하지 않는다.
예를 들어 사용자가 방금 로그인을 했더라도, 다음 요청에서 서버는 그대로 두면 이렇게 판단한다.
"방금 로그인한 사람인가?"
→ 모름
"이 사람이 자기 닉네임을 바꾸려는 건가?"
→ 모름
그래서 로그인 이후에도 사용자를 식별하려면 별도의 기억 장치가 필요하다. 이 역할을 하는 것이 Session이다.
이번 실습의 목표는 다음과 같다.
| 구현 항목 | 목적 |
|---|---|
| 회원가입 API | 사용자를 DB에 저장한다. |
| 로그인 API | 사용자를 확인하고 Session에 로그인 정보를 저장한다. |
| 닉네임 수정 API | 로그인한 사용자만 자신의 nickname을 수정할 수 있게 한다. |
| Postman 테스트 | 로그인 전/후 요청 결과와 쿠키 저장 여부를 확인한다. |
첫 회원가입 시 nickname은 랜덤하게 지정되고, 이후 사용자가 로그인한 뒤 nickname을 수정하는 구조다. 회원가입/로그인 API에 Session을 적용하고, 로그인이 필요한 API에 Session을 적용한 뒤 Postman으로 확인하며 마무리했다.
Session을 이해할 때 가장 쉬운 비유는 찜질방 신발장이다.
사용자 로그인
→ 서버가 보관함(Session)을 만든다
→ 사용자에게 번호표(Session ID)를 준다
→ 번호표는 Cookie에 담긴다
→ 다음 요청에서 Cookie를 같이 보낸다
→ 서버는 번호표를 보고 같은 사용자인지 확인한다
| 구분 | 역할 |
|---|---|
| Session | 서버가 보관하는 로그인 정보 |
| Session ID | 서버 보관함을 찾기 위한 번호표 |
| Cookie | 클라이언트가 Session ID를 들고 다니는 저장 공간 |
JSESSIONID | Spring에서 세션 식별에 사용되는 대표적인 쿠키 이름 |
중요한 점은 진짜 사용자 정보는 서버 Session에 있고, 클라이언트는 그 위치를 찾기 위한 번호표만 들고 다닌다는 것이다.
즉, Cookie가 사용자를 전부 기억하는 것이 아니라, Cookie는 서버 Session을 찾기 위한 JSESSIONID를 들고 다닌다. 이 흐름은 "세션 = 서버 보관함, 쿠키 = 번호표를 담는 그릇"으로 이해할 수 있다.
회원가입을 하려면 회원 정보를 저장할 DB가 먼저 필요하다.
이번 구현에서는 MySQL의 member 데이터베이스에 연결하고, JPA가 Member 엔티티를 기준으로 members 테이블을 생성하도록 설정했다.

DB 연결 및 members 테이블 생성 확인
Database 탭에서 member 데이터베이스와 members 테이블이 생성된 것을 확인한 화면이다. 아래 콘솔에는 Hibernate가 members 테이블을 생성하는 SQL 로그가 보인다. 이 이미지는 회원 데이터를 저장할 DB 준비가 완료되었음을 보여준다.
이번 API 흐름은 세 장면으로 정리할 수 있다.
[1] 로그인
사용자 name 전송
→ 서버가 회원 존재 여부 확인
→ SessionMember 생성
→ session.setAttribute("loginMember", sessionMember)
[2] 인증 확인
PATCH /members 요청
→ @SessionAttribute로 loginMember 조회
→ 없으면 401 Unauthorized
[3] 닉네임 수정
SessionMember에서 id 꺼냄
→ 해당 id의 Member 조회
→ nickname 수정
→ DB 반영
이 구조의 핵심은 수정 대상 memberId를 클라이언트가 보내지 않는 것이다.
사용자가 URL이나 Body에 memberId를 보내면 조작할 수 있다. 그래서 수정할 사용자는 클라이언트가 주장하는 값이 아니라, 서버 Session에 저장된 로그인 정보로 결정해야 한다.
세션 흐름은 “로그인 → 세션 저장, 인증 확인 → 세션 조회, 활용 → sessionMember id 사용”으로 정리했다.
| 코드 / 설계 포인트 | 위치 | 이유 |
|---|---|---|
session.setAttribute("loginMember", sessionMember) | Controller | HttpSession은 HTTP/Web 기술이므로 웹 계층에서 처리한다. |
| 회원 존재 검증 | Service | “이 회원이 존재하는가?”는 업무 규칙이므로 Service가 담당한다. |
Member → SessionMember 변환 | Service | Entity를 Session에 그대로 넣지 않고 필요한 최소 정보만 DTO로 담는다. |
@SessionAttribute + null 체크 | Controller | 로그인 여부를 Session에서 확인하고, 없으면 401 Unauthorized를 반환한다. |
URL에서 {memberId} 제거 | Controller / API 설계 | 클라이언트가 보낸 id를 믿지 않고 Session의 사용자 id를 사용한다. |
updateNickname() 사용 | Entity | setter 대신 의도가 드러나는 메서드로 상태를 변경한다. |
| 랜덤 닉네임 생성 | Entity 생성자 | “가입 시 nickname 랜덤 생성”은 Member 객체의 생성 규칙이다. |
@Transactional + save() 없음 | Service | JPA 변경 감지가 커밋 시점에 UPDATE SQL을 자동 실행한다. |
Controller는 손님을 맞는 홀 직원, Service는 실제 업무를 처리하는 주방에 가깝다.
회원 조회와 검증은 Service가 담당하고, HttpSession처럼 HTTP 요청과 직접 관련된 처리는 Controller에서 담당하는 것이 계층 분리에 맞다.
로그인 API에서는 이름으로 회원을 조회하고, 조회된 회원 정보를 SessionMember DTO로 변환한 뒤 Session에 저장한다.
@PostMapping("/login")
public ResponseEntity<Void> login(
@Valid @RequestBody LoginRequest request,
HttpSession session
) {
SessionMember sessionMember = memberService.login(request);
session.setAttribute("loginMember", sessionMember);
return ResponseEntity.status(HttpStatus.OK).build();
}
여기서 핵심은 이 코드다.
session.setAttribute("loginMember", sessionMember);
이 코드는 서버의 세션 보관함에 로그인한 사용자 정보를 넣는 역할을 한다.
"loginMember"는 세션 안에서 데이터를 꺼낼 때 사용할 이름표다.
Service에서는 DB에서 회원을 찾는다.
@Transactional(readOnly = true)
public SessionMember login(@Valid LoginRequest request) {
Member member = memberRepository.findByName(request.getName()).orElseThrow(
() -> new IllegalStateException("없는 멤버입니다.")
);
return new SessionMember(
member.getId(),
member.getName(),
member.getNickname()
);
}
처음에는 memberRepository.findByName(...).orElseThrow(...)로 회원을 찾기만 하고 결과를 변수에 담지 않아 member.getId()에서 컴파일 에러가 발생했다.
잘못된 흐름은 다음과 같다.
memberRepository.findByName(request.getName()).orElseThrow(
() -> new IllegalStateException("없는 멤버입니다.")
);
return new SessionMember(
member.getId(),
member.getName(),
member.getNickname()
);
orElseThrow()는 값이 있으면 그 값을 반환하고, 없으면 예외를 던지는 메서드다.
따라서 반환값을 반드시 Member member 변수에 담아야 다음 줄에서 사용할 수 있다.
DB에서 조회한 객체는 Member Entity다.
하지만 Session에는 Member를 그대로 넣지 않고 SessionMember DTO를 넣었다.
public class SessionMember {
private final Long id;
private final String name;
private final String nickname;
}
그 이유는 다음과 같다.
| 이유 | 설명 |
|---|---|
| 최소 정보 저장 | Session은 서버 메모리를 사용하므로 필요한 정보만 저장하는 것이 좋다. |
| 보안 | 실제 서비스에서는 Entity에 비밀번호, 주소, 전화번호 같은 민감 정보가 들어갈 수 있다. |
| 역할 분리 | Entity는 DB와 연결되는 객체이고, Session에는 로그인 식별용 DTO를 저장하는 것이 더 명확하다. |
| 안정성 | Entity를 오래 보관하면 DB의 최신 상태와 Session 속 데이터가 어긋날 수 있다. |
실무에서는 Controller, Session, Response 같은 바깥 경계로 Entity를 그대로 내보내기보다 DTO로 변환하는 방식을 주로 사용한다.
SessionMember를 사용하는 이유 :
“세션에는 최소 정보만 넣고, Entity를 그대로 밖으로 내보내지 않기 위한 구조”
닉네임 수정 API는 로그인한 사용자만 사용할 수 있어야 한다.
따라서 로그인하지 않고 PATCH /members를 요청하면 401 Unauthorized가 나와야 정상이다.

로그인 전 닉네인 수정 요청 실패
로그인하지 않은 상태에서 PATCH /members 요청을 보냈을 때 401 Unauthorized가 반환된 화면이다. 이는 Session에 로그인 정보가 없으면 닉네임 수정 API가 요청을 거절한다는 것을 보여준다. 단순한 실패가 아니라 Session 기반 인증 방어가 정상 작동했다는 증거다.
이 처리는 Controller에서 한다.
@PatchMapping("/members")
public ResponseEntity<UpdateMemberResponse> updateMemberNickname(
@SessionAttribute(name = "loginMember", required = false)
SessionMember sessionMember,
@RequestBody UpdateMemberRequest request
) {
if (sessionMember == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.status(HttpStatus.OK)
.body(memberService.updateNickname(sessionMember.getId(), request));
}
reauired = false를 사용한 이유는 세션에 loginMember가 없을 때 직접 401 Unauthorized를 반환하기 위해서다.
이렇게 하면 "요청 형식이 잘못됌"이 아니라 로그인이 필요함이라는 의미를 더 정확하게 전달할 수 있다.
기존 방식이 /members/{memberId} 였다면 사용자가 URL에 다른 사람의 id를 넣어 요청할 수 있다.
PATCH /members/2
이런 구조는 "내가 누구인지"를 클라이언트가 주장하는 방식이다.
하지만 클라이언트가 보낸 값은 조작될 수 있다.
Session 적용 후에는 다음처럼 요청한다.
PATCH /members
그리고 서버 내부에서 Session에 저장된 id를 사용한다.
memberService.updateNickname(sessionMember.getId(),, request);
이 구조의 의미는 다음과 같다.
| 기존 방식 | Session 적용 방식 |
|---|---|
클라이언트가 memberId를 보냄 | 서버가 Session에서 id를 꺼냄 |
| URL 조작 위험이 있음 | 서버가 로그인 사용자를 기준으로 처리함 |
| “이 사람을 수정해줘”라고 요청자가 주장함 | “현재 로그인한 사람을 수정한다”로 고정됨 |
닉네임 수정은 "내 닉네임 수정" 기능이다.
따라서 수정 대상은 요청자가 보내는 id가 아니라, 서버가 Session으로 확인한 로그인 사용자여야 한다.
로그인 요청이 성공하면 서버는 Session을 생성하고, 클라이언트에게 JSESSIONID 쿠키를 전달한다.

로그인 API 성공
POST /login 요청에 { "name": "송콩떡" }를 보내고 200 OK 응답을 받은 화면이다. 이 요청이 성공하면서 서버는 로그인 사용자를 Session에 저장한다.

Postman Cookies 탭의 JSESSIONID 확인
Postman Cookies 탭에서 localhost 도메인에 JSESSIONID 쿠키가 저장된 것을 확인한 화면이다.JSESSIONID=...; Path=/; HttpOnly;가 보이므로, 로그인 성공 후 서버가 세션 ID를 쿠키로 전달했고 Postman이 이를 저장했음을 알 수 있다.
JSESSIONID가 있다는 것은 다음 흐름이 가능하다는 뜻이다.
로그인 성공
→ 서버가 Session 생성
→ 클라이언트에게 JSESSIONID 전달
→ Postman이 Cookie로 저장
→ 다음 요청에서 Cookie 자동 전송
→ 서버가 같은 사용자로 인식
로그인 후 같은 PATCH /members 요청을 보내면 결과가 달라진다.
이번에는 Postman이 JSESSIONID 쿠키를 함께 보내고, 서버는 Session에서 loginMember를 찾을 수 있다.

로그인 후 닉네임 수정 성공
로그인 후 PATCH /members 요청으로 nickname을 "콩떡공주"로 변경한 화면이다. 응답은 200 OK이며, Body에는 { "id": 1, "nickname": "콩떡공주" }가 반환된다. 이는 Session에서 로그인 사용자를 확인한 뒤 해당 사용자의 nickname이 수정되었음을 보여준다.
이 테스트에서 중요한 점은 요청 URL에 memberId가 없다는 것이다.
PATCH localhost:8080/members
수정할 사용자는 클라이언트가 직접 지정하지 않고, 서버가 Session에서 꺼낸다.
마지막으로 GET /members를 호출하여 수정된 nickname이 실제로 조회되는지 확인했다.

변경된 닉네임 최종 조회
GET /members 요청 결과 id가 1인 회원의 nickname이"콩떡공주"로 조회되는 화면이다. 이는 닉네임 수정 결과가 응답에만 보인 것이 아니라 DB에 반영되었음을 확인하는 증거다.
테스트 흐름을 정리하면 다음과 같다.
로그인 전 PATCH /members
→ 401 Unauthorized
POST /login
→ 200 OK
→ JSESSIONID 쿠키 저장
로그인 후 PATCH /members
→ 200 OK
→ nickname 변경
GET /members
→ 변경된 nickname 조회
닉네임 수정 Service에는 memberRepository.save(member)가 없다.
@Transactional
public UpdateMemberResponse updateNickname(Long memberId, UpdateMemberRequest request) {
Member member = memberRepository.findById(memberId).orElseThrow(
() -> new IllegalStateException("없는 멤버입니다.")
);
member.updateNickname(request.getNickname());
return new UpdateMemberResponse(
member.getId(),
member.getNickname()
);
}
그런데도 DB 값이 바뀐다. 이유는 JPA의 변경 감지(Dirty Checking) 때문이다.
@Transactional 시작
→ Member 조회
→ JPA가 Member를 관리 상태로 둠
→ member.updateNickname()으로 값 변경
→ 트랜잭션 종료 시점에 변경 감지
→ UPDATE SQL 자동 실행
@Transactional 안에서 조회한 Entity는 JPA가 관리하는 상태가 된다.
이 상태에서 필드 값이 변경되면 트랜잭션 커밋 시점에 JPA가 변경을 감지하고 UPDATE SQL을 실행한다.
회원가입 시 nickname은 랜덤으로 생성된다.
public Member(String name) {
this.name = name;
this.nickname = UUID.randomUUID().toString();
}
이 규칙을 Controller나 Service에 흩어놓지 않고 Member 생성자 안에 둔 이유는 단순하다.
Member가 만들어질 때 nickname이 랜덤이어야 한다면, 그 규칙은 Member 안에 있는 것이 가장 자연스럽다.
닉네임 수정도 setter 대신 의미 있는 메서드로 처리했다.
public void updateNickname(String nickname) {
this.nickname = nickname;
}
setNickname()처럼 아무 곳에서나 값을 바꾸는 메서드보다, updateNickname()처럼 의도가 드러나는 메서드가 더 안전하다.
나중에 금지어 검사나 길이 제한이 필요해지면 이 메서드 안에 규칙을 추가할 수 있다. 이는 캡슐화, 즉 데이터를 숨기고 의미 있는 행동만 공개하는 방식과 연결된다.
POST /login 요청에서 가입되지 않은 이름을 보내면 서버에서 회원 조회에 실패한다.
MemberService.login()은 findByName()으로 회원을 찾고, 없으면 orElseThrow()로 예외를 던지기 때문이다.
따라서 테스트 순서는 반드시 다음과 같아야 한다.
회원가입
→ 로그인
→ 쿠키 확인
→ 닉네임 수정
→ 멤버 조회
로그인할 때는 다음 이름으로 저장했다.
session.setAttribute("loginMember", sessionMember);
닉네임 수정에서는 같은 이름으로 꺼냈다.
@SessionAttribute(name = "loginMember", required = false)
저장한 이름과 꺼내는 이름이 다르면 sessionMember가 null이 된다.
Session은 보관함이고, "loginMember"는 그 보관함 안의 물건 이름표라고 볼 수 있다.
처음에 401 Unauthorized가 나오면 무조건 에러처럼 보일 수 있다.
하지만 로그인하지 않은 사용자가 닉네임을 수정하려고 했을 때 401이 나오는 것은 정상이다.
Session에 loginMember 없음
→ 로그인하지 않은 사용자
→ 닉네임 수정 불가
→ 401 Unauthorized
즉, 이 응답은 API가 잘못된 것이 아니라 로그인한 사용자만 수정할 수 있도록 막는 로직이 작동했다는 증거다.
이번 구현의 핵심은 “로그인한 사용자를 서버가 어떻게 기억하는가”였다. HTTP는 기본적으로 요청 간 상태를 기억하지 않기 때문에, 로그인 이후의 요청에서 사용자를 식별하려면 Session이 필요하다.
로그인 성공 시
1. 서버는 사용자 정보를 Session에 저장하고,
2. 클라이언트는 JSESSIONID 쿠키를 보관한다.
3. 이후 요청에서 이 쿠키가 함께 전달되면 서버는 Session에서 로그인 사용자를 확인할 수 있다.
특히 닉네임 수정 API에서 {memberId}를 URL로 받지 않고, Session에 저장된 sessionMember.getId()를 사용한 점이 중요했다. 클라이언트가 보낸 id를 믿는 것이 아니라, 서버가 관리하는 로그인 정보를 기준으로 수정 대상을 결정했기 때문이다.
또한 로그인하지 않은 요청이 401 Unauthorized로 거절되고, 로그인 후 같은 요청이 200 OK로 성공하는 과정을 Postman으로 확인하면서 Session 기반 인증 흐름을 실제로 검증할 수 있었다.