HTTP Session
HTTP로 일반적인 API를 제공하는 업무가 아니라 사용자와의 상호작용하는 웹 서비스를 제공하다보면 현재 사용자와 상호작용된 결과 중 일부를 서버사이드에 저장해둘 필요가 생긴다. 이때 반영구적으로 저장하려면 데이터베이스나 파일을 활용할 수 있고, 사용자와의 상호작용이 중단되기 전까지만 관리되면 되는 상태 자료를 세션(Session)이라는 공간에 저장하게 된다.
이러한 세션을 활용하면 생성된 세션마다 별도의 공간이 할당되며, 할당된 공간에는 변수를 포함한 해당 상호작용의 상태값을 저장해둘 수 있어 상태(state)가 존재하기에 상태 관리형(Stateful) 연결이 구성된다. 이에 반대로는 상태 무관형(Stateless) 연결이 구성된다. 전자의 경우, 동일한 Request를 보내더라도 세션 상태에서 따라서 다른 결과를 낳을 수 있으므로 경우에 따라선 멱등성(Idempotency)[1]이 보장되지 않을 수 있다.[2]
일반적으로 HTTP 세션은 쿠키를 사용하여 구분하며, HttpOnly
설정을 통하여 쿠키를 좀 더 안전한 저장소에 저장하고 Javascript를 통하여 세션 쿠키가 무엇인지 확인하게 어렵게 만들 수 있다.
Spring Boot와 spring-session-jdbc
Spring/Spring Boot의 경우 SessionRepository
인터페이스를 직접 구현하여 원하는 세션 저장소 구현체를 만들 수도 있으나 보통은 Out-of-box로, 즉 바로 설정해서 쓸 수 있는 형태로 이미 준비된 세션 구현을 선택하게 된다.
Spring-session의 경우 특정 플랫폼이나 운영체제에 구애받지 않고 독립된 형태로 세션 구현을 적용할 수 있다.[3] 이 중에 운영체제에 정말 영향을 받지 않고, 별도의 서비스나 데몬 설치 없이 이용할 수 있는 세션 구현체로 spring-session-jdbc
가 있다. 이름에서 보다시피, JDBC 연결만 가능하면 이용 가능한 세션 구현으로 JDBC로 이용가능한 데이터베이스만 존재하면 가능하다.
JDBC 드라이버가 존재하는 데이터베이스 나열하면 MariaDB/MySQL, PostgreSQL, Oracle 부터 매우 적은 메모리를 사용하는 H2 같은 구현까지 모두 이용 가능한데 특히 H2는 인메모리(In-memory)로 운영 가능하며 클러스터링 구성시 자동 복제도 지원하기에 세션 서버로 활용해 볼만 하다. 특히 WAS에서 별도로 세션 클러스터링을 제공하지 않는다면 나쁘지 않은 선택이 된다.
다만 모든 걸 해결해줄 것 같은 이 물건에는 큰 문제가 존재한다. HTTP 요청에서 세션 키만 올바르게 전달해주면 한 세션으로 여러 요청을 보낼 수 있다. 그리고 필요에 따라선 한 경로에 여러 요청을 보내야 할 수도 있는데 이 상황에서 오류가 발생된다.
SessionAttributes 저장 시 문제
spring-session 이슈 트래커는 https://github.com/spring-projects/spring-session/issues/1213 링크를 확인한다.
spring-session-jdbc의 SessionAttributes 관리 체계
spring-session에서 JDBC 구현을 선택할 경우, Spring Context가 구동되며 각종 Configuration
들이 처리되는 동안에 JDBC 구현체가 사용할 데이터베이스 스키마를 구성하게 된다. 해당 SQL 코드들은 https://github.com/spring-projects/spring-session/tree/main/spring-session-jdbc/src/main/resources/org/springframework/session/jdbc 에서 확인 가능한데, 스키마 drop(삭제) 코드 이전에 생성 코드를 우선 살펴보자. 현재 내가 운영하는 환경에서는 오라클 데이터베이스를 활용하고 있으므로 오라클 제품군용 쿼리를 살펴보면 아래와 같다.
1 | CREATE TABLE SPRING_SESSION ( |
대략적으로 SPRING_SESSION
과 SPRING_SESSION_ATTRIBUTES
를 운용하겠으며, 세션 ID와 세션 ID+세션 애트리뷰트 조합은 고유하게 저장하겠음을 CONSTRAINT
구문을 통하여 지정하고 있다. 이것은 당연한 조건이다. 세션 ID는 고유해야하며, 예를 들어 A
라는 세션 애트리뷰트가 있을 때 고유하게 나오지 않는다면 spring-session을 어떤 데이터를 세션 데이터로 반환해줄지 모호해지는 상황에 빠진다.
다중 접속시 생길 수 있는 문제
여기까지만 보면 정상적이다. 실제로 잘 만들어져 있다. 그러나 아래 상황을 생각해보자. 매우 극단적인 경우지만, DBMS를 세션 백엔드로 쓰지 않는다면 이 문제가 생기지 않는다. 단, 이때는 high concurrency 환경에서 세션값이 원자적(Atomic)으로 바뀌는지 여부는 고려하지 않았다. 즉, 3번의 접속 후 세션 내 값이 3이 아니라 2가 남아도 세션 저장 중 오류가 나지 않으면 OK임을 염두하고 봐주시길 바란다.
- /pageX 는 접속한 사람의 세션에
count
애트리뷰트 값을 증가시킨다. 없다면 최초의 애트리뷰트를 생성하고 값을 1로 설정한다. 다음 요청부터는 수를 증가시킨다. - /pageY 는 접속과 동시에 /pageX 경로에 동시에 8개 요청을 보낸다.
- A 라는 사용자는 이 사이트에 처음 접속한 사용자로 /index 에서 첫 세션을 부여 받는다..
- A는 /pageY를 열었다. /pageY에 대한 HTML 응답을 받은 후 내부의 JS를 처리한 브라우저는 /pageX에 동시에 8개 요청을 보내야한다는 걸 확인했고 그대로 요청했다.
이때 /pageX 내부에서 httpSession.setAttribute(name, value)
가 호출될 때 spring-session-jdbc는 다음 절차를 통해 세션 속성을 저장한다.
SESSIONID
로 조회 쿼리를 날려 있으면 세션을 반환하고, 없으면 새로 생성한다. (별도로 생성에 제약이 없는 한)httpSession.setAttribute(name, value)
메소드가 호출될 때 항목이 존재하면UPDATE
문으로, 없으면INSERT
문으로 저장 절차를 수행한다.
2번의 후자의 경우에서 큰 문제가 위의 테이블 constraints 와 맞물려서 중대한 문제가 생기게 된다.
/pageX에 만약 매우 빠른 웹브라우저 덕분에 10ms 미만의 간격으로 8개의 요청이 들어갔다면, /pageX는 주어진 동작대로 세션에 count
라는 애트리뷰트 값을 설정하거나 증가시켜 주어야한다. 하지만 최초 접속이라면 8개 요청 모두가 현재 세션에 count
라는 이름으로 세팅된 애트리뷰트가 없다고 판단할 가능성이 높다. 그리고 8개 요청에서 세션 값을 설정하는 전략으로 INSERT
SQL 문을 선택하게 된다.
그러나 이미 spring-session-jdbc 에서 세션을 저장할 테이블을 생성할 때 한 세션 내에 한 애트리뷰트 이름만 존재하도록 constraint를 지정해둔 상태이다. 즉, /pageX 는 극단적인 경우 8개 요청이 모두 1이라고 세팅해달라고 세션 구현에 요청하게 되고, 동일 애트리뷰트 이름으로 8번의 INSERT가 일어나게 된다. DBMS에 제약조건을 걸어둔 덕분에 8번의 INSERT가 일어나진 않지만 두번째부터는 constraint를 위반하였기 때문에 모두 요청에서 SQLException
오류가 발생된다.
왜냐면 세션 애트리뷰트 이름은 중복되어 등록될 수 없기 때문이다.
해결 방향성
이런 환경은 가능하면 생기지 않는 것이 좋으나, 피할 수 없는 경우가 생기곤 한다. spring-session에서 최초에 만들어둔 설계의 의도대로 동작할 때, 오류가 발생되지 않도록 하는 방법은 UPSERT
형태의 쿼리를 작성하는 방법 밖에 없다.
UPSERT
는 공식적인 SQL 구분은 아니나, 조건에 일치되는 행이 있을 땐 UPDATE
를 실행하고 없을 때는 INSERT
를 실행하는 형태로 처리하는 다양한 형태를 지칭할 때 쓰이는 표현이다.
위의 경우에서 8개 요청이 모두 처리된 뒤에 count
의 값이 8보다 작거나(몇몇 요청이 중복되어 사라짐), 1이 나오더라도(요청이 너무 빨리 들어가서 모두 1만 저장됨) SQLException
이 나지 않길 원한다면 위의 전략을 선택하는 것이 최상이다.
해결방법, UPSERT.
SessionRepositoryCustomizer<T>
인터페이스는 spring-session 2.2.0부터 제공된다고 한다. 그 이하에서도 해법은 존재하겠지만 정리는 시간이 될 때 해야할 것 같다.
spring-session 2.2.0 이상 2.5.0 미만을 사용하는 경우:
spring-session GitHub의 issue 1213번 글타래 중 ‘shark300’ 이라는 분이 작성해준 형태로 JdbcIndexedSessionRepository
가 내부에서 세션에 저장하는 쿼리를 UPSERT
가 가능하게 바꾼다. https://github.com/spring-projects/spring-session/issues/1213#issuecomment-699455928
spring-session 2.5.0 이상을 사용하는 경우:
https://github.com/spring-projects/spring-session/pull/1726 이 PR(merged)과 함께 JdbcIndexedSessionRepository
를 커스터마이징할 수 있는 커스터마이저가 추가되었으니 이걸 이용한다. 아래 처럼 Configuration
내에 Bean
을 추가하는 것으로 Spring Context 가동시 호출되어 JdbIndexedSessionRepository
가 내부에서 사용하는 쿼리를 변경해준다. 예를 들어 오라클이라면,
1 |
|
처럼 설정해주는 것으로 충분하다. 2.5.0에서 쓰는 인터페이스/클래스가 2.5.0 미만에도 존재하므로, 2.5.0에서 설정하는 방법을 그대로 따라해서 처리해도 잘 동작한다. 다만 같은 이름으로 해두면 spring boot 버전업 이후에 고통받게 될 것이다.
글을 맺으며
Spring에서 제공되는 컴포넌트들은 매우 잘 만들어져 있다. spring-session-jdbc도 충분히 잘 만들어져있는 구현이지만, 경우에 따라서 이번처럼 예상치 못한 동작을 보여줄 수 있다. 물론 앞에서도 이야기했지만 이런 상황이 가능한 생기지 않도록 세션을 이용하는 것이 좋다.
spring-session 구현체 중에 JDBC 구현을 사용한 경우의 성능 글이나 후기 글이 드물어 글 하나를 보태고자 작성하였는데, 추후에 삽질 시작할 분들에게 도움이 되었으면 한다.
그리고 어느 정도 규모가 있는 곳이라면 세션 구현체 선택에 좀 더 신중한 고민을 하시기를 당부 드리고 싶다. 세션은 공짜가 아니다. 엄연히 서버사이드에 부담을 주고 있는 물건이다.
- 1.Mozilla MDN Web Docs Glossary: Idempotent ↩
- 2.예를 들어 GET /pageX 라는 첫 요청에 처리 중에 세션에 재요청을 막는 값을 설정하면 다음 요청에선 같은 결과를 받을 수 없다. (세션 쿠키가 정상적으로 전송된다면) ↩
- 3.그러나 한계는 존재하는데, 예를 들어 세션 백엔드로 선택 가능한 Redis 는 공식적으론 윈도우즈 운영체제를 지원하지 않는다. (There is no official support for Windows builds.) 비공식 빌드로 돌아가는 물건들은 I/O 스케쥴러나 파일 I/O 성능이 매우 떨어진다. OS에 최적화된 OS API가 아니라 POSIX 지원 범위 내 기본적인 구현만을 이용했기 때문. ↩