728x90
반응형
SMALL

테스트 코드를 작성할 때 Spring Security를 적용한 애플리케이션은 어떻게 인증/인가를 해야하는지 알아보자.

다음 테스트 코드를 보자.

package cwchoiit.springsecurity.domain.note.controller;

import cwchoiit.springsecurity.domain.user.entity.User;
import cwchoiit.springsecurity.domain.user.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.TestExecutionEvent;
import org.springframework.security.test.context.support.WithUserDetails;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;

import static cwchoiit.springsecurity.domain.user.entity.User.Role.ROLE_USER;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@Transactional
@ActiveProfiles("test")
class NoteControllerTest {
    private MockMvc mockMvc;
    @Autowired
    private WebApplicationContext webApplicationContext;
    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(springSecurity())
                .alwaysDo(print())
                .build();

        userRepository.save(new User("user", "user", ROLE_USER));
    }

    @Test
    void getNotes_not_authentication() throws Exception {
        mockMvc.perform(get("/note"))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrlPattern("**/login"));
    }

    @Test
    @WithUserDetails(
            value = "user",
            userDetailsServiceBeanName = "userServiceImpl", // UserDetailsService 구현한 구현체의 빈 이름
            setupBefore = TestExecutionEvent.TEST_EXECUTION //언제 유저가 세팅되는지를 지정 (TEST_EXECUTION -> 이 테스트가 실행되기 바로 직전에 유저를 세팅)
    )
    void getNotes_authenticated() throws Exception {
        mockMvc.perform(get("/note"))
                .andExpect(status().isOk());
    }
}
  • 우선 당연히 @SpringBootTest 애노테이션을 붙여서, 스프링 애플리케이션으로 테스트를 실행해야 한다. 스프링 시큐리티가 결국 스프링 위에 있는 녀석이니까.
  • 그리고 필요한게 MockMvc이다. 요청을 날려봐야 하니까.

그래서, 테스트가 실행되기 전에 사전 작업이 필요한데 그 부분이 아래 부분이다.

@BeforeEach
void setUp() {
    mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
            .apply(springSecurity())
            .alwaysDo(print())
            .build();

    userRepository.save(new User("user", "user", ROLE_USER));
}
  • @BeforeEach 애노테이션으로 각 테스트가 실행하기 전에 실행되게 한다.
  • springSecurity()를 적용해서 스프링 시큐리티 애플리케이션의 테스트임을 지정한다.
  • 유저를 Mocking해서 요청 시 인증/인가에 적용하려면 일단 유저가 필요하다. 그래서 `ROLE_USER`권한의 유저를 하나 만든다. 

 

다음은 인증이 되지 않은 요청이 들어왔을 때 테스트 코드이다.

@Test
void getNotes_not_authentication() throws Exception {
    mockMvc.perform(get("/note"))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrlPattern("**/login"));
}
  • 어떠한 인증도 하지 않은 요청이 들어오면 로그인 페이지로 리다이렉트 되는지를 확인하는 테스트이다.

 

다음은 인증을 특정 유저로 했을 때 정상적으로 동작하는지를 확인하는 테스트이다. 이 테스트가 중요하다.

@Test
@WithUserDetails(
        value = "user",
        userDetailsServiceBeanName = "userServiceImpl", // UserDetailsService 구현한 구현체의 빈 이름
        setupBefore = TestExecutionEvent.TEST_EXECUTION //언제 유저가 세팅되는지를 지정 (TEST_EXECUTION -> 이 테스트가 실행되기 바로 직전에 유저를 세팅)
)
void getNotes_authenticated() throws Exception {
    mockMvc.perform(get("/note"))
            .andExpect(status().isOk());
}
  • 여기서, @WithUserDetails() 애노테이션으로, UserDetails 타입의 유저를 mockMvc로 요청할 때 사용한다.
  • 그러니까, UserDetailsService를 구현한 구현체의 빈 이름을 알려주면, 해당 빈을 찾아서 그 빈이 구현한 loadUserByUsername()을 사용한다. 그때 유저이름을 value값으로 지정한 `user`로 사용한다.
  • 쉽게 말해, `/note`로 요청을 날릴 때 SecurityContext안에 Authentication으로 이 유저를 집어넣는다는 의미가 된다. 그래서 실제로 알려준 빈을 통해 loadUserByUsername('user')를 호출해서 찾아진 유저를 SecurityContext안에 넣는다.
참고로, @WithMockUser, @WithAnonymousUser라는 애노테이션도 있는데, @WithMockUser는 반환하는 타입이 org.springframework.security.core.userdetails.User라서 만약, 서비스 내에서 UserDetails를 구현한 본인만의 User를 사용한다면 이 애노테이션을 사용할 순 없다. 그리고 @WithAnonymousUser는 말 그대로 Authentication 정보로 `anonymousUser`가 들어간 상태로 테스트가 진행된다. 
또한, @WithSecurityContext 애노테이션도 있는데, 이 애노테이션은 Authentication을 가짜로 만드는게 아니라, 아예 진짜 SecurityContext를 만드는 애노테이션이다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

이번에는 Spring Security를 활성화하고, 여러 다양한 설정을 하는 방법을 알아보는 시간을 가져보자.

 

@EnableWebSecurity, SecurityFilterChain

우선, 설정을 하려면 이 애노테이션이 필요하다. 그래서 완성된 코드를 먼저 보자면 다음과 같다.

package cwchoiit.springsecurity.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic authentication disabled
                .csrf(AbstractHttpConfigurer::disable) // CSRF protection disabled
                .rememberMe(configurer -> configurer.tokenValiditySeconds(86400)) // 1 day
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .requestMatchers("/home", "/h2-console/**", "/css/**", "/js/**", "/images/**", "/login", "/user/signup").permitAll()
                        .requestMatchers("/note").hasRole("USER")
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .requestMatchers(HttpMethod.POST, "/notice").hasRole("ADMIN")
                        .requestMatchers(HttpMethod.DELETE, "/notice").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) // H2-Console iframe 정상 작동 (Origin 비교 후 같으면 그 iframe 요청은 허용)
                .formLogin(formLogin -> formLogin
                        .loginPage("/login")
                        .defaultSuccessUrl("/home")
                        .permitAll())
                .logout(logout -> logout
                        .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
                        .logoutSuccessUrl("/login"));
        return http.build();
    }
}
  • @EnableWebSecurity 애노테이션을 붙여서 스프링 애플리케이션 전반적으로 Spring Security가 활성화되도록 한다.
  • 이전 포스팅에서 배웠던 여러 필터들을 사용하기 위해선, SecurityFilterChain을 빈으로 등록해야 한다. 그래서 나의 경우, @Configuration 애노테이션을 붙인 Config 클래스를 하나 따로 만들고 이 파일에서 작업을 했다. 
  • 여기서부터 개발자가 원하는대로 필터를 적용하고 설정을 할 수 있다. 하나씩 살펴보자.
http.httpBasic(AbstractHttpConfigurer::disable)
  • BasicAuthenticationFilter를 비활성화하는 코드이다. 사용하지 않는 필터들은 명시적으로 disable로 비활성화 시키면 좋다.
http.csrf(AbstractHttpConfigurer::disable)
  • CsrfFilter를 비활성화하는 코드이다. 물론 활성화하면 보안적으로 더 도움이 될 것이지만, 이렇게 비활성화할 수도 있다.
http.rememberMe(configurer -> configurer.tokenValiditySeconds(86400))
  • RememberMeAuthenticationFilter를 활성화하고, 해당 토큰의 유효기간을 1일로 설정했다. 앞으로 유저가 로그인 시 RememberMe를 체크하고 로그인하면 로그인 유지가 1일 정도로 매우 길어질 것이다.
http.authorizeHttpRequests(authorizeRequests -> authorizeRequests
    .requestMatchers("/home", "/h2-console/**", "/css/**", "/js/**", "/images/**", "/login", "/user/signup").permitAll()
    .requestMatchers("/note").hasRole("USER")
    .requestMatchers("/admin").hasRole("ADMIN")
    .requestMatchers(HttpMethod.POST, "/notice").hasRole("ADMIN")
    .requestMatchers(HttpMethod.DELETE, "/notice").hasRole("ADMIN")
    .anyRequest().authenticated())
  • 인가를 설정한다. 즉, AuthorizationFilter가 활성화되고 여기서 설정한 값들을 토대로 현재 요청에 대한 인가가 정상적인지 판단하게 된다. 
  • 이 부분도 하나씩 살펴보자.
authorizeRequests.requestMatchers("/home", "/h2-console/**", "/css/**", "/js/**", "/images/**", "/login", "/user/signup").permitAll()
  • 인가 처리를 할 필요없는 요청도 반드시 있을것이다. 정적 파일이나, 로그인 화면, 회원가입 화면 홈 화면 등 말이다. 그런 경우에는 인가를 할 필요없도록 인가가 필요없는 경로들에 대해 .permitAll()을 설정해서 임의의 사용자 모두 허용하도록 설정한다.
authorizeRequests
    .requestMatchers("/note").hasRole("USER")
    .requestMatchers("/admin").hasRole("ADMIN")
    .requestMatchers(HttpMethod.POST, "/notice").hasRole("ADMIN")
    .requestMatchers(HttpMethod.DELETE, "/notice").hasRole("ADMIN")
  • 위 코드처럼, 특정 요청에 대한 모든 Method에 대해 인가를 지정할 수도 있고 위 코드의 아래 두줄 처럼 특정 요청의 특정 Method만 인가를 지정할 수도 있다. 
  • hasRole(...)을 사용해서 특정 권한을 가진 사용자만 허용한다. 여기서 `ROLE_`는 생략됐기 때문에 `ROLE_USER`로 작성하면 안되고 그냥 `USER`로 작성하면 된다. 이미 prefix로 `ROLE_`가 내부적으로 있다.
authorizeRequests.anyRequest().authenticated()
  • 그 외의 요청은 인증만 처리한다는 의미이다. (인가가 아니다, 인증만!)
http.headers(headers -> 
	headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
  • 이 부분은 스프링 부트를 사용하면 아주 편리한 점이 H2 데이터베이스를 매우 손쉽게 사용할 수 있다는 점이다. H2 데이터베이스 의존성만 내려받으면 자동으로 H2 데이터베이스를 실행시켜 주는데, 그때 H2 콘솔을 브라우저에서 접근할 수 있다. 근데 이 경우, H2 콘솔에서 iframe을 사용하기 때문에 기본적으로 iframe을 Spring Security가 허용하지 않는다. 그러나, 같은 origin인 경우에는 Iframe을 허용하겠다는 의미가 된다. 여기서는 H2 콘솔을 허용한다고 생각하면 된다.
http.formLogin(formLogin -> formLogin
        .loginPage("/login")
        .defaultSuccessUrl("/home")
        .permitAll())
  • 로그인 폼을 사용해서 로그인 절차를 거치게 한다. 이건 UsernamePasswordAuthenticationFilter를 사용한다는 의미와 같다. 그래서 로그인 페이지를 직접 만들었다면, 해당 경로를 위처럼 지정해주면 되고, 안 만들어도 기본으로 `/login` 경로로 스프링 시큐리티가 만들어준다. 그리고 가장 좋은 점 하나는 로그인 처리를 위한 POST 요청을 처리하는 과정을 자동으로 해주기 때문에 우리는 아예 아무것도 하지 않아도 되거나, 로그인 페이지를 직접 서비스의 디자인에 맞춰 만들고 싶은 개발자는 로그인 페이지만 만들면 된다.
  • 로그인에 성공했다면 리다이렉트 할 경로도 지정해준다.
  • 이 로그인 페이지는 당연히 모두가 접근할 수 있어야 하므로 permitAll()을 사용한다.
http.logout(logout -> logout
            .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
            .logoutSuccessUrl("/login"));
  • 로그아웃도 마찬가지로 등록만 하면 스프링 시큐리티가 알아서 다 처리해준다. 그래서 위처럼 로그아웃의 경로만 만들어주면 된다. 그럼 알아서 로그아웃 관련 모든 처리를 해줄 것이다.
return http.build();
  • 모든 설정이 끝났으니, 이제 이 HttpSecurity 객체를 반환하면 끝이다.

 

 

근데 여기서 재밌는 것을 봤다. 아래 두 코드를 보자.

new AntPathRequestMatcher("/user/logout")
.requestMatchers("/note")

AntPathRequestMatcher는 무엇이고, requestMatchers는 무엇일까?

AntPathRequestMatcher뿐 아니라, RegexRequestMatcher도 있고, MvcRequestMatcher있다.

  • RegexRequestMatcher: 정규 표현식으로 매칭한다.
  • MvcRequestMatcher: 스프링 MVC에서 사용하는 패턴을 기반으로 경로를 매칭한다. 그러니까 다음 코드를 보자.
new MvcRequestMatcher(mvcHandlerMappingIntrospector, "/user/{id}")

이렇게 PathVariable과 같은 MVC 스타일의 URL 처리를 지원한다. 저기서 mvcHandlerMappingIntrospector는 따로 만든게 아니라 스프링이 만들어 둔 것이고 가져다가 사용하면 된다.

  • AntPathRequestMatcherAnt 스타일 경로 패턴을 사용하여 매칭한다. 그러니까, URL 경로에서 일부의 와일드카드(*, **)도 사용할수가 있다. 

 

근데, 이게 결국은 다 requestMatchers로 통합이 된다.

.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll();
  • 이렇게 와일드 카드도 사용이 가능하고, 저 위에 예시처럼 명확하게 `/home`, `/note` 이렇게 사용도 할 수 있다. 또한, 인가가 필요없는 모든 경로를 다 정의해서 한번에 permitAll()을 할수도 있다. 그러니까 결국 이게 만능이라는 얘기다.

결론은 이 requestMatchers를 사용하면 된다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

Spring Security는 인증, 인가를 제공하고, 잘 알려져 있는 해커들의 공격을 막아주는 프레임워크이다. 당연히 이름답게 스프링 기반의 애플리케이션을 보호해준다.

 

이 프레임워크는 굉장히 크다. 그래서 깊이 있게 공부를 해야만 제대로 이해할 수 있다고 생각한다. 그래서 하나씩 하나씩 점진적으로 천천히 알아보자.

 

SecurityContextHolder

Spring Security 인증의 핵심이 되는 모델은 SecurityContextHolder이다.

SecurityContextHolder 안에는 SecurityContext가 있다.

스프링 시큐리티 공식 홈페이지

 

이렇게 생긴 구조이기 때문에 코드상에서 다음과 같이 호출할 수 있다.

 

SecurityContextHolder

그럼 SecurityContextHolder는 뭐하는 녀석일까? 이름 그대로 Context 가지고 있는, 담고 있는 객체이다. SecurityContext를 제공하는 static 메서드(getContext())를 지원한다.

 

SecurityContext

SecurityContext는 접근 주체와 인증에 대한 정보를 담고 있는 Context이다. 즉, Authentication을 담고 있다.

 

Authentication

Principal, Authorities, Crendentials를 제공한다. 인증이 이루어지면 해당 Authentication이 저장된다.

 

Principal

유저에 해당하는 정보이다. 대부분의 경우 PrincipalUserDetails를 반환한다.

 

Authorities

ROLE_ADMIN, ROLE_USERPrincipal이 가지고 있는 권한을 나타낸다. prefix`ROLE_`가 붙는다.

인증 이후 인가에 사용된다. 권한은 여러개일 수 있기 때문에 Collection<GrantedAuthority> 형태로 제공된다.

EX) ROLE_DEVELOPER, ROLE_USER

 

Credentials

일반적인 경우 패스워드 자체를 의미한다. 대부분의 경우, 누수의 문제를 막기 위해 유저가 인증된 후에 cleared된다. 

 

SecurityContext 자세히 살펴보기

그러면, 실제로 Spring Security로 로그인을 하고, 로그인 성공한 상태에서 SecurityContext를 살펴보면 그 안에 뭐가 있는지 한번 확인해보자. 아래 코드는 로그인 후 리다이렉트 되는 경로의 컨트롤러 메서드이다.

디버깅을 해서 SecurityContext와 그 안에 Authentication을 살펴보면 다음과 같이 보여진다.

신기하게도, 로그인만 했을 뿐인데 현재 사용자의 정보가 principal 객체에 다 담겨있다. 어떻게 된 일일까?

그러니까 사용자가 만약 3명이라면, 3명이 각각 다른 PC에서 요청을 할텐데 그럴땐 이 SecurityContext에 어떻게 그 3명의 요청을 각각 분리해서 담고 요청을 한 사용자가 현재 요청이 가능한지를 판단할까? 바로, ThreadLocal이다.

 

ThreadLocal

일반적으로 Spring MVC 기반으로 프로젝트를 만든다는 가정하에 대부분의 경우에는 요청 1개에 Thread 1개가 생성된다. 이때 ThreadLocal을 사용하면 Thread마다 고유한 공간을 만들수가 있고, 그 고유한 공간에 SecurityContext가 각각 하나씩 생길 수 있다. 그래서 A사용자가 요청을 했다면 A사용자를 요청을 처리할 ThreadLocal이 생기고 그 안에 하나의 SecurityContext가 생기게 된다.

요청 1개 : ThreadLocal 1개 : SecurityContext 1개

 

 

그래서 동시에 여러명의 사용자가 요청이 들어온다고 해도, 요청당 각자만의 ThreadLocal을 생성해서 SecurityContext를 생성하고 사용자 정보를 담아두고 있기 때문에, 각자의 요청에 대한 인증/인가가 잘 처리가 될 수 있다. 그리고 이 ThreadLocal을 사용하는 전략이 SecurityContextHolder의 기본 설정 모드이다. 원하면 변경할 수도 있다. 모드는 다음과 같이 3개가 있다.

 

  • MODE_THREADLOCAL: 기본 설정 모드이며, ThreadLocalSecurityContextHolderStrategy를 사용한다. ThreadLocal을 사용하여 같은 Thread안에서 SecurityContext를 공유한다. 
  • MODE_INHERITABLETHREADLOCAL: InheritableThreadLocalSecurityContextHolderStrategy를 사용한다. InheritableThreadLocal을 사용해서 자식 Thread까지도 SecurityContext를 공유한다.
  • MODE_MODE_GLOBAL: GlobalSecurityContextHolderStrategy를 사용한다. Global로 설정되어 애플리케이션 전체에서 SecurityContext를 공유한다.

 

실제로 MODE_THREADLOCALSecurityContextHolder의 기본 설정 모드인지 확인해보고 싶으면 코드 내부로 들어가보면 된다.

 

SecurityContextHolder 일부

package org.springframework.security.core.context;

import java.lang.reflect.Constructor;
import java.util.function.Supplier;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

public class SecurityContextHolder {
    public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
    public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
    public static final String MODE_GLOBAL = "MODE_GLOBAL";
    private static final String MODE_PRE_INITIALIZED = "MODE_PRE_INITIALIZED";
    public static final String SYSTEM_PROPERTY = "spring.security.strategy";
    private static String strategyName = System.getProperty("spring.security.strategy");
    private static SecurityContextHolderStrategy strategy;
    private static int initializeCount = 0;

