HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
🔒
Spring Boot — Spring Security Essentials
/
7️⃣
07. Spring Security, Spring Session
07. Spring Security, Spring Session
7️⃣

07. Spring Security, Spring Session

github.com
https://github.com/prgrms-be-devcourse/w16-SpringBoot_Part_C/tree/class_7

지난 미션 리뷰

JdbcDaoImpl 고급 설정 부분(Group-based Access Control 적용)을 JPA로 구현해보기 (com.prgrms.devcourse.user 패키지 아래에 생성)
  • 구현 클래스 목록
    • Entity 클래스
      • User 클래스 — users 테이블
      • Group 클래스 — groups 테이블
      • Permission 클래스 — permissions 테이블
      • GroupPermission 클래스 — group_permission 테이블 (다대다 매핑을 위한 교차 테이블)
    • UserRepository 클래스 (JPA 기반)
    • UserService 클래스 — UserDetailsService 인터페이스 구현체 (JdbcDaoImpl를 대체해야함)
  • 단, schema_new.sql, data_new.sql 쿼리 파일은 그대로 사용함
솔루션 (참, 쉽죠?)
먼저, schema_new.sql 쿼리 파일에 정의된 테이블의 관계를 파악
  • user의 login_id 컬럼은 Unique 속성이므로 중복 없음
  • user와 group은 일대다 관계이며, user는 반드시 1개의 group에 소속됨
  • group과 permission은 다대다 관계이며, group_permission 이라는 교차 테이블이 있음
    • group은 group_permission 과 일대다 관계로 해석
    • permission과 group_permission 과 일대다 관계로 해석
    • (group_id, permission_id) 쌍은 Unique 속성이므로 중복 없음
users, groups, permissions, group_permission 테이블에 대한 엔티티 클래스를 생성 (엔티티명은 순서대로 User, Group, Permission, GroupPermission 이라 하자)
  • User 엔티티와 Group 엔티티를 @ManyToOne 어노테이션으로 매핑
  • GroupPermission 엔티티와 Group엔티티, Permission 엔티티를 각각 @ManyToOne 어노테이션으로 매핑
  • Group 엔티티와 GroupPermission 엔티티를 @OneToMany 어노테이션으로 매핑
  • 엔티티 매핑을 마치고 나면, User → Group → List<GroupPermission> → Permission 참조가 가능
@Entity @Table(name = "groups") public class Group { // ... 생략 ... @OneToMany(mappedBy = "group") private List<GroupPermission> permissions = new ArrayList<>(); public List<GrantedAuthority> getAuthorities() { return permissions.stream() .map(gp -> new SimpleGrantedAuthority(gp.getPermission().getName())) .collect(toList()); } // ... 생략 ... }
GroupPermission 컬렉션을 GrantedAuthority 컬렉션으로 변환하는 메소드 예시
UserRepository 인터페이스 추가
  • loginId 필드를 통해 단 건 User를 조회할 수 있음 (반환타입이 Optional 인것에 주의)
public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByLoginId(String loginId); }
UserDetailsService 인터페이스 구현체 UserService 클래스 추가
  • UserDetailsService 인터페이스의 loadUserByUsername 메소드를 구현해야함
  • org.springframework.security.core.userdetails.User.UserBuilder를 이용해 반환 객체 UserDetails을 생성
  • 주어진 loginId로 사용자 조회가 되지 않는다면 예외 처리함
@Service public class UserService implements UserDetailsService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } @Override @Transactional(readOnly = true) public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return userRepository.findByLoginId(username) .map(user -> User.builder() .username(user.getLoginId()) .password(user.getPasswd()) .authorities(user.getGroup().getAuthorities()) .build() ) .orElseThrow(() -> new UsernameNotFoundException("Could not found user for " + username)); } }
  • configure(AuthenticationManagerBuilder auth) 메소드 override
    • 스프링 시큐리티에서 UserService 객체를 UserDetailsService 인터페이스 구현체로 사용할 수 있도록 등록
