저번 글에서 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;
}
}
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분
}
}
정확히는 세션 고정 공격 보호 (Session Fixation Attack Protection) 입니다. 세션 고정 공격은 악의적인 공격자가 사이트에 접속해서 세션을 생성한 다음 동일한 세션으로 다른 사용자가 접속하도록 유도할 수 있는 잠재적인 공격입니다. (예, 세션 식별자를 매개변수로 포함하는 링크를 전송) Spring Security는 사용자가 로그인할 때 새 세션을 생성하거나 세션 ID를 변경하여 이를 자동으로 보호하는데 이를 설정하는 것이 sessionFixation 설정입니다.
세션은 자체적으로 만료되며 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()
);
}
# 쿠키 만료 시간 설정
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 관련된 내용은 어마어마하게 많습니다. 다음글에서는 이 글을 토대로 어떻게 적용하는지에 대해서 알아보도록하겠습니다.
Spring Security의 Anonymous 인증은 인증되지 않은 사용자(로그인하지 않은 사용자)를 처리하는 메커니즘입니다. 인증되지 않은 요청에 대해 AnonymousAuthenticationToken을 생성하여 보안 컨텍스트에 저장합니다. 내용을 길지않으니 빠르게 알아보겠습니다.
사용법
anonymous 설정은 아래와 같습니다.
http.anonymous(anonymous -> anonymous
.principal("anonymousUser") // 익명 사용자의 주체
.authorities("ROLE_ANONYMOUS") // 익명 사용자의 권한
);
// 비활성화
http.anonymous(AbstractHttpConfigurer::disable);
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"));
}
@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 객체를 넣어줍니다.
이 권한 PrincipalDetails에 getAuthorities() 에 구현한 데이터를 기반으로 동작합니다. 그런데 우리는 "ROLE_USER" 를 넣었지 "USER"를 넣지는 않았습니다. 그 이유는 GrantedAuthority 기본 규칙은 "ROLE_" 접두사를 사용하는것이 default 이기 때문입니다.
특정 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 접근이 됩니다.
Spring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Spring Security를 통해 쉽게 해결할 수 있습니다. 다만 이 프래임워크가 워낙 방대하고 구조를 이해하고 있지않으면 활용하기 쉽지 않습니다. 하나의 게시물로 모든 Security 기능에 대해서 소개하지 못하므로, 이 글에서는 프레임워크에 대해 기본개념과 주요기능에 대해서 소개하고 각 기능에 대한 상세한 설명은 다음 글에서 이어가도록 하겠습니다.
이 글은 Spring Boot 3.4.0, Spring Security 6.4.1 환경에서 제작되었습니다.
들어가기전에
Security를 공부하기전에 짧게 기본개념을 잡고 가겠습니다. 이해하는데 도움이 될겁니다.
인증 이후 SecurityContext에 저장되는 인증된 사용자의 정보 예) 사원증을 소지한 직원
Role
권한
특정 리소스에 대한 접근 권한을 그룹화한 것
사용자에게 부여될 수 있는 권한의 집합 예) 회사에서의 직급 (사원, 대리, 과장 등)
용어 간의 관계
1. Authentication -> Principal 생성
(인증 성공 시 Principal 정보가 생성됨)
2. Principal -> Role 부여
(인증된 사용자에게 역할이 부여됨)
3. Role -> Authorization 결정
(부여된 역할에 따라 권한이 결정됨)
3. SecurityContextHolder
SecurityContextHolder는 인증된 사용자의 정보를 저장하는 곳입니다.
4. Filter, Authentication, Authorization
Security에서 크게 Filter, Authentication, Authorization 으로 나눌 수 있습니다. 아키텍처를 볼 때, 이렇게 3개로 구분해서 보면 이해가 조금 더 쉬울 수 있습니다.
시큐리티 공식문서에서 가져온 아키텍처입니다. 이 사진을 보고 이해해야할것은 아래와 같습니다.
SecurityFilterChain 은 Filter에서 동작한다.
Authentication을 통해 인증과정을 수행한다. 인증에 성공하면 SecurityContextHolder에 인증된 사용자의 정보를 담는다. 인증에 실패하면 SecurityContextHolder가 비워집니다.(cleared out)
2. Filter
2-1. DelegatingFilterProxy
서블릿 컨테이너와 Spring의 ApplicationContext 사이의 연결점입니다. 서블릿 컨테이너는 자체 표준을 사용하여 필터 인스턴스를 등록할 수 있지만 Bean에 대해서는 알지 못합니다. 그래서 DelegatingFilterProxy 에 Bean 등록을 위임하도록 합니다.
한줄정리 : DelegatingFilterProxy는 Servlet Container - Spring ApplicationContext를 연결하는 역할을 한다!
2-2. FilterChainProxy과 SecurityFilterChain
FilterChainProxy는 보안필터의 시작점입니다.
요청 URL에 따라(RequestMatcher) 적절한 SecurityFilterChain을 선택할 수 있도록 합니다.
Spring Security의 HttpFirewall을 적용하여 특정 유형의 공격으로부터 애플리케이션을 보호합니다.
SecurityFilterChain에는 여러 Filter가 존재하는데 그 Filter의 순서는 FilterOrderRegistration.class 를 통해 확인할 수 있습니다. Filter의 순서를 외울필요는 없지만 어느 정도 흐름은 알고있는게 좋습니다.
각 Filter의 용도와 사용법은 다음 글에서 알아보도록하겠습니다.
3. Authentication (인증)
사용자 정보를 가지고 인증을 하는 과정입니다.
3-1. SecurityContextHolder
SecurityContextHolder는 인증된 사용자의 정보를 담고있는 Container입니다. Spring Security는 SecurityContextHolder가 어떻게 채워지는지 묻지않습니다. 그저 값이 포함되어있으면 인증된 사용자로 인식합니다. 즉, SecurityContextHolder가 비어있으면 인증되지않은 사용자, 비어있지않으면 인증된 사용자로 인식됩니다.
ThreadLocal을 사용하여 보안정보를 저장합니다. 즉, SecurityContext는 동일한 스레드내에서는 어디서든 보안 정보를 쉽게 접근 가능합니다. 요청 처리가 끝나면 반드시 쓰레드의 정보를 지워야 하지만 FilterChainProxy가 이 청소를 자동으로 해주기때문에 매우 안전하게 사용할 수 있습니다.
ThreadLocal에 대해 안전하지않은 어플리케이션에 대해서는 ThreadLocal 설정을 변경할 수 있습니다.
@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");
}
}
}
검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다. 마지막인덱스는 띄어쓰기가 포함한 인덱스이다.
예외단어에 포함된 문자열은 금지어에서 제외한다.
검사할 문자열에서 금지된 단어를 블라인드하려면 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를 썼을까?
replaceAll 보다 StringBuilder를 사용하는게 더 빠릅니다.
금지단어가 여러개일 경우 for 문을 사용해 순환할 때 문자열의 길이가 변경되어 다음 순환 시 StringIndexOutOfBoundsException 가 발생할 수 있습니다.
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));
}
}
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 를 도입한 이유
factory와 util 간의 메소드 분리
사용자로부터 build 메소드가 노출되지 않아야함
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 자리에 들어갈겁니다.
가장 중요한 부분입니다. 바로 문자열 검사에 대한 알고리즘이 들어가는 부분인데요. 처음에 이 부분을 직접 구현해서 사용했었는데, 위의 우아한기술블로그에서 소개한 아호코라식 알고리즘 을 기반으로 커스텀해서 띄어쓰기를 허용한 데이터를 반환하게 구성했습니다.
@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;
}
}
@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 기본 데이터 외에 사용자가 금지어와 예외단어를 추가할 수 있습니다.
잘 동작합니다.
마무리하면서 느낀점
이번 금지어 검사기는 즉흥적으로 시작했던터라 생각보다 시간이 좀 걸렸던 것같습니다. 자그마한 프로젝트를 하면서 배운게 몇가지가 있는습니다.
JAVA 문자열 처리 앞서 말한대로 알고리즘을 사용하기 전에 직접 만들어보며 문자열에 대한 이해도가 높아졌고, 아호코라식 알고리즘을 알게 되고, 사용해본게 가장 유용했습니다.
기능분리 위의 자료구조를 보면 - 설정파트를 담당하는 InspectConfig, InnerInspectConfig - JSON을 불러오는 WordLoader - 설정파트에서 데이터를 로드해 Util을 Builder 해주는 WordFactoryBuilder - 금지어, 예외단어를 처리하는 BanWordUtil, ExceptWordUtil - 문자열 처리를 담당하는 WordUtil - 사용자가 사용하는 Inspector 이렇게 나누어져있는걸 볼 수 있습니다. 이런 역할을 분리하고, 인터페이스화, 추상화 단계를 직접 처리하며 기능분리에대한 이해도가 높아진것같습니다.
외부로 부터 주입받는 데이터 가장 고심했던게 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++;
}
}
}