    public SecurityContextHolder() {
    }

    private static void initialize() {
        initializeStrategy();
        ++initializeCount;
    }

    private static void initializeStrategy() {
        if ("MODE_PRE_INITIALIZED".equals(strategyName)) {
            Assert.state(strategy != null, "When using MODE_PRE_INITIALIZED, setContextHolderStrategy must be called with the fully constructed strategy");
        } else {
            if (!StringUtils.hasText(strategyName)) {
                strategyName = "MODE_THREADLOCAL";
            }

            if (strategyName.equals("MODE_THREADLOCAL")) {
                strategy = new ThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
                strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_GLOBAL")) {
                strategy = new GlobalSecurityContextHolderStrategy();
            } else {
                try {
                    Class<?> clazz = Class.forName(strategyName);
                    Constructor<?> customStrategy = clazz.getConstructor();
                    strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
                } catch (Exception var2) {
                    Exception ex = var2;
                    ReflectionUtils.handleReflectionException(ex);
                }

            }
        }
    }
	...
}
  • 전략을 설정하는 initializeStrategy() 안에서, strategyName의 값이 있는지 확인하고, 없다면 모드를 MODE_THREADLOCAL로 설정하고 있다.
  • 그리고 전략이 MODE_THREADLOCAL인 경우, new ThreadLocalSecurityContextHolderStrategy()로 객체를 생성한다. 이 안에 ThreadLocal 타입의 SecurityContext가 있다.

따라서, 굉장히 깊게 SecurityContextHolder와 이 녀석이 가지고 있는 SecurityContext는 어떻게 관리되고 동시에 여러 사용자에 대한 인증/인가를 할 수 있는지를 알아보았다.

 

PasswordEncoder

이 Spring Security를 사용할 때, 유저의 Password를 주의해서 관리하는건 정말 중요하다. 그리고 패스워드 관리 시엔 2가지가 충족되어야 한다.

  • 회원가입 시 Password를 입력받으면, 그 값을 암호화해서 저장해야 한다.
  • 로그인할 때 입력받은 Password와 회원가입할 때의 Password를 비교할 수 있어야 한다.

이 두가지를 만족하기 위해, 보통 해시함수라는 알고리즘을 사용한다. 해시 함수는 암호화는 비교적 쉽지만, 복호화가 거의 불가능한 방식의 알고리즘이다. 이것을 사용해서 Password를 더 안전하게 관리할 수 있다.

  1. 회원가입 시 password를 해시함수로 암호화해서 저장한다.
  2. 로그인할 때 password가 들어오면 같은 해시함수로 암호화한다.
  3. 저장된 값을 불러와서 2번의 암호화된 값과 비교한다.
  4. 동일하면 같은 암호로 간주한다.

그래서, 이 Spring Security를 사용할때는 PasswordEncoder를 빈으로 등록해줘야 하는데, 이 PasswordEncoder는 Spring Security가 만든 인터페이스다. 실제로 코드를 확인해보면 다음과 같이 생겼다.

package org.springframework.security.crypto.password;

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);

    boolean matches(CharSequence rawPassword, String encodedPassword);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}
  • 암호화하는 메서드인 encode()
  • 같은지 비교하는 matches()

그래서, 이 인터페이스를 구현하는 구현체가 상당히 다양하다. 아래 사진처럼 14개가 있다. 그 중에 하나를 구현체로 빈을 등록하면 된다.

 

나는 아래와 같이 빈을 등록했다.

PasswordEncoderConfig

package cwchoiit.springsecurity.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordEncoderConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
  • DelegatingPasswordEncoder는 모든 PasswordEncoder를 선택할 수 있는 대표 PasswordEncoder이다. 
  • 그래서, BCrypt, SCrypt, Argon2, Pbkdf2 등 다양한 PasswordEncoder를 지원하는데 이 중 하나를 사용해야 하니까 암호화된 결과물에 어떤 Encoder를 사용했는지 이런식으로 보여준다.
    • {bcrypt}$2a$10$dx.....
    • {sha256}97cde38.....
    • {pbkdf2}5d923b44.....
  • 너무 복잡하게 생각할 것 없이 그냥, PasswordEncoder의 구현체가 하나 필요하고, 내가 원하는 것 중 하나를 선택해서 빈으로 등록하면 되는데 선택하기 어려울땐 그냥 이 "DelegatingPasswordEncoder를 사용하면 되는구나" 정도로 생각하면 편하다.
  • 참고로, DelegatingPasswordEncoder는 기본 인코딩 전략으로 BCrypt를 사용한다.

 

그래서 이렇게 PasswordEncoder를 빈으로 등록하고 실제로 유저를 새로 생성할 때 패스워드를 받으면 암호화해서 집어넣어야 한다. 다음 코드가 그 예시이다.

...

private final PasswordEncoder passwordEncoder;

@Override
public void signup(UserRegisterDTO userRegisterDTO) {
    if (userRepository.findByUsername(userRegisterDTO.getUsername()).isPresent()) {
        throw new AlreadyRegisterUserException("User already exists");
    }
    User newUser = new User(userRegisterDTO.getUsername(), passwordEncoder.encode(userRegisterDTO.getPassword()), "ROLE_USER");
    userRepository.save(newUser);
}

...
  • PasswordEncoder를 주입받았다.
  • 주입받은 passwordEncoder를 이용해서 회원가입 시 받은 패스워드를 암호화해서 사용자를 만든다.
  • 해당 사용자를 데이터베이스에 저장한다.

 

Security Filter

Spring Security의 동작은 사실상 Filter로 동작한다고 해도 무방하다. 다양한 필터들이 존재하는데 이 필터들은 각자 다른 기능을 하고 있다. 이런 필터들은 제외할 수도 있고, 추가할 수도 있다. 필터에 동작하는 순서를 정해줘서 원하는대로 유기적으로 동작할수도 있다.

 

Spring Security 필터의 종류는 무수히 많은데 많이 쓰이는 필터는 다음과 같다.

  • SecurityContextPersistenceFilter
  • SecurityContextHolderFilter
  • BasicAuthenticationFilter
  • UsernamePasswordAuthenticationFilter
  • CsrfFilter
  • RememberMeAuthenticationFilter
  • AnonymousAuthenticationFilter
  • FilterSecurityInterceptor
  • AuthorizationFilter
  • ExceptionTranslationFilter

 

그래서 결국, Spring Security는 Filter에 대한 개념을 잘 알아야 하고, 사실 Spring Security를 사용하지 않더라도 그냥 직접 Filter를 구현해서 인증/인가를 구현할 수도 있다. 근데 이 Spring Security를 사용하면 더 많은 기능과 더 좋은 보안을 제공해주는 것 뿐이다.

이제 많이 사용되는 위에 언급한 필터들을 하나씩 알아보자.

 

SecurityContextPersistenceFilter,  SecurityContextHolderFilter

SecurityContextPersistenceFilter는 Spring Security에서 보통은 두번째로 실행되는 필터이다. 첫번째로 실행되는 필터는 Async 요청에 대해서도 SecurityContext를 처리할 수 있도록 해주는 WebAsyncManagerIntegrationFilter이다. 지금은 고려하지 말자. 

 

SecurityContextPersistenceFilter는 무엇을 하냐?

SecurityContext를 찾아와서 SecurityContextHolder에 넣어주는 역할을 하는 Filter이다.

(만약, SecurityContext를 찾았는데 없다면 그냥 새로 하나 만들어준다)

 

내부 코드를 살펴보자.

SecurityContextPersistenceFilter 일부

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
    if (request.getAttribute("__spring_security_scpf_applied") != null) {
        chain.doFilter(request, response);
    } else {
        request.setAttribute("__spring_security_scpf_applied", Boolean.TRUE);
        if (this.forceEagerSessionCreation) {
            HttpSession session = request.getSession();
            if (this.logger.isDebugEnabled() && session.isNew()) {
                this.logger.debug(LogMessage.format("Created session %s eagerly", session.getId()));
            }
        }

        HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
        SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
        boolean var10 = false;

        try {
            var10 = true;
            this.securityContextHolderStrategy.setContext(contextBeforeChainExecution);
            if (contextBeforeChainExecution.getAuthentication() == null) {
                this.logger.debug("Set SecurityContextHolder to empty SecurityContext");
            } else if (this.logger.isDebugEnabled()) {
                this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", contextBeforeChainExecution));
            }

            chain.doFilter(holder.getRequest(), holder.getResponse());
            var10 = false;
        } finally {
            if (var10) {
                SecurityContext contextAfterChainExecution = this.securityContextHolderStrategy.getContext();
                this.securityContextHolderStrategy.clearContext();
                this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
                request.removeAttribute("__spring_security_scpf_applied");
                this.logger.debug("Cleared SecurityContextHolder to complete request");
            }
        }

        SecurityContext contextAfterChainExecution = this.securityContextHolderStrategy.getContext();
        this.securityContextHolderStrategy.clearContext();
        this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
        request.removeAttribute("__spring_security_scpf_applied");
        this.logger.debug("Cleared SecurityContextHolder to complete request");
    }
}
  • doFilter에서 중요한 부분은 바로 이 부분이다.
SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
  • Context를 로드해서, 가져온다.
this.securityContextHolderStrategy.setContext(contextBeforeChainExecution);
  • 그리고 그 가져온 ContextSecurityContextHolder에 추가한다.

그럼 여기서 한가지 의문점은, 어디서 load를 하는걸까? 저 this.repo는 무엇을 가리킬까? 살짝만 위로 올려보면 아래와 같은 필드가 보인다.

private SecurityContextRepository repo;

그럼 SecurityContextRepository의 구현체는 어떤것일까? Spring Security는 무엇을 사용하고 있을까?

바로 HttpSessionSecurityContextRepository이다. (기본 구현체가 HttpSessionSecurityContextRepository이다) 여기서 알 수 있는점은 Spring Security는 기본적으로 Session을 통해 사용자가 어떤 사용자인지 인증/인가를 한다는 의미이다. 즉,  사용자가 로그인을 했을 때 세션을 생성해서 SecurityContext를 저장하고 사용자에게 응답을 세션을 담은 쿠키와 함께 돌려주면, 이제 이 사용자가 추가적인 요청을 했을 때 쿠키에 담긴 세션을 가져와서 SecurityContext 찾아 SecurityContextHolder에 담아주는 것이다.

 

그러니까 요청 하나 당 LocalThread를 만들어 하나의 SecurityContextHolder를 가지고 있다고 해도, 사용자의 요청에 포함된 세션으로부터 SecurityContext를 얻어서, 이 사람이 어떤 사람인지 지속적으로 인증/인가가 가능해진다.

 

재밌으니까 좀 더 깊게 들어가보자. 저 this.repo.loadContext(holder) 안으로 들어가보면 이런 코드가 나온다.

// HttpSessionSecurityContextRepository 일부분

public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
    HttpServletRequest request = requestResponseHolder.getRequest();
    HttpServletResponse response = requestResponseHolder.getResponse();
    HttpSession httpSession = request.getSession(false);
    SecurityContext context = this.readSecurityContextFromSession(httpSession);
    if (context == null) {
        context = this.generateNewContext();
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.format("Created %s", context));
        }
    }

    if (response != null) {
        SaveToSessionResponseWrapper wrappedResponse = new SaveToSessionResponseWrapper(response, request, httpSession != null, context);
        wrappedResponse.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
        requestResponseHolder.setResponse(wrappedResponse);
        requestResponseHolder.setRequest(new SaveToSessionRequestWrapper(request, wrappedResponse));
    }

    return context;
}
  • request.getSession() → 가장 먼저 요청으로부터 세션을 찾고 있다. 
  • this.readSecurityContextFromSession(httpSession) → 세션에서 SecurityContext를 찾는다.
  • SecurityContext가 없다면, 새로 만든다. 
  • 그리고 결국 new SaveToSessionResponseWrapper(...)를 호출하면서 세션을 생성하거나 기존 세션이 있으면 해당 세션에 SecurityContext를 추가한다.
그런데, 이 필터는 Spring Security 6부터 Deprecated됐다. 이제 이 필터는 사용하지 않는다.

 

그럼 이 필터가 하는 역할은 누가 대신하나? 그게 바로 SecurityContextHolderFilter이다.

 

SecurityContextHolderFilter?

▶ 기존 SecurityContextPersistenceFilter는 인증/인가가 필요하든, 필요하지 않든 무조건 세션이 없다면 새로 만들고 있다면 해당 세션에 SecurityContext를 담았다. 그런데 잘 생각해보면, 애플리케이션에서 모든 요청이 다 인증/인가가 필요한 건 아닌데 이 방식대로라면 불필요한 리소스를 사용하게 된다. 그래서 이런 문제점을 극복하고자 인증/인가가 필요한 경우에만 세션을 생성하고 기존에 있는 세션에 SecurityContext를 담아 사용할 수 있도록 변경됐다. 그래서 필요하지 않다면 세션도 만들지 않는다. 또한, Supplier 타입을 사용해서, 지연 로딩을 통해 더 효율적으로 리소스를 사용할 수 있도록 변경했다.

 

 

BasicAuthenticationFilter

이 필터는 Basic Authentication을 가능하게 하는 필터이다. 길게 말할 필요없이 아래 과정을 살펴보자.

 

다음 명령어를 터미널에서 입력해보자.

curl -u <username>:<password> -L http://localhost:8080/note

그 전에, `/note` path는 로그인 한 사용자만 진입할 수 있다. 그래서 만약, 로그인이 되지 않았다면 로그인 페이지로 리다이렉트 한다.

이렇게, CURL 명령어 시, `-u <username>:<password>` 옵션을 넣으면 Basic Authentication으로 인증을 하는 것이다. 내부적으로 Base64로 인코딩하는 과정이 있지만 여튼간 그럼에도 불구하고 위 사진처럼 로그인 페이지로 리다이렉트 됐다.

 

그 이유는, BasicAuthenticationFilter를 비활성화했기 때문이다.

 

그럼 이번에는, BasicAuthenticationFilter를 다음과 같이 활성화한 후 다시 실행해보자.

이번에는 화이트 레이블 에러 페이지가 나왔다. 즉, 인증은 성공했다는 의미이다. 물론 이 경로로 왔을 때 보여줄 뷰 화면을 만들지 않아서 404 화이트 레이블 페이지가 나왔지만 어쨌든 인증이 성공한다. 


즉, 이 BasicAuthenticationFilter는 따로 로그인이라는 과정을 하지 않아도 로그인 데이터를 Base64로 인코딩해서 모든 요청에 포함해서 보내면 BasicAuthenticationFilter가 이것을 가지고 인증을 해준다. 그렇기 때문에 세션이 필요없고 요청이 올때마다 인증이 이루어진다. 그러나 이런 방식은 요청할 때마다 아이디와 비밀번호가 반복해서 노출되기 때문에 보안에 취약하다. 그래서 혹시나 이 필터를 굳이 사용해야 한다면 반드시 https를 사용하도록 권장된다.

 

UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilterForm 데이터로부터 Username, Password가 들어오는 경우, 인증을 처리해주는 필터이다. 즉, 폼으로부터 요청이 들어왔는데 그게 사전에 정의한 로그인 폼이고 들어온 데이터가 Username, Password라면 이때 이 필터가 동작한다.

 

흐름은 이렇게 된다.

UsernamePasswordAuthenticationFilter -> ProviderManager -> AbstractUserDetailsAuthenticationProvider -> DaoAuthenticationProvider -> UserDetailsService
  • UsernamePasswordAuthenticationFilter: Username, Password 인증 필터
  • ProviderManager: 인증 정보 제공 관리자
  • AbstractUserDetailsAuthenticationProvider: 인증 정보 제공 (계정의 상태나 패스워드 일치 여부등을 파악)
  • DaoAuthenticationProvider: 유저 정보 제공
  • UserDetailsService: 유저 정보를 제공하는 서비스

우선, 이 UsernamePasswordAuthenticationFilter를 활성화하려면 아래와 같이 Spring Security 설정을 정의한다.

그다음, 실제로 `/login` 경로로 이동하면 로그인 하는 폼을 보여주는 화면을 만들어야 한다. 지금은 그게 중요한 게 아니니까 만들어진 상태라고 가정하자. 

 

UsernamePasswordAuthenticationFilter

package org.springframework.security.web.authentication;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
    private String usernameParameter = "username";
    private String passwordParameter = "password";
    private boolean postOnly = true;

    public UsernamePasswordAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
    }

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            username = username != null ? username.trim() : "";
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

    @Nullable
    protected String obtainPassword(HttpServletRequest request) {
        return request.getParameter(this.passwordParameter);
    }

    @Nullable
    protected String obtainUsername(HttpServletRequest request) {
        return request.getParameter(this.usernameParameter);
    }

    protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }

    public void setUsernameParameter(String usernameParameter) {
        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
        this.usernameParameter = usernameParameter;
    }

    public void setPasswordParameter(String passwordParameter) {
        Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
        this.passwordParameter = passwordParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getUsernameParameter() {
        return this.usernameParameter;
    }

    public final String getPasswordParameter() {
        return this.passwordParameter;
    }
}
  • UsernamePasswordAuthenticationFilter의 전체 소스 코드이다. 저기서 보면, attemptAuthentication() 메서드가 있다. 저 메서드 내부 코드를 보면 이렇게 되어 있다.
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    if (this.postOnly && !request.getMethod().equals("POST")) {
        throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
    } else {
        String username = this.obtainUsername(request);
        username = username != null ? username.trim() : "";
        String password = this.obtainPassword(request);
        password = password != null ? password : "";
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
        this.setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}
  • 우선, POST Method가 아닌 요청이면 바로 에러를 던진다.
  • POST Method가 맞다면, 요청 정보에서 username, password를 가져오는 것을 볼 수 있다.
  • 가져온 username, password를 통해 UsernamePasswordAuthenticationToken 객체를 만든다. 이 객체는 별건 없고, username, password를 저장하고 있는 객체라고 생각하면 된다.
  • 그리고 getAuthenticationManager().authenticate(authRequest)를 반환한다. 

this.getAuthenticationManager()ProviderManager이다. AuthenticationManager를 구현한 구현체다.

