전체 글
-
[Spring] 로그인 시 Cors 에러 해결2023.11.14
-
[Spring] Thymeleaf를 통한 페이지네이션 구현2023.09.07
-
[Thymeleaf] th:href 경로에 변수를 사용한 경로 설정2023.09.07
-
[Thymeleaf] layout fragment로 변수 넘겨주기2023.09.07
-
[Git] branch 이름 master에서 main으로 변경하기2023.09.05
-
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현2023.08.29
-
[Spring] 다중 칼럼 인덱스를 활용한 쿼리 속도 개선2023.05.14
-
[AWS] EC2 인스턴스에 MySQL 및 redis 설치2023.04.16
-
[Spring] Scheduler(@Scheduled)를 활용한 마감 시간 관리2023.03.23
-
[Java] 백그라운드에서 빌드 파일 실행하기 (nohup, &)2022.12.05
[Spring] 로그인 시 Cors 에러 해결
기존 SSR(Thymeleaf + SpringBoot) 방식의 프로젝트를 CSR(React + SpringBoot)로 바꾸다가 발생한 에러이다.
로그인 방식
기존에 폼 로그인과 세션을 사용해서 인증하던 방식을 JWT를 사용하도록 변경하고 있었다..
그 과정에서 로그인은 UsernamePasswordAuthenticationFilter를 상속한 클래스를 만들어서 처리할 수 있도록 했다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
ObjectMapper om = new ObjectMapper();
LoginRequestDto dto = om.readValue(request.getInputStream(), LoginRequestDto.class);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
dto.username(),
dto.password());
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) throws ServletException, IOException {
String accessToken = jwtTokenProvider.generateAccessToken(authResult);
String refreshToken = jwtTokenProvider.generateRefreshToken(authResult);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
}
}
해당 클래스를 SecurityConfiguration 클래스에서 사용하여 인증을 처리할 수 있도록 했다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
http
.apply(new JwtSecurityConfig());
return http.build();
}
public class JwtSecurityConfig extends AbstractHttpConfigurer<JwtSecurityConfig, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager am = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthFilter = new JwtAuthenticationFilter(am, jwtTokenProvider);
jwtAuthFilter.setFilterProcessesUrl("/auth/login");
jwtAuthFilter.setAuthenticationSuccessHandler(new CustomAuthenticationSuccessHandler());
jwtAuthFilter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler());
JwtFilter jwtFilter = new JwtFilter(jwtTokenProvider);
builder
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilter(jwtAuthFilter);
}
}
}
추가적으로 CORS에 대해서는 CorsFilter로 설정해주었다.
@Component
public class CorsFilter implements Filter {
private static final String CLIENT_URL = "http://localhost:3000";
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws
IOException,
ServletException {
HttpServletRequest req = (HttpServletRequest)request;
HttpServletResponse res = (HttpServletResponse)response;
// 요청을 허용하는 출처
res.setHeader("Access-Control-Allow-Origin", CLIENT_URL);
// 요청을 허용하는 HTTP 메소드
res.setHeader("Access-Control-Allow-Methods", "*");
// 쿠키 요청 허용
res.setHeader("Access-Control-Allow-Credentials", "true");
// 요청을 허용하는 헤더 이름
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization");
// 클라이언트에서 preflight 요청 결과를 저장할 시간
res.setHeader("Access-Control-Max-Age", "3600");
if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
res.setStatus(HttpServletResponse.SC_OK);
} else {
chain.doFilter(request, response);
}
}
@Override
public void destroy() {
Filter.super.destroy();
}
}
에러 발생
위와 같이 서버 코드가 작성되었고, 클라이언트 측에서 로그인 요청을 하면 아래와 같은 에러가 발생한다.
CORS 정책에 막혔다고 하는 것을 볼 수 있다.
다른 url 요청에 대해서는 문제가 없지만 login 요청에 대해서만 거부된다.
서버 측에서는 아래와 같은 에러가 발생한다.
com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input
at [Source: (org.apache.catalina.connector.CoyoteInputStream); line: 1, column: 0]
at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ObjectMapper._initForReading(ObjectMapper.java:4916) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4818) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3809) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.e.commerce.global.security.jwt.JwtAuthenticationFilter.attemptAuthentication(JwtAuthenticationFilter.java:30) ~[main/:na]
...
확인해보면 JwtAuthenticationFilter 클래스에서 사용한 ObjectMapper에서 null을 받아 발생한 에러이다.
문제점 파악
Postman을 사용한 로그인 시에는 정상적으로 로그인이 되지만, 리액트앱을 사용했을 때만 에러가 발생한다.
그리고 구글링하던 중 다음과 같은 글을 보았다.
하지만 UsernamePasswordAuthenticationFilter는 특징이 있어 이 특징을 잘 처리해줘야한다.
- 해당 FIlter를 거치고 다음 Filter로 가지 않는다. 그렇기 때문에 인증 성공 여부에 따른 메서드 successAuthenticaiton/unSuccessfulAuthentication을 구현해야한다.
출처 : https://jaewoo2233.tistory.com/72
Spring Security - JWT 설정
해당 url로 { "email":"user", "password":"1111" } 데이터를 바디에 담아 요청하면 토큰이 발행되도록 만들려고 한다. Spring 2.7.0 기준 일단 먼저일반적으로 Spring Security에 로그인 요청을 날리면 UsernamePassword
jaewoo2233.tistory.com
문제 해결
이에 UsernamePasswordAuthenticationFilter를 사용한 로그인 방식이 CorsFilter를 거치지 않기 때문에
CORS 정책에서 거부당했다고 생각하여 CorsFilter를 CorsConfig를 설정해주는 형식으로 변경하였다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// cors 설정
http
.cors(cors -> cors.configurationSource(
request -> {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://localhost:3000");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
config.setAllowedHeaders(List.of("Origin", "X-Requested-With", "Content-Type", "Accept",
"Authorization", "Refresh"));
config.setExposedHeaders(List.of("Authorization", "Refresh"));
config.setMaxAge(3600L);
return config;
}
));
...
return http.build();
}
}
이제 로그인이 성공적으로 된다.
서버 측에서도 성공적으로 인증이 된 것을 볼 수 있다.
CustomAuthenticationSuccessHandler : ### 1. UsernamePasswordAuthenticationToken [Principal=jwanna, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]
CustomAuthenticationSuccessHandler : ### 2. jwanna
CustomAuthenticationSuccessHandler : # Authenticated Successful
'Spring' 카테고리의 다른 글
[Spring] Thymeleaf를 통한 페이지네이션 구현 (0) | 2023.09.07 |
---|---|
[Thymeleaf] th:href 경로에 변수를 사용한 경로 설정 (0) | 2023.09.07 |
[Thymeleaf] layout fragment로 변수 넘겨주기 (0) | 2023.09.07 |
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Spring] Thymeleaf를 통한 페이지네이션 구현
매장 목록 조회 구현
@Transactional(readOnly = true)
public MultiResponseDto<StoreResponseDto> getAllStoresByPaging(int page, int size) {
Page<Store> stores = storeRepository.findAll(PageRequest.of(page, size));
List<StoreResponseDto> responses = stores.getContent()
.stream()
.map(StoreResponseDto::from)
.toList();
return new MultiResponseDto<>(responses, PageInfo.from(stores));
}
page, size 2가지의 변수를 받아 Pagable 객체를 생성하여 Repository에서 검색한 뒤
임의로 생성한 DTO에 Response body와 Page 정보를 담아 리턴하도록 했다.
@GetMapping("/stores")
public String searchStoresForm(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, Model model) {
MultiResponseDto<StoreResponseDto> responses = storeService.getAllStoresByPaging(page - 1, size);
model.addAttribute("storeList", responses.data());
model.addAttribute("pageInfo", responses.pageInfo());
return "search-stores";
}
리턴받은 정보는 model에 담아서 전달했다.
매장 목록 조회 템플릿
<div class="container" layout:fragment="content">
<table style="table-layout: fixed">
<thead class="table-light">
<tr>
<th scope="col">매장 이름</th>
<th scope="col">매장 주소</th>
</tr>
</thead>
<tbody>
<tr th:each="store: ${storeList}">
<td th:text="${store.storeName}"></td>
<td th:text="${store.address}"></td>
</tr>
</tbody>
</table>
<nav th:replace="~{fragments/pageNavBar::pageNavBar(${pageInfo}, 'stores')}"></nav>
</div>
간단하게 매장 이름과 주소만 전달 받아서 테이블 형식으로 출력하도록 구성했다.
하단에는 페이지네이션에 대한 네비게이션 바를 공통으로 사용할 수 있게 fragment로 구성했다.
<!-- pageNavBar.html -->
<nav style="text-align: center" th:fragment="pageNavBar (pageInfo, path)">
<!-- maxPage : 네비게이션바에 나타나는 페이지 수
start : 해당 네비게이션 바의 시작 페이지
end : 해당 네비게이션 바의 끝 페이지-->
<ul th:with="url=|/search/${path}|,
maxPage=5,
start=(${(pageInfo.page() % maxPage == 0) ?
((pageInfo.page() - 1) / maxPage) * maxPage + 1:
(pageInfo.page() / maxPage) * maxPage + 1}),
end=(${(pageInfo.totalPages() == 0) ? 1 :
(start + (maxPage - 1) < pageInfo.totalPages ? start + (maxPage - 1) : pageInfo.totalPages)})">
<li th:if="${start > 1}">
<a th:href="|${url}?page=1&size=${pageInfo.size()}|" th:text="'<<'"></a>
</li>
<li th:if="${start > 1}">
<a th:href="|${url}?page=${start - maxPage}&size=${pageInfo.size()}|" th:text="'<'"></a>
</li>
<li th:each="pageNum: ${#numbers.sequence(start, end)}">
<a th:text="${pageNum}" th:href="|${url}?page=${pageNum}&size=${pageInfo.size()}|"></a>
</li>
<li th:if="${end < pageInfo.totalPages()}">
<a th:href="|${url}?page=${start + maxPage}&size=${pageInfo.size()}|" th:text="'>'"></a>
</li>
<li th:if="${end < pageInfo.totalPages()}">
<a th:href="|${url}?page=${pageInfo.totalPages()}&size=${pageInfo.size()}|" th:text="'>>'"></a>
</li>
</ul>
</nav>
해당 부분을 나눠서 보면
<nav style="text-align: center" th:fragment="pageNavBar (pageInfo, path)">
fragment를 통해 공통으로 사용할 수 있도록 하고, 필요한 변수를 전달받았다.
<ul th:with="url=|/search/${path}|,
maxPage=5,
start=(${(pageInfo.page() % maxPage == 0) ?
((pageInfo.page() - 1) / maxPage) * maxPage + 1:
(pageInfo.page() / maxPage) * maxPage + 1}),
end=(${(pageInfo.totalPages() == 0) ? 1 :
(start + (maxPage - 1) < pageInfo.totalPages ? start + (maxPage - 1) : pageInfo.totalPages)})">
url은 pathVariable이 아닌 경로 자체를 변수로 받아서 사용하기 위해 설정한 값이다.
maxPage는 한 네비게이션 바에 몇 개의 목록이 나열될지 최대 수를 정한 것이다.
start는 네비게이션 바의 시작점
end는 네비게이션 바의 끝점이다.
현재 maxPage=5이므로 1 2 3 4 5 > 다음은 6 7 8 이라고 가정하자.
1 2 3 4 5에서 start는 1, end는 5이고, 6 7 8에서 start는 6, end는 8이다.
현재 페이지 정보(pageInfo.page())를 전달 받을 때, 0부터 시작하면 start는 아래와 같이 간단하게 설정이 가능하다.
<ul th:with="start=${pageInfo.page() / maxPage) * maxPage + 1}">
나는 아무 생각 없이 1부터 시작하도록 했더니 maxPage의 배수인 경우에 이상한 오류가 발생해서 저렇게 조건을 붙였다.
앞으로 프론트엔드에게 데이터를 전달할 땐 0부터 시작하도록 하는게 좋은지 물어보도록 해야겠다.
end는 데이터가 없을 때 1이 표시되도록 하고, 6 7 8 처럼 마지막 페이지 수가 maxPage 갯수에 모자랄 경우에 대한 설정을 해줬다.
<li th:if="${start > 1}">
<a th:href="|${url}?page=1&size=${pageInfo.size()}|" th:text="'<<'"></a>
</li>
<li th:if="${start > 1}">
<a th:href="|${url}?page=${start - maxPage}&size=${pageInfo.size()}|" th:text="'<'"></a>
</li>
시작 번호가 1보다 클 때 (첫 페이지가 아닐 때) 처음으로 이동하거나 이전 페이지로 이동하는 버튼을 만들어 주었고, 마찬가지로 반대에도 다음 페이지로 이동하는 버튼을 만들어 주었다.
<li th:each="pageNum: ${#numbers.sequence(start, end)}">
<a th:text="${pageNum}" th:href="|${url}?page=${pageNum}&size=${pageInfo.size()}|"></a>
</li>
해당 버튼 사이에는 start에서 end까지 해당 페이지로 이동하는 페이지 번호를 남겨주었다.
결과 페이지
추후 개선 사항
pagination할 때 지금은 page, size 정보만 받지만 이후에 발전되면 재고순, 거리순 등 상점을 검색하는 조건들을 Enum으로 만든 뒤, 해당 조건을 파라미터로 받아 CustomPageRequest를 구성해서 정렬 기준을 추가해보면 좋을 것 같다.
사용하면서 공부한 기술 정리
[Thymeleaf] 변수를 통해 경로 지정하기 (PathVariable 아님)
더 자세한 코드는 여기에서 확인이 가능합니다.
'Spring' 카테고리의 다른 글
[Spring] 로그인 시 Cors 에러 해결 (0) | 2023.11.14 |
---|---|
[Thymeleaf] th:href 경로에 변수를 사용한 경로 설정 (0) | 2023.09.07 |
[Thymeleaf] layout fragment로 변수 넘겨주기 (0) | 2023.09.07 |
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Thymeleaf] th:href 경로에 변수를 사용한 경로 설정
네비게이션 바를 분리해서 공용으로 사용하고 싶어서 변수를 통해서 경로 설정하는 방법을 찾는데,
PathVariable 관련 설명만 나와서 찾느라 힘들었다..
이전 글(링크)에서 fragment를 통해 변수를 전달하는 방법을 설명했는데, 이렇게 전달한 변수를 통해서 URL 경로를 설정해보았다.
<ul th:with="url=|/search/${path}|">
먼저 공용으로 사용할 경로를 설정해준다.
<a th:href="|${url}?page=1&size=${pageInfo.size()}|" th:text="'<<'"></a>
이후 리터럴 표현식을 통해 경로에 쿼리 파라미터를 붙여주어서 경로 설정을 했다.
'Spring' 카테고리의 다른 글
[Spring] 로그인 시 Cors 에러 해결 (0) | 2023.11.14 |
---|---|
[Spring] Thymeleaf를 통한 페이지네이션 구현 (0) | 2023.09.07 |
[Thymeleaf] layout fragment로 변수 넘겨주기 (0) | 2023.09.07 |
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Thymeleaf] layout fragment로 변수 넘겨주기
예를 들어 공용으로 사용하는 페이지네이션 바 페이지와 해당 바를 사용하는 페이지가 있다고 하자
공용으로 사용하는 페이지에 아래와 같이 받을 매개변수를 정해줄 수 있다.
<!-- pageNavBar.html -->
<div style="text-align: center" th:fragment="pageNavBar (pageInfo, path)">
공용 페이지를 사용할 페이지에서는 아래와 같이 매개변수를 전달해 줄 수 있다.
<!-- search-stores.html -->
<div th:replace="~{fragments/pageNavBar::pageNavBar(${pageInfo}, 'stores')}">
전달 받은 매개변수를 사용할 때에도 아래와 같이 사용할 수 있다.
<ul th:with="maxPage=5,
start=${(pageInfo.page() / maxPage) * maxPage + 1}) - 1}">
단, 리터럴 내에서 사용할 때에는 ${} 변수 표현식을 사용해야 한다.
<ul th:with="url=|/search/${path}|">
'Spring' 카테고리의 다른 글
[Spring] Thymeleaf를 통한 페이지네이션 구현 (0) | 2023.09.07 |
---|---|
[Thymeleaf] th:href 경로에 변수를 사용한 경로 설정 (0) | 2023.09.07 |
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Spring] 다중 칼럼 인덱스를 활용한 쿼리 속도 개선 (0) | 2023.05.14 |
[Git] branch 이름 master에서 main으로 변경하기
인텔리제이나 VSCODE를 활용해 프로젝트를 깃허브에 생성하게 되면 기본 브랜치명이 master로 생성된다.
불편하니까 main으로 바꾸는 방법을 알아보았다.
1. 원격 저장소의 브랜치 명을 master -> main으로 바꾼다.
Github에서 Repository -> Settings로 들어간다.
우측 하단에 Default branch가 보이는데 연필 모양을 눌러 브랜치명을 수정한다.
2. 로컬 작업 환경의 브랜치 명을 master -> main으로 바꿔야한다.
인텔리제이나 VSCODE의 터미널을 사용해도 좋고, 터미널을 사용해도 좋다.
프로젝트 메인 경로에서 아래와 같이 실행한다.
git branch -m master main
git fetch origin
git branch -u origin/main main
git branch -m master main
(로컬) master 브랜치의 이름을 main 브랜치로 변경한다.
git fetch origin
원격 저장소로부터 저장소 정보를 받아 동기화한다.
(git pull = git fetch + git merge)
git branch -u origin/main main
로컬에 존재하는 main 브랜치를 원격 저장소에 존재하는 origin/main 브랜치의 트래킹 브랜치(Upstream 브랜치)로 만든다.
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현
Spring Security를 통한 세션 방식 로그인 구현
SpringBoot 3.1.3 버전을 사용했습니다.
1. SecurityConfiguration 구현
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
// 정적 자원에 대한 Security Ignoring 처리
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.requestMatchers(HttpMethod.POST, "/registers");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> {
auth.requestMatchers("*").permitAll();
auth.requestMatchers("auths/**").permitAll();
auth.requestMatchers("members/**").hasRole("USER");
});
http
.formLogin(form -> form
.usernameParameter("username")
.passwordParameter("password")
.loginPage("/auths/login-form")
.loginProcessingUrl("/process_login")
.failureUrl("/auths/login-form?error"));
http
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/"));
http
.exceptionHandling(handle -> handle
.accessDeniedPage("/auths/access-denied"));
http.sessionManagement(session ->
session
.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/session-expired"));
return http.build();
}
// 만료된 세션 정리
@Bean
public static ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() {
return new ServletListenerRegistrationBean<>(new HttpSessionEventPublisher());
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
정적 자원과 로그인 템플릿에 대한 권한을 모두에게 허용하고, 커스텀한 로그인 페이지를 통해 로그인을 할 수 있도록 함
템플릿 페이지는 Thymeleaf 3.1을 사용하여 구성하였으며, header ・ menu ・ footer fragments와 layouts을 구성한 뒤
레이아웃에 content를 생성하여 구성하도록 함
<!-- 메인 페이지 레이아웃 -->
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<body>
<head>
<title>Home</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<!-- CSS Stylesheet -->
</head>
<body>
<div class="container-lg">
<div th:replace="~{fragments/header::header}"></div>
<hr>
<div th:replace="~{fragments/menu::menu}"></div>
<hr>
<div layout:fragment="content"></div>
<hr>
<div th:replace="~{fragments/footer::footer}"></div>
<hr>
</div>
<!-- Script -->
</body>
</body>
</html>
CustomAuthorityUtils를 통한 User Role 생성
@Slf4j
@Component
public class CustomAuthorityUtils {
// 사이드 프로젝트니까 그냥 안 숨기고 admin으로 설정
private final String ADMIN_USERNAME = "admin";
private final List<GrantedAuthority> ADMIN_ROLES =
AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_OWNER", "ROLE_USER");
private final List<GrantedAuthority> OWNER_ROLES =
AuthorityUtils.createAuthorityList("ROLE_OWNER", "ROLE_USER");
private final List<GrantedAuthority> USER_ROLES =
AuthorityUtils.createAuthorityList("ROLE_USER");
private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "OWNER", "USER");
private final List<String> USER_ROLES_STRING = List.of("USER");
// DB에 저장된 Role 기반으로 권한 정보 생성
public List<GrantedAuthority> createAuthorities(List<String> roles) {
List<GrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
log.info("Create Authorities : {}", authorities);
return authorities;
}
public List<String> createRoles(String username) {
if (username.equals(ADMIN_USERNAME)) {
return ADMIN_ROLES_STRING;
}
return USER_ROLES_STRING;
}
}
Role은 ADMIN, OWNER, USER 3가지로 구성하고,
OWNER는 Member Entity에 updateRoles() 메서드를 생성한 뒤 AdminService에서 추가 및 제거하는 로직을 생성할 예정
public class Member {
...
@ElementCollection(fetch = FetchType.EAGER)
private List<String> roles = new ArrayList<>();
}
Roles는 별도의 클래스를 생성하지 않고 @ElementCollection을 사용해서 별도의 테이블을 생성해서 관리
기타 사항
회원가입 폼 및 컨트롤러 생성
Exception 처리를 위한 Exception Controller 및 GlobalExceptionAdvice(Handler) 생성
현재 발생한 문제점
서버를 종료 후 재 실행 시에 로그아웃이 풀리지 않고 그대로 로그인 되어있는 상태로 남아있는 문제가 있음
로그아웃 시에도 쿠키가 삭제되지 않아 쿠키에 세션 ID를 가지고 있는 것을 확인할 수 있음
사용하면서 공부한 기술 정리
더 자세한 코드는 여기에서 확인이 가능합니다.
'Spring' 카테고리의 다른 글
[Thymeleaf] th:href 경로에 변수를 사용한 경로 설정 (0) | 2023.09.07 |
---|---|
[Thymeleaf] layout fragment로 변수 넘겨주기 (0) | 2023.09.07 |
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Spring] 다중 칼럼 인덱스를 활용한 쿼리 속도 개선 (0) | 2023.05.14 |
[Spring] SimpleJdbcInsert 사용 시 테이블이나 칼럼을 제대로 불러오지 못할 때 해결 방법 (0) | 2023.04.08 |
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때
원인
html의 img 태그를 사용하여 지정한 이미지가 어플리케이션 실행 시 불러와지지 않는 문제가 발생했다.
<html lang="ko"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/main-layout}">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title>Home</title>
</head>
<body>
<div layout:fragment="content">
<img src="/img/home_1.png" loading="lazy" alt="img" width="100%"/>
</div>
</body>
</html>
자세히 보면 home_1.png 파일에 대해 Redirect 요청이 가고 있는 것을 볼 수 있다.
Configuration 파일을 보면 정적 리소스에 대해 Ignore 처리를 해줬음에도 파일이 로드가 되지 않았다.
@Configuration
public class WebConfiguration {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
...
}
해결
위 코드의 atCommonLocations() 메소드에 Cmd + B (Ctrl + B) 또는 Cmd (Ctrl) 클릭을 통해 타고 들어가면
public final class StaticResourceRequest {
public StaticResourceRequestMatcher atCommonLocations() {
return at(EnumSet.allOf(StaticResourceLocation.class));
}
}
위와 같은 코드를 볼 수 있고, StaticResourceLocation.class를 타고 들어가면
public enum StaticResourceLocation {
CSS("/css/**"),
JAVA_SCRIPT("/js/**"),
IMAGES("/images/**"),
WEB_JARS("/webjars/**"),
FAVICON("/favicon.*", "/*/icon-*");
...
}
위와 같은 enum 클래스로 구현된 것을 볼 수 있다.
여기를 보면 Default 경로인 src/main/resources/static/ 하위의 경로를 지정한 것을 볼 수 있는데
(Default 경로는 application.properties와 같은 설정을 통해서 변경할 수 있음)
패키지 이름을 enum 클래스의 경로와 다르게 지정해주어서 생긴 기본적인 에러였다..
images로 패키지 이름을 변경해주면 이미지가 정상적으로 로딩되는 것을 볼 수 있다.
'Spring' 카테고리의 다른 글
[Thymeleaf] layout fragment로 변수 넘겨주기 (0) | 2023.09.07 |
---|---|
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
[Spring] 다중 칼럼 인덱스를 활용한 쿼리 속도 개선 (0) | 2023.05.14 |
[Spring] SimpleJdbcInsert 사용 시 테이블이나 칼럼을 제대로 불러오지 못할 때 해결 방법 (0) | 2023.04.08 |
[Spring] Scheduler(@Scheduled)를 활용한 마감 시간 관리 (0) | 2023.03.23 |
[Spring] 다중 칼럼 인덱스를 활용한 쿼리 속도 개선
Content 테이블에는 Category와 Location이라는 칼럼이 존재한다.
@Entity
public class Content extends BaseEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "location_id")
private Location location;
}
해당 칼럼들은 외래키로 연결되어 있어 기본으로 단일 칼럼에 대한 인덱스를 가지고 있다.
하지만 카테고리와 지역 정보 두 개를 조건으로 조회하고자 할 때, 인덱스를 둘 다 사용할 수 없으므로 다중 칼럼 인덱스를 적용해주었다.
ALTER TABLE content ADD INDEX idx__category_location(category_id, location_id);
▲ 인덱스 생성
SHOW INDEX FROM content;
▲ 인덱스 조회
Spring에서 인덱스를 추가하는 방법은 해당 어노테이션을 Entity 클래스에 작성해주면 된다.
단, columnList는 실제 테이블을 기준으로 정의되므로 필드명이 아닌 JoinColumn name을 기준으로 설정
@Table(indexes = { @Index(name = "idx__category_location", columnList = "category_id, location_id") })
이후 약 200만 건의 데이터를 추가해서 단일 칼럼 인덱스와 다중 칼럼 인덱스의 속도 차이를 측정해보고자 했다.
DELIMITER $$
DROP PROCEDURE IF EXISTS insertLoop$$
CREATE PROCEDURE insertLoop()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 2000000 DO
INSERT INTO testdb.Content(member_id, content_type, title, recruiting_count, work_content, price, is_premium,
category_id, location_id, status, dead_line)
VALUES (1, 1, concat('title', i), i, concat('content', i), i, false,
FLOOR(RAND() * 13) + 1, FLOOR(RAND() * 25) + 1, 'RECRUITING',
FROM_UNIXTIME(FLOOR(unix_timestamp('2023-07-01 00:00:00')+(RAND()*(unix_timestamp('2024-07-01 00:00:00')-unix_timestamp('2023-07-01 00:00:00'))))));
SET i = i + 1;
END WHILE;
END$$
DELIMITER $$
CALL insertLoop;
$$
▲ 프로시저를 활용한 랜덤 데이터 삽입 (느림)
카테고리 ID와 지역 ID는 랜덤으로 삽입하도록 설정했다.
EasyRandom 라이브러리와 Stream의 parallel을 사용하면 빠르게 객체를 생성하고 저장할 수 있다.
결과
우선 데이터가 잘 들어갔는지 확인
단일 칼럼 인덱스 조회와 다중 칼럼 인덱스 조회 비교
단일 칼럼 인덱스 | 다중 칼럼 인덱스 | |
1 | 0.014s | 0.0044s |
2 | 0.018s | 0.0020s |
3 | 0.019s | 0.0020s |
4 | 0.019s | 0.0020s |
5 | 0.017s | 0.0020s |
6 | 0.018s | 0.0020s |
7 | 0.019s | 0.0020s |
8 | 0.018s | 0.0021s |
9 | 0.019s | 0.0020s |
10 | 0.018s | 0.0020s |
평균 | 0.0179s | 0.00225s |
다중 칼럼 인덱스를 사용한 후, 약 8배의 성능 향상을 보여주었다.
'Spring' 카테고리의 다른 글
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
---|---|
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Spring] SimpleJdbcInsert 사용 시 테이블이나 칼럼을 제대로 불러오지 못할 때 해결 방법 (0) | 2023.04.08 |
[Spring] Scheduler(@Scheduled)를 활용한 마감 시간 관리 (0) | 2023.03.23 |
[Spring] Postman 404, 405 Error (0) | 2022.10.24 |
[AWS] EC2 인스턴스에 MySQL 및 redis 설치
본문에서 사용된 인스턴스는 프리티어이며 Ubuntu Server 20.04 LTS를 사용하고 있습니다.
MySQL 설치
1. 패키지 업데이트
sudo apt update
2. MySQL 설치
sudo apt install mysql-server
3. MySQL Secure 초기화
sudo mysql_secure_installation
이후 Yes | No는 본인의 선택에 따라 하면 된다.
나는 처음 강력함 암호키 사용을 제외하고 모두 Yes를 선택했다.
비밀번호는 영문 대문자 + 영문 소문자 + 숫자 + 특수문자를 포함하여 8자리 이상으로 설정한다.
+ 비밀번호 변경하는 방법
-- MySQL 5.7 이전
UPDATE USER SET password=password('비밀번호') WHERE USER = '계정';
-- MySQL 5.7 이후
ALTER USER '계정'@'localhost' IDENTIFIED WITH mysql_native_password BY '비밀번호';
-- 변경사항 적용
FLUSH PRIVILEGES;
4. 외부 접속 허용을 위한 mysqld.cnf 수정
- root 계정으로 전환
sudo -i
혹은
sudo su
root 계정을 탈출할 때는 exit를 입력하면 된다.
- 디렉토리 이동
cd /etc/mysql/mysql.conf.d
- Vi 에디터로 파일 실행
vi mysqld.cnf
- bind-address를 찾아서 0.0.0.0으로 변경 혹은 bind-address, mysqlx-bind-address 주석 처리
vi 에디터 사용법
/ 를 누르고 하단에 검색할 내용을 입력한 뒤 엔터를 누른다.
n 을 누르면 해당 단어가 포함된 내용을 순차적으로 검색한다.
i 를 누르면 insert 모드로 변경되어 수정할 수 있다.
esc 혹은 ctrl+c 를 통해 해당 모드를 빠져나올 수 있다.
:wq 를 입력하여 vi 에디터를 종료할 수 있다.
5. MySQL 서버 재시작
sudo systemctl restart mysql
혹은
sudo service mysql restart
(Option) MySQL 워크벤치 사용을 위한 사용자 계정 생성
- 먼저 MySQL 서버로 접속을 한다.
mysql -uroot -p
- 외부 접근을 위한 계정 생성
CREATE USER '계정'@'%' identified by '비밀번호';
@'%' : 어떤 클라이언트에서든 접근 가능하다.
@'localhost' : 해당 컴퓨터에서만 접근 가능하다.
- 데이터베이스에 권한 추가
GRANT ALL ON DB명.* TO '계정'@'%' WITH GRANT OPTION;
-- 아래와 같이 모든 DB에 대한 권한을 줄 수도 있다.
GRANT ALL ON *.* TO '계정'@'%' WITH GRANT OPTION;
-- 변경사항 적용
FLUSH PRIVILEGES;
+ root 계정을 외부 접근이 가능하도록 설정하는 방법
UPDATE USER SET Host='%' WHERE User='root';
redis 설치
1. 패키지 업데이트
sudo apt update
2. redis 설치
sudo apt install redis-server
3. 외부 접속 허용 및 Redis 설정을 위한 redis.conf 수정
- root 계정으로 전환
sudo -i
혹은
sudo su
- 디렉토리 이동
cd /etc/redis
- Vi 에디터로 파일 실행
vi redis.conf
vi 에디터 사용법
/ 를 누르고 하단에 검색할 내용을 입력한 뒤 엔터를 누른다.
n 을 누르면 해당 단어가 포함된 내용을 순차적으로 검색한다.
i 를 누르면 insert 모드로 변경되어 수정할 수 있다.
esc 혹은 ctrl+c 를 통해 해당 모드를 빠져나올 수 있다.
:wq 를 입력하여 vi 에디터를 종료할 수 있다.
1. 외부 접속 허용
- bind를 찾는다.
- bind 127.0.0.1 ::1 부분을 bind 0.0.0.0 으로 변경한다.
2. 비밀번호 변경 (Option)
- requirepass를 찾는다.
- requirepass foobared 부분의 주석을 해제하고 foobared 자리에 원하는 비밀번호를 입력한다.
3. Max 메모리 설정
- maxmemory를 찾는다.
- maxmemory <bytes> 부분의 주석을 해제하고 <bytes> 자리에 max 메모리 값을 입력한다.
byte 단위의 숫자로 입력해도 되고,
500mb, 1gb 등 단위를 포함하여 원하는 메모리 값을 설정할 수 있다. (개인의 인스턴스 성능에 따라)
💡 참고 : 프리티어 인스턴스는 1GB 메모리를 가지고 있어 500mb 정도만 설정하는 것을 추천
4. 데이터 교체 알고리즘 설정
- policy를 찾는다.
- maxmemory-policy noeviction 부분의 주석을 해제하고 noeviction 자리에 원하는 데이터 교체 알고리즘을 입력한다.
데이터 교체 알고리즘은 redis 문서의 Eviction Policy에서 확인 가능하다.
나는 사용된 지 가장 오래된 데이터를 삭제하는 allkeys-lru 알고리즘을 선택했다.
5. redis 서버 재시작
sudo systemctl start redis-server
6. EC2 포트 개방
- 인스턴스 상세 설정에서 [보안] > 보안 그룹을 클릭
- [인바운드 규칙 편집] 클릭
- 아래와 같이 규칙 설정 후 저장
참고 자료
[Spring] SimpleJdbcInsert 사용 시 테이블이나 칼럼을 제대로 불러오지 못할 때 해결 방법
문제 발생
JDBC로 insert를 구현하던 중에 에러를 만났다.
우선 코드는 아래와 같다.
@RequiredArgsConstructor
@Repository
public class MemberRepository {
private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
...
private Member insert(Member member) {
SimpleJdbcInsertOperations simpleJdbcInsert =
new SimpleJdbcInsert(namedParameterJdbcTemplate.getJdbcTemplate())
.withTableName("Member") // INSERT INTO 'TABLE'
.usingGeneratedKeyColumns("id"); // AUTO_INCREMENT
SqlParameterSource params = new BeanPropertySqlParameterSource(member);
long id = simpleJdbcInsert
.executeAndReturnKey(params)
.longValue();
return Member
.builder()
.id(id)
.email(member.getEmail())
.nickname(member.getNickname())
.birthDay(member.getBirthDay())
.createdAt(member.getCreatedAt())
.build();
}
}
▲ Member의 Id 칼럼을 PK로 하여 Auto_increment를 설정해서 insert하기위한 Method
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${LOCAL_DB_URL}?rewriteBatchedStatements=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
username: ${LOCAL_DB_USERNAME}
password: ${LOCAL_DB_PASSWORD}
▲ application.yml의 Datasource 설정
LOCAL_DB_URL=localhost:3306/fast_sns
▲ 환경 변수
해당 코드를 실행하면 아래와 같은 에러가 발생한다.
java.sql.SQLSyntaxErrorException: Unknown column 'member_id' in 'field list'
??????
내가 생성한 Column 중에 member_id는 존재하지 않는데 member_id를 왜 찾지??
원인
그래서 위쪽을 보니 아래와 같은 실행 구문이 있었다.
Retrieving meta-data for testdb2/root@localhost/member
...
Compiled insert object: insert string is [INSERT INTO Member (member_id, created_at, last_modified_at, about, email, member_status, nick_name, password, picture_url, profile_id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]
SimpleJdbcInsert가 현재 프로젝트에 연동된 DB가 아닌 다른 DB의 Member 테이블을 찾아서 로직을 실행하는 것 같았다.
그래서 SimpleJdbcInsert 공식 문서를 통해 관련 내용을 찾아보았다.
SimpleJdbcInsert는 테이블에 대한 쉬운 삽입 기능을 제공한다.
기본 삽입문을 구성하는데 필요한 코드를 단순화하기 위해 메타데이터 처리를 제공한다.
그러니까 테이블 이름과 열 이름, 열 값이 포함된 Map만 제공하면 된다.
메타데이터 처리는 JDBC Driver에서 제공하는 DatabaseMetaData를 기반으로 한다.
JDBC Driver가 지정된 테이블의 열 이름을 제공할 수 있는 한 자동 탐지 기능을 사용할 수 있다.
쉽게 말해 withTableName()을 통해서 제공된 테이블 명을 통해 자동으로 탐지한다는 것 같다.
그렇다면 공식 문서를 참고해 withSchemaName()이나 withCatalogName()을 활용해서 해결해보기로 했다.
해결
StackOverflow를 통해 Catalog와 Schema의 차이를 파악해서 withCatalogName()을 사용해 DB명을 지정해주었더니 문제가 해결되었다.
@RequiredArgsConstructor
@Repository
public class MemberRepository {
private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
private Member insert(Member member) {
SimpleJdbcInsertOperations simpleJdbcInsert =
new SimpleJdbcInsert(namedParameterJdbcTemplate.getJdbcTemplate())
.withCatalogName("fast_sns") // 추가된 부분 (DB명 지정)
.withTableName("Member") // INSERT INTO 'TABLE'
.usingGeneratedKeyColumns("id"); // AUTO_INCREMENT
SqlParameterSource params = new BeanPropertySqlParameterSource(member);
long id = simpleJdbcInsert
.executeAndReturnKey(params)
.longValue();
return Member
.builder()
.id(id)
.email(member.getEmail())
.nickname(member.getNickname())
.birthDay(member.getBirthDay())
.createdAt(member.getCreatedAt())
.build();
}
}
참고 자료
[Velog@lango - Kakao Cloud School 10번째 회고록]
[StackOverflow - What`s the difference between a catalog and a schema in a relational database?]
'Spring' 카테고리의 다른 글
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
---|---|
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Spring] 다중 칼럼 인덱스를 활용한 쿼리 속도 개선 (0) | 2023.05.14 |
[Spring] Scheduler(@Scheduled)를 활용한 마감 시간 관리 (0) | 2023.03.23 |
[Spring] Postman 404, 405 Error (0) | 2022.10.24 |
[Spring] Scheduler(@Scheduled)를 활용한 마감 시간 관리
프로젝트 진행 중, 게시글의 모집 마감 시간이 될 경우 게시글의 상태를 '마감'으로 변경하는 로직을 생성해야 했다.
Spring Scheduler는 일정한 시간 간격 혹은 특정 시간에 로직을 반복하는 기능을 가지고 있으므로 이를 사용하고자 했다.
Spring Scheduler 사용법 (Link)
게시글 지원 마감 시간을 30분 간격으로 설정해 두었기 때문에 매 30분 마다 모집 중인 게시글을 확인하는 로직을 만들어 해결할 수 있었다.
// 30분 마다 만료된 글을 찾아서 상태를 변경해준다.
@Scheduled(cron = "2 0/30 * * * *")
public void scheduledExpiry() {
List<Content> contents = contentRepository.findAllByStatus(Content.Status.RECRUITING);
for (Content content : contents) {
// null 아니고, 마감 시간(0초)이 현재 시간(2초)보다 이전인가?
if (content.getDeadLine() != null && content.getDeadLine().isBefore(LocalDateTime.now())) {
content.setStatus(Content.Status.EXPIRED);
}
}
}
같은 방법으로 게시글에 지원 요청이 승인되어 일을 한 후, 완료 시간이 되었을 경우 '작업 완료' 상태로 변경하는 로직도 생성할 수 있었다.
// 30분 마다 완료된 지원과 글을 찾아서 상태를 변경해준다.
@Scheduled(cron = "1 0/30 * * * *")
public void scheduledCompletion() {
List<ContentApply> applies = applyRepository.findAllByApplyStatus(ContentApply.ApplyStatus.MATCH);
// 작업 시간을 가져온다.
for (ContentApply apply : applies) {
List<WorkTime> workTimes = apply.getContent().getWorkTimes();
long count = -1;
// 아직 종료 시간이 되지 않은 작업이 있는지 확인
if (workTimes != null && workTimes.size() != 0) {
count = workTimes.stream()
// 완료 시간(0초)이 현재 시간(1초)보다 이후인가?
.filter(workTime -> workTime.getEndWorkTime().isAfter(LocalDateTime.now()))
.count();
}
if (count == 0) {
apply.complete(); // COMPLETED로 상태 변경
apply.getContent().setStatus(Content.Status.COMPLETED);
}
}
}
'Spring' 카테고리의 다른 글
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
---|---|
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Spring] 다중 칼럼 인덱스를 활용한 쿼리 속도 개선 (0) | 2023.05.14 |
[Spring] SimpleJdbcInsert 사용 시 테이블이나 칼럼을 제대로 불러오지 못할 때 해결 방법 (0) | 2023.04.08 |
[Spring] Postman 404, 405 Error (0) | 2022.10.24 |
[Java] 백그라운드에서 빌드 파일 실행하기 (nohup, &)
일반적으로 터미널에서 어플리케이션을 실행하는 방법은 아래와 같다.
java -jar build/libs/{Project Name}-0.0.1-SNAPSHOT.jar
백그라운드에서 터미널을 종료해도 어플리케이션의 실행 상태를 유지하기 위해서는 아래와 같이 실행하면 된다.
nohup java -jar build/libs/{Project Name}-0.0.1-SNAPSHOT.jar &
- nohup은 no hang up의 약자로 '끊지마'라는 뜻을 가지고 있다.
- &는 어플리케이션이 백그라운드에서 돌아갈 수 있도록 하는 명령어이다.
백그라운드 프로그램의 로그를 확인하고 싶은 경우에는 아래와 같이 실행하면 된다.
tail -f nohup.out
Ctrl + C를 사용하여 로그 보기를 종료할 수 있다.
백그라운드 어플리케이션의 실행을 종료하는 방법은 아래와 같다.
1. 해당 포트에서 동작하고 있는 프로세스 ID (PID) 확인
lsof -i :8080
2. 해당 프로세스 종료
kill -9 {PID}
'Java' 카테고리의 다른 글
[Java] 깊은 복사와 얕은 복사 (Deep Copy & Shallow Copy) (0) | 2022.09.29 |
---|---|
[Java] Stream Sorted(Comparator.reverseOrder()) 오류 (0) | 2022.09.27 |