개요

저번 글에서 SessionManagement 에 대해서 소개하는 글을 적었습니다. 공식문서에 있는 글을 최대한 줄이고 압축해서 글을 쓰려다보니 설명이 부족한 부분도 많았던 것 같습니다. 그래서 두 파트로 나누었고 이번 글에서는 SessionManagement를 어떤식으로 적용하는지 보여드리도록 하겠습니다.

 

 

 

1. 동시 로그인 차단하기

먼저 동시 세션을 제어해서 하나의 로그인한 허용해보도록 하겠습니다.

저는 최대 1개의 세션만 허용하고 새로운 로그인이 발생하면 이전 세션을 만료시키겠습니다. 그리고 세션이 만료가 되면 "/join?expired" 로 이동시키도록 제어해 보겠습니다.

 

 

UserDetails

세션을 1개로 제한한다는 것은 이전 세션과 비교해서 같은세션인지 확인하는 과정이 있어야 가능합니다. 이전 시간에 UserDetails를 구현한 PrincipalDetails 구현체가 있었습니다. 이 PrincipalDetails 의 equals와 hashCode를 override하겠습니다.

 

여기서 한가지 알아야할 것은 PrincipalDetails 객체 자체를 비교하는것은 중요하지 않습니다. 그 안에 있는 User를 비교하는 것이 더 중요합니다. User의 기본키는 교유하기 때문입니다.

 

먼저 User 객체의 equals와 hashCode를 override 하겠습니다.

 

@Getter @Setter
@EqualsAndHashCode(of = "username")
public class User {

    private String username;
    private String password;
    private String role;
    private String name;
}

저는 간단한 개발환경을 위해서 기본키값을 username으로 했지만 실제 DB를 사용하는 환경에서는 id값을 비교하면됩니다.

 

 

lombok을 사용하지 않을 경우

@Getter @Setter
public class User {

    private String username;
    private String password;
    private String role;
    private String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(username, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(username, username);
    }
}

 

JPA가 기본적으로 id 기반으로 equals/hashCode 제공합니다. 복합키를 사용하거나 또는 명확성을 위해서 객체에서 override해주는 것도 좋은 방법이라고 생각합니다.

 

 

public record PrincipalDetails(User user) implements UserDetails {

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public String getPassword() {
        return user == null ? null : user.getPassword();
    }

    @Override
    public String getUsername() {
        return user == null ? null : user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

 

 

record의 특성을 이해하길 원하신다면 다음 글을 읽어보시길 추천드립니다.

 

 

[JAVA] Record와 Sealed

개요오늘은 Java 14부터 도입된 Record와 Java 15에서 preview로 시작된 Sealed Classes 에 대해서 알아보겠습니다.참고로 Sealed Classes 는 Java 17부터 정식적으로 확정된 것같습니다. JDK 17 Release Notes, Important C

tmd8633.tistory.com

 

 

 

SecurityConfig

        http.sessionManagement(session -> session
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(true) // 새로운 로그인 차단
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

 

기존 세션을 만료시키기 전에 새로운 로그인을 차단하는 기능에 대해서 알아 보겠습니다. 이렇게 설정하고 동시 로그인을 하게되면

 

 

해당 url로 이동하게 됩니다. maxSessionPreventsLogin(true)는 

        http.formLogin(login -> login
                .loginPage("/join")
                .loginProcessingUrl("/join")
                .defaultSuccessUrl("/user")
                .failureUrl("/join?error")
            )

formLogin 에서 로그인 실패로 남게됩니다.

        http.formLogin(login -> login
                .loginPage("/join")
                .loginProcessingUrl("/join")
                .defaultSuccessUrl("/user")
                .failureHandler((request, response, exception) -> {
                    exception.printStackTrace();
                    response.sendRedirect("/join?error");
                })
            )

 

failureHandler에서 printStackTrace를 해보면

org.springframework.security.web.authentication.session.SessionAuthenticationException: Maximum sessions of 1 for this principal exceeded

이렇게 뜨는 것을 확인할 수 있습니다.  따라서 새로운 로그인을 차단하는 기능을 활성화 시킨다면 formLogin failureHandler에서 처리를 해야한다는 뜻입니다.

 

 

자 다시 돌아와서 maxSessionPreventsLogin(false) : 기존 세션 만료 로 돌려놓고 새로운 로그인을 시도해보겠습니다.

 

기존 로그인 했던 페이지에서 새로고침을 해보면 '/join?expired' 로 redirect 되었습니다. 세션 만료가 잘 되었군요. 이제 서버에는 세션은 최대 1개까지만 접속할 수 있게 되었습니다.

 

 

잠깐 SessionRegistry 는 왜 등록하지 않았나?

동시세션을 제어하기 위해서는 SessionRegistry를 등록해서 제어해야합니다. 그런데 위에 코드에는

.sessionRegistry(new SessionRegistryImpl())

이런 코드는 작성하지 않았습니다.

 

결론부터 말하자면 동시세션제어를 활성화할때 Bean에 SessionRegistry를 등록하지 않는다면 내부에서 new SessionRegistryImpl()을 생성해서 넣어줍니다.

 

 

.maximumSessions(1) 을 할때 내부 코드를 보면 ConcurrencyControlConfigurer 를 반환합니다.

    public SessionManagementConfigurer<H>.ConcurrencyControlConfigurer maximumSessions(int maximumSessions) {
        this.maximumSessions = maximumSessions;
        this.propertiesThatRequireImplicitAuthentication.add("maximumSessions = " + maximumSessions);
        return new ConcurrencyControlConfigurer();
    }

 

그 내부를 들어가보면 SessionManagementConfigurer 중첩클래스로 정의 되어있습니다.

 

그리고 getSessionRegistry 를 보면 sessionRegistry == null 이면 sessionRegistryImpl() 을 내부적으로 가지는것을 확인할 수 있습니다.

    // SessionManagementConfigurer 내부
    private SessionRegistry getSessionRegistry(H http) {
        if (this.sessionRegistry == null) {
            this.sessionRegistry = (SessionRegistry)this.getBeanOrNull(SessionRegistry.class);
        }

        if (this.sessionRegistry == null) {
            SessionRegistryImpl sessionRegistry = new SessionRegistryImpl();
            this.registerDelegateApplicationListener(http, sessionRegistry);
            this.sessionRegistry = sessionRegistry;
        }

        return this.sessionRegistry;
    }

 

사용처를 본다면

private ConcurrentSessionFilter createConcurrencyFilter(H http) {
    SessionInformationExpiredStrategy expireStrategy = this.getExpiredSessionStrategy();
    SessionRegistry sessionRegistry = this.getSessionRegistry(http);
    // ... 중략
    return concurrentSessionFilter;
}

private SessionAuthenticationStrategy getSessionAuthenticationStrategy(H http) {
        if (this.sessionAuthenticationStrategy != null) {
            return this.sessionAuthenticationStrategy;
        } else {
            // ... 중략
            if (this.isConcurrentSessionControlEnabled()) {
                SessionRegistry sessionRegistry = this.getSessionRegistry(http);
                // ... 중략
            } else {
                delegateStrategies.add(defaultSessionAuthenticationStrategy);
            }

            this.sessionAuthenticationStrategy = (SessionAuthenticationStrategy)this.postProcess(new CompositeSessionAuthenticationStrategy(delegateStrategies));
            return this.sessionAuthenticationStrategy;
        }
}

 

동시세션필터를 생성할때, SessionAuthenticationStrategy를 가져올때 사용되는것을 볼 수 있습니다.

 

 

2. 세션 생성 정책 설정

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)     // 필요한 경우에만 생성
        );

이 값은 변경할 필요가 없을 것같습니다. 그렇기 때문에 생략하도록 하겠습니다.

다만, 이후에 JWT를 다룰때 SessionCreationPolicy.STATELESS(세션을 사용하지 않음)으로 변경할 예정입니다.

 

 

3. 세션 고정 보호 설정

        http.sessionManagement(session -> session
            .sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(false) // 기존 세션 만료
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

세션 생성 정책과 마찬가지로 changeSessionId 가 기본값이기 때문에 생략하도록 하겠습니다.

 

 

 

4. 세션 타임아웃 설정

유효하지 않은 세션에 대한 설정인데요. 값이 변조되었거나 서버 재구동등 여러 요인으로 인해서 유효하지 않은 세션이 들어왔을때 처리를 담당합니다. 

 

        http.sessionManagement(session -> session
            .invalidSessionUrl("/join?invalid")
            .maximumSessions(1) // 사용자당 세션 최대 1개
            .maxSessionsPreventsLogin(false) // 기존 세션 만료
            .expiredUrl("/join?expired") // 세션 만료시 로그인으로 이동
        );

 

invalidSessionUrl을 "/join?invalid" 로 설정해줍니다.

 

.invalidSessionStrategy(new CustomInvalidSessionStrategy())

이렇게 추가적인 로직이 필요한경우 InvalidSessionStrategy 를 구현해서 넣을 수 있지만 쿠키제거, 세션무효화를 직접 작성해야하는 불편함이 있고 굳이 따로 객체를 만들어서 관리하는 수고로움 보다 JavaScript로 구현하는게 조금더 편한것 같더라구요.

// joinForm.html
<script>
    const urlParams = new URLSearchParams(window.location.search);

    // invalid 파라미터 체크
    if (urlParams.has('invalid')) {
        alert('세션이 유효하지 않습니다');
    }
    // expired 파라미터 체크
    else if (urlParams.has('expired')) {
        alert('세션이 만료되었습니다');
    }
</script>

무엇보다 사용자가 세션만료건 유효하지않건 알아봤자 중요하지도 않고 그게 무슨 뜻인지도 모를것이니까요. 물론 경우에 따라서는 Strategy를 구현해 사용하는 것도 방법입니다.

1. Session Management란?

Spring Security의 세션 관리는 사용자의 세션을 생성, 유지, 파괴하는 전반적인 프로세스를 관리합니다. 이는 보안과 사용자 경험 모두에 중요한 영향을 미칩니다. 또한 JWT이나 Session 관리를 하지않는 API를 사용하기전에 알아두어야 하기때문에 공부해보도록 하겠습니다.

 

 

 

2. Session Creation Policy (세션 생성 정책)

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)     // 필요한 경우에만 생성(Default)
        );

 

정책 설명
SessionCreationPolicy.ALWAYS 항상 세션 생성
SessionCreationPolicy.IF_REQUIRED 필요한 경우에만 생성 (Default)
SessionCreationPolicy.NEVER 생성하지 않지만 존재하면 사용
SessionCreationPolicy.STATELESS 세션을 완전히 사용하지 않음 (JWT 등의 토큰 기반 인증에 적합)

 

 