내부로 들어가서 authenticate() 메서드를 확인해보면 다음과 같이 생겼다.

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    Class<? extends Authentication> toTest = authentication.getClass();
    AuthenticationException lastException = null;
    AuthenticationException parentException = null;
    Authentication result = null;
    Authentication parentResult = null;
    int currentPosition = 0;
    int size = this.providers.size();
    Iterator var9 = this.getProviders().iterator();

    while(var9.hasNext()) {
        AuthenticationProvider provider = (AuthenticationProvider)var9.next();
        if (provider.supports(toTest)) {
            if (logger.isTraceEnabled()) {
                Log var10000 = logger;
                String var10002 = provider.getClass().getSimpleName();
                ++currentPosition;
                var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
            }

            try {
                result = provider.authenticate(authentication);
                if (result != null) {
                    this.copyDetails(authentication, result);
                    break;
                }
            } catch (InternalAuthenticationServiceException | AccountStatusException var14) {
                this.prepareException(var14, authentication);
                throw var14;
            } catch (AuthenticationException var15) {
                AuthenticationException ex = var15;
                lastException = ex;
            }
        }
    }

    if (result == null && this.parent != null) {
        try {
            parentResult = this.parent.authenticate(authentication);
            result = parentResult;
        } catch (ProviderNotFoundException var12) {
        } catch (AuthenticationException var13) {
            parentException = var13;
            lastException = var13;
        }
    }

    if (result != null) {
        if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
            ((CredentialsContainer)result).eraseCredentials();
        }

        if (parentResult == null) {
            this.eventPublisher.publishAuthenticationSuccess(result);
        }

        return result;
    } else {
        if (lastException == null) {
            lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
        }

        if (parentException == null) {
            this.prepareException((AuthenticationException)lastException, authentication);
        }

        throw lastException;
    }
}
  • 여기서 중요한 부분은 바로 이 부분이다.
result = provider.authenticate(authentication);
  • 이 코드가 실제로 인증을 실행하는 코드이다. 이 역시 내부로 들어가보자.
  • 참고로, providerAuthenticationProvider고, 스프링 시큐리티는 기본으로 AuthenticationProvider를 구현한 구현체로 AbstractUserDetailsAuthenticationProvider를 사용한다.

내부로 들어가보면, 이렇게 생겼다.

AbstractUserDetailsAuthenticationProvider authenticate()

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
        return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
    });
    String username = this.determineUsername(authentication);
    boolean cacheWasUsed = true;
    UserDetails user = this.userCache.getUserFromCache(username);
    if (user == null) {
        cacheWasUsed = false;

        try {
            user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
        } catch (UsernameNotFoundException var6) {
            UsernameNotFoundException ex = var6;
            this.logger.debug("Failed to find user '" + username + "'");
            if (!this.hideUserNotFoundExceptions) {
                throw ex;
            }

            throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }

        Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
    }

    try {
        this.preAuthenticationChecks.check(user);
        this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
    } catch (AuthenticationException var7) {
        AuthenticationException ex = var7;
        if (!cacheWasUsed) {
            throw ex;
        }

        cacheWasUsed = false;
        user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
        this.preAuthenticationChecks.check(user);
        this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
    }

    this.postAuthenticationChecks.check(user);
    if (!cacheWasUsed) {
        this.userCache.putUserInCache(user);
    }

    Object principalToReturn = user;
    if (this.forcePrincipalAsString) {
        principalToReturn = user.getUsername();
    }

    return this.createSuccessAuthentication(principalToReturn, authentication, user);
}
  • 여기서 중요한 부분은 바로 이 부분이다.
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
  • 이 코드가 실제로 유저가 존재하는지 받은 username으로 유저를 가져오는데, 이 코드를 보면 다음과 같이 protected abstract 메서드로 이 메서드를 사용하려면 이 클래스를 상속받는 클래스가 구현해야 한다. 참고로 이 AbstractUserDetailsAuthenticationProvider 클래스는 abstract 클래스이다.
  • 그리고 저 클래스를 상속받아 retrieveUser를 구현한 구현체가 DaoAuthenticationProvider 클래스다.

DaoAuthenticationProviderretrieveUser()

protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    this.prepareTimingAttackProtection();

    try {
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        if (loadedUser == null) {
            throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
        } else {
            return loadedUser;
        }
    } catch (UsernameNotFoundException var4) {
        UsernameNotFoundException ex = var4;
        this.mitigateAgainstTimingAttack(authentication);
        throw ex;
    } catch (InternalAuthenticationServiceException var5) {
        InternalAuthenticationServiceException ex = var5;
        throw ex;
    } catch (Exception var6) {
        Exception ex = var6;
        throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
    }
}
  • 여기서 중요한 부분은 바로 이 부분이다.
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
  • 이 코드가 드디어 UserDetailsService를 사용하는 코드이고, 이 UserDetailsService를 구현한 구현체 중 하나를 사용할텐데, 그게 바로 Spring Security를 사용하기 위해 항상 하는 UserDetailsService 인터페이스를 구현한 구현체를 직접 만들어서 저 loadUserByUsername()을 구현하는 이유이다. 무슨 말인지 모르겠다면, 추후에 알게된다. 

그래서 이 일련의 과정을 거쳐 비로소 Username, Password를 통해 인증/인가를 할 수 있게 된다. 그러기 위해 필요한 건 다음 절차이다.

 

  • UsernamePasswordAuthenticationFilter 활성화 (SecurityConfig에서 설정)
  • UserDetailsService 구현 (또는 스프링 시큐리티가 사용하는 User를 그대로 사용하거나)

보통은 서비스를 만든다면 유저 정보는 서비스마다 가지각색이다. 그렇기 때문에 스프링 시큐리티가 제공하는 User를 사용하는 프로젝트는 없다. 아마도. 그래서 내가 만든 유저 정보를 가지고 스프링 시큐리티를 사용해서 UsernamePasswordAuthenticationFilter를 통해 인증/인가를 하려면 이 필터가 사용하는 UserDetailsServiceloadUserByUsername()을 직접 구현해야 한다는 소리다.

 

전혀 어렵지도 않다. 아래는 내가 만든 UserService, UserServiceImpl이다.

UserService

package cwchoiit.springsecurity.domain.user.service;

import cwchoiit.springsecurity.domain.user.dto.UserRegisterDTO;
import cwchoiit.springsecurity.domain.user.entity.User;

public interface UserService {
    void signup(UserRegisterDTO userRegisterDTO);

    User signupAdmin(UserRegisterDTO userRegisterDTO);
}

 

UserServiceImpl

package cwchoiit.springsecurity.domain.user.service.impl;

import cwchoiit.springsecurity.domain.user.dto.UserRegisterDTO;
import cwchoiit.springsecurity.domain.user.entity.User;
import cwchoiit.springsecurity.domain.user.exception.AlreadyRegisterUserException;
import cwchoiit.springsecurity.domain.user.repository.UserRepository;
import cwchoiit.springsecurity.domain.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService, UserDetailsService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public void signup(UserRegisterDTO userRegisterDTO) {
        if (userRepository.findByUsername(userRegisterDTO.getUsername()).isPresent()) {
            throw new AlreadyRegisterUserException("User already exists");
        }
        User newUser = new User(userRegisterDTO.getUsername(), passwordEncoder.encode(userRegisterDTO.getPassword()), "ROLE_USER");
        userRepository.save(newUser);
    }

    @Override
    public User signupAdmin(UserRegisterDTO userRegisterDTO) {
        if (userRepository.findByUsername(userRegisterDTO.getUsername()).isPresent()) {
            throw new AlreadyRegisterUserException("User already exists");
        }
        User newAdmin = new User(userRegisterDTO.getUsername(), passwordEncoder.encode(userRegisterDTO.getPassword()), "ROLE_ADMIN");
        return userRepository.save(newAdmin);
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User findUser = userRepository.findByUsername(username).orElse(null);
        if (findUser == null) {
            throw new UsernameNotFoundException("User not found");
        }
        return new org.springframework.security.core.userdetails.User(findUser.getUsername(), findUser.getPassword(), findUser.getAuthorities());
    }
}
  • 이 클래스가 UserDetailsService도 구현하게 하면 된다. 그리고 그 구현체가 되려면 가장 아래 loadUserByUsername을 구현하면 된다.
  • loadUserByUsername() 메서드를 보면, 반환하는 유저는 스프링 시큐리티의 User 객체인데, 저렇게 해도 되고, 내가 만든 User 객체가 스프링 시큐리티의 UserDetails를 구현하게 하고 내가 만든 User 객체를 반환해도 된다.

 

CsrfFilter

이 필터는, CSRF 공격을 방어하는 필터이다.

 

CSRF 공격이란?

다음 그림을 보자.

  • 1. 내가 사용하는 주거래 인터넷뱅킹 홈페이지를 들어갔다. 
  • 2. 알고보니 그 홈페이지는 매우 정밀하고 정교하게 만들어놓은 위조 페이지라 위조 페이지인 줄 모르고 친구에게 100만원을 송금한다.
  • 3. 송금하기 위해 필요한 나의 계좌와, 계좌 비밀번호 정보들을 모두 보냈고 이 위조 페이지는 그 정보를 탈취한다.
  • 4. 해커는 이 위조 페이지로부터 얻은 위 계좌 정보를 가지고 진짜 인터넷뱅킹에 들어가서 내 계좌번호와 내 계좌 비밀번호로부터 해커의 계좌로 1000만원을 보내는 요청을 한다.
  • 5. 송금이 성공한다.

이게 바로 교차 사이트 요청 위조 공격인 CSRF(Cross Site Request Forgery) 공격이다.

 

그럼 이 공격을 어떻게 막는걸까?

잘 만든 사이트는 저 4번까지의 행위를 해커가 시도했을 때, CSRF 토큰이 있는지 확인한다. 즉, 잘 만든 사이트는 송금 요청에 대해서는 CSRF 토큰을 함께 요청할때 전송하도록 설계를 해서 해당 토큰이 있는지 없는지 판단한다. 해당 토큰이 없다면 이 요청은 CSRF 공격일 수도 있다고 판단하여 진행되지 않는다. 설령, CSRF 토큰이 있더라도 이 토큰은 암호화됐기 때문에 잘 만든 사이트에서는 이 날아오는 토큰의 값에 대한 기대값을 가지고 있고 그 기대값과 다른 토큰이 요청과 같이 딸려와도 더 이상 진행되지 않는다.

 

그래서 이 필터는 기본으로 활성화되어 있다. 그리고 만약, 이 필터를 사용하고 싶지 않다면 다음과 같이 설정해주면 된다.

 


RememberMeAuthenticationFilter

이 필터는 이름이 되게 재밌다. 이게 어떤거냐면, 바로 이 화면을 보면 이해가 된다.

 

저 체크박스를 체크하고 로그인하면, 브라우저를 끄던, 시간이 조금 많이 지나던 내 로그인 세션이 계속 살아있던 경험이 있을것이다. 바로 이게 RememberMeAuthenticationFilter이다. 실제로 테스트를 해보자. 만약, 체크를 하지 않고 로그인을 하면 다음과 같이 쿠키에는 이런 값뿐이 없다.

 

JSESSIONID가 Spring Security에서 만들어주는 세션이다. 이 세션안에 로그인 한 유저의 정보가 SecurityContext에 담겨있다.

이 상태에서 이 쿠키를 날려버리면 당연히 서버에서는 쿠키에서 자기가 찾고자하는 세션 정보가 없으니까 로그인 정보를 찾지 못해 인증에 실패하게 된다. 실제로 삭제하고 로그인이 필요한 화면으로 이동하면 로그인 페이지로 리다이렉트 될 것이다.

 

근데, 저 로그인 유지하기 체크박스를 체크하고 로그인하면 아래와 같이 재밌게 생긴 쿠키 이름이 하나 더 생긴다.

`JSESSIONID`뿐 아니라 `remember-me` 라는 쿠키도 들어왔다. 이게 있으면, 저 JSESSIONID가 만료되든, 삭제되든 로그인 정보가 유지된다. 브라우저를 꺼도 유지된다. 실제로 해보면 그렇다. 

 

이걸 가능하게 해주는 게 RememberMeAuthenticationFilter이다. 이 필터를 활성화 하려면 다음과 같이 하면 된다.

이렇게 원하는 유효 시간까지 지정할 수도 있다. 그리고 이걸 어떻게 사용하느냐, 지정한 로그인 경로에서 보여주는 로그인 폼에서 다음과 같이 체크박스 하나를 만들면 된다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <link rel="stylesheet" type="text/css" href="/css/signin.css">
    <head th:insert=(~{fragments.html::header})></head>
</head>
<body>
<header th:insert="(~{fragments.html::nav})"></header>
<div class="container">
    <form class="form-signin" method="POST">
        <h2 class="form-signin-heading">로그인</h2>
        <p>
            <label for="username" class="sr-only">Username</label>
            <input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
        </p>
        <p>
            <label for="password" class="sr-only">Password</label>
            <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
        </p>
        <div>
            <span>로그인 유지하기</span>
            <input type="checkbox" id="remember-me" name="remember-me" class="form-check-input mt-0" autocomplete="off">
        </div>
        <button class="btn btn-lg btn-primary btn-block" type="submit">로그인</button>
    </form>
</div>
</body>
</html>
  • 여기서 이 부분을 보자.
<input type="checkbox" id="remember-me" name="remember-me" class="form-check-input mt-0" autocomplete="off">
  • id, name`remember-me`로 되어 있는 체크박스가 체크된 상태로 로그인 요청이 들어오면 저 RememberMeAuthenticationFilter가 해당 값을 보고 `remember-me`라는 쿠키이름의 특별한 세션 하나를 저장한다. 

물론, 저 `remember-me`라는 이름은 바꿀 수 있다. 원하는대로. 그러나 굳이 그럴 필요까진 없으니까.

그래서 이 필터는 장시간 로그인을 유지 시켜줄 때 사용한다. 

 

AnonymousAuthenticationFilter

이 필터는 인증이 안된 유저가 요청이 들어오면 Anonymous 유저라는 문자열을 Authentication에 넣어주는 필터이다. 기본으로 활성화되어 있다. 그래서 다른 Filter에서 Anonymous 유저인지 정상적으로 인증된 유저인지 분기 처리를 할 수 있게 도와준다.

 

간단하게, 인증이 필요없는 화면에 SecurityContext를 가져와서 브레이크 포인트를 찍어보자.

 

회원 가입을 하는 화면은 인증이 필요없다. 그래서 이 부분에서 디버깅을 해보면 다음과 같은 결과를 볼 수 있다.

 

 

FilterSecurityInterceptor, AuthorizationFilter

이름만 보고는 Filter가 아니라 Interceptor인가? 싶지만 Filter다.

앞서 살펴본 SecurityContextHolderFilter, UsernamePasswordAuthenticationFilter, AnonymousAuthenticationFilter에서 SecurityContext를 찾거나 만들어서 넘겨주고 있다는 것을 확인했는데, FilterSecurityInterceptor에서는 이렇게 넘어온 Authentication의 내용을 기반으로 최종 인가 판단을 내린다. 그렇기 때문에 대부분의 경우에는 필터중에 뒤쪽에 위치한다.

 

먼저, 인증(Authentication)을 가져오고 만약, 인증에 문제가 있다면 AuthenticationException을 발생시킨다.

인증에 문제가 없다면 해당 인증으로 인가를 판단한다. 이때, 인가가 거절된다면 AccessDeniedException을 발생시킨다. 인가에도 문제가 없다면 정상적으로 필터가 종료된다.

 

그러나, 이 필터는 Spring Security 6부터는 Deprecated 됐다.

 

이제는 AuthenticationUsernamePasswordAuthenticationFilter와 같은 AuthenticationFilter가 담당한다. 즉, 로그인 시에 SecurityContextAuthentication을 넣어주는 역할도 하지만, 인증도 같이 하는것이다. 사실 그게 더 합리적이기도 하다.

 

그럼, Authorization은 누가하냐? AuthorizationFilter가 담당한다. 

자 다음 코드를 보자.

 

AuthorizationFilter doFilter()

public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
    HttpServletRequest request = (HttpServletRequest)servletRequest;
    HttpServletResponse response = (HttpServletResponse)servletResponse;
    if (this.observeOncePerRequest && this.isApplied(request)) {
        chain.doFilter(request, response);
    } else if (this.skipDispatch(request)) {
        chain.doFilter(request, response);
    } else {
        String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
        request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);

        try {
            AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
            this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
            if (decision != null && !decision.isGranted()) {
                throw new AccessDeniedException("Access Denied");
            }

            chain.doFilter(request, response);
        } finally {
            request.removeAttribute(alreadyFilteredAttributeName);
        }

    }
}
  • 여기서, 인가가 필요한 부분까지 왔다면, 이 부분이 호출된다.
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
  • 그래서 인가 허용인지 아닌지 판단한 후 인가가 되지 않는 사용자라면, 보이는 것과 같이 AccessDeniedException을 호출한다.

 

ExceptionTranslationFilter

앞서 본 AuthenticationException, AccessDeniedException 둘 중 하나가 발생했을 때 어떤 행동을 취해야 하는지를 결정해주는 Filter이다.

 

예를 들어, AuthenticationException이 발생하면 당연히 인증이 안됐기 때문에 로그인 페이지로 리다이렉트한다. 그러나, AnonymousAccessDeniedException이 발생하면 이 또한 로그인 페이지로 리다이렉트한다. 그 이유는 인가가 안된게 아니라 인증이 안됐으니 인가가 불명확해 로그인은 할 수 있게끔 설계한 것이다. 이런 처리를 바로 이 ExceptionTranslationFilter가 해준다.

 

근데 그 외 AccessDeniedException이 발생하면, 당연히 403에러를 반환한다.

 

ExceptionTranslationFilter handleSpringSecurityException()

private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
    if (exception instanceof AuthenticationException) {
        this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception);
    } else if (exception instanceof AccessDeniedException) {
        this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception);
    }

}
  • 이 메서드에서 보면, 예외 객체의 타입이 AuthenticationException인지, AccessDeniedException인지에 따라 분기 처리가 되고 있다.
  • 근데 저 중에 handleAccessDeniedException() 메서드 내부로 들어가보면 이렇게 코드가 짜여 있다.
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
    Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
    boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
    if (!isAnonymous && !this.authenticationTrustResolver.isRememberMe(authentication)) {
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.format("Sending %s to access denied handler since access is denied", authentication), exception);
        }

        this.accessDeniedHandler.handle(request, response, exception);
    } else {
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied", authentication), exception);
        }

        this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
    }

}
  • 보면, anonymous 유저인지 체크하고, rememberMe 체크를 활성화한 유저인지 체크해서 둘 중 하나라도 true를 반환하면 sendStartAuthentication(...)을 호출하는 것을 볼 수 있다.
  • 근데 둘 다 아니라면 인증은 됐지만, 인가되지 않은 사용자라 판단해서 accessDeniedHandler.handle(...)을 호출하는 것을 볼 수 있다.

 

정리를 하자면

으아.. 진짜 엄청 길었다. Spring Security는 저것들 외에도 더 많은 필터들을 제공한다. 그리고 유기적으로 원하는 필터를 추가할수도 필요없는 필터는 제거할수도 있다. 그리고 각각의 필터들이 어떤 것들을 해주는지를 자세히 들여다 봤기 때문에, ExceptionTranslationFilter를 통해서 이런 경우에는 저런 예외 처리를 하고 저런 경우에는 이런 예외 처리를 하는구나를 이해할 수 있게 됐다. 폼을 통한 로그인 방식을 사용할 땐 어떤 필터(UsernamePasswordAuthenticationFilter)가 동작하는지도 이해했다. 

 

이제 제대로 사용해보는 시간만 남았다.

728x90
반응형
LIST
728x90
반응형
SMALL