@Configuration @EnableWebSecurity public class WebSecurityConfigure extends WebSecurityConfigurerAdapter { private final Logger log = LoggerFactory.getLogger(getClass()); private UserService userService; @Autowired private void setUserService(UserService userService) { this.userService = userService; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userService); } // ... 생략 ... }
최적화
  • 인증 처리 과정에서 실행되는 SQL 쿼리를 잘 살펴보면 생각보다 많은 수의 SQL 쿼리가 실행 됨 (admin 으로 로그인)
    • users 테이블에서 user 조회 SQL 쿼리
    • 조회된 사용자의 group 조회 SQL 쿼리
    • group에 관계되 있는 permission 조회 SQL 쿼리
    • notion image
  • users 테이블에서 사용자 조회시 group을 inner join으로 함께 가져와 보기
    • SQL 쿼리 실행 횟수가 세 번에서 두 번으로 줄어듬
    • users, group 테이블에서 user, group 조회 SQL 쿼리
    • group에 관계되 있는 permission 조회 SQL 쿼리
    • public interface UserRepository extends JpaRepository<User, Long> { @EntityGraph(attributePaths = "group") Optional<User> findByLoginId(String loginId); }
      notion image
  • 모든 것을 한 번에!
    • fetch join 기능을 활용해 필요한 모든 데이터를 한번에 가져올수 있도록 SQL 쿼리를 최적화
    • users, groups 테이블 조인 — INNER JOIN
    • groups, group_permission 테이블 조인 — LEFT OUTER JOIN
    • group_permission, permissions 테이블 조인 — INNER JOIN
    • public interface UserRepository extends JpaRepository<User, Long> { @Query("select u from User u join fetch u.group g left join fetch g.permissions gp join fetch gp.permission where u.loginId = :loginId") Optional<User> findByLoginId(String loginId); }
      notion image

3-Tier Architecture, HTTP Session 그리고 Session Cluster

3-Tier Architecture
  • 가장 보편적이고 이해하기 쉬운 아키텍처
    • 프레젠테이션 레이어 — 사용자와의 접점 제공
    • 애플리케이션 레이어 — 트랜잭션 처리를 위한 비즈니스 로직 제공
    • 데이터 레이어 — 데이터를 저장하고 조회하는 기능 제공
  • 그 외 많은 장점들
    • 프론트엔드, 백엔드 엔지니어 역할 분리에 따른 업무 효율화
    • 각 계층을 모듈화해 다른 계층에 미치는 영향을 최소화하며 확장이 용이함
    • https://www.jinfonet.com/resources/bi-defined/3-tier-architecture-complete-overview/
  • 서비스 이용자가 매우 빠르게 증가하고 있는 상황에서 백엔드 개발자가 당장 할 수 있는 일
    • 애플리케이션 레이어의 서버를 수평 확장 (Scale-Out)
    • 그리고 서비스 앞단에 로드 밸랜서를 배치하여 트래픽을 분산함
    • notion image
  • 하지만 추가적으로 고려해야 하는 것 — 특정 서버에서 장애가 발생하면 어떤일이 벌어질까?
    • 서비스 가용성 측면에서는 문제가 없음 그러나...
    • 사용자 인증 처리에서 Session을 사용했고, 그 외 특별한 조치가 없었다면 일부 사용자는 문제가 될 수 있음
    • 장애가 발생한 서버에서 인증된 사용자는 인증이 풀리게 되고 다시 인증해야 함 → 결코 바람직한 사용자 경험이 아님