3. Concurrent Session Control (동시 세션 제어)

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
        http.sessionManagement(sessionManagement -> sessionManagement
            .maximumSessions(1)                     // 사용자당 최대 세션 수
            .maxSessionsPreventsLogin(true)         // true: 새로운 로그인 차단, false: 기존 세션 만료(기본값)
            .expiredUrl("/session-expired")         // 세션 만료시 이동할 URL
        );

 

3-1. HttpSessionEventPublisher

HttpSessionEventPublisher는 세션 생명주기 이벤트를 Spring의 ApplicationContext에 발행하는 역할을 합니다.

@Component
public class SessionEventListener {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @EventListener
    public void handleSessionCreated(HttpSessionCreatedEvent event) {
        HttpSession session = event.getSession();
        logger.info("새 세션 생성: {}", session.getId());
    }

    @EventListener
    public void handleSessionDestroyed(HttpSessionDestroyedEvent event) {
        HttpSession session = event.getSession();
        logger.info("세션 파괴됨: {}", session.getId());
    }

}

 

세션 모니터링

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

        http.sessionManagement(session -> session
            .sessionRegistry(sessionRegistry())
        );
@Component
@Slf4j
@RequiredArgsConstructor
public class SessionEventListener {

    private final SessionRegistry sessionRegistry;

    @EventListener
    public void handleSessionDestroyed(HttpSessionDestroyedEvent event) {
        log.info("세션만료 : {}", event.getSession().getId());
        // 세션이 만료되면 해당 사용자의 세션 정보 정리
        SecurityContext securityContext = (SecurityContext) event.getSession()
            .getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
        if (securityContext != null) {
            sessionRegistry.removeSessionInformation(event.getSession().getId());
        }
    }

    @EventListener
    public void handleSessionCreated(HttpSessionCreatedEvent event) {
        log.info("세션생성 : {}", event.getSession().getId());
        HttpSession session = event.getSession();
        // 세션 생성 시 기본 타임아웃 설정
        session.setMaxInactiveInterval(60 * 30); // 30분
    }

}

 

3-2. 이전 세션 만료 전략

.maximumSessions(1)
.maxSessionsPreventsLogin(false)  // 기본값

 

3-3. 새 로그인 차단 전략

.maximumSessions(1)
.maxSessionsPreventsLogin(true)

 

 

4. Session Fixation Protection (세션 고정 보호)

 

4-1. 세션 고정 보호가 뭘까?

정확히는 세션 고정 공격 보호 (Session Fixation Attack Protection) 입니다. 세션 고정 공격은 악의적인 공격자가 사이트에 접속해서 세션을 생성한 다음 동일한 세션으로 다른 사용자가 접속하도록 유도할 수 있는 잠재적인 공격입니다. (예, 세션 식별자를 매개변수로 포함하는 링크를 전송) Spring Security는 사용자가 로그인할 때 새 세션을 생성하거나 세션 ID를 변경하여 이를 자동으로 보호하는데 이를 설정하는 것이 sessionFixation 설정입니다.

출처 : https://blog.pollra.com/odop-day-76

 

4-2. 세션 고정 보호 설정

        http.sessionManagement(sessionManagement -> sessionManagement
            .sessionFixation(fixation -> fixation
                .changeSessionId() // 세션 ID만 변경 (Servlet 3.1+ 컨테이너 기본값)
            )
        );

 

 

메소드 설명
newSession() 완전히 새로운 세션 생성
migrateSession() 이전 세션 속성을 새 세션으로 복사 (Servlet 3.0이하 Default)
changeSessionId() 세션 ID만 변경 (Servlet 3.1 이상 Default)
none() 보호 비활성화

 

 

 

5. Session Timeout (세션 타임아웃)

세션은 자체적으로 만료되며 Security Context가 제거되도록 해야할 것은 없습니다. 그러므로 세션이 만료된 시점을 감지하고 사용자가 특정 작업을 수행할 수 있도록 엔드포인트를 리디렉션할 수 있게만 해주면됩니다. 이는 invalidSessionUrl 에서 이루어집니다.

 

        http.sessionManagement(session -> session
            .invalidSessionUrl("/invalidSession") // 만료된 세선이 접근할 경우 "/invalidSession" 으로 이동
        );

 

 

        http.sessionManagement(session -> session
            .invalidSessionStrategy(new CustomInvalidSessionStrategy())
        );
        
public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {

    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // Session 무효화
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }

        // JSESSIONID 쿠키 삭제
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("JSESSIONID".equals(cookie.getName())) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                    break;
                }
            }
        }
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
        out.println("<script>alert('세션이 만료되었습니다.');</script>");
        out.flush();
    }

}

 

invalidSessionUrl과 invalidSessionStrategy는 동시에 사용할 수 없습니다. 둘다 정의 되어있다면 invalidSessionStrategy만 적용됩니다. 그냥 세션 만료시 이동을 원할경우 invalidSessionUrl을 사용하고, 복잡한 로직이 추가되어야 한다면 invalidSessionStrategy를 사용하시면 됩니다.

 

 

 

주의사항

로그아웃 시에 쿠키가 제대로 삭제되지 않아 문제가 발생할 수 있습니다.

        http.logout(logout -> logout
            // 모든 쿠키 제거
            .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(ClearSiteDataHeaderWriter.Directive.COOKIES)))
            // 특정 쿠키 제거
            .deleteCookies("JSESSIONID", "remember-me")
        );

이렇게 명시적으로 쿠키를 제거해줍시다.

ClearSiteData를 지원하지 않는 브라우저에서는 정상적으로 동작하지 않을 수 있습니다.

 

 

 

 

6. SessionManagementFilter

sessionManagement는 SessionManagementFilter에서 사용됩니다. 그 역할로는 

 

  • 세션 생성 전략 관리
  • 동시 세션 제어
  • 세션 고정 보호
  • 유효하지 않은 세션 처리

우리가 위에서 알아본 것과 같습니다.

 

6-1. SessionManagementFilter 구조

  • SecurityContextRepository의 내용을 현재 SecurityContextHolder의 내용과 비교 검사
  • 이를 통해 현재 요청 중에 사용자가 인증되었는지 확인
  • 주로 pre-authentication이나 remember-me와 같은 비대화형 인증 메커니즘에서 사용됨

처리 흐름

  • SecurityContextRepository에 Security Context가 있다면 아무 작업도 하지 않음
  • SecurityContextRepository에 context가 없고, SecurityContext가 Authentication 객체를 포함하고 있다면 (익명객체가 아닌)
    • 이전 필터에서 이미 인증되었다고 가정
    • 설정된 SessionAuthenticationStrategy를 실행

미인증 사용자 처리

  • 유효하지 않은 세션 ID가 요청되었는지 확인 (예: 타임아웃으로 인한 유효하지 않은 세션)
  • 설정된 InvalidSessionStrategy가 있다면 이를 실행

 

6-2. SecurityContextRepository

SecurityContextRepository는 SecurityContext를 저장하고 불러오는 역할을 하는 인터페이스입니다. 주로 HTTP 세션에 SecurityContext를 저장하고 검색하는 작업을 담당합니다. 기본객체는 HttpSessionSecurityContextRepository 입니다.

기본 구현

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.securityContext((securityContext) -> securityContext
            .securityContextRepository(new HttpSessionSecurityContextRepository())
        );
        return http.build();
    }
}​


DelegatingSecurityContextRepository 사용 시

@Bean
public SecurityContextRepository securityContextRepository() {
    return new DelegatingSecurityContextRepository(
        new HttpSessionSecurityContextRepository(),
        new RequestAttributeSecurityContextRepository()
    );
}​

 

 

 

7. 보안설정

server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true

 

server.servlet.session.cookie.secure=true

  • HTTPS를 통해서만 쿠키가 전송되도록 설정
  • HTTP로 요청시 쿠키가 전송되지 않음
  • 중간자 공격(Man-in-the-Middle Attack) 방지
  • 쿠키 탈취 위험 감소

server.servlet.session.cookie.http-only=true

  • JavaScript를 통한 쿠키 접근을 차단
  • XSS(Cross-Site Scripting) 공격 방지
  • 클라이언트 측 스크립트에서 document.cookie로 접근 불가

 

7-1. 추가 설정

# 쿠키 만료 시간 설정
server.servlet.session.timeout=30m

# 쿠키 경로 설정
server.servlet.session.cookie.path=/

# 쿠키 도메인 설정
server.servlet.session.cookie.domain=example.com

# 쿠키 이름 변경
server.servlet.session.cookie.name=MYSESSIONID

# SameSite 설정
server.servlet.session.cookie.same-site=strict

 

 

 

글이 너무 길어져서 여기까지 작성하겠습니다.

SessionManagement 관련된 내용은 어마어마하게 많습니다. 다음글에서는 이 글을 토대로 어떻게 적용하는지에 대해서 알아보도록하겠습니다.

Anonymous 인증이란?

Spring Security의 Anonymous 인증은 인증되지 않은 사용자(로그인하지 않은 사용자)를 처리하는 메커니즘입니다. 인증되지 않은 요청에 대해 AnonymousAuthenticationToken을 생성하여 보안 컨텍스트에 저장합니다. 내용을 길지않으니 빠르게 알아보겠습니다.

 

 

 

사용법

anonymous 설정은 아래와 같습니다.

        http.anonymous(anonymous -> anonymous
            .principal("anonymousUser")      // 익명 사용자의 주체
            .authorities("ROLE_ANONYMOUS")    // 익명 사용자의 권한
        );
        // 비활성화
        http.anonymous(AbstractHttpConfigurer::disable);

 

 

 

사용처

로그인/회원가입은 로그인한 회원은 접근하지 못해야합니다.

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .requestMatchers("/join", "/signup").anonymous()
            .anyRequest().permitAll()
        );

/join 과 /signup URL에는 로그인하지 않은 사용자만 접근하도록 설정했습니다.

 

 

결과

이제 로그인하고 /join 에 접속해보면

403 Forbidden 이 발생합니다.

 

 

Anonymous 인증의 동작 방식

 

Anonymous 인증 필터

  • AnonymousAuthenticationFilter가 인증되지 않은 요청을 처리
  • SecurityContextHolder에 인증 객체가 없을 때 AnonymousAuthenticationToken 생성
  • 필터 체인의 맨 마지막 부분에서 동작

 

인증 객체 구조

AnonymousAuthenticationToken {
    principal: "anonymousUser",
    authorities: ["ROLE_ANONYMOUS"],
    details: WebAuthenticationDetails,
    authenticated: true
}

 

 

 

주의사항

Anonymous 를 사용할 때에는 알고있어야 하는 것이 있습니다. AnonymousAuthenticationToken 이 SecurityContextHolder에 생성되었다는 것은 인증된 상태로 간주된다는 것입니다.

 

 

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

이 특징에 대해서 3-1 SecurityContextHolder 부분을 읽어보시면 이해가 되실겁니다.

 

 

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
System.out.println("isAuthenticated: " + auth.isAuthenticated());  // true 반환

이렇게 true가 반환되니 주의하시기 바랍니다.

 