이번에는 TestContainer라는 기술을 사용해서 Spring Boot에서 테스트 하는 방법을 알아보고자 한다. 

이 포스팅은 아래 포스팅 이후의 포스팅이다. 그래서 아래 포스팅을 보지 않았다면 이 포스팅을 먼저 보고와야한다.

2024.10.02 - [Spring Advanced] - Redis를 사용해서 캐싱하기

 

Redis를 사용해서 캐싱하기

Spring Boot + Redis는 환상의 조합이다. 정말 많은 개발자들이 사용하는 조합이고, Redis는 대규모 트래픽을 처리하는 서비스에서는 거의 필수요소라고 볼 수 있다. 왜냐? 아주 빠르고 캐싱이 가능하

cwchoiit.tistory.com

 

TestContainer 써야 할까?

우선, 이 TestContainer가 유용한 경우는 이럴때다.

  • DB 연동을 해야 하는 테스트를 해야할 때
  • 테스트 환경에서 DB 연동은 해야겠는데 귀찮을 때
  • DB를 새로 만들거나 구축하기는 정말 싫어서 테스트 후 바로 없애버리고 싶을 때
  • Docker는 깔려있을 때

이런 경우 이 TestContainer가 장점을 발휘할 수 있다. 혹자는 그냥 H2로 테스트할때만 하면 되는거 아닌가? 싶을수 있다. MySQLH2는 같은 데이터베이스가 아니다. 즉, 동일한 작업을 해도 둘 중 하나는 에러가 발생하는데 둘 중 하나는 발생하지 않을 수 있다. 그래서 정확한 테스트라고 할 수는 없다.

 

또 다른 혹자는 그럼 그냥 Mocking하면 되는거 아닌가? 싶을수 있다. 이 또한, 사실 Mocking은 데이터베이스 연동 테스트가 전혀 아니다. 비즈니스 로직의 결함을 찾는것에 더 가까운 테스트이지 데이터베이스까지 연동한 테스트는 전혀 아니다.

 

TestContainer Dependencies

build.gradle

dependencies {
    ...
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    ...
}

 

TestContainer Test Code

package cwchoiit.dmaker;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import cwchoiit.dmaker.dto.CreateDeveloperRequestDTO;
import cwchoiit.dmaker.dto.DeveloperDetailDTO;
import cwchoiit.dmaker.service.DMakerService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import java.io.IOException;

import static cwchoiit.dmaker.type.DeveloperLevel.SENIOR;
import static cwchoiit.dmaker.type.DeveloperType.BACK_END;
import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@Testcontainers
@SpringBootTest
class DMakerApplicationTests {

    @Container
    private static final GenericContainer<?> redisContainer =
            new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379);
    @Autowired
    DMakerService dMakerService;

    @BeforeAll
    static void setUp() {
        redisContainer.followOutput(new Slf4jLogConsumer(log));
    }

    @AfterAll
    static void tearDown() {
        redisContainer.close();
    }

    @DynamicPropertySource
    private static void registerRedisProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379).toString());
    }

    @Test
    void contextLoads() throws IOException, InterruptedException {
        CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
                .developerLevel(SENIOR)
                .developerType(BACK_END)
                .experienceYears(15)
                .name("Xayah")
                .age(33)
                .memberId("xayah")
                .build();
        dMakerService.createDeveloper(xayah);

        dMakerService.getDeveloperByMemberId("xayah");
        dMakerService.getDeveloperByMemberId("xayah");

        GenericContainer.ExecResult execResult = redisContainer.execInContainer("redis-cli", "get", "developer:xayah");

        ObjectMapper mapper = new ObjectMapper();
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        DeveloperDetailDTO developerDetailDTO = mapper.readValue(execResult.getStdout(), DeveloperDetailDTO.class);

        assertThat(redisContainer.isRunning()).isTrue();
        assertThat(developerDetailDTO.getName()).isEqualTo("Xayah");
        assertThat(developerDetailDTO.getAge()).isEqualTo(33);
        assertThat(developerDetailDTO.getMemberId()).isEqualTo("xayah");
    }
}
  • 우선 클래스 레벨에 @Testcontainers 애노테이션을 붙여준다.
  • 그리고 실제로 컨테이너를 생성해서 데이터베이스를 만들어야 하는데 나는 Redis를 사용해서 테스트해보기로 한다. 그래서 다음과 같이 필드 하나를 추가한다.
@Container
private static final GenericContainer<?> redisContainer =
        new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379);
  • @Container 애노테이션을 붙여서 이 필드가 컨테이너가 될 것임을 알려준다.
  • 가장 최근의 이미지인 "redis:latest"를 사용하고, Redis의 기본 포트인 6379를 Expose한다.
@BeforeAll
static void setUp() {
    redisContainer.followOutput(new Slf4jLogConsumer(log));
}

@AfterAll
static void tearDown() {
    redisContainer.close();
}
  • 이 부분은 둘 다 옵셔널하다. 나는 저 컨테이너에서 출력하는 로그 내용을 테스트 하면서 출력하고 싶어서 setUp()을 구현했고, tearDown()은 필요가 사실 없다. 왜냐하면 테스트가 끝나면 자동으로 컨테이너가 삭제된다.
@DynamicPropertySource
private static void registerRedisProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379).toString());
}
  • 중요한 부분은 이 부분이다. 컨테이너를 띄울때 6379와 매핑되는 포트는 임의로 지정된다. 같은 6379:6379면 좋갰지만 그게 아니다. 그래서 컨테이너가 띄워진 후 6379와 매핑된 포트를 스프링 부트의 설정값 중 spring.data.redis.port에 지정해줘야 한다. 그래야 정상적으로 통신이 가능해질테니.
  • 그래서, 동적으로 속성값을 설정할 수 있는 방법을 이렇게 @DynamicPropertySource 애노테이션으로 제공한다.

 

@Test
void contextLoads() throws IOException, InterruptedException {
    CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
            .developerLevel(SENIOR)
            .developerType(BACK_END)
            .experienceYears(15)
            .name("Xayah")
            .age(33)
            .memberId("xayah")
            .build();
    dMakerService.createDeveloper(xayah);

    dMakerService.getDeveloperByMemberId("xayah");
    dMakerService.getDeveloperByMemberId("xayah");

    GenericContainer.ExecResult execResult = redisContainer.execInContainer("redis-cli", "get", "developer:xayah");

    ObjectMapper mapper = new ObjectMapper();
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    DeveloperDetailDTO developerDetailDTO = mapper.readValue(execResult.getStdout(), DeveloperDetailDTO.class);

    assertThat(redisContainer.isRunning()).isTrue();
    assertThat(developerDetailDTO.getName()).isEqualTo("Xayah");
    assertThat(developerDetailDTO.getAge()).isEqualTo(33);
    assertThat(developerDetailDTO.getMemberId()).isEqualTo("xayah");
}
  • 이 부분은 실제 테스트 코드다. 사실 이 부분은 별 게 없다. 나의 경우 getDeveloperByMemberId()Redis로 캐시할 수 있게 구현했다. 그래서 저 메서드를 두번 호출헀을 땐, 첫번째는 redis에 저장된 값이 없기 때문에 실제 데이터베이스에서 값을 가져올 것이고, 두번째 호출했을 땐, redis에 저장된 값이 있기 때문에 바로 캐싱이 가능해진다.
  • 그리고, redis에 저장됐기 때문에, redisContainer.execInContainer("redis-cli", "get", "developer:xayah")를 호출하면 저장된 캐시의 value가 반환될 것이다.
  • 그 반환된 값을 JSON으로 역직렬화를 하여 객체가 가진 값들을 비교한다.
어떻게 JSON으로 직렬화해서 저장이 바로 됐나요? → 이전 포스팅을 참고해야 한다. RedisConfig 클래스로 설정을 했다. 그리고 @SpringBootTest이므로 스프링 컨테이너가 온전히 띄워지기 때문에 설정값이 적용된 상태에서 이 테스트가 진행되는 것이다.

 

테스트 결과는 다음과 같이 성공이다.

 

 

참고. 컨테이너가 띄워지고 삭제되는 것을 확인

저 테스트가 실행되면, 컨테이너가 실제로 띄워진다. 그리고 테스트가 끝나면 컨테이너가 자동으로 삭제된다. 확인해보면 좋을것이다.

테스트가 진행중
테스트 종료 후

 

728x90
반응형
LIST
728x90
반응형
SMALL

Spring Boot + Redis는 환상의 조합이다. 정말 많은 개발자들이 사용하는 조합이고, Redis는 대규모 트래픽을 처리하는 서비스에서는 거의 필수요소라고 볼 수 있다. 왜냐? 아주 빠르고 캐싱이 가능하기 때문이다.

 

간단하게 캐싱 테스트를 해 볼 목적으로 Redis를 로컬에 설치해보자. (모든 설명은 macOS 기준으로 되어 있다)

 

Redis 설치

다음 링크에 아주 간단하고 자세하게 설치 방법이 나와있다.

 

Install Redis on macOS

Use Homebrew to install and start Redis on macOS

redis.io

 

먼저 다음 명령어로 redis를 설치한다.

brew install redis

 

설치가 다 됐으면, 다음 명령어로 redis를 서비스로 등록한다.

brew services start redis

 

그럼 앞으로 백그라운드로 이 redis가 실행될 것이다. 만약 이 서비스를 종료하고 싶으면 다음 명령어를 실행하면 된다.

brew services stop redis

 

이 서비스의 정보를 확인하는 방법은 다음과 같다.

brew services info redis

이 명령어를 치면 다음과 같이 나온다.

redis (homebrew.mxcl.redis)
Running: ✔
Loaded: ✔
Schedulable: ✘
User: choichiwon
PID: 3404

 

Spring Boot에서 redis 의존성 내려받기

build.gradle

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    ...
}
  • 이 한줄만 추가해주면 끝난다.

Spring Boot에서 redis 설정하기

우선, 여러 방법으로 설정을 할 수 있는데, application.yaml 파일에서도 redis 설정을 할 수 있다. 근데 이렇게 하지 않을 것이다. 왜냐하면, 우선 첫번째 이유로는, redis가 로컬에 설치된 게 아니라 다른 외부에 있는게 아니라면 추가적인 설정이 필요가 없다.

 

왜냐하면 redis 의존성을 추가해주면 기본 설정이 다음과 같기 때문이다.

application.yaml

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis

 

두번째로는, 이후에 알아보겠지만 관련 설정을 이 외부 설정 파일 말고 자바 코드로 풀 것이다.

그래서, 자바 코드로 하나씩 풀어보자. 나는 일단, RedisConfig라는 클래스 하나를 만들것이다.

 

RedisConfig

package cwchoiit.dmaker.config.redis;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
public class RedisConfig {}
  • 빈 껍데기의 클래스이다. 우선은 @Configuration, @EnableCaching 이 두개의 애노테이션만 있어도 충분하다.

 

이제 캐시를 적용할 것이다. 실제로 캐시가 적용되길 원하는 메서드에 다음과 같이 애노테이션을 붙여준다.

@Cacheable("developer")
public DeveloperDetailDTO getDeveloperByMemberId(@NonNull String memberId) {
    log.info("[getDeveloperByMemberId] memberId = {}", memberId);
    return developerRepository.findByMemberId(memberId)
            .map(DeveloperDetailDTO::fromEntity)
            .orElseThrow(() -> new DMakerException(NO_DEVELOPER));
}
  • @Cacheable() 애노테이션을 붙여준다. 그러면, 이 메서드가 호출되면 캐시된 값이 있으면 그 값을 가져오게 된다. 저기서 "developer"key를 의미한다.
  • 이대로 끝나면 안된다. 왜냐하면, redis는 스프링 부트 외부에 있는 서비스이다. 그렇다는 것은 서비스와 서비스간 통신을 하려면 규약이 필요하다. 데이터가 전송될 때, 전송할 때 같은 포맷, 형식으로 데이터를 주고 받아야 한다. 그래서 가장 간단한 방법은 캐시하려는 데이터(여기서는 DeveloperDetailDTO가 된다) 객체가 Serializable을 구현하면 된다. 다음 코드처럼.

DeveloperDetailDTO

package cwchoiit.dmaker.dto;

import cwchoiit.dmaker.entity.Developer;
import cwchoiit.dmaker.type.DeveloperLevel;
import cwchoiit.dmaker.type.DeveloperType;
import cwchoiit.dmaker.type.StatusCode;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeveloperDetailDTO implements Serializable {
    private DeveloperLevel developerLevel;
    private DeveloperType developerType;
    private String memberId;
    private Integer experienceYears;
    private String name;
    private Integer age;
    private StatusCode statusCode;

    public static DeveloperDetailDTO fromEntity(Developer developer) {
        return DeveloperDetailDTO.builder()
                .developerLevel(developer.getDeveloperLevel())
                .developerType(developer.getDeveloperType())
                .memberId(developer.getMemberId())
                .experienceYears(developer.getExperienceYears())
                .name(developer.getName())
                .age(developer.getAge())
                .statusCode(developer.getStatusCode())
                .build();
    }
}
  • implements Serializable을 선언한다. 이럼 끝이다.

테스트 해보기

실제로 API를 날려보자. 다음 사진을 보자.

  • 최초에는 캐시가 없기 때문에, 직접 데이터베이스에서 값을 가져오는 모습이 보인다. SELECT문이 실행됐다.

저 이후에 다시 한번 API를 날려보면, 다음과 같이 캐시데이터를 가져온다.

  • 아예 서비스의 memberId를 보여주는 로그조차 찍히지 않았다. 즉, 캐시 데이터를 그대로 반환한 것이다.

 

그리고 실제로 redis-cli로 확인을 해보면 잘 저장되어 있다.

 

그럼 실제로 저 데이터가 어떻게 저장되어 있나 확인해보자.

우리가 알아볼 수 없는 유니코드로 보여진다. 직렬화를 하기 위해 Serializable을 구현했는데, 이게 자바 Serialization이기 때문에 사람이 알아보기가 힘들다. 그래서 사람이 알아보기 좋은 포맷이 뭘까? 바로 JSON이다. JSON으로 직렬화할 수 있겠지? 당연히 있다! 해보자!

 

JSON으로 직렬화 방법 바꾸기

이제 자바의 Serialization이 아닌 JSON 형태로 직렬화하기 위해 아까 빈 껍데기로 만들어 두었던 RedisConfig를 사용할 차례다.

 

RedisConfig

package cwchoiit.dmaker.config.redis;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

import static org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair.fromSerializer;

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }
}
  • RedisCacheConfiguration을 반환하는 빈을 등록한다.
  • serializeValuesWith() 메서드를 사용해서, key 말고 value에 대한 직렬화를 JSON으로 하도록 설정한다. (어차피 key는 문자열로 잘 직렬화 된 것을 이미 위에서 확인했기 때문에)

 

그리고 이렇게 직렬화 방식을 변경했으면, 다시 아까 DeveloperDetailDTO가 Serializable을 구현한 것을 지워줘야 한다. 이제는 자바 방식이 아니라 JSON 방식으로 수정했으니 당연히 지워줘야 한다.

DeveloperDetailDTO

package cwchoiit.dmaker.dto;

import cwchoiit.dmaker.entity.Developer;
import cwchoiit.dmaker.type.DeveloperLevel;
import cwchoiit.dmaker.type.DeveloperType;
import cwchoiit.dmaker.type.StatusCode;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeveloperDetailDTO {
    private DeveloperLevel developerLevel;
    private DeveloperType developerType;
    private String memberId;
    private Integer experienceYears;
    private String name;
    private Integer age;
    private StatusCode statusCode;

    public static DeveloperDetailDTO fromEntity(Developer developer) {
        return DeveloperDetailDTO.builder()
                .developerLevel(developer.getDeveloperLevel())
                .developerType(developer.getDeveloperType())
                .memberId(developer.getMemberId())
                .experienceYears(developer.getExperienceYears())
                .name(developer.getName())
                .age(developer.getAge())
                .statusCode(developer.getStatusCode())
                .build();
    }
}
  • implements Serializable을 제거했다.

 

그리고, 아까 테스트 하면서 생긴 캐시는 다시 삭제하자. redis-cli에서 `flushall` 명령어를 실행하면 된다.

 

 

이제 다시 테스트 해보자.

  • 실행 결과를 보면, 첫번째 요청은 캐시가 없기 때문에 데이터베이스에서 조회해왔다.
  • 두번째 요청은 서비스의 로그조차 호출되지 않고 바로 캐시 데이터를 반환했다.

 

그리고 redis-cli로 확인해봐도 아주 잘 나오고 이제는 value값도 아주 잘 보인다.

 

 

RedisConfig 추가 설정하기

Prefix 설정

다만, 한가지 아쉬운 점이 있다. 캐시를 저장한 모습을 보면 이렇게 보인다.

  • :: 이게 두번 나온다. 그리고 Redis는 컨벤션을 : 하나를 사용하는 것을 말하고 있다. 그런데 스프링은 왜 두개를 붙이는지는 모른다. 그래도 이걸 바꿀순 있다.

이땐, 아까 RedisConfig 클래스에서 설정 정보를 추가해준다 다음처럼.

@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .computePrefixWith(name -> name + ":")
            .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
  • computePrefixWith(name -> name + ":") → 이렇게 prefix를 설정한다.
  • 이 상태에서 다시 캐시를 만들어보면 다음과 같이 `:`이 하나만 나온다.

 

TTL 설정

이번엔 Time to Live 값 설정이다. 지금 상태로는 서버가 내려가기전 까진 캐시가 무한정 살아있기 때문에 대규모 트래픽이나 사용량이 높은 서비스라면 이 경우 메모리가 부족한 현상이 일어나게 될 것이다. 그래서 캐시의 유효시간을 설정해줘야 한다.

 

RedisConfig

@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .computePrefixWith(name -> name + ":")
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
  • entryTtl(Duration.ofMinutes(10)) → 간단하게 10분정도로 설정했다. 

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

Spring 프로젝트에서 테스트를 작성할 때 단위 테스트를 위한 Mockito를 사용해보자.

Mockito를 사용하면 좋은 점은 다음과 같다.

  • 빠르고 독립적이다.
  • 외부 서비스나 데이터베이스와의 상호작용을 실제로 하고 싶지 않을 때 유용하다.

왜 빠르냐, 스프링이 띄워질 때 처리되는 여러 자동화된 작업들이 필요없으므로 @SpringBootTest 애노테이션을 사용한 스프링을 띄운 테스트보다 훨씬 빠르다. 

 

그래서 일단 간단하게 테스트 코드를 만들기 위한 필요 코드들을 살펴보자.

 

DMakerService

@Slf4j
@Service
@RequiredArgsConstructor
public class DMakerService {

    /**
     * The repository for accessing and manipulating {@link Developer} data.
     */
    private final DeveloperRepository developerRepository;
    private final RetiredDeveloperRepository retiredDeveloperRepository;

