Spring

기존 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

 

매장 목록 조회 구현

@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까지 해당 페이지로 이동하는 페이지 번호를 남겨주었다.

 

 

결과 페이지

CSS는 없습니다.. 많이 누추하시죠.. 죄송합니다..

 

추후 개선 사항

pagination할 때 지금은 page, size 정보만 받지만 이후에 발전되면 재고순, 거리순 등 상점을 검색하는 조건들을 Enum으로 만든 뒤, 해당 조건을 파라미터로 받아 CustomPageRequest를 구성해서 정렬 기준을 추가해보면 좋을 것 같다.

 

 

 

사용하면서 공부한 기술 정리

[Thymeleaf] fragment로 변수 넘겨주기

 

[Thymeleaf] 변수를 통해 경로 지정하기 (PathVariable 아님)

 

 

 

 

더 자세한 코드는 여기에서 확인이 가능합니다.

네비게이션 바를 분리해서 공용으로 사용하고 싶어서 변수를 통해서 경로 설정하는 방법을 찾는데,

PathVariable 관련 설명만 나와서 찾느라 힘들었다..

 

이전 글(링크)에서 fragment를 통해 변수를 전달하는 방법을 설명했는데, 이렇게 전달한 변수를 통해서 URL 경로를 설정해보았다.

 

<ul th:with="url=|/search/${path}|">

 

먼저 공용으로 사용할 경로를 설정해준다.

 

 

 

<a th:href="|${url}?page=1&size=${pageInfo.size()}|" th:text="'<<'"></a>

 

이후 리터럴 표현식을 통해 경로에 쿼리 파라미터를 붙여주어서 경로 설정을 했다.

예를 들어 공용으로 사용하는 페이지네이션 바 페이지와 해당 바를 사용하는 페이지가 있다고 하자

 

공용으로 사용하는 페이지에 아래와 같이 받을 매개변수를 정해줄 수 있다.

<!-- 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 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 fragmentslayouts을 구성한 뒤

레이아웃에 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 Security

 

Thymeleaf

 

 

더 자세한 코드는 여기에서 확인이 가능합니다.

원인

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로 패키지 이름을 변경해주면 이미지가 정상적으로 로딩되는 것을 볼 수 있다.

 

 

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을 사용하면 빠르게 객체를 생성하고 저장할 수 있다.

 

 


 

결과

 

우선 데이터가 잘 들어갔는지 확인

100개는 이전에 프로시저 테스트한다고 넣어본 데이터이다

 

 

단일 칼럼 인덱스 조회와 다중 칼럼 인덱스 조회 비교

단일 칼럼 인덱스

 

다중 칼럼 인덱스

 

 

  단일 칼럼 인덱스 다중 칼럼 인덱스
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배의 성능 향상을 보여주었다.

문제 발생

 

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 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분 간격으로 설정할 수 있다.

 

// 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] Postman 404, 405 Error

2022. 10. 24. 10:14

Spring MVC를 공부하던 중 Postman에서 에러가 발생했다.

 

 

🚨 404 Error

404, Not Found

 

이유는 Spring 구동 파일인 Application.java 파일의 경로 문제였다.

 

수정 전
수정 후

 

자세한 원인은 Application.java의 @SpringBootApplication Annotation을 보면 확인할 수 있다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
        @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
    ...
}

 

Application.java 파일은 기본적으로 @SpringBootApplication이 작성되어 있는데,

@SpringBootApplication@ComponentScan을 포함하고 있다.

 

기본적으로 @ComponentScan은 하위 패키지만 스캔하므로,

작성된 파일의 최상위 패키지에 위치해야 스캔이 가능하다.

 

 

 

 

변경후 실행을 했더니?

 

🚨 405 Error

405, Method Not Allowed

 

현재 내가 작성한 코드의 POST 기능에는 URI Path를 받아오지 않는데,

URI Path를 입력해서 발생한 문제이다.

 

@PostMapping
public ResponseEntity postMember(...) {
    ...
}

@PatchMapping("/{member-id}")
public ResponseEntity patchMember(...) {
    ...
}

 

또는 POST를 사용해야 하는데, PATCHGET을 사용하는 등 잘못된 HTTP Method를 사용해도 405 Error가 발생한다.

 

 

 

해결

+ Recent posts