지난 미션 리뷰
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()); } // ... 생략 ... }
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 쿼리

- 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); }

- 모든 것을 한 번에!
- 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); }

3-Tier Architecture, HTTP Session 그리고 Session Cluster
3-Tier Architecture
- 가장 보편적이고 이해하기 쉬운 아키텍처
- 프레젠테이션 레이어 — 사용자와의 접점 제공
- 애플리케이션 레이어 — 트랜잭션 처리를 위한 비즈니스 로직 제공
- 데이터 레이어 — 데이터를 저장하고 조회하는 기능 제공
- 서비스 이용자가 매우 빠르게 증가하고 있는 상황에서 백엔드 개발자가 당장 할 수 있는 일
- 애플리케이션 레이어의 서버를 수평 확장 (Scale-Out)
- 그리고 서비스 앞단에 로드 밸랜서를 배치하여 트래픽을 분산함

- 하지만 추가적으로 고려해야 하는 것 — 특정 서버에서 장애가 발생하면 어떤일이 벌어질까?
- 서비스 가용성 측면에서는 문제가 없음 그러나...
- 사용자 인증 처리에서 Session을 사용했고, 그 외 특별한 조치가 없었다면 일부 사용자는 문제가 될 수 있음
- 장애가 발생한 서버에서 인증된 사용자는 인증이 풀리게 되고 다시 인증해야 함 → 결코 바람직한 사용자 경험이 아님
HTTP와 Session
Session Cluster
- Session 기반 인증 처리의 문제점이 Session이 서버 메모리에 저장되는 것이라면, Session을 별도의 외부 스토리지에 저장한다는 개념
- 외부 스토리지는 조회 속도를 위해 보통 In-Memory 데이터베이스를 많이 사용함
- 특정 서버에 문제가 생겨도 다른 정상적인 서버에서 Session을 외부 스토리지에서 가져올 수 있으므로 사용자 인증이 풀리지 않음
- Sticky Connection(동일한 사용자가 발생시킨 요청은 동일한 WAS에서 처리됨을 보장) 제약에서 자유로움

- 단연히 단점도 있음
- Session을 저장하기 위한 별도의 외부 스토리지가 필요 (관리 포인트 증가)
- 외부 스토리지 장애 발생 시 대규모 장애 발생 가능성이 커짐
- Session 클러스터를 위한 외부 스토리지가 SPOF 지점이 되는것을 방지하기 위해 외부 스토리지는 보통 클러스터로 구성됨
Spring Session
Spring Session 프로젝트는 Spring Boot 웹 어플리케이션에서 Session Cluster를 구현하는데 다양한 기능을 제공한다. 특히 Session을 저장하기 위한 외부 스토리지를 추상화함으로써 일관된 API로 JDBC, Redis, Hazelcast 등 다양한 스토리지를 활용할 수 있다.
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
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 상세 분석
- 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
- SessionRepository
- Session의 생성, 저장, 조회, 삭제 처리에 대한 책임
- 스토리지 종류에 따라 다양한 구현체를 제공함
- MapSessionRepository — In-Memory Map기반이며, 별도의 의존 라이브러리 필요 없음
- RedisIndexedSessionRepository — redis 기반이며, @EnableRedisHttpSession 어노테이션으로 생성됨
- JdbcIndexedSessionRepository — jdbc 기반이며, @EnableJdbcHttpSession 어노테이션으로 생성됨
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.
public interface SessionRepository<S extends Session> { S createSession(); void save(S session); S findById(String id); void deleteById(String id); }
- 모든 HTTP 요청에 대해 동작함
- HttpServletRequest, HttpServletResponse 인터페이스 구현을 SessionRepositoryRequestWrapper, SessionRepositoryResponseWrapper 구현체로 교체함
- HttpServletRequest, HttpServletResponse 인터페이스의 Session 처리와 관련한 처리를 Override
- Session 관련 생성 및 입출력은 SessionRepository 인터페이스를 통해 처리함
- HttpSession 인터페이스에 대해 Spring Session 구현체 HttpSessionWrapper를 사용하도록 함
- HttpSessionWrapper 구현체는 org.springframework.session.Session 인터페이스를 포함하고 있음
- 스토리지 종류에 따라 org.springframework.session.Session 인터페이스 구현체가 달라짐

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; }
- 결과적으로, Spring Security는 아무것도 수정할 필요가 없음 — Spring Session은 HttpSession과의 투명한 통합(transparent integration)을 제공함