    /**
     * Creates a new developer based on the provided {@link CreateDeveloperRequestDTO} payload.
     * It first validates the payload and then saves the developer to the database.
     *
     * @param createDeveloperRequestDTO The payload containing the developer's information.
     * @throws DMakerException If the payload validation fails or if a developer with the same memberId already exists.
     */
    @Transactional
    public CreateDeveloperResponseDTO createDeveloper(CreateDeveloperRequestDTO createDeveloperRequestDTO) {
        validationCreatePayload(createDeveloperRequestDTO);

        Developer developer = Developer.builder()
                .developerLevel(createDeveloperRequestDTO.getDeveloperLevel())
                .developerType(createDeveloperRequestDTO.getDeveloperType())
                .name(createDeveloperRequestDTO.getName())
                .age(createDeveloperRequestDTO.getAge())
                .experienceYears(createDeveloperRequestDTO.getExperienceYears())
                .memberId(createDeveloperRequestDTO.getMemberId())
                .statusCode(EMPLOYED)
                .build();

        developerRepository.save(developer);

        return CreateDeveloperResponseDTO.fromEntity(developer);
    }

    public List<DeveloperDTO> getAllEmployedDevelopers() {
        return developerRepository.findDevelopersByStatusCodeEquals(EMPLOYED).stream()
                .map(DeveloperDTO::fromEntity)
                .collect(Collectors.toList());
    }

    public DeveloperDetailDTO getDeveloperByMemberId(String memberId) {
        return developerRepository.findByMemberId(memberId)
                .map(DeveloperDetailDTO::fromEntity)
                .orElseThrow(() -> new DMakerException(NO_DEVELOPER));
    }

    public DeveloperDetailDTO updateDeveloperByMemberId(String memberId, UpdateDeveloperRequestDTO updateDeveloperRequestDTO) {
        Developer findDeveloper = developerRepository.findByMemberId(memberId)
                .orElseThrow(() -> new DMakerException(NO_DEVELOPER));

        validationUpdatePayload(updateDeveloperRequestDTO, findDeveloper);
        findDeveloper.changeDeveloperData(updateDeveloperRequestDTO);
        return DeveloperDetailDTO.fromEntity(findDeveloper);
    }

    @Transactional
    public DeveloperDetailDTO deleteDeveloperByMemberId(String memberId) {
        Developer findDeveloper = developerRepository.findByMemberId(memberId)
                .orElseThrow(() -> new DMakerException(NO_DEVELOPER));

        findDeveloper.changeStatusCode(RETIRED);

        RetiredDeveloper retiredDeveloper = RetiredDeveloper.builder()
                .memberId(findDeveloper.getMemberId())
                .age(findDeveloper.getAge())
                .name(findDeveloper.getName())
                .build();

        retiredDeveloperRepository.save(retiredDeveloper);
        return DeveloperDetailDTO.fromEntity(findDeveloper);
    }

    private void validationUpdatePayload(UpdateDeveloperRequestDTO updateDeveloperRequestDTO, Developer updateDeveloper) {
        DeveloperLevel developerLevel = updateDeveloperRequestDTO.getDeveloperLevel() != null ? updateDeveloperRequestDTO.getDeveloperLevel() : updateDeveloper.getDeveloperLevel();
        Integer experienceYears = updateDeveloperRequestDTO.getExperienceYears() != null ? updateDeveloperRequestDTO.getExperienceYears() : updateDeveloper.getExperienceYears();

        compareLevelWithExperienceYears(developerLevel, experienceYears);
    }

    /**
     * Validates the provided {@link CreateDeveloperRequestDTO} payload.
     * It checks if the developer's experience years match the developer level.
     * It also checks if a developer with the same memberId already exists.
     *
     * @param createDeveloperRequestDTO The payload containing the developer's information.
     * @throws DMakerException If the payload validation fails.
     */
    private void validationCreatePayload(CreateDeveloperRequestDTO createDeveloperRequestDTO) {
        DeveloperLevel developerLevel = createDeveloperRequestDTO.getDeveloperLevel();

        compareLevelWithExperienceYears(developerLevel, createDeveloperRequestDTO.getExperienceYears());

        Optional<Developer> findDeveloper = developerRepository.findByMemberId(createDeveloperRequestDTO.getMemberId());
        if (findDeveloper.isPresent()) {
            throw new DMakerException(DMakerErrorCode.DUPLICATE_MEMBER_ID);
        }
    }

    private static void compareLevelWithExperienceYears(DeveloperLevel developerLevel, Integer developerExperienceYears) {
        if (developerLevel.equals(SENIOR) && developerExperienceYears < 10) {
            throw new DMakerException(LEVEL_EXPERIENCE_YEARS_NOT_MATCHED);
        }
        if (developerLevel.equals(MIDDLE) &&
                (developerExperienceYears < 4 || developerExperienceYears > 10)) {
            throw new DMakerException(LEVEL_EXPERIENCE_YEARS_NOT_MATCHED);
        }
        if (developerLevel.equals(JUNIOR) && developerExperienceYears > 4) {
            throw new DMakerException(LEVEL_EXPERIENCE_YEARS_NOT_MATCHED);
        }
    }
}
  • 서비스 클래스 하나가 있다. 이 서비스 클래스는 두가지 의존성을 주입받는다.
    • DeveloperRepository
    • RetiredDeveloperRepository

DeveloperRepository

@Repository
public interface DeveloperRepository extends JpaRepository<Developer, Long> {

    Optional<Developer> findByMemberId(String memberId);

    List<Developer> findDevelopersByStatusCodeEquals(StatusCode statusCode);
}

 

RetiredDeveloperRepository

@Repository
public interface RetiredDeveloperRepository extends JpaRepository<RetiredDeveloper, Long> {
}

 

이런 관계를 가지고 있을 때, 테스트 코드를 작성해보자.

MockitoDMakerServiceTest

package cwchoiit.dmaker.service;

import cwchoiit.dmaker.dto.DeveloperDetailDTO;
import cwchoiit.dmaker.entity.Developer;
import cwchoiit.dmaker.repository.DeveloperRepository;
import cwchoiit.dmaker.repository.RetiredDeveloperRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static cwchoiit.dmaker.type.DeveloperLevel.SENIOR;
import static cwchoiit.dmaker.type.DeveloperType.BACK_END;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;

@ExtendWith(MockitoExtension.class)
class MockitoDMakerServiceTest {

    @Mock
    private DeveloperRepository developerRepository;
    @Mock
    private RetiredDeveloperRepository retiredDeveloperRepository;

    @InjectMocks
    private DMakerService dMakerService;

    @BeforeEach
    void setUp() {
        given(developerRepository.findByMemberId(anyString()))
                .willReturn(Optional.of(Developer.builder()
                        .developerLevel(SENIOR)
                        .developerType(BACK_END)
                        .experienceYears(15)
                        .name("Samira")
                        .age(30)
                        .memberId("samira")
                        .build()));
    }

    @Test
    void getAllEmployedDevelopers() {

        DeveloperDetailDTO samira = dMakerService.getDeveloperByMemberId("samira");

        assertThat(samira.getDeveloperLevel()).isEqualTo(SENIOR);
        assertThat(samira.getDeveloperType()).isEqualTo(BACK_END);
        assertThat(samira.getName()).isEqualTo("Samira");
        assertThat(samira.getAge()).isEqualTo(30);
        assertThat(samira.getMemberId()).isEqualTo("samira");
    }
}
  • 보통이라면 해당 서비스는 빈으로 등록된 서비스이기 때문에 주입을 받아야 하지만 Mockito를 사용해서 가짜로 주입받는다.
  • Mockito를 사용하려면 먼저 다음 애노테이션이 필요하다.
@ExtendWith(MockitoExtension.class)
class MockitoDMakerServiceTest {...}
  • @ExtendWith(MockitoExtension.class) 애노테이션을 클래스 레벨에 붙여준다.

그리고 아까 위에서 봤지만, DMakerService는 두가지 의존성을 주입 받는다. 그래서 이 DMakerServiceMock으로 주입받기 위해선다음 작업이 필요하다.

@Mock
private DeveloperRepository developerRepository;
@Mock
private RetiredDeveloperRepository retiredDeveloperRepository;

@InjectMocks
private DMakerService dMakerService;
  • 주입받을 의존성을 @Mock 애노테이션이 달린 상태로 선언한다. 
  • 실제로 이 테스트 클래스에 주입 받을 DMakerServiceMock으로 주입하는데 이때 사용하는 애노테이션은 @InjectMocks이다.
  • 그런데 만약, 저 두개의 의존성 중 이 테스트에서는 사용하지 않는 의존관계가 있다면 제거해도 무방하다. 다음 코드처럼.
@Mock
private DeveloperRepository developerRepository;

@InjectMocks
private DMakerService dMakerService;

 

 

이렇게 하면 가짜로 주입을 받을 수 있다. 이제 테스트를 위한 데이터가 필요하다. 어떻게 데이터를 가짜로 만들 수 있냐면 다음 코드를 보자.

@BeforeEach
void setUp() {
    given(developerRepository.findByMemberId(anyString()))
            .willReturn(Optional.of(Developer.builder()
                    .developerLevel(SENIOR)
                    .developerType(BACK_END)
                    .experienceYears(15)
                    .name("Samira")
                    .age(30)
                    .memberId("samira")
                    .build()));
}
  • given() 메서드는 `org.mockito.BDDMockito.given`에서 제공하는 메서드이고, 사전 조건을 정의할 수 있다. 이때 developerRepository가 가지고 있는 findByMemberId() 메서드가 어떤 값을 받을 때 반환하는 가짜 데이터를 임의로 생성할 수 있다. 저기서는 anyString() 이라는 `org.mockito.ArgumentMatchers.anyString`에서 제공하는 메서드를 사용해서 어떤 문자열이 들어와도 동일한 반환을 할 수 있게 정의했다.
  • findByMemberId는 반환값이 Optional이다. 그렇게 때문에 willReturn(Optional.of(...))을 사용한다.
  • 이제 적절한 데이터를 만들어서 이 메서드가 호출되면 어떤 문자열이 들어와도 항상 코드에서 정의한 객체가 반환되도록 하였다.

이제 실제 테스트는 아래와 같이 작성하면 된다.

@Test
void getAllEmployedDevelopers() {

    DeveloperDetailDTO samira = dMakerService.getDeveloperByMemberId("samira");

    assertThat(samira.getDeveloperLevel()).isEqualTo(SENIOR);
    assertThat(samira.getDeveloperType()).isEqualTo(BACK_END);
    assertThat(samira.getName()).isEqualTo("Samira");
    assertThat(samira.getAge()).isEqualTo(30);
    assertThat(samira.getMemberId()).isEqualTo("samira");
}
  • dMakerService.getDeveloperByMemberId() 메서드는 내부적으로 findByMemberId를 사용해서 데이터를 가져온다. 그렇게 되면 위 setUp() 메서드에서 정의한 가짜 데이터가 튀어나오게 될 것이다.

실행 결과

 

생성 관련 서비스 테스트

이번에는 좀 더 복잡한, 생성 관련 테스트를 진행해보자.

@Test
void createDeveloper_pass() {
    CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
            .developerLevel(SENIOR)
            .developerType(BACK_END)
            .experienceYears(15)
            .name("Xayah")
            .age(33)
            .memberId("xayah")
            .build();

    // DMakerService.createDeveloper()를 호출할 때 검증 단계에서 실행되는 findByMemberId()
    given(developerRepository.findByMemberId(anyString())).willReturn(Optional.empty());

    // DMakerService.createDeveloper()를 호출할 때 DeveloperRepository.save()가 실행되는데, 그때 save()가 받는 Developer 객체를 Captor에 capture
    ArgumentCaptor<Developer> captor = ArgumentCaptor.forClass(Developer.class);

    // 서비스 호출
    dMakerService.createDeveloper(xayah);

    // DeveloperRepository.save()가 1번 호출되었는지 확인, save()가 받은 Developer 객체를 Captor에 저장
    verify(developerRepository, times(1)).save(captor.capture());

    // Captor에 저장된 값을 Retrieved
    Developer newDeveloper = captor.getValue();

    // 검증
    assertThat(newDeveloper.getDeveloperLevel()).isEqualTo(SENIOR);
    assertThat(newDeveloper.getDeveloperType()).isEqualTo(BACK_END);
    assertThat(newDeveloper.getExperienceYears()).isEqualTo(15);
    assertThat(newDeveloper.getName()).isEqualTo("Xayah");
    assertThat(newDeveloper.getAge()).isEqualTo(33);
    assertThat(newDeveloper.getMemberId()).isEqualTo("xayah");
}
  • 먼저 생성할 CreateDeveloperRequestDTO로 저장할 객체를 만든다.
  • DMakerServicecreateDeveloper() 메서드는 다음과 같이 검증 로직이 존재한다.
private void validationCreatePayload(CreateDeveloperRequestDTO createDeveloperRequestDTO) {
    DeveloperLevel developerLevel = createDeveloperRequestDTO.getDeveloperLevel();

    compareLevelWithExperienceYears(developerLevel, createDeveloperRequestDTO.getExperienceYears());

    Optional<Developer> findDeveloper = developerRepository.findByMemberId(createDeveloperRequestDTO.getMemberId());
    if (findDeveloper.isPresent()) {
        throw new DMakerException(DMakerErrorCode.DUPLICATE_MEMBER_ID);
    }
}
  • 이 검증 로직에는 DeveloperRepositoryfindByMemberId()를 호출해서 생성하려는 DevelopermemberId가 이미 있는지 체크하는 부분이 있다. 그렇다는 것은 findByMemberId()에 대한 Mocking이 필요하다는 얘기이다.
  • 그렇기 때문에 위 테스트 코드에서 다음 코드가 존재한다.
given(developerRepository.findByMemberId(anyString())).willReturn(Optional.empty());
  • findByMemberId()가 호출되면, Optional.empty()를 반환하게 Mocking하여, 검증에 통과하도록 한다.

여기서 끝나면 안된다. 왜냐하면 생성 관련 테스트는 실제로 전달한 생성 데이터가 제대로 생성이 됐는지 확인을 해줘야 하기 때문이다. 그리고 제대로 생성된 데이터를 확인하기 위해 그 데이터를 보관할 객체를 Mockito는 제공한다.

// DMakerService.createDeveloper()를 호출할 때 DeveloperRepository.save()가 실행되는데, 그때 save()가 받는 Developer 객체를 Captor에 capture
ArgumentCaptor<Developer> captor = ArgumentCaptor.forClass(Developer.class);
  • 이렇게 ArgumentCaptor<T>로 생성 객체를 저장할 수 있다. 우리가 확인하길 원하는 생성 객체의 타입은 Developer이므로, ArgumentCaptor<Developer> 타입으로 지정한다.
// 서비스 호출
dMakerService.createDeveloper(xayah);
  • 서비스를 호출한다.
// DeveloperRepository.save()가 1번 호출되었는지 확인, save()가 받은 Developer 객체를 Captor에 저장
verify(developerRepository, times(1)).save(captor.capture());
  • 호출한 서비스에서 DeveloperRepositorysave() 메서드가 1번 실행됐음을 확인한다.
  • 그리고 해당 save()에 던져진 파라미터를 위에서 선언한 Captor에 저장한다.
// Captor에 저장된 값을 Retrieved
Developer newDeveloper = captor.getValue();
  • Captor에 저장한 객체를 꺼내온다.
// 검증
assertThat(newDeveloper.getDeveloperLevel()).isEqualTo(SENIOR);
assertThat(newDeveloper.getDeveloperType()).isEqualTo(BACK_END);
assertThat(newDeveloper.getExperienceYears()).isEqualTo(15);
assertThat(newDeveloper.getName()).isEqualTo("Xayah");
assertThat(newDeveloper.getAge()).isEqualTo(33);
assertThat(newDeveloper.getMemberId()).isEqualTo("xayah");
  • 해당 객체는 우리가 생성 테스트를 위해 만든 객체와 동일한 값들을 가지는지 확인한다.

생성 관련 테스트 (실패 케이스)

이번엔 생성 테스트 중 실패 케이스를 보자.

@Test
void createDeveloper_fail() {
    // DMakerService.createDeveloper()를 호출할 때 검증 단계에서 실행되는 findByMemberId()
    given(developerRepository.findByMemberId(anyString()))
            .willReturn(Optional.of(Developer.builder()
                    .developerLevel(SENIOR)
                    .developerType(BACK_END)
                    .experienceYears(15)
                    .name("Xayah")
                    .age(33)
                    .memberId("xayah")
                    .build()));

    CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
            .developerLevel(SENIOR)
            .developerType(BACK_END)
            .experienceYears(15)
            .name("Xayah")
            .age(33)
            .memberId("xayah")
            .build();

    // 서비스 호출 시 중복 memberId로 Developer 생성 시, DMakerException 발생
    assertThatThrownBy(() -> dMakerService.createDeveloper(xayah)).isInstanceOf(DMakerException.class);
}
  • 한 라인씩 알아보자.
// DMakerService.createDeveloper()를 호출할 때 검증 단계에서 실행되는 findByMemberId()
given(developerRepository.findByMemberId(anyString()))
        .willReturn(Optional.of(Developer.builder()
                .developerLevel(SENIOR)
                .developerType(BACK_END)
                .experienceYears(15)
                .name("Xayah")
                .age(33)
                .memberId("xayah")
                .build()));
  • 이번엔 DeveloperRepository.findByMemberId()Mocking을 우리가 생성하고자 하는 데이터와 동일하게 해서 반환하도록 설정한다.
CreateDeveloperRequestDTO xayah = CreateDeveloperRequestDTO.builder()
                .developerLevel(SENIOR)
                .developerType(BACK_END)
                .experienceYears(15)
                .name("Xayah")
                .age(33)
                .memberId("xayah")
                .build();

// 서비스 호출 시 중복 memberId로 Developer 생성 시, DMakerException 발생
assertThatThrownBy(() -> dMakerService.createDeveloper(xayah)).isInstanceOf(DMakerException.class);
  • 그리고 생성 테스트를 위해 서비스의 createDeveloper()를 호출하면, DMakerException 예외를 터트릴 것을 예상한다.
  • 왜냐하면, 검증 단계에서 같은 memberId가 있는 경우 다음과 같이 예외를 터트린다.
Optional<Developer> findDeveloper = developerRepository.findByMemberId(createDeveloperRequestDTO.getMemberId());
if (findDeveloper.isPresent()) {
    throw new DMakerException(DMakerErrorCode.DUPLICATE_MEMBER_ID);
}

컨트롤러 테스트

이번엔 아래와 같은 컨트롤러에 대한 테스트도 진행해보자.

DMakerController

package cwchoiit.dmaker.controller;

import cwchoiit.dmaker.dto.*;
import cwchoiit.dmaker.service.DMakerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * This RESTful controller provides endpoints for retrieving developer names.
 */
@Slf4j
@RestController
@RequiredArgsConstructor
public class DMakerController {

    private final DMakerService dMakerService;

    /**
     * Retrieves a list of developer names.
     *
     * @return A list of developer names.
     */
    @GetMapping("/developers")
    public List<DeveloperDTO> getDevelopers() {
        log.info("GET /developers");
        return dMakerService.getAllEmployedDevelopers();
    }