실제 로그인한 사용자인지 파악하기위해서는

if (auth instanceof AnonymousAuthenticationToken) {
    // 익명 사용자
} else {
    // 실제 인증된 사용자
}

이렇게 사용하셔야합니다.

 

 

 

 

ExceptionHandling

http.exceptionHandling(handling -> handling
    .accessDeniedHandler()           // 권한 부족 시 처리
    .accessDeniedPage()             // 권한 부족 시 리다이렉트할 페이지
    .authenticationEntryPoint()     // 인증되지 않은 사용자 처리
    .defaultAuthenticationEntryPointFor()  // 특정 요청에 대한 인증 진입점 설정
);

 

 

accessDeniedHandler

.accessDeniedHandler((request, response, accessDeniedException) -> {
    // 인증된 사용자가 권한이 부족한 리소스에 접근할 때
    response.setContentType("text/html;charset=UTF-8");
    PrintWriter out = response.getWriter();
    out.println("<script>alert('권한이 없습니다.'); window.location.href='/';</script>");
    out.flush();
})

 

 

accessDeniedPage

.accessDeniedPage("/error/403")  // 간단히 특정 페이지로 리다이렉트

 

 

authenticationEntryPoint

.authenticationEntryPoint((request, response, authException) -> {
    // 인증되지 않은 사용자가 보호된 리소스에 접근할 때
    response.sendRedirect("/login");
})

 

 

defaultAuthenticationEntryPointFor

.defaultAuthenticationEntryPointFor(
    new LoginUrlAuthenticationEntryPoint("/api/login"),
    new AntPathRequestMatcher("/api/**")
)

 

 

전체 코드

        http.exceptionHandling(handling -> handling
            // 1. 권한 부족 처리 (403)
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                response.setContentType("text/html;charset=UTF-8");
                PrintWriter out = response.getWriter();
                out.println("<script>alert('권한이 없습니다.'); history.back();</script>");
                out.flush();
            })
            
            // 2. 인증되지 않은 사용자 처리 (401)
            .authenticationEntryPoint((request, response, authException) -> {
                if (isAjaxRequest(request)) {
                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                } else {
                    response.sendRedirect("/login");
                }
            })
            
            // 3. API 요청에 대한 특별한 처리
            .defaultAuthenticationEntryPointFor(
                new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                new AntPathRequestMatcher("/api/**")
            )
        );
        
    // AJAX 요청 확인
    private boolean isAjaxRequest(HttpServletRequest request) {
        return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
    }

 

코드만 봐서는 헷갈리는게 많습니다. 정리해보자면 다음과 같습니다.

 

주요 차이점

  1. accessDeniedHandler vs authenticationEntryPoint
    • accessDeniedHandler: 인증은 됐지만 권한이 없을 때 (403)
    • authenticationEntryPoint: 인증이 안된 경우 (401)
  2. accessDeniedPage vs accessDeniedHandler
    • accessDeniedPage: 단순 페이지 리다이렉트
    • accessDeniedHandler: 더 복잡한 로직 구현 가능
  3. defaultAuthenticationEntryPointFor
    • 특정 URL 패턴에 대해서만 다른 인증 진입점 설정 가능
    • API와 웹 페이지를 다르게 처리할 때 유용

 

 

오늘은 여기까지하고 마치겠습니다.

Remember-Me란?

Remember-Me는 사용자가 브라우저를 닫았다가 다시 열어도 로그인 상태를 유지하는 기능입니다. 쿠키를 사용하여 구현되며, Spring Security는 두 가지 구현 방식을 제공합니다. 

 

  • Simple Hash-Based Token
  • Persistent Token (Database)

두가지 다 알아보도록 합시다.

 

 

 

RememberMe 처리과정

구현하기 전에 어떤 과정을 통해 쿠키로 만들어지는지 짚고 넘어가겠습니다.

  1. 로그인 시 rememberMe 체크박스를 선택하고 로그인
  2. 토큰생성 : base64(username + ":" + expirationTime + ":" + md5Hex(username + ":" + expirationTime + ":" + password + ":" + key))
  3. 토큰 저장
    • Simple Hash-Based Token : 쿠키 생성
    • Persistent Token : 쿠키생성, DB 저장ㅋ
  4. 사용자 접근 시 토큰 검증
    • Simple Hash-Based Token : 쿠키값이 유효한 경우 자동로그인
    • Persistent Token : DB 에 저장된 값과 비교하고 유효한 경우 자동로그인

 

 

Simple Hash-Based Token

    <form th:action="@{/join}" th:method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <label>
            <input type="checkbox" name="remember-me" /> Remember Me
        </label>
        <input type="submit" value="로그인">
    </form>

먼저 jonForm.html에 remember-me 체크박스를 하나 추가해줍시다.

 

그다음 RememberMeConfig 를 하나 만듭니다.

@Configuration
@RequiredArgsConstructor
public class RememberMeConfig {

    private static final String KEY = "MY_KEY";

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(KEY, userDetailsService);
        rememberMe.setAlwaysRemember(false);
        rememberMe.setTokenValiditySeconds(60 * 60 * 24);
        return rememberMe;
    }
    
}

KEY는 비밀키 역할을 합니다. TokenBasedRememberMeServices에는 인코딩 알고리즘과 매칭 알고리즘이 존재합니다.

인코딩 알고리즘 기본값은 SHA-256, 매칭 알고리즘은 MD5입니다. 알고리즘은 설정을 통해 수정할 수 있습니다.

 

UserDetailsService는 저번 글에서 Bean으로 등록해놓았습니다.

 

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        RememberMeTokenAlgorithm sha256 = RememberMeTokenAlgorithm.SHA256;
        TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(KEY, userDetailsService, sha256);
        rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
        rememberMe.setAlwaysRemember(false);
        rememberMe.setTokenValiditySeconds(60 * 60 * 24);
        return rememberMe;
    }

 

 

마지막으로 SecurityConfig로 돌아와

        http.rememberMe(remember -> remember
            .rememberMeServices(rememberMeServices)
        );

이렇게 설정해줍시다.

 

설정이 끝났습니다. 이제 로그인할때 rememberMe를 체크하고 로그인하면 쿠키가 살아있는동안 자동로그인이 되는것을 확인할 수 있습니다.

rememberMe 체크 후 로그인 -> 브라우저 종료 -> /user 로 바로 접속

 

 

 

 

Persistent Token

다음은 DB에 쿠키를 저장하는 방식으로 구현해보겠습니다.

 

build.gradle

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'com.h2database:h2'

 

application.properties

# DataSource 설정
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA 설정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

 

@Entity
public class PersistentLogins {

    @Id
    private String series;
    private String username;
    private String token;
    private LocalDateTime lastUsed;
}

 

이렇게 추가해주도록 합시다.

PersistentLogins 객체는 공식문서에 적힌대로 작성했습니다. 실제로도 이렇게 해야합니다.

username: 사용자 이름
series: 쿠키의 series 값과 매칭됨
token: 쿠키의 token 값과 매칭됨
last_used: 마지막 사용 시간
 

Remember-Me Authentication :: Spring Security

Remember-me or persistent-login authentication refers to web sites being able to remember the identity of a principal between sessions. This is typically accomplished by sending a cookie to the browser, with the cookie being detected during future sessions

docs.spring.io

 

 

    @Bean
    RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
        PersistentTokenBasedRememberMeServices service = new PersistentTokenBasedRememberMeServices(KEY, userDetailsService, new CustomInMemoryTokenRepository());
        service.setAlwaysRemember(false);
        service.setTokenValiditySeconds(2592000);
        return service;
    }

 

RememberMeServices에 구현체를 TokenBasedRememberMeServices 에서 PersistentTokenBasedRememberMeServices로 변경해줍니다. PersistentTokenBasedRememberMeServices에는 PersistentTokenRepository가 들어가야하는데 그 구현체는

InMemoryTokenRepositoryImpl과 JdbcTokenRepositoryImpl이 있습니다.

 

저는 테스트하기 위해 InMemoryRepositoryImpl 을 구현할거고, 로그도 확인하기 위해 CustomInMemoryRepository 를 따로 구현했습니다. 그냥 InMemoryRepositoryImpl 넣으셔도 무방합니다.

 

public class CustomInMemoryTokenRepository extends InMemoryTokenRepositoryImpl {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public void createNewToken(PersistentRememberMeToken token) {
        super.createNewToken(token);
        logger.info("New token created: {}", token.getUsername());
    }

    @Override
    public void updateToken(String series, String tokenValue, Date lastUsed) {
        super.updateToken(series, tokenValue, lastUsed);
        logger.info("Token updated - series: {}, lastUsed: {}", series, lastUsed);
    }
}

 

 

이제 로그인해보면

정상적으로 토큰이 생성되었습니다. 이제 브라우저를 닫고 다시 접속해보면

 

잘 되는걸 볼 수 있습니다.

 

 

 

비교

따로 장단점을 적지않더라도 Persistent Token이 더 안전하다고 느끼실겁니다.

 

Persistent Token의 장점

  1. 토큰이 데이터베이스에 저장되므로 더 안전
  2. 토큰 탈취 시도를 감지 가능
  3. 사용자별 로그인 세션 관리 가능
  4. 특정 사용자의 remember-me 토큰만 선택적으로 만료 가능

Persistent Token 주의사항:

  1. 반드시 HTTPS 사용 권장
  2. 토큰 유효기간을 적절히 설정
  3. 중요한 작업 시에는 재인증 요구
  4. 정기적으로 만료된 토큰 정리 필요

Persistent Token을 사용할때

service.setUseSecureCookie(true);

로 설정하는게 좋습니다.

 

 

  • true로 설정 시
    • HTTPS 연결에서만 쿠키 전송
    • HTTP 연결에서는 쿠키가 전송되지 않음
    • 중간자 공격(Man-in-the-Middle) 방지
    • 쿠키 탈취 위험 감소
  • false로 설정 시
    • HTTP, HTTPS 모두에서 쿠키 전송
    • 개발 환경에서 주로 사용
    • 보안상 취약할 수 있음

개발환경에서는 false, 서비스환경에서는 true로 설정해줘야합니다.

 

이전글

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

 

이전글에서 Spring Security 기본개념과 구조이해를 했습니다. 이번 글에서는 Spring Security를 적용해보도록 하겠습니다.

 

 

dependencies

build.gradle에 의존성을 주입해줍니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 

테스트 환경을 위해 타임리프도 추가했습니다.

 

 

 

시작하기

<!-- index.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span>메인페이지</span>
</body>
</html>

 

@Controller
public class MainController {

    @GetMapping
    public String mainPage() {
        return "index";
    }
}

 

이렇게 실행하고 접속해보면

 

 설정하지 않은 로그인 페이지가 뜨고, /login 으로 이동되었습니다. 이는 스프링 시큐리티에서 웹 보안에 가장 기본적인 로그인기능을 지원하기 때문에 나오는건데요.

 