HTTP와 Session
  • 근본적으로 HTTP는 무상태 프로토콜이고 어떤 정보도 저장하지 않음
    • 서버는 인증된 사용자 정보를 저장하기 위해 Session을 만들고, 식별자인 session-id를 클라이언트로 응답함
      • 클라이언트가 웹 브라우저인 경우 session-id는 보통 Cookie 에 저장됨
    • 클라이언트는 HTTP 요청에 session-id를 포함시켜, 서버가 클라이언트를 식별할 수 있도록 해야함
    • Session은 서버 메모리를 사용하기 때문에 너무 많아질 경우 서버 메모리 부족이 발생할 수 있음
    • 서버 장애시 복제본이 없는 Session 정보는 유실 됨
    https://cscie12.dce.harvard.edu/lecture_notes/2007-08/20080423/slide51.html
    Session Cluster
    • Session 기반 인증 처리의 문제점이 Session이 서버 메모리에 저장되는 것이라면, Session을 별도의 외부 스토리지에 저장한다는 개념
    • 외부 스토리지는 조회 속도를 위해 보통 In-Memory 데이터베이스를 많이 사용함
    • 특정 서버에 문제가 생겨도 다른 정상적인 서버에서 Session을 외부 스토리지에서 가져올 수 있으므로 사용자 인증이 풀리지 않음
    • Sticky Connection(동일한 사용자가 발생시킨 요청은 동일한 WAS에서 처리됨을 보장) 제약에서 자유로움
    https://ignite.apache.org/use-cases/caching/web-session-clustering.html
    • 단연히 단점도 있음
      • Session을 저장하기 위한 별도의 외부 스토리지가 필요 (관리 포인트 증가)
      • 외부 스토리지 장애 발생 시 대규모 장애 발생 가능성이 커짐
        • Session 클러스터를 위한 외부 스토리지가 SPOF 지점이 되는것을 방지하기 위해 외부 스토리지는 보통 클러스터로 구성됨

    Spring Session

    Spring Session 프로젝트는 Spring Boot 웹 어플리케이션에서 Session Cluster를 구현하는데 다양한 기능을 제공한다. 특히 Session을 저장하기 위한 외부 스토리지를 추상화함으로써 일관된 API로 JDBC, Redis, Hazelcast 등 다양한 스토리지를 활용할 수 있다.
    Spring Session
    Spring Session makes it trivial to support clustered sessions without being tied to an application container specific solution. It also provides transparent integration with: HttpSession - allows replacing the HttpSession in an application container (i.e.
    Spring Session
    https://spring.io/projects/spring-session
    Spring Session 의존성 추가 및 설정
    • spring-session-jdbc — jdbc 기반 spring session 모듈 (본 예제에서는 spring-session-jdbc를 사용함)
      • <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-jdbc</artifactId> </dependency>
        💡
        spring-session-jdbc 외에 스토리지 종류에 따라 spring-session-data-redis, spring-session-hazelcast, spring-session-data-mongodb 등의 모듈이 있다.
    • H2 운영 모드 변경 및 Spring Session 관련 테이블 초기화
      • 지금까지는 H2 데이터베이스를 In-Memory 모드로만 사용했지만, 본 예제에서는 파일 모드로 사용함
        • datasource.url 부분을 수정 — jdbc:h2:file:./database/spring_security.db;MODE=MYSQL;DB_CLOSE_DELAY=-1
        • 기존 h2:mem 부분이 h2:file:파일경로.db 형태로 변경됨
      • sql.init.mode 부분을 always로 변경
        • 기존에는 embedded (기본값) 이였으며, H2가 In-Memory 모드일 경우 해당됨
        • H2를 파일 기반 모드로 변경하면 embedded 에 해당하지 않으므로, 항상 초기화를 의미하는 always로 변경이 필요함
      • spring-session-jdbc는 session 정보를 저장하기 위해 2개의 테이블을 사용함
        • sql.init.schema-locations 부분에 spring-session-jdbc에서 사용하는 테이블 생성 SQL 쿼리 파일을 지정
        • 테이블을 추가하기 위한 SQL 쿼리는 spring-session-jdbc jar 내부에 위치함
        datasource: driver-class-name: org.h2.Driver url: "jdbc:h2:file:./database/spring_security.db;MODE=MYSQL;DB_CLOSE_DELAY=-1" username: sa password: hikari: minimum-idle: 1 maximum-pool-size: 5 pool-name: H2_DB sql: init: platform: h2 mode: always schema-locations: classpath:sql/schema_new.sql, classpath:org/springframework/session/jdbc/schema-h2.sql data-locations: classpath:sql/data_new.sql encoding: UTF-8
    • Spring Session 관련 설정
      • session.store-type 부분에 jdbc 를 입력함
        • jdbc 외에 입력 가능한 값은 redis, mongodb, hazelcast 등 이 있음
      • session.jdbc.initialize-schema 부분에 never를 입력함
        • sql.init.schema-locations 부분에 spring-session-jdbc에서 사용하는 테이블 생성 SQL 쿼리 파일을 지정했기 때문에 사용하지 않음
        session: store-type: jdbc jdbc: initialize-schema: never
      • @EnableJdbcHttpSession 어노테이션을 추가하여, jdbc 기반 spring session을 활성화
      • 💡
        org.springframework.session.SessionRepository와 org.springframework.session.web.http.SessionRepositoryFilter 2개의 클래스는 Spring Session에서 가장 핵심적인 역할을 수행한다. 이들 클래스의 Bean 설정은 Spring Session Jdbc의 경우 JdbcHttpSessionConfiguration 클래스에서 처리된다.
    • 서비스 기동 및 데이터 확인
      • 서비스를 시작하면, 콘솔 출력 창을 통해 SPRING_SESSION, SPRING_SESSION_ATTRIBUTES 테이블이 생성되는 것을 확인할 수 있음
      • 로그인 후 h2-console에서 SPRING_SESSION, SPRING_SESSION_ATTRIBUTES 테이블 조회해보면, row가 입력되 있는것을 확인할 수 있음
        • session_id — 사용자의 Session을 식별하기 위한 고유 Key
        • 브라우저 Cookie를 확인해보면 session_id 값이 Base64 인코딩된 형태로 저장되어 있음
        로그인 후 SPRING_SESSION 테이블 조회 결과
        로그인 후 SPRING_SESSION 테이블 조회 결과
        로그인 후 SPRING_SESSION_ATTRIBUTES 테이블 조회 결과
        로그인 후 SPRING_SESSION_ATTRIBUTES 테이블 조회 결과
    Spring Session 상세 분석
    • Session Cluster가 제대로 동작하는지 시뮬레이션 해보기 위해 아래 절차대로 진행
      • 최초 서비스 시작 및 정상 로그인 (브라우저는 종료하지 않음)
      • 서비스를 종료함 — H2를 파일 모드로 설정했기 때문에 지정된 경로에 db 파일이 생성됨 (jdbc url 경로에 따라 파일 경로는 상이할 수 있음)
        • 서비스를 종료하는 것은 WAS에 장애가 발생하고, 다른 WAS에서 요청을 처리하게 되는 것을 시뮬레이션하기 위함
      • sql.init.mode를 never로 변경하고 서비스를 시작 — 서비스 시작시 테이블이 중복 초기화 되는 것을 막기 위함
        • Spring Securir에서 Cookie 설정을 5분으로 했기 때문에 Cookie가 만료되기 전 재시작해야 함 — 5분 이내
      • 최초 정상 로그인을 수행했던 브라우저로 https://localhost/me 페이지 접근 — 정상 접근됨
        • 이것은 최초 로그인을 수행했던 WAS가 아닌 다른 WAS에서 Session Cluster 기능을 통해 정상적으로 인증을 처리할 수 있음을 의미함
    • Spring Session의 핵심 — SessionRepository 그리고 SessionRepositoryFilter
      • 💡
        Spring Session provides transparent integration with HttpSession. This means that developers can switch the HttpSession implementation out with an implementation that is backed by Spring Session.
      • SessionRepository
        • Session의 생성, 저장, 조회, 삭제 처리에 대한 책임
        • 스토리지 종류에 따라 다양한 구현체를 제공함
          • MapSessionRepository — In-Memory Map기반이며, 별도의 의존 라이브러리 필요 없음
          • RedisIndexedSessionRepository — redis 기반이며, @EnableRedisHttpSession 어노테이션으로 생성됨
          • JdbcIndexedSessionRepository — jdbc 기반이며, @EnableJdbcHttpSession 어노테이션으로 생성됨
          public interface SessionRepository<S extends Session> { S createSession(); void save(S session); S findById(String id); void deleteById(String id); }
          SessionRepository 구현 일부 발췌
      • SessionRepositoryFilter
        • 모든 HTTP 요청에 대해 동작함
        • HttpServletRequest, HttpServletResponse 인터페이스 구현을 SessionRepositoryRequestWrapper, SessionRepositoryResponseWrapper 구현체로 교체함
        • HttpServletRequest, HttpServletResponse 인터페이스의 Session 처리와 관련한 처리를 Override
          • Session 관련 생성 및 입출력은 SessionRepository 인터페이스를 통해 처리함
          • HttpSession 인터페이스에 대해 Spring Session 구현체 HttpSessionWrapper를 사용하도록 함
          • HttpSessionWrapper 구현체는 org.springframework.session.Session 인터페이스를 포함하고 있음
            • 스토리지 종류에 따라 org.springframework.session.Session 인터페이스 구현체가 달라짐
          https://docs.spring.io/spring-boot-data-geode-build/1.1.x/reference/html5/guides/caching-http-session.html — Session 스토리지가 GemFire 이지만, 아키텍처는 스토리지 종류에 관계없이 모두 동일함
    Spring Security, Spring Session
    • Spring Session의 SessionRepositoryFilter 클래스는 Spring Security의 DelegatingFilterProxy 보다 먼저 실행됨
    • Spring Security의 SecurityContextPersistenceFilter는 SecurityContextRepository 인터페이스 구현체를 통해 사용자의 SecurityContext를 가져오거나 갱신함
      • SecurityContextRepository 인터페이스 기본 구현은 Session을 이용하는 HttpSessionSecurityContextRepository 클래스
      • HttpServletRequest 인터페이스의 getSession() 메소드를 통해 Session을 가져옴
        • 바로 이 지점에서 HttpServletRequest 인터페이스의 스프링 세션 구현체인 SessionRepositoryRequestWrapper 클래스가 사용됨
        @Override public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) { HttpServletRequest request = requestResponseHolder.getRequest(); HttpServletResponse response = requestResponseHolder.getResponse(); HttpSession httpSession = request.getSession(false); SecurityContext context = readSecurityContextFromSession(httpSession); if (context == null) { context = generateNewContext(); if (this.logger.isTraceEnabled()) { this.logger.trace(LogMessage.format("Created %s", context)); } } SaveToSessionResponseWrapper wrappedResponse = new SaveToSessionResponseWrapper(response, request, httpSession != null, context); requestResponseHolder.setResponse(wrappedResponse); requestResponseHolder.setRequest(new SaveToSessionRequestWrapper(request, wrappedResponse)); return context; }
        HttpSessionSecurityContextRepository 구현 일부 발췌
    • 결과적으로, Spring Security는 아무것도 수정할 필요가 없음 — Spring Session은 HttpSession과의 투명한 통합(transparent integration)을 제공함