    @GetMapping("/developers/{memberId}")
    public DeveloperDetailDTO getDeveloperByMemberId(@PathVariable String memberId) {
        log.info("GET /developers/{}", memberId);
        return dMakerService.getDeveloperByMemberId(memberId);
    }


    /**
     * Creates a new developer.
     *
     * @param createDeveloperRequestDTO The request object containing the details of the developer to be created.
     * @return A message indicating that the developer was created successfully.
     */
    @PostMapping("/developers")
    public CreateDeveloperResponseDTO createDeveloper(@Validated @RequestBody CreateDeveloperRequestDTO createDeveloperRequestDTO) {
        log.info("POST /developers");

        return dMakerService.createDeveloper(createDeveloperRequestDTO);
    }

    @PutMapping("/developers/{memberId}")
    public DeveloperDetailDTO updateDeveloperByMemberId(@PathVariable String memberId,
                                                        @Validated @RequestBody UpdateDeveloperRequestDTO updateDeveloperRequestDTO) {
        log.info("PUT /developers/{}", memberId);
        return dMakerService.updateDeveloperByMemberId(memberId, updateDeveloperRequestDTO);
    }

    @DeleteMapping("/developers/{memberId}")
    public DeveloperDetailDTO deleteDeveloperByMemberId(@PathVariable String memberId) {
        log.info("DELETE /developers/{}", memberId);
        return dMakerService.deleteDeveloperByMemberId(memberId);
    }
}

 

테스트 코드를 작성하자.

DMakerControllerTest

package cwchoiit.dmaker.controller;

import cwchoiit.dmaker.dto.DeveloperDTO;
import cwchoiit.dmaker.service.DMakerService;
import cwchoiit.dmaker.type.DeveloperLevel;
import cwchoiit.dmaker.type.DeveloperType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.nio.charset.StandardCharsets;
import java.util.List;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(DMakerController.class)
class DMakerControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private DMakerService dMakerService;

    private final MediaType contentType =
            new MediaType(MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(), StandardCharsets.UTF_8);

    @BeforeEach
    void setUp() {
        DeveloperDTO samira = DeveloperDTO.builder()
                .developerLevel(DeveloperLevel.SENIOR)
                .developerType(DeveloperType.BACK_END)
                .memberId("samira")
                .build();

        DeveloperDTO jin = DeveloperDTO.builder()
                .developerLevel(DeveloperLevel.MIDDLE)
                .developerType(DeveloperType.BACK_END)
                .memberId("Jin")
                .build();

        given(dMakerService.getAllEmployedDevelopers()).willReturn(List.of(samira, jin));
    }

    @Test
    void getAllDevelopers() throws Exception {
        mockMvc.perform(get("/developers").contentType(contentType))
                .andExpect(status().isOk())
                .andDo(print())
                .andExpect(jsonPath("$.[0].memberId").value("samira"))
                .andExpect(jsonPath("$.[0].developerLevel").value("SENIOR"))
                .andExpect(jsonPath("$.[1].memberId").value("Jin"))
                .andExpect(jsonPath("$.[1].developerLevel").value("MIDDLE"));
    }
}
  • 컨트롤러 테스트만을 할건데, 모든 스프링 빈을 등록할 필요는 없을 것이다. 그래서 딱 원하는 빈만 스프링 컨테이너에 등록해서 테스트를 할 수 있게 해주는 기능이 있는데 바로 다음 애노테이션이다.
@WebMvcTest(DMakerController.class)
class DMakerControllerTest {...}
  • 이렇게 @WebMvcTest(DMakerController.class) 라는 애노테이션을 등록하면, 이 테스트를 실행할 때 저 컨트롤러만 빈으로 등록되어 사용할 수 있다.
@Autowired
private MockMvc mockMvc;

@MockBean
private DMakerService dMakerService;
  • HTTP REST 요청을 하기 위해 필요한 MockMvc
  • DMakerController가 주입 받아야 하는 DMakerServiceMock으로 빈 등록을 한다. 
  • 위에서 서비스 관련 테스트를 할때는 필요한 주입을 @Mock 애노테이션으로 사용했는데 여기서는 @MockBean으로 하는 이유는 이 테스트를 실행해보면 알겠지만 스프링이 띄워진다. @WebMvcTest는 스프링을 띄우긴 띄우는데 선언한 빈들만 등록하게 해서 띄우는 간단한 스프링이라고 생각하면 좋다. 그래서 스프링 Bean으로 등록될 Mock을 선언하는 @MockBean 애노테이션을 선언한다.
@BeforeEach
void setUp() {
    DeveloperDTO samira = DeveloperDTO.builder()
            .developerLevel(DeveloperLevel.SENIOR)
            .developerType(DeveloperType.BACK_END)
            .memberId("samira")
            .build();

    DeveloperDTO jin = DeveloperDTO.builder()
            .developerLevel(DeveloperLevel.MIDDLE)
            .developerType(DeveloperType.BACK_END)
            .memberId("Jin")
            .build();

    given(dMakerService.getAllEmployedDevelopers()).willReturn(List.of(samira, jin));
}
  • 테스트를 실행하기 앞서, 테스트 데이터가 필요하므로 테스트 데이터를 생성한다. 그래서 DMakerServicegetAllEmployedDevelopers()가 호출되면 반환되는 리스트를 가짜로 만든다.
@Test
void getAllDevelopers() throws Exception {
    mockMvc.perform(get("/developers").contentType(contentType))
            .andExpect(status().isOk())
            .andDo(print())
            .andExpect(jsonPath("$.[0].memberId").value("samira"))
            .andExpect(jsonPath("$.[0].developerLevel").value("SENIOR"))
            .andExpect(jsonPath("$.[1].memberId").value("Jin"))
            .andExpect(jsonPath("$.[1].developerLevel").value("MIDDLE"));
}
  • 실제 테스트 코드는 다음과 같다. 주입 받은 MockMvc를 사용해서 HTTP 요청을 날린다. 이때 Content-Type은 JSON으로 지정하기 위해 필드로 선언한 `contentType`을 사용한다. 그리고 그 응답 결과를 우리가 만든 가짜 데이터가 나올 것으로 예상한 테스트 코드를 작성한다.
  • andDo(print())는 이 요청에 대한 요청-응답 정보를 출력하는 메서드이다.

실행 결과

정리를 하자면

이렇게 간단하게 데이터베이스가 필요한 서비스지만, 데이터베이스를 사용하고 싶지 않을 때 Mockito를 사용해서 가짜로 데이터를 만들어 내는 방법을 배워봤다. 간단한 단위 테스트를 작성할 때 Mockito를 사용하면 코드 자체적인 문제를 잘 찾아낼 수 있을 것 같다.

728x90
반응형
LIST
728x90
반응형
SMALL

인텔리제이가 단축키 좋은게 정말 많다(진짜 찾아보면 좋은거 너무 많다). 근데 여기서 문제가 너무 많아서 단축키를 까먹는 경우가 더러 생긴다. 

 

그 중에 내가 Macro를 만들어서 사용할 때 정말 편리한 게

  • Optimize Imports: 사용하지 않는 임포트들 제거 (⌃ + ⌥ + O)
  • Reformat Code: 코드 포맷팅(정렬) (⌥ + ⌘ + L)

이 두 액션을 ⌘ + S를 눌렀을때 실행하게 하는것이다.

 

하는 방법은 Shift를 두번 누르면 다음과 같은 창이 뜬다. 여기서, 'Start Macro Recording'을 검색하면 뭐가 나온다.

 

그걸 클릭하면, 레코딩이 시작되고 내가 어떤 작업을 하고 싶은지 레코딩 이후에 하면 된다. 그러면 나는 저 위에 명시한 두가지 작업을 했다.

그리고 다시 Shift를 두번 눌러서 'Stop Macro Recording'을 검색해서 클릭한다.

 

그럼 이렇게 Macro 이름을 지정하라는 창이 뜬다.

 

원하는대로 이름을 지정해주자. 그리고 저장을 하면 Macro가 만들어진다. 이제 Keymap에서 이 Macro를 매핑하면 된다.

Settings > Keymap > Macro 에서 방금 만든 Macro로 단축키를 지정해주면 된다.

 

근데, 문제는 이렇게만 하면 Start Macro Recording을 검색해서 실행하는 부분까지 전부 실행하기 때문에 그 부분들을 빼줘야한다. 어떻게 빼냐면, Edit > Macros > Edit Macros 가 있다. 클릭하면 다음과 같이 매크로를 수정할 수 있다.

여기서, 이 매크로에서 딱 원하는 액션만 남기고 다 지워버리면 된다. 나는 Action: OptimizeImports, Action:ReformatCode 이 두가지만 남겨두고 다 지웠다. 그리고 이제 내가 매핑한 단축키를 누르면 저 두액션이 한번에 실행된다. 매우 매우 편리하다.

728x90
반응형
LIST

'Etc' 카테고리의 다른 글

MacOS에서 키보드 입력하다 마침표 찍히는 현상 없애기  (0) 2024.10.11
Zsh Option key  (0) 2024.03.22
728x90
반응형
SMALL

참고자료

 

풀스택을 위한 도커와 최신 서버 기술(리눅스, nginx, AWS, HTTPS, flask 배포) [풀스택 Part3] 강의 | 잔재

잔재미코딩 DaveLee | 본 강의는 풀스택 강의 시리즈 Part3 강의로 최신 서버 기술과 도커 기술을 탄탄하게 익히는 강의입니다. 본 강의는 실질적으로 도커를 내 기술 스택에 포함시킬 수 있도록, 도

www.inflearn.com

우선 프록시라는 단어는 매개체, 대리자이다. 그래서 nginxreverse proxy라는 단어를 종종 사용하는데 같은 의미의 프록시다.

 

Proxy Server

클라이언트가 자신을 통해, 다른 네트워크 서비스에 접속하게 해줄 수 있는 서버

Forward Proxy

클라이언트가 외부 인터넷에 직접 접근하는 게 아니라, 클라이언트가 Proxy Server에 외부 인터넷 접근 요청을 하고, Proxy Server가 외부 인터넷에 대신 접속하여 결과를 받은 후 클라이언트에게 전달하는 서버

Reverse Proxy

클라이언트가 Reverse Proxy에 요청하면, Reverse Proxy가 관련 요청에 따라 적절한 내부 서버에 접속하여 결과를 받은 후 클라이언트에게 전달

 

그러니까, 말이 조금 애매한데 하나의 네트워크 안에 존재하는 클라이언트가 해당 네트워크가 아니라 외부에 있는 서비스에 접근 요청을 할때 그 요청을 대신 해주는 것이 Forward Proxy라고 생각하면 되고, 외부에 있는 특정 호스트가 특정 네트워크에 요청을 하면 그 네트워크에 요청을 처리할 수 있는 서비스가 요청을 직접 받는게 아니라 프록시를 통해서 요청을 하고 프록시가 그 요청을 처리할 수 있는 적절한 서비스를 찾아 요청을 대신 전달해주고 받은 응답을 외부 특정 호스트에게 돌려주는 게 Reverse Proxy이다.

 

우리가 만약 서비스를 운영하고 Nginx를 사용해서 서비스를 외부에 노출 시킨다면 그건 Reverse Proxy를 사용하는 것이다. 아래와 같은 구조를 의미한다.

 

Reverse Proxy를 사용하면 어떤 장점이 있을까? 외부에서 접근하려는 서비스가 데이터베이스같은 굉장히 보안적으로 중요한 데이터라면 이 프록시가 접근을 막을 수 있다. 또한, 한 서비스에 대한 요청 트래픽이 매우 많아질 때 해당 서비스를 여러개로 복제해 로드 밸런싱도 할 수 있다. 

 

 

그러면, 이 구조 자체는 이해를 했다. 어떻게 그럼 프록시가 들어오는 요청이 A 서비스에 가야하는구나, B 서비스에 가야하는구나를 알까?

  • URI
  • 포트

이 두가지를 통해서 알 수 있다. 이제 이것도 테스트를 해보자.

 

docker-composeNginx proxy 띄우기 (포트로 서비스 구분)

우선, docker-compose 파일을 하나 준비했다.

docker-compose.yml

version: "3"

services:
    nginxproxy:
        image: nginx:1.18.0
        ports:
            - "8080:8080"
            - "8081:8081"
        restart: always
        volumes:
            - "./nginx/nginx.conf:/etc/nginx/nginx.conf"

    nginx:
        depends_on:
            - nginxproxy
        image: nginx:1.18.0
        restart: always

    apache:
        depends_on:
            - nginxproxy
        image: httpd:2.4.46
        restart: always
  • 3개의 서비스가 있다.
  • 프록시 역할을 하는 nginx, 그냥 nginx, 그냥 apache
  • 프록시 역할을 하는 nginx8080, 8081 포트를 열어두었다.
  • 프록시 역할을 하는 nginx는 볼륨 마운트도 하는데 해당 파일은 nginx 설정 파일이다.
  • 그래서 결론부터 말하면, 프록시 역할을 하는 nginx가 외부 요청에 따라 그냥 nginx 서비스를 호출하거나, 그냥 apache 서비스를 호출한다.

그래서 그 nginx 설정 파일을 열어보면 다음과 같다.

./nginx/nginx.conf

user nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events { 
    worker_connections 1024; 
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;    
    sendfile on;
    keepalive_timeout 65;

    upstream docker-nginx {
        server nginx:80;
    }

    upstream docker-apache {
        server apache:80;
    }

    server {
        listen 8080;

        location / {
            proxy_pass         http://docker-nginx;
            proxy_redirect     off;
            proxy_set_header   Host $host;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
        }
    }

    server {
        listen 8081;

        location / {
            proxy_pass         http://docker-apache;
            proxy_redirect     off;
            proxy_set_header   Host $host;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
        }
    }
}

한 부분씩 자세히 알아보자.

 

events { 
    worker_connections 1024; 
}
  • events {...} 블록은 nginx의 이벤트 처리 방식을 설정하는 곳이다.
  • worker_connections 1024; 는 각 워커 프로세스가 동시에 처리할 수 있는 최대 클라이언트 연결 수를 1024로 지정한다는 의미이다. 그래서 만약, nginx가 4개의 워커 프로세스를 사용하고 있다면 최대 4 * 1024 = 4096개의 동시 연결을 처리할 수 있게 된다. 
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;    
    sendfile on;
    keepalive_timeout 65;
    
    ...
    
}
  • include /etc/nginx/mime.types; → 이 설정은 nginx가 MIME 타입을 정의한 파일을 포함시키는 지시어다. 이 mime.types 파일은 다양한 파일 확장자와 그에 해당하는 MIME 타입을 매핑한 정보가 들어있다. 예를 들어, .html 파일은 text/html 타입으로, .jpg 파일은 image/jpeg 타입으로 처리되게끔 정의한 파일이다. 그래서 nginx는 이 파일을 사용해서 요청된 파일의 MIME 타입을 올바르게 설정하고, 클라이언트에 적절한 응답 헤더를 보내게 된다. 
  • default_type application/octet-stream; → 이 설정은 요청된 파일의 MIME 타입을 자동으로 결정할 수 없을 때, 기본 MIME 타입을 설정하는 지시어다. 그래서 기본 MIME 타입으로 application/octet-stream으로 지정한다는 의미이다. application/octet-stream는 기본 바이너리 데이터 타입을 의미하며, Nginx가 MIME 타입을 특정하지 못하는 파일(확장자나 내용이 불분명한 파일)을 바이너리 파일로 취급한다. 이는 파일 다운로드를 처리할 때 많이 사용되는 타입이다.
  • log_format main '...' → 로그 형식을 지정한다. main 이라는 이름으로 새로운 로그 형식을 정의하고 있다. 
    • $remote_addr: 클라이언트의 IP주소
    • $remote_user: 클라이언트가 인증된 사용자명(있을 경우)
    • $time_local: 로컬 서버 시간
    • $request: 클라이언트가 보낸 HTTP 요청 라인(메서드, URI, 프로토콜)
    • $status: HTTP 응답 상태 코드
    • $body_bytes_sent: 클라이언트로 전송된 응답 본문의 바이트 수
    • $http_referer: 클라이언트가 요청을 보낼 때 보낸 Referer 헤더 값(클라이언트가 이전에 방문한 페이지 정보)
    • $http_user_agent: 클라이언트의 웹 브라우저 및 운영체제 정보
    • $http_x_forwarded_for: 프록시를 거친 요청에 대해 클라이언트의 원래 IP 주소를 기록하는 값(프록시 서버에서 사용)
  • access_log /var/log/nginx/access.log main;  접속 로그를 기록할 파일의 경로와 사용할 로그 형식을 설정하는 지시어이다. 그래서 접속 로그가 저장될 파일 경로는 /var/log/nginx/access.log이고, 그 때 로그의 형식은 앞서 정의한 main이다.
  • sendfile on;Nginx가 파일을 클라이언트에게 전송할 때 사용하는 파일 전송 방식을 설정하는 지시어이다. on으로 설정하면 nginxsendfile 시스템 호출을 사용하여 파일 전송을 효율적으로 처리한다. 이 방식은 nginx가 파일을 직접 읽고 전송하는 대신 커널이 파일을 바로 전송하게 함으로써 성능을 크게 향상 시킬 수 있다. 주로 정적 파일(이미지, CSS, JavaScript 등)을 빠르게 전송하는 데 사용된다.
  • keepalive_timeout 65; → 클라이언트와 Nginx 서버 간 유지되는 연결의 유효 시간(초 단위)을 설정하는 지시어이다. 65초로 설정되어 있기 떄문에 클라이언트와 서버 간 연결이 65초 동안 유지됩니다. 이 시간 동안 추가적인 요청이 발생하면 새로운 TCP 연결을 생성하지 않고 기존 연결을 재사용 한다. 
upstream docker-nginx {
    server nginx:80;
}

upstream docker-apache {
    server apache:80;
}
  • docker-compose를 사용하면 같은 네트워크 안에서 서비스 명으로 각각의 서비스를 호출할 수 있는데 그것에 대한 정보이다. docker-nginx라는 이름은 아무렇게나 지을 수 있다. 저렇게 꼭 지어야 하는 게 아니다. 그래서 docker-nginx라는 키워드를 사용하면 `nginx`라는 docker-compose로 띄운 서비스의 80포트로 요청을 전달한다.
  • docker-apache도 마찬가지다. 이름은 아무렇게나 지어도 된다. 그리고 이 docker-apache라는 키워드를 사용하면, `apache`라는 docker-compose로 띄운 서비스의 80포트로 요청을 전달한다.
  • 쉽게 말해 그냥, docker-nginx, docker-composealias같은 것이다.
server {
    listen 8080;

    location / {
        proxy_pass         http://docker-nginx;
        proxy_redirect     off;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Host $server_name;
    }
}