로그에 표시된 password와 username에 'user' 라고 적고 로그인 해봅시다.

 

localhost:8080

성공 적으로 메인페이지로 넘어왔습니다. 우리는 기본적으로 제공하는 로그인 페이지를 사용하지 않고 로그인을 구현할것이기 때문에 일단 모든 요청을 허용해놓겠습니다.

 

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests(request ->
                // 어떤요청이든(anyRequest) 모두허용(permitAll)하겠다.
                request.anyRequest().permitAll()
            );

        return http.build();
    }

}

 

 

 

로그인

 

Security LoginForm 적용 전

 

HTML

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span>메인페이지</span>

    <a href="/join">로그인</a>
    <a href="/signup">회원가입</a>
</body>
</html>
<!-- joinForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/join}" th:method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="submit" value="로그인">
    </form>
</body>
</html>
<!-- signupForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/signup}" method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="text" name="name">
        <input type="submit" value="회원가입">
    </form>
</body>
</html>
<!-- userForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${user + '님 안녕하세요'}"></span>
</body>
</html>

메인페이지, 로그인, 회원가입, 유저페이지를 구성했습니다.

유저페이지는 로그인 한 유저의 정보를 볼 수 있는 페이지입니다.

 

Domain

@Getter @Setter
@ToString
public class User {

    private String username;
    private String password;
    private String role;
    private String name;
}

User에는 아이디, 비밀번호, 권한, 이름을 저장합니다.

 

 

MainController

@Controller
public class MainController {

    @GetMapping
    public String mainPage() {
        return "index";
    }

    @GetMapping("/user")
    public String userPage(@SessionAttribute("user") String username, Model model) {
        model.addAttribute("user", username);
        return "userForm";
    }

    @GetMapping("/join")
    public String loginPage() {
        return "joinForm";
    }

    @GetMapping("/signup")
    public String signupPage() {
        return "signupForm";
    }
}

HTML과 매핑해줍니다.

 

UserController

@Controller
@RequiredArgsConstructor
public class UserController {

    private final MemoryDB db;
    private final BCryptPasswordEncoder encoder;

    @PostMapping("/signup")
    public String signup(User user) {
        user.setRole("ROLE_USER");
        db.save(user);
        return "redirect:/join";
    }

    @PostMapping("/join")
    public String login(HttpServletRequest request, User user) {
        User findUser = db.find(user.getUsername());
        if (findUser == null || !encoder.matches(user.getPassword(), findUser.getPassword())) {
            return "redirect:/join";
        }
        request.getSession().setAttribute("user", findUser.getUsername());
        return "redirect:/user";
    }
}

로그인과 회원가입 POST 요청을 처리합니다.

 

DB

@Component
@RequiredArgsConstructor
public class MemoryDB {

    private final Map<String, User> db = new HashMap<>();
    private final BCryptPasswordEncoder encoder;

    public User save(User user) {
        user.setPassword(encoder.encode(user.getPassword()));
        db.put(user.getUsername(), user);
        return user;
    }

    public User find(String username) {
        return db.get(username);
    }
}

DB에 유저정보를 저장하고 간단한 예제 구현을 위해 Key값을 아이디로 잡았습니다.

 

 

Config

    @Bean
    BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

비밀번호는 암호화를 위해 BCryptPasswordEncode를 사용했습니다.

 

이렇게 하면 잘 동작할겁니다. 실제로는 예외처리등 수 많은 로직이 존재할겁니다. 이걸 Spring Security에게 위임해보겠습니다.

 

 

 

Security LoginForm 적용 후

먼저 UserController에서 우리가 로그인을 직접 구현했던 @PostMapping("/join") 부분을 제거해줍니다.

@Controller
@RequiredArgsConstructor
public class UserController {

    private final MemoryDB db;
    private final BCryptPasswordEncoder encoder;

    @PostMapping("/signup")
    public String signup(User user) {
        user.setRole("ROLE_USER");
        db.save(user);
        return "redirect:/join";
    }

}

 

 

@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

UserDetails 를 구현해줍니다. 여기에 우리가 구현한 User를 멤버변수로 받고 getUsername과 getPassword를 이어줍니다.

나머지는 일단 true로 둡시다.

 

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemoryDB db;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User findUser = db.find(username);
        if (findUser == null) {
            throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
        }
        return new PrincipalDetails(findUser);
    }
}

 

UserDetailsService를 구현하고 DB와 연동해줍니다. loadUserByUsername의 반환값은 위에서 구현한 PrincipalDetails로 해주고 User 객체를 넣어줍니다.

 

 

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.formLogin(login -> login
            .loginPage("/join")
            .loginProcessingUrl("/join")
            .defaultSuccessUrl("/user")
        );

        http.authorizeHttpRequests(request ->
            request.anyRequest().permitAll()
        );

        return http.build();
    }

SecurityConfig에서 formLogin을 작성해줍니다. formLogin().authorizeHttpRequests() 이렇게 이을 수 있는데 전 따로 분리하는게 더 보기 편하더라구요.

 

메소드 설명
loginPage 로그인 페이지 URL 설정
loginProcessingUrl 로그인 처리 URL 설정 (form action 값)
defaultSuccessUrl 로그인 성공 시 이동할 URL, 두번째 인자인 boolean alwaysUse false가 default
false : 사용자가 보호된 페이지 접근 시도 후 로그인 → 시도했던 페이지로 이동
true : 무조건 defaultSuccessUrl로 설정된 URL로 이동
failureUrl 로그인 실패 시 이동할 URL
usernameParameter 아이디 파라미터명 설정, username이 default
form input name이 username이 아닌경우 (예, email, account 등) 설정
passwordParameter 비밀번호 파라미터명 설정, password가 default
successHandler 로그인 성공 핸들러
failureHandler 로그인 실패 핸들러
disabled 사용안함

 

 

이렇게 설정하고

    @GetMapping("/user")
    public String userPage(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {
        model.addAttribute("user", principalDetails.getUser().getName());
        return "userForm";
    }

SessionAttribute를 제거하고 SecurityContextHolder에 저장된 인증된 사용자의 데이터를 호출하는 @AuthenticationPrincipal을 이용해 데이터를 불러옵니다.

회원가입

 

userForm

로그인 로직을 직접구현하지 않고도 정상적으로 작동되었습니다.

 

 

 

권한설정

여기서 문제가 있습니다.

 

로그인을 하지 않고 /user 로 접속하니 500 에러가 발생했습니다.

java.lang.NullPointerException: Cannot invoke "com.security.demo.config.security.PrincipalDetails.getUser()" because "principalDetails" is null

 

PrincipalDetails에 user를 넣는과정에서 user = null 이 되었기때문에 문제가 발생한건데요. null check를 한다고해도 

로그인하지 않은 사용자가 접근이 가능하다는 것입니다. 이 문제를 해결해보도록 하겠습니다.

User 객체에는 Role 이라는 데이터가 있었습니다. 그리고 회원가입한 User는 "ROLE_USER" 를 부여받습니다. 이 데이터를 이용하겠습니다.

 

몇 가지 가정을 해보겠습니다.

  1. 권한에는 일반 유저("USER")와 관리자("ADMIN") 이 있습니다.
  2. 유저정보를 볼 수 있는 '/user' 는 일반유저와 관리자가 접근할 수 있고,
    관리자정보를 볼 수 있는 '/admin'은 관리자만 접근할 수 있습니다.

 

먼저 관리자 페이지를 간단하게 만들겠습니다.

<!-- adminForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${admin + '관리자님 안녕하세요'}"></span>
</body>
</html>
    @GetMapping("/admin")
    public String adminPage(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {
        model.addAttribute("admin", principalDetails.getUser().getName());
        return "adminForm";
    }

 

 

이제 SecurityConfig를 수정해보겠습니다.

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.formLogin(login -> login
            .loginPage("/join")
            .loginProcessingUrl("/join")
            .defaultSuccessUrl("/user")
            .failureUrl("/join?error")
        );

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().permitAll()
        );

        return http.build();
    }

 