server {
    listen 8081;

    location / {
        proxy_pass         http://docker-apache;
        proxy_redirect     off;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Host $server_name;
    }
}
  • 저번 포스팅에서도 본 적이 있는 server 블록이다.
  • 첫번째 server 블록 (8080 포트)
    • listen 8080 nginx가 8080번 포트로 들어오는 요청을 수신하도록 설정했다.
    • location / {...} 로 루트 경로(/)로 들어오는 모든 요청에 대한 설정을 했다.
    • proxy_pass http://docker-nginx; 모든 요청을 docker-nginx로 정의된 서버로 요청을 전달한다.
    • proxy_redirect off; 프록시 서버가 리다이렉션 응답을 받을 때, 리다이렉션 URL을 변경하지 않도록 설정한다.
    • proxy_set_header Host $host; 클라이언트가 보낸 호스트 헤더를 그대로 서버에 전달한다.
    • proxy_set_header X-Real-IP $remote_addr; 클라이언트의 실제 IP 주소를 X-Real-IP 헤더로 전달한다. 
    • proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 클라이언트의 원래 IP 주소와 프록시를 거친 IP 주소들을 X-Forwarded-For 헤더로 전달한다.(그러니까 프록시에 또 프록시를 거칠수도 있으니까 말이다)
    • proxy_set_header X-Forwarded-Host $server_name; 서버의 이름을 X-Forwarded-Host 헤더로 전달한다.
  • 두번째 server 블록 (8081 포트)
    • listen 8081 nginx가 8081번 포트로 들어오는 요청을 수신하도록 설정했다.
    • location / {...} 로 루트 경로(/)로 들어오는 모든 요청에 대한 설정을 했다.
    • proxy_pass http://docker-apache; 모든 요청을 docker-apache로 정의된 서버로 요청을 전달한다.

 

이렇게 설정된 nginx 설정 파일이다. 이 파일을 nginx proxy가 사용할 것이다. 이제 docker-compose를 띄워보자.

#docker-compose.yml 파일이 있는 경로에서

docker-compose up -d

 

 

잘 띄워졌으면 다음 경로에 각각 요청해보자.

 

 

 

우리가 매핑한 정보대로 8080은 nginx 서버를, 8081은 apache 서버를 띄워주고 있다. 이게 nginx reverse proxy를 띄우고 각 서비스를 포트로 구분해서 포워딩하는 내용이다.

 

docker-compose Nginx proxy 띄우기 (경로로 서비스 구분)

이번엔 포트말고 경로로 두 서비스를 구분해보자.

docker-compose.yml

version: "3"

services:
    nginxproxy:
        image: nginx:1.18.0
        ports:
            - "80:80"
        restart: always
        volumes:
            - "./nginx/nginx.conf:/etc/nginx/nginx.conf"

    nginx:
        depends_on:
            - nginxproxy
        image: nginx:1.18.0
        restart: always

    apache:
        depends_on:
            - nginxproxy
        image: httpd:2.4.46
        restart: always
  • 이번엔 proxy 역할을 하는 nginx의 포트를 80만 열어두었다.
  • 마찬가지로 nginx.conf 파일을 그대로 컨테이너에 옮겨주는데 해당 설정 파일은 아래와 같다.
user nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events { 
    worker_connections 1024; 
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;    
    sendfile on;
    keepalive_timeout 65;

    upstream docker-nginx {
        server nginx:80;
    }

    upstream docker-apache {
        server apache:80;
    }

    server {
        listen 80;

        location /nginx/ {
            proxy_pass         http://docker-nginx;
            proxy_redirect     off;
            proxy_set_header   Host $host;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
        }

        location /apache/ {
            proxy_pass         http://docker-apache;
            proxy_redirect     off;
            proxy_set_header   Host $host;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
        }
    }

}
  • 이번엔 서버 블록이 하나만 있고 80 포트로 listen하고 있다.
  • 그리고 location으로 경로를 `/nginx/`, `/apache/` 로 구분지었다.

이 상태로 docker-compose를 띄워보자.

# docker-compose.yml 파일이 있는 경로에서

docker-compose up -d

 

잘 띄워졌다. 그럼 한번 브라우저에서 접속해볼까?

 

어라? nginx가 띄워지긴 한 모양인데 404가 떴다. 즉, 우리가 원하는 파일을 못 찾은것이다. 왜 그럴까?

우선 접속 경로는 `/nginx/`이다. 그럼 nginx 프록시 서버 설정에 의해서 docker-nginx가 지정한 nginx 서버로 요청을 전달할 것이다. 근데, 이 전달받은 서버는 nginx이다. 즉, 이 내부에 있는 nginx 서버는 어떤 경로를 받냐면 `/nginx/`를 그대로 받는데 이 내부에 있는 nginx 서버는 해당 경로를 모른다. 왜? 우리가 설정한 적이 없으니까! 

 

무슨 말인지 말로만 하면 이해가 안가니까, 내부 nginx 컨테이너로 들어가보자.

들어가기 위해 내부 nginx 컨테이너 이름을 확인해보자. 확인했으면 다음 명령어를 실행한다.

 

docker exec -it 04_nginx_proxy_path-nginx-1 /bin/bash

 

들어왔으면 설정 파일 위치를 찾자.

$ find -name nginx.conf
./etc/nginx/nginx.conf

 

위 예시처럼 찾았으면 해당 파일을 열어보면, 아래와 같이 http 블록안에 server 블록이 있지 않고 include로 나뉘어져 있다.

 

그럼 저 경로로 가서 어떤 파일이 있는지 확인을 해보면, default.conf 파일이 존재한다.

 

이 파일을 열어보면, 아래와 같이 80으로 listen하고 있는 서버가 하나 있지만, 이 서버의 location 정보에 /nginx/는 없다.

 

그렇다면, 모든 요청에 대해 location / {...} 가 처리하게 되고 이 녀석의 root 경로는 `/usr/share/nginx/html`이다.

그럼 외부에서 `/nginx/` 이런 경로로 요청이 들어오면 /usr/share/nginx/html/nginx/index.html 파일을 찾을것이다. 

왜냐하면 `/nginx/`는 마지막에 `/`만 붙고 다른 URI가 안 붙었으니 디렉토리를 찾고 그 안에서 index로 설정한 index.html이나 index.htm 파일을 찾을 것이니까. 

 

그래서 /usr/share/nginx/html 경로에 nginx 폴더를 일단 만들어야 한다.

# /usr/share/nginx/html 경로에서

mkdir nginx

 

그리고 이 폴더안에 /usr/share/nginx/html 경로에 있던 index.html 파일을 복사해서 넣어두자.

그러고 다시 브라우저에서 접속해보면, 아래와 같이 잘 나올것이다.

 

apache도 같은 이유로 제대로 나오지는 않을 것이다. 이왕 한 김에 apache도 제대로 나오도록 작업해보자.

우선 컨테이너 내부로 들어와서, 아파치의 설정 파일 경로는 여기에 있다.

/usr/local/apache2/conf

 

이 경로 안에 `httpd.conf` 파일이 설정 파일이다. 이 설정 파일을 열어보면,

위 사진과 같이 root 경로는 `/usr/local/apache2`이다. 

여기로 가보면, 실제 파일을 두는 경로는 `/usr/local/apache2/htdocs` 이곳이다.

 

이 경로에서 apache라는 폴더를 만들고 index.html 파일을 복사하자.

그러고 나서 브라우저에서 `/apache/`로 접속해보면, 잘 나온다.

 

근데, 솔직히 이 작업은 너무 귀찮다. 왜냐하면, 일단 위에서 했던 작업들이 필요하기 때문이다. 

Nginx 프록시 서버에서 경로 설정으로 location /nginx/ {...} 이렇게 한 순간 요청을 할 때, `/nginx/`이렇게 요청을 할 것이고, 그럼 그 요청 경로가 그대로 내부 nginx 서버에 전달될텐데 지금처럼 내부 nginx 서버에는 이 경로에 대해 정의한 것이 없으니 위처럼 작업이 필요해진다. 

 

이럴때 만약 `http://localhost/nginx` 이렇게 요청한 것을 프록시 서버가 전달할 때는 `http://localhost`로 바꿔주면 얼마나 좋을까? 그럼 내부 nginx 서버에서는 따로 경로에 대한 작업을 할 필요가 없어질 테니 말이다.

 

rewrite 옵션

위의 문제를 이렇게 해결할 수 있다.

location /nginx/ {
    rewrite ^/nginx(.*)$ $1 break;
    proxy_pass         http://docker-nginx;
    proxy_redirect     off;
    proxy_set_header   Host $host;
    proxy_set_header   X-Real-IP $remote_addr;
    proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Host $server_name;
}
  • 위 코드를 보면, rewrite 라는 옵션이 추가됐음을 확인할 수 있다. 그리고 정규 표현식이 나오고 뭐 등등 복잡하다.

표현식은 이렇다.

rewrite regex URL [flag]
  • regex: 매칭되는 URL 패턴을 설정한다. 
  • URL: 변경할 URL 기재
  • flag: 추가적인 옵션을 기재, 여기서는 break를 사용했는데 이 break는 어떤거냐면, 여러 개의 location이 있을 때, 변경된 URL이 그 여러개의 location 중 우연히 매칭이 될 수도 있기 때문에 break를 사용해서 변경된 URL은 다시 다른 location 설정에 따르지 않고 현재의 location 설정에만 적용한다는 의미이다.

그럼 저 `^/nginx(.*)$ $1`이 의미하는 건 무엇이냐면,

  • ^: 시작을 나타낸다.
  • /nginx: 시작한 후 나오는 문자
  • (.*): 임의의 문자열을 나타낸다. 
    • `.`: 임의의 한 문자를 나타낸다. (만약, 진짜 `.`을 의미하게 하고 싶으면 `\.` 이렇게 역슬래시를 붙여준다.)
    • `*`: 0회 이상의 문자를 나타낸다.
  • $: 문자 끝을 나타낸다.
  • $1: (.*)을 의미한다. 즉, ()로 묶인 부분을 $1로 받아올 수 있다.
    • 그래서 만약, `^/nginx(.*)/abc/(.*)$ $1` 이렇게 되어 있으면, 이제 $1, $2로 저 두개의 괄호 묶음을 가져올 수도 있다.

그럼 만약, `localhost/nginx/`로 요청이 들어오면, `localhost/`로 변경이 되는 것이다.

그래서 이 옵션을 적용한 파일은 이렇게 생겼다. 다른 것 없이 그냥 rewrite 더해주면 된다.

user nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events { 
    worker_connections 1024; 
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;    
    sendfile on;
    keepalive_timeout 65;

    upstream docker-nginx {
        server nginx:80;
    }

    upstream docker-apache {
        server apache:80;
    }

    server {
        listen 80;

        location /nginx/ {
            rewrite            ^/nginx(.*)$ $1 break;
            proxy_pass         http://docker-nginx;
            proxy_redirect     off;
            proxy_set_header   Host $host;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
        }

        location /apache/ {
            rewrite 	       ^/apache(.*)$ $1 break;
            proxy_pass         http://docker-apache;
            proxy_redirect     off;
            proxy_set_header   Host $host;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
        }
    }

}

 

이대로 docker-compose를 다시 실행해서 확인해보자!

결과는 동일하게 잘 나오지만, 난 내부 nginx 서버에 어떠한 경로도 적용해주지 않았다. 즉, 저 rewrite 옵션으로 인해 내부 nginx에 요청을 전달할 때는 `http://localhost`로 그냥 간 것이다.

 

그리고 좋은 사이트 하나가 있다.

 

https://nginx.viraptor.info/

 

nginx.viraptor.info

이 사이트는 뭐냐면, 이제 저렇게 여러 location이 막 있을 때, 어떤 location에 현재 요청이 걸리는지 헷갈릴 때가 언젠가 생긴다. 그럴때 그걸 알려주는 것이다.

 

 

추가적인 내용들

사실 nginx 설정이란 게 굉장히 다양하고 상황에 따라 달라지기 때문에 그때그때 필요한 것들을 찾아가야 한다. 그럼에도 불구하고, 자주 사용되는 것들은 정리해보려고 한다. 

 

에러 페이지 설정

error_page 403 404 405 406 411 497 500 501 502 503 504 505 /error.html;
location = /error.html {
	root /usr/share/nginx/html;
}
  • error_page 지시어는 HTTP 상태 코드가 403, 404, ... 505 인 경우 `/error.html` 경로로 리다이렉션 하라는 의미이다. 즉, 여기서는 `/error.html` 페이지를 클라이언트에게 보여주겠다는 의미가 된다.
  • location = /error.html { ... } 은 경로가 정확하게(=) `/error.html` 인 경우에 이 정의를 따른다.

 

정규 표현식을 사용하는 location

location ~ \.php$ {
	...
}
  • `~` 표시는 정규 표현식을 사용하겠다는 의미가 된다. 그래서 \.php$ `\.`가 정확히 딱 `.`을 의미하고 php가 나오고 $는 끝을 의미하니까 경로가 .php로 끝나는 경우를 의미한다.

 

캐시 설정

location ~* \.(ico|css|js|glf|jpe?g|png)$ {
    expires max;
    add_header Pragma public;
    add_header Cache-Control "public, must-revalidate, proxy-revalidate";
}
  • 정적 파일(CSS, JS, 이미지, ico)은 변경될 가능성이 극히 드물다. 그런데 그런 파일들을 요청이 들어올때마다 HTTP 요청 응답으로 처리하면 속도가 느리니 캐쉬 설정을 하는 것이다.
  • `~*`: 의미하는건 정규표현식은 정규표현식인데 대소문자를 구분하지 않겠다는 의미이다.
  • `\.(ico|css|js|glf|jpe?g|png)$`: 의미하는 건 .ico 또는 .css 또는 .js 또는 .glf 또는 .jpeg 또는 .jpg 또는 .png 대상을 의미한다. 저기 jpe?g는 e가 있을수도 없을수도 있다는 의미이다.
728x90
반응형
LIST

'Nginx' 카테고리의 다른 글

Nginx 기본 이해 (구동 방식, 설정 파일)  (0) 2024.09.19
728x90
반응형
SMALL

참고자료

 

풀스택을 위한 도커와 최신 서버 기술(리눅스, nginx, AWS, HTTPS, flask 배포) [풀스택 Part3] 강의 | 잔재

잔재미코딩 DaveLee | 본 강의는 풀스택 강의 시리즈 Part3 강의로 최신 서버 기술과 도커 기술을 탄탄하게 익히는 강의입니다. 본 강의는 실질적으로 도커를 내 기술 스택에 포함시킬 수 있도록, 도

www.inflearn.com

 

웹 서버는 HTTP 요청을 받아서 해당 요청에 대한 응답을 해주는 프로그램이다. 대표적인 웹 서버로 Apache, Nginx가 있는데 요즘 점점 더 뜨고 있는 웹 서버가 Nginx인것 같아 공부해 보려고 한다. 웹 서버는 프로그램을 서버상에 설치해서, 특정 HTTP 요청에 따라 서비스를 제공해주는 방식으로 구현된다.

 

NginxApache보다 점점 추세가 좋아질까? 난 아래 구동 방식의 차이에서 나온다고 생각한다.

Apache 구동 방식

Prefork MPM(Multi Processing Module) 방식

→ HTTP 요청이 올 때마다, 프로세스를 복제하여 각각 별도 프로세스에서 해당 HTTP 요청 처리

 

Worker MPM(Multi Processing Module) 방식

→ 하나의 HTTP 연결 후, 여러 요청을 처리하기 위해 복제된 프로세스 내에서 여러 쓰레드를 생성하여, 여러 HTTP 요청을 처리하는 방식

 

그러니까 결론은, HTTP 요청이 많아지면 많아질수록 프로세스도 여러개 만들어지고 프로세스 내 쓰레드도 여러개가 만들어진다.

Nginx 구동 방식

Event Driven 방식

  • 하나의 프로세스로 동작하며, HTTP 요청을 event로 비동기식으로 처리한다.
  • 대부분의 HTTP 응답은 결국 HTML 파일을 제공하는 것이므로, IO 작업이다.
  • 따라서, IO 작업으로 event를 포워딩하고, 요청 순이 아닌 요청 작업이 끝난 순으로 처리한다.

이렇게 되면, HTTP 요청마다 프로세스든 쓰레드든 생성이 필요없으니 시스템 자원 관리에 장점이 있다.

보통 접속자가 많을 때, 시스템 자원 관리 효율성 때문에 Nginx가 이론적으로는 성능이 좋을 수 있는데, 어떤 요청이고 그 요청이 어떤 작업을 하느냐에 따라 성능의 차이는 천차만별이라 꼭 그렇지도 않다. 그리고 결론적으로 웹 서버는 다양한 추가 기능과 함께 동작한다. 예를 들면, 플러그인의 작업이라든가 해서, 종합적인 성능에는 큰 차이를 보이지 않는다. 

그러나, 개인적인 생각으로는, 요즘 같이 대규모 서비스로 대고객의 요청을 받아들이는 세상에서 프로세스나 쓰레드를 지속적으로 만들어내서 요청을 처리하는 방식은 아무래도 오버헤드가 생길 수 밖에 없다고 생각한다. 그래서 Nginx가 요즘 더 뜨는 추세가 아닌가 싶다.

 

간단하게 ApacheNginx의 구동방식을 이해해 보았다. 이제 Nginx를 직접 설정해보자.

 

Nginx 설치

우선, Linux 환경안에서 시작해보자. 그러기 위해 도커를 사용한다.

docker run -dit -p 80:8080 --name myos ubuntu:20.04
docker exec -it myos /bin/bash
  • 여기까지 진행하면 해당 컨테이너의 쉘로 들어오게 된다. 이 안에서 다음 명령어를 실행하자.

 

apt-get update
apt-get install nginx
apt-get install vim

 

  • Nginx를 설치할 때 특정 버전을 명시하고 싶으면 nginx=XXX로 설치하면 된다. 
  • 설정 파일을 다루기 위해 vim도 설치해주자.

 

만약, 특정 버전이 어떻게 되는지 궁금하면 다음 명령어를 입력한다.

apt list -a nginx

 

아무튼 nginx 설치를 하면 다음과 같이 지역을 고르라고 나온다. Asia/Seoul로 하면 된다.

 

Nginx 설정 파일

nginx의 기본 설정 파일은 `nginx.conf` 파일이다. 이 파일의 경로를 찾기 위해 다음 명령어를 입력하자.

find -name nginx.conf

 

나의 경우 이 경로 `./etc/nginx/nginx.conf`에 있다. 이 파일을 vim으로 열어보자.

vi ./etc/nginx/nginx.conf

 

다음과 같이 보여질 것이다.

  • 상단에 `user`는 이 웹 서버를 실행할 수 있는 권한이 있는 사람을 의미한다. 그룹이 될 수도 있고 개인이 될 수도 있다. 굳이 건드리지 말자.
  • 상단에 `worker_processes`nginx의 워커 프로세스 수를 설정한다. 요청을 처리하는 nginx의 개별 프로세스 수를 나타내며, auto로 지정되어 있으면 nginx는 시스템의 CPU 코어수에 맞춰 자동으로 적절한 수의 프로세스를 생성한다. 이 부분도 굳이 건드리지 말자.  
  • 상단에 `pid`nginx의 메인 프로세스 ID를 저장하는 파일의 경로를 의미한다. 이 nginx가 실행될 때 해당 파일에 자신의 PID를 기록한다.
  • 상단에 `include`는 지정된 경로에 있는 파일을 이 설정 파일에 포함시킨다. 그래서 여러개의 설정 파일을 쉽게 관리할 수 있게 해준다.
"어? nginx는 이벤트 비동기 처리 방식으로 요청별로 프로세스가 늘어나는 방식이 아니라면서요?" → 아주 좋은 질문이다. 말처럼 nginx는 이벤트 비동기 처리 방식으로 요청을 처리하지만, 하나의 프로세스만으로 모든 요청을 효과적으로 처리할 수는 없다. 생각을 해보라. 프로세스가 하나만 도는데 요청이 만건이다. 그럼 프로세스는 하나라 CPU는 남아도는데 하나의 프로세스가 모든 요청을 처리해야 하면 정말 비효율적으로 요청을 처리하는 것이다.  그래서, 워커 프로세스를 CPU 코어 수에 맞춰(예를 들면 4개) 만들어 두면 4개의 프로세스가 무수히 들어오는 요청에 대해 이벤트 비동기 처리를 하게 되고 그러면 하나의 프로세스보다는 훨씬 더 효율적으로 처리할 수 있게 된다. 그래서 auto로 지정하면 CPU 코어수에 맞춰 적절하게 프로세스를 생성한다. 

 

그 다음 그 아래 있는 http 블록이 가장 중요하고, 이 블록이 전체 웹 서버 기본 설정 블록이다.

http 블록 하단에 보면 여기도 `include`가 있다. 일반적으로 nginx 웹 서버를 하나 띄우고 얘가 관리하는 서비스가 하나가 아니라 여러개이다. 그리고 그 각각의 서비스는 별도의 설정 파일로 관리하고 저렇게 `include`로 해당 설정 파일을 포함시킨다. 

 

그래서 일반적으로, `/etc/nginx/conf.d/xxxsite.conf` 이렇게 사이트(서비스)별로 설정파일을 따로 분리하여 설정한 후 저렇게 포함시킨다.

 

그리고 `/etc/nginx/site-enabled/*``include`로 포함시켰는데 이 역시 해당 경로의 모든 것들을 다 포함시킨다는 의미가 된다. 한번 이 두개의 경로를 직접 들어가보자.

 

`/etc/nginx/conf.d/` 이 경로에는 아무것도 없다. 당연한 게 일단 우리가 만든 서비스도 없고 뭐 아무것도 없기 때문에 없는게 당연하다. 그리고 `/etc/nginx/site-enabled/` 이 경로에는 다음과 같이 default가 하나 있다.

이게 이제 nginx 설치하고 80으로 진입하면 기본으로 보여주는 사이트이다. 한번 저 default 파일을 열어보면,

  • 이렇게 보여지는데, 이 server 블록이 결국 http 블록안에 포함되어서 여러 서비스(서버)가 설정될 수 있게 된다. (왜냐? 이 경로의 모든 파일을 다 include 시킨 것을 위에서 확인했으니까)
  • 그래서 http { ... server {} server {} server {} ... } 뭐 이런 모양으로 이제 구성이 된다. 

그리고 이 sites-enabled 경로에서 `ls -al` 명령어를 쳐보면, 다음과 같이 default 파일은 심볼릭 링크가 걸려있음을 알 수 있다.

그러니까 실질적으로 저 파일이 위치하는 경로는 이 sites-enabled가 아니라, sites-available에 있다. 한번 들어가보면 다음과 같다.

"근데, 왜? 왜 이렇게 해놓은거지 구조를?"

의아하다. 실질적으로 default 파일의 경로는 sites-available에 있으면 nginx.conf 파일에서 includesites-available로 하면 될 것을 굳이 sites-enabled로 해놓고 default 파일을 심볼릭 링크를 걸어 둔 이유가 무엇일까? 

 

추측컨데, 개발자의 의도는 'available'(이용가능한) 사이트와 'enabled'(현재 활성화) 된 사이트를 구분하고 싶었던 것 아닐까? 그래서 "서비스로 띄울 사이트들을 일단은 available에 올려두고, 이 중에 enabled 시킨 것들만 서비스하게 하자." 이런 의도가 있지 않았나 싶다. 

 

server 블록

이제 default 파일에서 봤던 server 블록 내부를 이해해보자. 이 블록 전체는 다음과 같이 생겼다.

server {
        listen 80 default_server;
        listen [::]:80 default_server;

        # SSL configuration
        #
        # listen 443 ssl default_server;
        # listen [::]:443 ssl default_server;
        #
        # Note: You should disable gzip for SSL traffic.
        # See: https://bugs.debian.org/773332
        #
        # Read up on ssl_ciphers to ensure a secure configuration.
        # See: https://bugs.debian.org/765782
        #
        # Self signed certs generated by the ssl-cert package
        # Don't use them in a production server!
        #
        # include snippets/snakeoil.conf;

        root /var/www/html;

        # Add index.php to the list if you are using PHP
        index index.html index.htm index.nginx-debian.html;

        server_name _;

        location / {
                # First attempt to serve request as file, then
                # as directory, then fall back to displaying a 404.
                try_files $uri $uri/ =404;
        }

        # pass PHP scripts to FastCGI server
        #
        #location ~ \.php$ {
        #       include snippets/fastcgi-php.conf;
        #
        #       # With php-fpm (or other unix sockets):
        #       fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
        #       # With php-cgi (or other tcp sockets):
        #       fastcgi_pass 127.0.0.1:9000;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ~ /\.ht {
        #       deny all;
        #}
}

 

가장 첫번째로 보이는 이 부분이다.

listen 80 default_server;
listen [::]:80 default_server;
  • 어떤 포트를 잡을지를 의미한다. 이 서버는 80포트와 매핑한다. 이 포트를 변경하면 해당 포트로 이 서버를 매핑한다.
  • nginxhttp 블록안에 여러개의 server 블록을 가진다고 했다. default_server; 이 부분은 해당 포트(80)으로 들어온 요청이 특정 서버 블록과 매칭되지 않을 때 기본적으로 처리할 서버임을 의미한다. 예를 들어 도메인이나 호스트 이름이 매칭되지 않는 경우 nginx는 이 default_server로 정의된 서버 블록을 사용해서 요청을 처리하게 된다.
  • [::]:80 이 부분은 IPv6 주소를 의미한다.
root /var/www/html;

# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;
  • `root`는 웹 서버에서 요청된 파일을 찾기 위한 기본 디렉토리를 설정하는 지시어다. `/var/www/html`은 이 서버 블록에 대한 웹 컨텐츠가 저장된 디렉토리 경로이다. 즉, 클라이언트가 웹 서버에 요청을 보내면, nginx는 해당 파일을 /var/www/html 디렉토리에서 찾는다. 
  • 예를 들어, 클라이언트가 http://example.com/page.html을 요청하면, nginx/var/www/html/page.html을 찾는다.
  • 이 경로는 웹 서버의 기본 웹 컨텐츠 폴더로 많이 사용된다. 웹 사이트의 HTML, 이미지, CSS 파일 등이 이 디렉토리에 위치하게 된다.
  • `index`nginx가 디렉토리 요청을 처리할 때 사용할 기본 인덱스 파일을 지정하는 지시어다.
  • 예를 들어, 클라이언트가 http://example.com/처럼 디렉토리만 요청했을 때, nginx는 자동으로 해당 디렉토리에서 설정된 인덱스 파일을 찾아서 제공한다. 
  • `index` 지시어 뒤에 나열된 파일들은 기본 인덱스 파일의 우선순위를 나타낸다. 왼쪽에서부터 가장 먼저 찾는 파일을 반환한다. 
  • 나의 경우 해당 경로에 다음 파일이 있다.

server_name _;

location / {
        # First attempt to serve request as file, then
        # as directory, then fall back to displaying a 404.
        try_files $uri $uri/ =404;
}
  • `server_name _;`nginx가 이 서버 블록에서 처리할 도메인 이름이나 호스트 이름을 지정하는 것이다. 여기서 `_`는 특별한 의미를 가지고 있지 않으며, 일반적으로 매칭되지 않은 모든 요청에 대해 이 서버 블록이 처리되도록 설정할 때 사용된다.
  • 그러니까 만약, `server_name cwchoiit.org www.cwchoiit.org;` 이런식으로 되어 있으면 이 서버 블록은 이 도메인으로 들어오는 요청만 처리하는 것이다. 
  • location 블록은 nginx가 특정 요청 경로(URI)에 대해 어떻게 처리할지를 정의하는 부분이다. 예를 들어 아래와 같이 작성할 수가 있다.
location /blog {
	root /var/www;
}
location /flask {
	root /var/www;
}
location / {
	try_files $uri $uri/ =404;
}
  • 그럼 요청이 `/blog/xxx`로 들어오면 저 location /blog {...} 로 처리하는 것이고, `/flask/xxx`로 들어오면 저 location /flask {...}로 처리하는 것이다.
  • try_files $uri $uri/ =404; 이 부분은 try_files부터 살펴보면, 이 try_filesnginx가 요청된 리소스를 찾을 때 여러 경로를 시도하도록 지시하는 명령어다. 
  • $uri, $uri/ 는 각각 요청된 URI와 그 디렉토리 경로를 의미한다. 이 지시어는 아래 순서로 파일을 찾는다.
    • $uri: 요청된 URI에 해당하는 파일이 있는지 확인한다. 예를 들어, 클라이언트가 /about.html을 요청했을 때, /var/www/html/about.html 파일이 있는지 확인한다. 
    • $uri/: 요청된 URI가 디렉토리일 경우, 해당 디렉토리가 있는지 확인한다. 예를 들어, /about/으로 요청이 들어오면 /var/www/html/about/ 디렉토리가 있는지 확인한다.
    • =404: 만약, 위 두가지 방법으로도 파일이나 디렉토리를 찾을 수 없다면, 404 오류를 반환한다. 

 

Nginx server 블록 수정 후 재실행

맨 처음에 도커 컨테이너 실행할 때 매핑한 포트 기억하는가? 80:8080으로 매핑했다. 즉, 외부에서 80으로 들어오면 컨테이너는 8080에 매칭시킨다는 의미가 된다. 그래서 이 default 파일의 포트를 8080으로 수정해보자.

server {
        listen 8080 default_server;
        listen [::]:8080 default_server;
        
        ...
        
}

이렇게 수정을 하자. 즉, 이제 이 default server 블록은 8080 포트를 리슨한다. 그리고 이 default 파일을 http 블록에서 include하고 있기 때문에 웹 서버가 서비스하고 있는 상태이다. 이렇게 설정 파일을 수정했으면 nginx를 재실행해야 한다.

service nginx restart

 

이 명령어를 실행해서 재실행하고 나서, 내 브라우저를 열어서 그냥 `localhost`로 접속해보자.

그럼 이렇게 nginx 기본 페이지가 보여진다! 이 파일이 바로 default가 보여주는 서비스인 것이다. 그리고 어떠한 URI도 붙이지 않았으니, root 경로의 index 파일을 찾을 것이고 그 index 파일은 다음과 같이 생겼다.

 

index.nginx-debian.html

이 HTML이 보여진 것이다. 이 HTML을 수정해보면? 그대로 적용될 것이다.

그럼 이번에는 location을 다음과 같이 수정해보자.

default

 location /blog {
        root /var/www;
}

location /flask {
        root /var/www;
}

location / {
        # First attempt to serve request as file, then
        # as directory, then fall back to displaying a 404.
        try_files $uri $uri/ =404;
}
  • 이제 외부에서 요청을 했을 때 `/blog/a.html`로 요청을 하면 저 location /blog {...} 정의대로 처리가 된다. 
    • /var/www/blog/a.html 파일을 찾을것이다.
  • 이제 외부에서 요청을 했을 때 `/flask/b.html`로 요청을 하면 저 location /flask {...} 정의대로 처리가 된다.
    • /var/www/flask/b.html 파일을 찾을 것이다.
  • 만약, `/bbb/c.html`로 호출하면? 그렇다. 아무것도 매칭이 안됐으니 location / {...} 정의대로 처리가 된다.
    • location / {...} 에는 root가 정의되지 않았으니 글로벌로 정의된 위쪽의 root /var/www/html을 사용할 것이고 찾는 경로는 /var/www/html/bbb/c.html을 찾을것이다.

 

이렇게 설정을 한 후 `/var/www` 경로에서 blog, flask 폴더를 하나씩 만들자.

# /var/www

mkdir blog
mkdir flask

 

그리고 이 각각의 폴더에 `/var/www/html` 경로에 있는 index.nginx-debian.html 파일을 복사해서 index.html 파일로 만들자.

# /var/www/html

cp index.nginx-debian.html ../blog/index.html
cp index.nginx-debian.html ../flask/index.html

 

그리고 각각의 index.html 파일의 타이틀을 blog!!!, flask!!!로 변경하고 nginx를 재실행 해보자.

 

 

그럼 우선 `localhost`로 접속하면 다음과 같이 보일것이다.

 

이번엔 `localhost/flask`로 접속하면 이렇게 보일것이다.

왜냐? `/flask/`로 요청이 들어오면 location 설정값에 의해 /var/www/flask 디렉토리를 찾고, 그 이후에 붙은 URI가 없으니 index 파일을 찾을것이므로.

 

같은 이유로 `localhost/blog`로 접속하면 이렇게 보여질 것이다.

 

정리를 하자면

이렇게 기본적인 nginx의 구동 방식, 설정 파일에 대해 알아보았다.

728x90
반응형
LIST

'Nginx' 카테고리의 다른 글

Nginx - Reverse Proxy 설정  (6) 2024.09.19
728x90
반응형
SMALL

자바는 두가지 객체 소멸자를 제공한다. 그 중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있다. 아예 사용하지 않아야 한다. 그래서 자바9부터는 아예 Deprecated됐다. 또 다른 하나로는 cleaner가 있다. 이 cleanerfinalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고 역시나 일반적으로 불필요하다. 

 

자바 언어 명세에서 finalizer, cleaner는 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다. 그러니까 제때 실행되어야 하는 작업은 저 두가지로 절대 할 수 없다.

 

그럼 자바에서 적절하게 객체를 소멸하는 방법은 뭐가 있을까? 

→ AutoCloseable을 구현한 객체를 try-with-resource 구문으로 처리하는 것이다.

(클라이언트에서 인스턴스를 다 쓰고 나면 AutoCloseable이 구현해야 하는 메서드인 close 메서드를 호출해도 되지만, 일반적으로 예외가 발생해도 제대로 종료되도록 try-with-resource 구문을 사용해야 한다)

 

AutoCloseable을 구현한 객체를 try-with-resource 구문으로 처리할 때, 구체적으로 알아두면 좋을 내용이 있다.

→ 각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋다. 다시 말해, close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것이다.

 

그럼 finalizerDeprecated 됐다고 치면, cleaner는 여전히 사용가능하게 되어 있는데 도대체 어디에 쓰는 물건인고? 싶을것이다.

적절한 쓰임새가 한가지 정도 있다고 보는데, 자원의 소유자가 close 메서드를 호출하지 않는 것에 대한 안전망 역할이다. 즉, 사용자가 다 쓰고 close 메서드를 호출하지 않거나 try-with-resource 구문도 사용하지 않는 그런 예. 그렇다고 cleaner가 즉시 호출되리라는 보장은 없지만 아예 자원 회수를 안하는 것 보다는 나으니 말이다. 

 

자바 라이브러리도 이와 유사한 클래스가 있다. FileInputStream, FileOutputStream 등등이 말이다.

 

그래서 가장 좋은 방법은 cleaner를 사용한 클래스를 AutoCloseable로 구현하고 사용할 땐 try-with-resource 구문을 꼭 사용하는데 까먹고 사용하지 않은 경우 cleaner에게 제발 실행만이라도 되도록 기도하는 것이다. 

 

다음은 cleaner만 사용한 예시이다. 어떻게 사용하는지는 알아야 하니까.

import java.lang.ref.Cleaner;

class Resource {
    private static final Cleaner cleaner = Cleaner.create();
    private final Cleaner.Cleanable cleanable;

    public Resource() {
        // 리소스 초기화
        System.out.println("리소스가 할당되었습니다.");

        // 리소스가 수거될 때 수행할 작업 정의
        this.cleanable = cleaner.register(this, () -> {
            System.out.println("리소스가 해제되었습니다.");
        });
    }

    public void close() {
        cleanable.clean();  // 명시적으로 클리닝 작업 실행 가능
    }
}

public class CleanerExample {
    public static void main(String[] args) {
        Resource resource = new Resource();
        resource.close();  // 명시적으로 리소스를 해제
    }
}

 

근데 완전하지 않으니 AutoCloseable까지 구현해보자. try-with-resource 구문을 사용해서 반드시 객체를 소멸시키게 만들 수 있도록 말이다.

 

Resource (AutoCloseable + Cleaner)

package items.item8;

import java.lang.ref.Cleaner;

public class Resource implements AutoCloseable {
    private static final Cleaner cleaner = Cleaner.create();
    private final Cleaner.Cleanable cleanable;

    public Resource() {
        // 리소스 초기화
        System.out.println("리소스가 할당되었습니다.");

        // 리소스가 수거될 때 수행할 작업 정의
        this.cleanable = cleaner.register(this, () -> {
            System.out.println("리소스가 해제되었습니다.");
        });
    }

    public void close() {
        cleanable.clean();  // 명시적으로 클리닝 작업 실행 가능
    }
}
  • AutoCloseableCleaner를 같이 사용한다. Cleaner만으로는 확신을 가질 수 없다. 그래서 AutoCloseableclose 메서드안에 Cleaner를 사용해 자원을 정리하게 한다.
package items.item8;

public class Main {
    public static void main(String[] args) {

        try (Resource r = new Resource()) {
            System.out.println("안녕?");
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

실행결과

리소스가 할당되었습니다.
안녕?
리소스가 해제되었습니다.

Process finished with exit code 0
  • 완벽하게 close 메서드가 실행됐고 원하는대로 자원을 소멸시킨다.

 

그런데 만약, 이렇게 호출하면 어떻게 될까?

package items.item8;

public class Main {
    public static void main(String[] args) {

        Resource r = new Resource();
        System.out.println("안녕?");
    }
}

실행결과

리소스가 할당되었습니다.
안녕?

Process finished with exit code 0
  • Cleaner가 실행되지 않았다. 이렇다. cleaner는 믿을게 못된다. 그래서 안전망 역할 정도만을 기대하는 게 가장 좋다. 절대 AutoCloseable을 두고 Cleaner를 사용하려고 하지마라.

 

참고로, 다음과 같이 System.gc()를 호출해서는 정상적으로 리소스가 종료됐지만 이걸 따라하고 있는 당신도 그러리란 보장이 없다.

package items.item8;

public class Main {
    public static void main(String[] args) {

        Resource r = new Resource();
        System.out.println("안녕?");
        System.gc();
    }
}
728x90
반응형
LIST

+ Recent posts