/user/** 에 접근하려면 "USER" 또는 "ADMIN" 권한이 존재해야합니다.

/admin/** 에 접근하려면 "ADMIN" 권한이 존재해야합니다.

 

이 권한 PrincipalDetails에 getAuthorities() 에 구현한 데이터를 기반으로 동작합니다. 그런데 우리는 "ROLE_USER" 를 넣었지 "USER"를 넣지는 않았습니다. 그 이유는 GrantedAuthority 기본 규칙은 "ROLE_" 접두사를 사용하는것이 default 이기 때문입니다.

 

조금 더 알고싶거나 "ROLE_" 접두사는 변경하고싶으면

 

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

이 글 4. Authorization (인가) 부분을 참고하시기 바랍니다.

 

메소드 설명
requestMatchers(String... patterns)
requestMatchers(HttpMethod method, String... patterns)
requestMatchers(HttpMethod method)
특정 URL 패턴에 대한 접근 설정
requestMatchers("/user/**") : /user 하위 URL에 대한 접근 설정
requestMatchers(HttpMethod.GET, "/user"/**") : /user 하위 URL에 GET 접근에 대한 설정
requestMatchers(HttpMethod.GET) : GET 접근에 대한 설정
authenticated() 인증된 사용자만 접근 허용
permitAll() 모든 사용자 접근 허용
denyAll() 모든 접근 거부
hasAuthority(String authority) 특정 권한을 가진 사용자만 접근
hasAuthority("USER") -> "USER" 접근
hasAnyAuthority(String... authorities) 여러 권한 중 하나라도 가진 사용자만 접근
hasRole(String role) 특정 권한을 가진 사용자만 접근 (ROLE_ 접두사 자동 추가)
hasRole("USER") -> "ROLE_USER" 에 접근
hasAnyRole(String... roles) 여러 역할 중 하나라도 가진 사용자 접근 (ROLE_ 접두사 자동 추가)

 

 

결과

로그인이 되어있지 않은 유저가 /user 로 접근하는 경우 /join 으로 redirect되어 로그인이 진행된 후에 /user 접근이 됩니다.

만약 USER가 ADMIN에 접근하려는 경우 403 Forbidden 이 발생합니다.

 

 

로그아웃

<!-- userForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${user + '님 안녕하세요'}"></span>
    <form th:action="@{/logout}" th:method="post">
        <input type="submit" th:value="로그아웃">
    </form>
</body>
</html>

userForm.html에서 로그아웃 form을 만들어줍니다.

 

        http.logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/")
        );

http.logout을 설정해주면 로그아웃도 완성입니다.

 

로그아웃 설정

메소드 설명
logoutURL 로그아웃 처리 URL default : "/logout"
logoutSuccessUrl 로그아웃 성공 시 이동할 URL
logoutSuccessHandler 로그아웃 성공 시 핸들러
addLogoutHandler 로그아웃 핸들러 추가
clearAuthentication SecurityContext에서 인증정보 제거 default : true
invalidateHttpSession 세션 무효화 default true
deleteCookies(String... cookieNamesToClear) 삭제할 쿠키 지정
logoutRequestMatcher 로그아웃 요청 매처 설정
예) GET으로 로그아웃 요청 시 .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))

 

 

결론

Spring Security에서 가장 기본적으로 다루는 부분을 구현해보았습니다.  아직 알아야할거도 많고 추가된것들도 많아서 공부를 계속해 나가면서 시리즈 더 추가해보겠습니다.

 

Spring Security

Spring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Spring Security를 통해 쉽게 해결할 수 있습니다. 다만 이 프래임워크가 워낙 방대하고 구조를 이해하고 있지않으면 활용하기 쉽지 않습니다. 하나의 게시물로 모든 Security 기능에 대해서 소개하지 못하므로, 이 글에서는 프레임워크에 대해 기본개념과 주요기능에 대해서 소개하고 각 기능에 대한 상세한 설명은 다음 글에서 이어가도록 하겠습니다.

 

이 글은 Spring Boot 3.4.0, Spring Security 6.4.1 환경에서 제작되었습니다.

 

 

 

들어가기전에

Security를 공부하기전에 짧게 기본개념을 잡고 가겠습니다. 이해하는데 도움이 될겁니다.

 

 

1. Filter

Spring Security는 Servlet Filter를 기반으로 동작합니다.

Client - Filter - DispatcherServlet - Interceptor - Controller

 

 

2. 용어

용어 설명
Authentication 인증
  • 사용자가 자신이 주장하는 사람이 맞는지 확인하는 과정
  • '신원 확인'의 과정
    예) 공항에서 여권으로 신원을 확인하는 것
Authorization 인가
  • 인증된 사용자가 특정 리소스에 접근할 수 있는 권한이 있는지 확인하는 과정
  • '권한 확인'의 과정
    예) 회사에서 직급에 따라 접근할 수 있는 시스템이 다른 것
Principal 접근주체
  • 시스템에 접근하는 사용자, 디바이스 또는 다른 시스템 등의 주체
  • 인증 이후 SecurityContext에 저장되는 인증된 사용자의 정보
    예) 사원증을 소지한 직원
Role 권한
  • 특정 리소스에 대한 접근 권한을 그룹화한 것
  • 사용자에게 부여될 수 있는 권한의 집합
    예) 회사에서의 직급 (사원, 대리, 과장 등)

 

용어 간의 관계

1. Authentication -> Principal 생성
   (인증 성공 시 Principal 정보가 생성됨)

2. Principal -> Role 부여
   (인증된 사용자에게 역할이 부여됨)

3. Role -> Authorization 결정
   (부여된 역할에 따라 권한이 결정됨)

 

 

3. SecurityContextHolder

SecurityContextHolder는 인증된 사용자의 정보를 저장하는 곳입니다.

 

 

4. Filter, Authentication, Authorization

Security에서 크게 Filter, Authentication, Authorization 으로 나눌 수 있습니다. 아키텍처를 볼 때, 이렇게 3개로 구분해서 보면 이해가 조금 더 쉬울 수 있습니다.

 

 

 

 

 

 

1. 아키텍처

 

출처 : https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

 

시큐리티 공식문서에서 가져온 아키텍처입니다. 이 사진을 보고 이해해야할것은 아래와 같습니다.

 

  1. SecurityFilterChain 은 Filter에서 동작한다.
  2. Authentication을 통해 인증과정을 수행한다.
    인증에 성공하면 SecurityContextHolder에 인증된 사용자의 정보를 담는다.
    인증에 실패하면 SecurityContextHolder가 비워집니다.(cleared out)

 

 

2. Filter

 

2-1.  DelegatingFilterProxy

 

서블릿 컨테이너와 Spring의 ApplicationContext 사이의 연결점입니다. 서블릿 컨테이너는 자체 표준을 사용하여  필터 인스턴스를 등록할 수 있지만 Bean에 대해서는 알지 못합니다. 그래서 DelegatingFilterProxy 에 Bean 등록을 위임하도록 합니다.

 

한줄정리 : DelegatingFilterProxy는 Servlet Container - Spring ApplicationContext를 연결하는 역할을 한다!

 

 

2-2. FilterChainProxy과  SecurityFilterChain

 

  1. FilterChainProxy는 보안필터의 시작점입니다.
  2. 요청 URL에 따라(RequestMatcher) 적절한 SecurityFilterChain을 선택할 수 있도록 합니다.
  3. Spring Security의 HttpFirewall을 적용하여 특정 유형의 공격으로부터 애플리케이션을 보호합니다.
  4. FilterChianProxy는 bean 이기때문에 DelegatingFilterProxy로 감싸져있습니다.

 

 

요청 URL에 따라 SecurityFilterChain에서 Filter를 선택

 

 

 

2-3. SecurityFilterChain 내부 Filter

 

spring-security/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java at

Spring Security. Contribute to spring-projects/spring-security development by creating an account on GitHub.

github.com

SecurityFilterChain에는 여러 Filter가 존재하는데 그 Filter의 순서는  FilterOrderRegistration.class 를 통해 확인할 수 있습니다. Filter의 순서를 외울필요는 없지만 어느 정도 흐름은 알고있는게 좋습니다. 

각 Filter의 용도와 사용법은 다음 글에서 알아보도록하겠습니다.

 

 

 

 

3.  Authentication (인증)

사용자 정보를 가지고 인증을 하는 과정입니다.

 

3-1. SecurityContextHolder

 

 

  • SecurityContextHolder는 인증된 사용자의 정보를 담고있는 Container입니다. Spring Security는 SecurityContextHolder가 어떻게 채워지는지 묻지않습니다. 그저 값이 포함되어있으면 인증된 사용자로 인식합니다. 즉, SecurityContextHolder가 비어있으면 인증되지않은 사용자, 비어있지않으면 인증된 사용자로 인식됩니다.
  • ThreadLocal을 사용하여 보안정보를 저장합니다. 즉, SecurityContext는 동일한 스레드내에서는 어디서든 보안 정보를 쉽게 접근 가능합니다. 요청 처리가 끝나면 반드시 쓰레드의 정보를 지워야 하지만 FilterChainProxy가 이 청소를 자동으로 해주기때문에 매우 안전하게 사용할 수 있습니다.
  • ThreadLocal에 대해 안전하지않은 어플리케이션에 대해서는 ThreadLocal 설정을 변경할 수 있습니다.
 

[JAVA] ThreadLocal에 대해서 알아보자

개요Spring Security 에서 ThreadLocal에대해서 언급한적이 있어 글로 남겨봅니다. ThreadLocal이란?ThreadLocal은 Java에서 제공하는 클래스로, java.lang 패키지에 존재합니다.각 스레드마다 독립적인 변수를

tmd8633.tistory.com

 

3-2. SecurityContext

인증된 객체(Authentication)를 보관하는 역할을 합니다. SecurityContextHolder로 부터 가져올 수 있습니다.

 

 

3-3. Authentication

현재 접근하는 사용자의 정보와 권한을 담은 인터페이스 입니다. isAuthenticated() 메소드로부터 인증되었는지 확인할 수 있습니다.

 

용어 설명
principal 아이디/비밀번호로 인증할때 사용자를 식별하는 역할을 합니다. UserDetails의 인스턴스로 사용됩니다.
credentials 비밀번호로 사요됩니다. 사용자가 인증된 후에는 비밀번호가 지워집니다.
authorities 사용자에게 부여된 권한들입니다. GrantedAuthority의 List형식으로 되어있습니다.

 

GrantedAuthority

주체에게 부여된 권한입니다. "ROLE_ADNATIOR", "ROLE_HR_SUPERVISOR" 과 같은 '역할'로 사용됩니다.

사용자 아이디/비밀번호 기반 인증을 사용할 때, UserDetailsService에 의해 로드됩니다.

 

 

3-4. AuthenticationManager

AuthenticationManager는 Spring Security의 필터가 인증을 수행하는 방식을 정의하는 인터페이스입니다.

AuthenticationManager는 인터페이스이므로 따로 구현할 수 있지만 기본적으로 ProviderManager가 구현되어있습니다.

 

 

3-5. AuthenticationProvider

실제 인증에 대한 부분을 담당합니다. 인증전의 Authentication 객체를 받아서 인증이 완료된 Authentication 객체를 반환합니다.

 

 

3-6. UserDetailService

UserDetailService를 implements해서 DB 데이터를 주입하여 UserDetails 를 반환하게 합니다.

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

 

 

3-7. UserDetails

인증된 사용자로써 사용될 인터페이스 입니다. implements해서 Custom해야합니다.

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    default boolean isAccountNonExpired() {
        return true;
    }

    default boolean isAccountNonLocked() {
        return true;
    }

    default boolean isCredentialsNonExpired() {
        return true;
    }

    default boolean isEnabled() {
        return true;
    }
}

 

 

4. Authorization (인가)

인증된 정보를 가지고 접근에 대한 허용여부를 결정하는 과정입니다. GrantedAuthority에 String getAuthority() 메소드가 사용됩니다.

기본적인 규칙으로 'ROLE_' 접두사가 포함됩니다. 즉, "USER"라는 권한이 있는 경우 GrantedAuthority#getAuthority 를 사용하면 "ROLE_USER" 권한을 찾습니다.

 

'ROLE_' 접두사를 변경할 수 있습니다.

@Bean
static GrantedAuthorityDefaults grantedAuthorityDefaults() {
    return new GrantedAuthorityDefaults("MYPREFIX_");
}
// static 필수!!

 

 

 

 

5. 인증처리 과정

 

 

5-1. HTTP 요청

사용자가 로그인 요청을 합니다.

 

5-2 AuthenticationFilter 인증

SecurityFilterChain에서 로그인을 담당하는 UsernamePasswordAuthenticationFilter 에서 인증을 처리합니다.

 

5-3. UsernameAuthenticationToken 발급

아이디와 비밀번호를 통해 토큰을 발급하고 AuthenticationManager로 토큰을 보냅니다.

 

5-4. AuthenticationProvider 인증

인증된 객체를 UserDetailsService에게 넘겨줍니다.

 

5-5. UserDetails 생성

전달된 인증된 객체를 DB에서 조회하여 데이터를 UserDetails 반환

 

5-6. AuthenticationProvider

인증에 성공하면 AuthenticationProvider에서 AuthenticationManager에게 인증에 성공한 객체를 반환

 

5-7. AuthenticationManager

인증에 성공한 객체를 AuthentifacionFIlter에 전달

 

5-8. SecurityContextHolder

인증에 성공한 객체는 SecurityContextHolder에 저장

 

 

 

6. 설정

SecurityFilterChain 에 어떤 기능을 사용할지 설정하는 부분입니다. 이 부분에서는 Filter의 기능을 사용자의 API에 맞게 수정할 수 있고, 해당 Filter의 전과 후에 Custom Filter를 넣는 등 여러 기능을 설정할 수 있습니다.

 

6-1. 기본설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.csrf(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            );

        return http.build();
    }

}

 

메소드 필터 설명
csrf CsrfFilter csrf에 대한 설정
httpBasic BasicAuthenticationFilter HTTP Basic Authentication에 대한 설정
formLogin UsernamePasswordAuthenticationFilter form login에 대한 설정
authorizeHttpRequests AuthorizationFilter 권한에 대한 설정

 

위의 4개의 메소드뿐만아니라 수많은 filter에 대한 설정을 할 수 있습니다.

 

Customizer.withDefaults() 는 Security 기본설정에 따른다는 메소드입니다.

 

CSRF에 대해서 이해하기

 

[CS][Spring Security] CSRF란?

CSRF란?CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를

tmd8633.tistory.com

 

 

6-2. Filter 추가

http.

// atFilter를 filter로 변경
addFilterAt(Filter filter, Class<? extends Filter> atFilter)

// beforeFilter 전에 filter를 추가
addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter)

// afterFilter 후에 filter를 추가
addFilterAfter(Filter filter, Class<? extends Filter> afterFilter)
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class); 
    return http.build();
}

 

 

Filter를 Bean으로 등록

Filter를 Bean으로 선언하면 Spring Container에 등록되면서 Filter가 2번 호출 될 수 있습니다. 이를 방지하기 위해 setEnabled(false) 로 설정해주어야 합니다.

 

@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
    FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false);
    return registration;
}

이렇게 하면 HttpSecurity 에 추가한 것만 유효하게 됩니다.

 

 

 

참고자료

공식문서 : https://docs.spring.io/spring-security/reference/servlet/architecture.html

 

Spring Scheduler

스케줄러는 스프링 프레임워크에 포함된 기능입니다. 특정 시간이나 정해진 주기에 따라 작업을 수행하는데 사용하는데 오늘은 이 기능에 대해서 알아봅시다.

 

 

 

사용방법

 

설정

Spring Scheduler를 사용하기 위해서는 @EnableScheduling 어노테이션을 선언해주어야 합니다.

@EnableScheduling
@SpringBootApplication
public class Application {

	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}

}

 

 

구현

다음은 스케줄을 구현하면되는데 스프링 빈으로 등록되어야지 동작합니다.

@Component
public class TestSchedule {

    @Scheduled(fixedRate = 1000)
    public void test() {
        System.out.println("TestSchedule");
    }

}

 

 

실행해보면 1초간격으로 동작하는것을 확인할 수 있습니다.

 

 

 

@Scheduled 사용법

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(Schedules.class)
@Reflective
public @interface Scheduled {
    String CRON_DISABLED = "-";

    String cron() default "";

    String zone() default "";

    long fixedDelay() default -1L;

    String fixedDelayString() default "";

    long fixedRate() default -1L;

    String fixedRateString() default "";

    long initialDelay() default -1L;

    String initialDelayString() default "";

    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;

    String scheduler() default "";
}

 

  1. cron
    • 표현식을 이용해 스케줄을 지정할 수 있습니다. 
    • "초 분 시 일 월 요일" 형식으로 작성합니다.
    • 자세한 설명을 글 하단에서 하겠습니다.
  2. zone
    • cron 표현식에서 사용할 시간대를 지정합니다.
    • default 는 서버의 로컬 시간대입니다.
    • "Asia/Seoul", "UTC" 등
  3. fixedDelay, fixedDelayString
    • 이전 작업의 종료시점부터 다음 작업의 시작까지의 지연시간입니다.
    • default fixedDelay = 1000 : 1초 (이전 작업 완료 후 1초 뒤 실행)
    • 단위는 timeUnit에 따라 변경될 수 있습니다.
  4. fixedRate, fixedRateString
    • 이전 작업의 시작지점부터 다음 작업의 시작까지의 시간간격입니다.
    • default fixedRate = 1000 : 1초 (이전 작업 시작으로부터 1초 마다 실행)
    • 단위는 timeUnit에 따라 변경될 수 있습니다.
  5. initialDelay, initalDelayString
    • 애플리케이션 시작 후 첫 작업이 실행되기까지의 지연시간을 지정할 수 있습니다.
    • default는 위와 같습니다.
    • 단위는 timeUnit에 따라 변경될 수 있습니다.
  6. timeUnit
    • fixedDelay, fixedRate, initialDelay의 시간단위를 지정합니다.
    • default TimeUnit.MILLISECONDS
  7. scheduler
    • 스케줄러의 이름을 지정할 수 있습니다.
@Scheduled(cron = "0 0 12 * * ?")  // 매일 12시에 실행
public void test1() {
    // 작업 내용
}

@Scheduled(fixedRate = 5000)  // 5초마다 실행
public void test2() {
    // 작업 내용
}

@Scheduled(fixedDelay = 1000, initialDelay = 5000)  // 시작 5초 후 첫 실행, 이후 1초 간격으로 실행
public void test3() {
    // 작업 내용
}
  •  

 

 

 

cron

cron은 "초 분 시 일 월 요일" 형식으로 된 String 문자열입니다.

필드 허용값
0 - 59
0 - 59
0 - 23
1 - 31
1- 12 | JAN-DEC
요일 0 - 6 | SUN - SAT

 

cron 특수문자

특수문자 설명 예제
* 모든 값  
? 특정한 값이 없음 일, 요일에서만 사용가능
- 범위를 나타냄 1-3 : 1월 ~ 3월
, 값을 여러개 나열 MON,SAT,SUN : 월,토,일
/ 시작시간과 단위 분에서 1/5 : 1분부터 5분단위로
L 마지막 값 일, 요일에서만 사용가능
일에서 사용하면 마지막 일,
요일에서 사용하면 마지막 요일(6)
W 가장 가까운 평일 일 에서만 사용가능
1W : 1일에서 가장 가까운 평일
# 몇째주의 무슨요일 0#3 : 3번째 주 일요일

 

 

 

cron 예제

// 매일 자정에 실행
@Scheduled(cron = "0 0 0 * * ?")

// 평일 오전 9시에 실행
@Scheduled(cron = "0 0 9 * * MON-FRI")

// 매월 1일 오전 3시에 실행
@Scheduled(cron = "0 0 3 1 * ?")

// 매주 일요일 자정에 실행
@Scheduled(cron = "0 0 0 ? * SUN")

// 매월 마지막 날 오후 11시 45분에 실행
@Scheduled(cron = "0 45 23 L * ?")

// 매월 마지막 토요일 오후 10시에 실행
@Scheduled(cron = "0 0 22 ? * 6L")

// 매월 첫번째 화요일 오전 9시에 실행
@Scheduled(cron = "0 0 9 ? * 2#1")

// 매일 오전 9시부터 오후 5시까지 30분마다 실행
@Scheduled(cron = "0 0/30 9-17 * * ?")

// 매일 10시 15분에 실행
@Scheduled(cron = "0 15 10 * * ?")

// 매주 월요일 10시에 실행
@Scheduled(cron = "0 0 10 ? * MON")

// 매주 월,수,금 10시에 실행
@Scheduled(cron = "0 0 10 ? * MON,WED,FRI")

// 15분마다 실행
@Scheduled(cron = "0 0/15 * * * ?")

// 평일 9시-18시 정각마다 실행
@Scheduled(cron = "0 0 9-18 * * MON-FRI")

// 매월 마지막날 12시에 실행
@Scheduled(cron = "0 0 12 L * ?")

// 매월 마지막 평일 12시에 실행
@Scheduled(cron = "0 0 12 LW * ?")

 

 

설정분리

schedule.cron=3 * * * * ?
schedule.enabled=true

application.properties에 설정값을 분리하고

 

@Component
public class TestSchedule {

    @Value("${schedule.enabled}")
    private boolean enabled;

    @Scheduled(cron = "${schedule.cron}")
    public void test() {
        if (enabled) {
            System.out.println("TestSchedule");
        }
    }

}

 

이렇게 작성하면 설정부분과 실행부분을 나눌 수 있어서 좋은것 같습니다.

개요

Spring JPA에는 open-in-view 를 true / false 로 설정할 수 있습니다.

spring.jpa.open-in-view=true // true : default

오늘은 이 기능이 무엇을 하는지 간단하게 알아보도록 합시다.

 

 

 

 

OSIV

open-in-view(OSIV) 는 영속성 컨텍스트가 데이터베이스 커넥션을 언제 돌려주는지 설정하는 것입니다.

OSIV의 기본전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 까지 영속성 컨텍스트

데이터베이스 커넥션을 유지하는 것입니다. 즉, 트랜잭션이 종료되어도 데이터베이스 커넥션을 유지하는 것인데요.

이 전략은 Controller에서 지연로딩을 통해 데이터를 받아올 수 있다는 장점이 있습니다.

다만, 이 기본전략은 데이터베이스 커넥션을 필요 이상으로 오래 사용하고, 오래 사용하기 때문에 트래픽이 많은 어플리케이션에서는 커넥션이 모자라 문제를 일으킬 수 있다는 것입니다.

 

 

spring.jpa.open-in-view=false

따라서 OSIV를 false로 하여 커넥션 반환시점을 트랜잭션 종료시점과 맞추어 사용하는 것이 조금더 나을 수 있습니다.

'FrameWork > JPA' 카테고리의 다른 글

[JPA] default_batch_fetch_size  (0) 2024.11.08
JPA 스키마 자동 생성  (0) 2023.11.17
spring.jpa.properties.hibernate.default_batch_fetch_size

application.properties

 

default batch fetch size

JPA에서 default_batch_fetch_size 는 지연로딩되는 쿼리를 IN 으로 묶어 한번에 보내는 기능입니다. 조회 시 성능을 개선하는 방법중 하나죠.

 

 

 

List<Order> orders = orderRepository.findAll();
orders.forEach(order -> System.out.println(order.getMember().getName()));

예를들어, 위와 같은 로직이 있을때 orders의 사이즈가 100이고, Member는 fetch=LAZY 이라고 가정해보겠습니다.

 

order.getMember().getName()을 호출하면 영속성 컨텍스트에 데이터가 없기때문에 먼저 쿼리가 나가고 영속성 컨텍스트에 저장됩니다. Member의 중복이 없다면 이 과정에서 100번 반복되어 총 쿼리는 최대 101번이 나갈 수 있게되는것입니다.

 

여기에서 default_batch_fetch_size=100 라고 설정해준다면 지연로딩되는 member를 IN절로 100개까지 묶어 한번에 모두 가져와 컨텍스트에 넣게됩니다. 그러면 이 경우에 2번의 쿼리만 발생하게됩니다.

 

따라서 default_batch_fetch_size를 적절하게 설정해놓는것이 좋겠습니다.

'FrameWork > JPA' 카테고리의 다른 글

[JPA] Open Session In View  (0) 2024.11.10
JPA 스키마 자동 생성  (0) 2023.11.17

개요

웹이나 앱을 개발하다보면 사용자가 입력한 데이터를 검증하는 과정에서 어쩔 수 없이 빼놓는게 금지어였습니다. 왜냐하면 사용할만한 라이브러리가 존재하지 않았기때문입니다. (제가 못찾을 것일수도 있습니다.)

그래서 Spring과 JAVA에 대해서 공부도 할겸 직접 라이브러리를 만들어보았습니다.

 

 

고르곤졸라는 되지만 고르곤 졸라는 안 돼! 배달의민족에서 금칙어를 관리하는 방법 | 우아한형

안녕하세요! 셀러시스템팀에서 서버 개발을 하고 있는 김예빈이라고 합니다. 배달의민족에는 금칙어를 관리하는 "통합금칙어시스템"이라는 것이 있습니다. 금칙어란? 법 혹은 규칙으로 사용이

techblog.woowahan.com

 

아이디어는 우아한기술블로그에서 가져왔으며 기술블로그에는 금지어 라이브러리를 제공하고 있지않습니다. 그래서 제가 직접 만드는겁니다.

 

 

어떤식으로 만들까?

금지어 검사기에 대한 대략적인 명세는 다음과 같습니다.

  1. 금지어는 json 데이터를 기반으로 데이터를 수집해야합니다.
  2. 검사기는 한번 로드되고 Singleton 이 보장되어야 합니다.
  3. 금지어이지만 예외단어는 금지어가 아니어야합니다. (예, "고르곤졸라" 에서 "졸라"는 금지어이지만 "고르곤졸라"는 예외단어)
  4. 사용자가 추가로 금지어나 예외단어를 추가할 수 있어야합니다.
  5. 금지어와 예외단어를 검사할때 띄어쓰기는 무시되어야합니다. (예, "AB가 금지어일경우 "A B"도 금지어)
  6. 금지어를 블라인드 할 수 있는 메소드가 있어야합니다.

이 정도로 정의하고 다음으로 넘어가겠습니다.

 

자료구조

클래스 설명
Word 금지어의 데이터
Inspector 사용자가 금지어 유무를 확인하는 최상위 클래스
InspecConfig 사용자가 금지어나 예외단어를 직접 추가할 수 있도록 하기위한 설정클래스
WordLoader Json 파일에서 단어들을 읽어오는 클래스
WordFactory 사용자가 factory에 직접 접근하지 않고 오로지 단어만 추가할 수 있도록 하는 클래스
WordFactoryBuilder<T> 금지어와 예외단어를 WordFactory를 통해 Json과 사용자로부터 주입받아 build 해주는 클래스
AbstractWordFactory<T> Factory의 공통 메소드 관리, 주입받은 단어들의 중복을 제거하고 build를 통해 WordUitl 에 데이터를 로드하는 클래스
BanWordFactoryImpl, ExceptWordFactoryImpl 생성자 주입용 구현체
BanWordUtil, ExceptWordUtil 자료구조인 WordUtil 를 통해 데이터를 제어하는 클래스
AbstractWordUtil BanWordUtil, ExceptWordUtil 의 공통 메소드 관리
BanWordUtil, ExceptWordUtil WordUtil, AbstractWordUtil의 구현체
WordUtil 데이터 자료구조

 

 

요청 시 예상 응답

// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "바나 나 먹을래"

List<Word> words = inspector.inspect(검사할문자열);
// 예상 결과 : Word(word="바나나", startIndex=0, endIndex=4)


// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 예외단어 : "사과주스"
// 검사할 문자열 : "사과주스 먹을래"

List<Word> words = inspector.inspect(검사할문자열);
// 예상 결과 : words.isEmpty() == true


// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "사과먹을래"

String mask = inspector.mask(검사할문자열);
// 예상결과 : "?먹을래"


// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "사과먹을래"
String mask = inspector.mask(검사할문자열, "X");
// 예상결과 : "X먹을래"

 

  1. 검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.
    마지막인덱스는 띄어쓰기가 포함한 인덱스이다.
  2. 예외단어에 포함된 문자열은 금지어에서 제외한다.
  3. 검사할 문자열에서 금지된 단어를 블라인드하려면 mask() 메소드를 사용한다. 두번째인자로 "?" 대신 변경할 문자열을 넣으면 해당 문자로 변경되어 반환한다.

 

 

 

구현

 

1. Inspector

public interface Inspector {

    List<Word> inspect(String word);
    
    default String mask(String word) {
        return mask(word, "?");
    }
    
    default String mask(String word, String replace) {
        StringBuilder sb = new StringBuilder(word);
        List<Word> data = inspect(word);
        for (int i = data.size() - 1; i >= 0; i--) {
            sb.replace(data.get(i).startIndex(), data.get(i).endIndex(), replace);
        }
        return sb.toString();
    }
    
}

 

가장 먼저 Inspector 인터페이스를 만들어줍니다.

메소드 설명
inspect(String word) : List<Word> 검사할 문자열이 인자로 들어오면 금지어 목록을 반환합니다.
default mask(String word) : String 금지어들을 replace 할 기본 문자열을 "?" 로 정의해 반환합니다.
default mask(String word, String replace) : String 금지어들을 replace 문자열로 변환합니다.

 

※ 왜 StringBuilder를 썼을까?

  1. replaceAll 보다 StringBuilder를 사용하는게 더 빠릅니다.
  2. 금지단어가 여러개일 경우 for 문을 사용해 순환할 때 문자열의 길이가 변경되어 다음 순환 시 StringIndexOutOfBoundsException 가 발생할 수 있습니다.
  3. replaceAll에는 예외를 둘 수가 없습니다.
    2번을 보완해 기존 데이터를 살려두고 replaceAll을 하는 경우에는 금지단어가 제외되지만 같은 단어로써 예외단어까지 영향을 미칠 수 있습니다.
// 금지어 : ["사과", "바나나"]
// 예외단어 : ["사과주스"]

String word = "사 과랑바나나";

List<Word> banWords = [
	(Word: word="사과", startIndex=0, endIndex=3), 
	(Word: word="바나나", startIndex=4, endIndex=7)
];

// 1.
 for (Word data : banWords) {
	word = word.replaceAll(word.substring(data.startIndex, data.endIndex), replace);
    // StringIndexOutOfBoundsException 발생
 }
 
 // 2.
String word = "사과랑 사과주스";
String replace = "?";
 String temp = word;
  for (Word data : banWords) {
	word = word.replaceAll(temp.substring(data.startIndex, data.endIndex), replace);
 }
 // 예상한 결과 : ?랑 사과주스
 // 실제 결과 : ?랑 ?주스

 

 

 

2. Inspector 구현체

@Component
public class InspectorImpl implements Inspector {

    private final BanWordUtil banWordUtil;
    private final ExceptWordUtil exceptWordUtil;

    @Autowired
    public InspectorImpl(InnerInspectConfig config) {
        banWordUtil = config.getBanWordUtil();
        exceptWordUtil = config.getExceptWordUtil();
    }

    private List<Word> executeBanWord(String word) {
        return banWordUtil.filter(word);
    }

    private List<Word> executeExceptWord(String word, List<Word> beforeWords) {
        return exceptWordUtil.filter(word, beforeWords);
    }

    @Override
    public List<Word> inspect(String word) {
        return executeExceptWord(word, executeBanWord(word));
    }
}

 

메소드 설명
생성자 Configuration을 주입받아 BanWordUtil, ExceptWordUtil을 설정해줍니다.

 

mask() 메소드는 default 에 정의해놓았기때문에 Override를 하지 않았습니다.

executeExceptWord(word, executeBanWord(word));

메소드는 예외단어처리(검사할문자열, 금지어단어목록) 으로 보시면됩니다.

 

 

3. Config

@Component
public class InnerInspectConfig {

    private final WordFactoryBuilder<BanWordUtil> banWordFactory;
    private final WordFactoryBuilder<ExceptWordUtil> exceptWordFactory;
    private final WordLoader wordLoader;
    private InspectConfig inspectConfig;

    @Autowired
    public InnerInspectConfig(WordFactoryBuilder<BanWordUtil> banWordFactory, WordFactoryBuilder<ExceptWordUtil> exceptWordFactory, WordLoader wordLoader) {
        this.banWordFactory = banWordFactory;
        this.exceptWordFactory = exceptWordFactory;
        this.wordLoader = wordLoader;
    }

    @Autowired(required = false)
    public void setInspectConfig(InspectConfig inspectConfig) {
        this.inspectConfig = inspectConfig;
    }

    @PostConstruct
    private void onApplicationReady() {
        if (inspectConfig != null) {
            inspectConfig.addBanWords(banWordFactory);
            inspectConfig.addExceptWords(exceptWordFactory);
        }

        banWordFactory.add(wordLoader.readBanWords());
        exceptWordFactory.add(wordLoader.readExceptWords());
    }

    public BanWordUtil getBanWordUtil() {
        return banWordFactory.build();
    }

    public ExceptWordUtil getExceptWordUtil() {
        return exceptWordFactory.build();
    }

}

 

내부 설정을 담당하는 클래스입니다.

이곳에서 BanWordUtil과 ExceptWordUtil을 build해서 Inspector에 전달해주는 역할을 하게 만들었습니다.

 

메소드 설명
setInspectConfig 사용자로부터 금지어 및 예외단어를 추가할 수 있게 하기 위해 setter 주입으로 구현했습니다.
Autowired(required = false)를 함으로써 사용자가 Config를 구현하지 않더라도 문제가 없도록 구현하기 위해 required = false를 했습니다.
onApplicationReady 의존성 주입이 완료되고(@PostConstruct) 사용자가 추가한 데이터(inspectConfig)와 기본데이터인 json 데이터(wordLoader.read~)를 각 factory에 추가합니다.
getBanWordUtil
getExceptWordUtil
build 된 WordUtil을 Inspector에서 get 하는 메소드입니다.

 

 

4. WordLoader

public interface WordLoader {

    List<String> readBanWords();
    List<String> readExceptWords();
}

@Component
public class WordLoaderImpl implements WordLoader {

    private final Log logger = LogFactory.getLog(WordLoaderImpl.class);

    @Override
    public List<String> readBanWords() {
        return read("static/BanWords.json");
    }

    @Override
    public List<String> readExceptWords() {
        return read("static/ExceptWords.json");
    }

    private List<String> read(String path) {
        try {
            return new ObjectMapper().readValue(new ClassPathResource(path).getInputStream(), new TypeReference<>() {});
        } catch (IOException e) {
            logger.error(e);
            return Collections.emptyList();
        }
    }

}

 

Json 데이터를 읽어오는 클래스입니다.

 

 

 

5. WordFactory

public interface WordFactory {

    WordFactory add(List<String> words);

}

public interface WordFactoryBuilder<T extends AbstractWordUtil> extends WordFactory {

    T build();

}

 

WordFactory 인터페이스는 사용자로부터 금지어, 예외단어를 주입받기 위해 사용하는 클래스이고,

WordFactoryBuilder<T extends AbstractWordUtil> 는 Config에서 WordUtil을 build 하기 위해 사용되는 인터페이스입니다.

 

 WordFactoryBuilder 를 도입한 이유

  1. factory와 util 간의 메소드 분리
  2. 사용자로부터 build 메소드가 노출되지 않아야함
  3. BanWordFactory, ExceptWordFactory에 build는 무조건 실행되어야 하기때문에 interface로 강제하기위함

 

구현체

public abstract class AbstractWordFactory<T extends AbstractWordUtil> implements WordFactoryBuilder<T> {

    private final T wordUtil;
    private final Set<String> distinctWordSet = new HashSet<>();

    public AbstractWordFactory(T wordUtil) {
        this.wordUtil = wordUtil;
    }

    @Override
    public WordFactory add(List<String> words) {
        distinctWordSet.addAll(words);
        return this;
    }

    @Override
    public T build() {
        distinctWordSet.forEach(wordUtil::addWord);
        wordUtil.build();
        return wordUtil;
    }
}

@Component
public class BanWordFactoryImpl extends AbstractWordFactory<BanWordUtil> {

    @Autowired
    public BanWordFactoryImpl(BanWordUtil banWordUtil) {
        super(banWordUtil);
    }

}

@Component
public class ExceptWordFactoryImpl extends AbstractWordFactory<ExceptWordUtil> {
    
    public ExceptWordFactoryImpl(ExceptWordUtil exceptWordUtil) {
        super(exceptWordUtil);
    }
    
}

 

add된 words 는 Set으로 중복제거를 거쳐 마지막에 build로 wordUtil을 반환시킨다. Impl 인 구현체들은 생성자 주입을 담당하는 역할만 수행합니다.

 

 

 

6. AbstractWordUtil

public abstract class AbstractWordUtil {

    protected final WordUtil wordUtil;

    public AbstractWordUtil(WordUtil wordUtil) {
        this.wordUtil = wordUtil;
    }

    public void addWord(String word) {
        wordUtil.addWord(word);
    }


    public void build() {
        wordUtil.build();
    }

}

@Component
public class BanWordUtil extends AbstractWordUtil {

    public BanWordUtil(@Qualifier("ban") WordUtil wordUtil) {
        super(wordUtil);
    }

    public final List<Word> filter(String word) {
        return wordUtil.search(word);
    }

}

@Component
public class ExceptWordUtil extends AbstractWordUtil {

    public ExceptWordUtil(@Qualifier("except") WordUtil wordUtil) {
        super(wordUtil);
    }

    public final List<Word> filter(String newWord, List<Word> banWords) {
        return (banWords.isEmpty()) ? List.of() : expectFilter(newWord, banWords);
    }

    private List<Word> expectFilter(String newWord, List<Word> banWords) {
        List<Word> exceptWords = wordUtil.search(newWord);

        if (exceptWords.isEmpty()) return banWords;

        List<Word> newWords = new ArrayList<>();

        a:for (Word banWord : banWords) {
            for (Word exceptWord : exceptWords) {
                if (banWord.isInclude(exceptWord)) continue a;
            }
            newWords.add(banWord);
        }
        return newWords;
    }

}

 

자료구조를 제어하는 클래스입니다. BanWordUtil과 ExceptWordUtil에 반드시 존재해야하는 addWord(), build() 메소드를 추상클래스로 정의했습니다. 자료구조는 WordUtil 자리에 들어갈겁니다.

 

 

 

7. WordUtil

public interface WordUtil {

    void addWord(String word);
    void build();
    List<Word> search(String word);

}

 

가장 중요한 부분입니다. 바로 문자열 검사에 대한 알고리즘이 들어가는 부분인데요. 처음에 이 부분을 직접 구현해서 사용했었는데, 위의 우아한기술블로그에서 소개한 아호코라식 알고리즘 을 기반으로 커스텀해서 띄어쓰기를 허용한 데이터를 반환하게 구성했습니다.

 

@Component
public class AhoCorasickWordUtil implements WordUtil {

    private final TrieNode root;

    public AhoCorasickWordUtil() {
        this.root = new TrieNode();
    }

    static private class TrieNode {
        Map<Character, TrieNode> children = new HashMap<>();
        TrieNode failureLink = null;
        Set<String> output = new HashSet<>();
    }

    @Override
    public void addWord(String word) {
        TrieNode node = root;
        for (int i = 0; i < word.length(); i++) {
            node = node.children.computeIfAbsent(word.charAt(i), k -> new TrieNode());
        }
        node.output.add(word);
    }

    @Override
    public void build() {
        Queue<TrieNode> queue = new LinkedList<>();
        root.failureLink = root;

        for (TrieNode node : root.children.values()) {
            node.failureLink = root;
            queue.add(node);
        }

        while (!queue.isEmpty()) {
            TrieNode current = queue.poll();

            for (Map.Entry<Character, TrieNode> entry : current.children.entrySet()) {
                char c = entry.getKey();
                TrieNode child = entry.getValue();

                TrieNode failure = current.failureLink;
                while (failure != root && !failure.children.containsKey(c)) {
                    failure = failure.failureLink;
                }

                if (failure.children.containsKey(c) && failure.children.get(c) != child) {
                    child.failureLink = failure.children.get(c);
                } else {
                    child.failureLink = root;
                }

                child.output.addAll(child.failureLink.output);
                queue.add(child);
            }
        }
    }

    @Override
    public List<Word> search(String word) {
        List<Word> result = new ArrayList<>();
        if (word == null || word.isEmpty()) return result;
        TrieNode node = root;

        int realIndex = 0;
        int nonSpaceIndex = 0;

        Map<Integer, Integer> startIndices = new HashMap<>();

        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);

            if (Character.isWhitespace(c)) {
                realIndex++;
                continue;
            }

            startIndices.put(nonSpaceIndex, realIndex);

            while (node != root && !node.children.containsKey(c)) {
                node = node.failureLink;
            }

            if (node.children.containsKey(c)) {
                node = node.children.get(c);
            }

            for (String pattern : node.output) {
                int start = startIndices.getOrDefault(nonSpaceIndex - (pattern.length() - 1), -1);
                int end = realIndex + 1;

                if (start != -1) {
                    result.add(new Word(pattern, start, end));
                }
            }

            nonSpaceIndex++;
            realIndex++;
        }

        return result;
    }

}

 

8. Word

public record Word(String word, int startIndex, int endIndex) {

    public boolean isInclude(Word word) {
        return word.startIndex <= startIndex && word.endIndex >= endIndex;
    }

}

 금지어의 domain 객체입니다.

startIndex = includeIndex 이고, endIndex = excludeIndex 입니다.

 

 

 

테스트도 완료!

 

 

실행결과

@Configuration
public class TestConfig implements InspectConfig {

    @Override
    public void addBanWords(WordFactory factory) {
        factory
            .add(List.of("사과", "바나나", "오렌지", "수박"))
            .add(List.of("오이", "감자", "고구마"));
    }

    @Override
    public void addExceptWords(WordFactory factory) {
        factory.add(List.of("사과주스", "호박고구마"));
    }
    
}

 

json 기본 데이터 외에 사용자가 금지어와 예외단어를 추가할 수 있습니다.

 

 

 

 

잘 동작합니다.

 

 

마무리하면서 느낀점

이번 금지어 검사기는 즉흥적으로 시작했던터라 생각보다 시간이 좀 걸렸던 것같습니다. 자그마한 프로젝트를 하면서 배운게 몇가지가 있는습니다.

  1. JAVA 문자열 처리
    앞서 말한대로 알고리즘을 사용하기 전에 직접 만들어보며 문자열에 대한 이해도가 높아졌고, 아호코라식 알고리즘을 알게 되고, 사용해본게 가장 유용했습니다.
  2. 기능분리
    위의 자료구조를 보면
    - 설정파트를 담당하는 InspectConfig, InnerInspectConfig
    - JSON을 불러오는 WordLoader
    - 설정파트에서 데이터를 로드해 Util을 Builder 해주는 WordFactoryBuilder
    - 금지어, 예외단어를 처리하는 BanWordUtil, ExceptWordUtil
    - 문자열 처리를 담당하는 WordUtil
    - 사용자가 사용하는 Inspector
    이렇게 나누어져있는걸 볼 수 있습니다. 이런 역할을 분리하고, 인터페이스화, 추상화 단계를 직접 처리하며 기능분리에대한 이해도가 높아진것같습니다.
  3. 외부로 부터 주입받는 데이터
    가장 고심했던게 InspectConfig 를 사용하는 것이었습니다. 사용자가 인터페이스를 상속하지 않더라도 문제가 없어야하고, 상속했다면 데이터를 넣어주어야 하는것에 대해서 어떻게 해야하는지 고민이 많았습니다. 그 결과로 required = false의 setter 주입을 통해 이 문제를 해결했고, null 체크를 해서 데이터를 외부로부터 주입받는 방법으로 이 문제를 해결했습니다.

뭐 아무튼 그렇고, 이 문자열 검사기는 아직 미완성입니다. 완성이 되면 Maven Central Repository 에 업로드할 예정입니다.

 

9월 30일 업데이트!
WordLoader 객체를 삭제해서 json 파일을 더 이상 사용하지 않게 변경되었고,
https://mvnrepository.com/artifact/io.github.kimseungwo/wordinspector
0.0.10 버전부터 적용되었습니다.

 

 

 

public interface WordUtil {

    default void addWord(String word) { push(word, 0);}

    void push(String word, int index);

    int find(String str, int index, int deep, boolean ignoreSpace);

}

@Component
public class WordUtilImpl implements WordUtil {

    private final Map<Character, WordUtilImpl> data = new HashMap<>();

    @Override
    public void push(String word, int index) {
        if (word.length() <= index) return;

        char c = word.charAt(index);

        if (!data.containsKey(c)) data.put(c, new WordUtilImpl());
        data.get(c).push(word, index + 1);
    }


    @Override
    public int find(String str, int index, int deep, boolean ignoreSpace) {
        WordUtilImpl wordUtil = this;
        while (true) {
            if (wordUtil.data.isEmpty()) return deep == 0 ? -1 : deep;
            if (str.length() <= index) return -1;

            if (ignoreSpace && str.charAt(index) == ' ') {
                if (deep == 0) return -1;
                index++;    deep++;
                continue;
            }

            if (!wordUtil.data.containsKey(str.charAt(index))) return -1;
            wordUtil = wordUtil.data.get(str.charAt(index));
            index++;    deep++;
        }
    }

}

직접 만들어본 자료구조입니다..

+ Recent posts