어떠한 인증도 하지 않은 요청이 들어오면 로그인 페이지로 리다이렉트 되는지를 확인하는 테스트이다.
다음은 인증을 특정 유저로 했을 때 정상적으로 동작하는지를 확인하는 테스트이다. 이 테스트가 중요하다.
@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를 만드는 애노테이션이다.
이 부분은 스프링 부트를 사용하면 아주 편리한 점이 H2 데이터베이스를 매우 손쉽게 사용할 수 있다는 점이다. H2 데이터베이스 의존성만 내려받으면 자동으로 H2 데이터베이스를 실행시켜 주는데, 그때 H2 콘솔을 브라우저에서 접근할 수 있다. 근데 이 경우, H2 콘솔에서 iframe을 사용하기 때문에 기본적으로 iframe을 Spring Security가 허용하지 않는다. 그러나, 같은 origin인 경우에는 Iframe을 허용하겠다는 의미가 된다. 여기서는 H2 콘솔을 허용한다고 생각하면 된다.
로그인 폼을 사용해서 로그인 절차를 거치게 한다. 이건 UsernamePasswordAuthenticationFilter를 사용한다는 의미와 같다. 그래서 로그인 페이지를 직접 만들었다면, 해당 경로를 위처럼 지정해주면 되고, 안 만들어도 기본으로 `/login` 경로로 스프링 시큐리티가 만들어준다. 그리고 가장 좋은 점 하나는 로그인 처리를 위한 POST 요청을 처리하는 과정을 자동으로 해주기 때문에 우리는 아예 아무것도 하지 않아도 되거나, 로그인 페이지를 직접 서비스의 디자인에 맞춰 만들고 싶은 개발자는 로그인 페이지만 만들면 된다.
로그인에 성공했다면 리다이렉트 할 경로도 지정해준다.
이 로그인 페이지는 당연히 모두가 접근할 수 있어야 하므로 permitAll()을 사용한다.
Spring Security는 인증, 인가를 제공하고, 잘 알려져 있는 해커들의 공격을 막아주는 프레임워크이다. 당연히 이름답게 스프링 기반의 애플리케이션을 보호해준다.
이 프레임워크는 굉장히 크다. 그래서 깊이 있게 공부를 해야만 제대로 이해할 수 있다고 생각한다. 그래서 하나씩 하나씩 점진적으로 천천히 알아보자.
SecurityContextHolder
Spring Security 인증의 핵심이 되는 모델은 SecurityContextHolder이다.
이 SecurityContextHolder 안에는 SecurityContext가 있다.
이렇게 생긴 구조이기 때문에 코드상에서 다음과 같이 호출할 수 있다.
SecurityContextHolder
그럼 SecurityContextHolder는 뭐하는 녀석일까? 이름 그대로 Context를가지고 있는, 담고 있는 객체이다. SecurityContext를 제공하는 static 메서드(getContext())를 지원한다.
SecurityContext
SecurityContext는 접근 주체와 인증에 대한 정보를 담고 있는 Context이다. 즉, Authentication을 담고 있다.
Authentication
Principal, Authorities, Crendentials를 제공한다. 인증이 이루어지면 해당 Authentication이 저장된다.
Principal
유저에 해당하는 정보이다. 대부분의 경우 Principal로 UserDetails를 반환한다.
Authorities
ROLE_ADMIN, ROLE_USER등 Principal이 가지고 있는 권한을 나타낸다. 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_THREADLOCAL이 SecurityContextHolder의 기본 설정 모드인지 확인해보고 싶으면 코드 내부로 들어가보면 된다.
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를 더 안전하게 관리할 수 있다.
회원가입 시 password를 해시함수로 암호화해서 저장한다.
로그인할 때 password가 들어오면 같은 해시함수로 암호화한다.
저장된 값을 불러와서 2번의 암호화된 값과 비교한다.
동일하면 같은 암호로 간주한다.
그래서, 이 Spring Security를 사용할때는 PasswordEncoder를 빈으로 등록해줘야 하는데, 이 PasswordEncoder는 Spring Security가 만든 인터페이스다. 실제로 코드를 확인해보면 다음과 같이 생겼다.
그래서, 이 인터페이스를 구현하는 구현체가 상당히 다양하다. 아래 사진처럼 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는 Spring Security에서 보통은 두번째로 실행되는 필터이다. 첫번째로 실행되는 필터는 Async 요청에 대해서도 SecurityContext를 처리할 수 있도록 해주는 WebAsyncManagerIntegrationFilter이다. 지금은 고려하지 말자.
이 SecurityContextPersistenceFilter는 무엇을 하냐?
▶ SecurityContext를 찾아와서 SecurityContextHolder에 넣어주는 역할을 하는 Filter이다.
그럼 여기서 한가지 의문점은, 어디서 load를 하는걸까? 저 this.repo는 무엇을 가리킬까? 살짝만 위로 올려보면 아래와 같은 필드가 보인다.
private SecurityContextRepository repo;
그럼 SecurityContextRepository의 구현체는 어떤것일까? Spring Security는 무엇을 사용하고 있을까?
바로 HttpSessionSecurityContextRepository이다. (기본 구현체가 HttpSessionSecurityContextRepository이다) 여기서 알 수 있는점은 Spring Security는 기본적으로 Session을 통해 사용자가 어떤 사용자인지 인증/인가를 한다는 의미이다. 즉, 사용자가 로그인을 했을 때 세션을 생성해서 SecurityContext를 저장하고 사용자에게 응답을 세션을 담은 쿠키와 함께 돌려주면, 이제 이 사용자가 추가적인 요청을 했을 때 쿠키에 담긴 세션을 가져와서 SecurityContext 찾아 SecurityContextHolder에 담아주는 것이다.
그러니까 요청 하나 당 LocalThread를 만들어 하나의 SecurityContextHolder를 가지고 있다고 해도, 사용자의 요청에 포함된 세션으로부터 SecurityContext를 얻어서, 이 사람이 어떤 사람인지 지속적으로 인증/인가가 가능해진다.
재밌으니까 좀 더 깊게 들어가보자. 저 this.repo.loadContext(holder) 안으로 들어가보면 이런 코드가 나온다.
this.readSecurityContextFromSession(httpSession) → 세션에서 SecurityContext를 찾는다.
SecurityContext가 없다면, 새로 만든다.
그리고 결국 new SaveToSessionResponseWrapper(...)를 호출하면서 세션을 생성하거나 기존 세션이 있으면 해당 세션에 SecurityContext를 추가한다.
그런데, 이 필터는 Spring Security 6부터 Deprecated됐다. 이제 이 필터는 사용하지 않는다.
그럼 이 필터가 하는 역할은 누가 대신하나? 그게 바로 SecurityContextHolderFilter이다.
왜 SecurityContextHolderFilter?
▶ 기존 SecurityContextPersistenceFilter는 인증/인가가 필요하든, 필요하지 않든 무조건 세션이 없다면 새로 만들고 있다면 해당 세션에 SecurityContext를 담았다. 그런데 잘 생각해보면, 애플리케이션에서 모든 요청이 다 인증/인가가 필요한 건 아닌데 이 방식대로라면 불필요한 리소스를 사용하게 된다. 그래서 이런 문제점을 극복하고자 인증/인가가 필요한 경우에만 세션을 생성하고 기존에 있는 세션에SecurityContext를 담아 사용할 수 있도록 변경됐다. 그래서 필요하지 않다면 세션도 만들지 않는다. 또한, Supplier 타입을 사용해서, 지연 로딩을 통해 더 효율적으로 리소스를 사용할 수 있도록 변경했다.
BasicAuthenticationFilter
이 필터는 Basic Authentication을 가능하게 하는 필터이다. 길게 말할 필요없이 아래 과정을 살펴보자.
그 전에, `/note` path는 로그인 한 사용자만 진입할 수 있다. 그래서 만약, 로그인이 되지 않았다면 로그인 페이지로 리다이렉트 한다.
이렇게, CURL 명령어 시, `-u <username>:<password>` 옵션을 넣으면 Basic Authentication으로 인증을 하는 것이다. 내부적으로 Base64로 인코딩하는 과정이 있지만 여튼간 그럼에도 불구하고 위 사진처럼 로그인 페이지로 리다이렉트 됐다.
그 이유는, BasicAuthenticationFilter를 비활성화했기 때문이다.
그럼 이번에는, BasicAuthenticationFilter를 다음과 같이 활성화한 후 다시 실행해보자.
이번에는 화이트 레이블 에러 페이지가 나왔다. 즉, 인증은 성공했다는 의미이다. 물론 이 경로로 왔을 때 보여줄 뷰 화면을 만들지 않아서 404 화이트 레이블 페이지가 나왔지만 어쨌든 인증이 성공한다.
즉, 이 BasicAuthenticationFilter는 따로 로그인이라는 과정을 하지 않아도 로그인 데이터를 Base64로 인코딩해서 모든 요청에 포함해서 보내면 BasicAuthenticationFilter가 이것을 가지고 인증을 해준다. 그렇기 때문에 세션이 필요없고 요청이 올때마다 인증이 이루어진다. 그러나 이런 방식은 요청할 때마다 아이디와 비밀번호가 반복해서 노출되기 때문에 보안에 취약하다. 그래서 혹시나 이 필터를 굳이 사용해야 한다면 반드시 https를 사용하도록 권장된다.
UsernamePasswordAuthenticationFilter
이 UsernamePasswordAuthenticationFilter는 Form 데이터로부터 Username, Password가 들어오는 경우, 인증을 처리해주는 필터이다. 즉, 폼으로부터 요청이 들어왔는데 그게 사전에 정의한 로그인 폼이고 들어온 데이터가 Username, Password라면 이때 이 필터가 동작한다.
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 클래스다.
DaoAuthenticationProvider의 retrieveUser()
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);
}
}
이 코드가 드디어 UserDetailsService를 사용하는 코드이고, 이 UserDetailsService를 구현한 구현체 중 하나를 사용할텐데, 그게 바로 Spring Security를 사용하기 위해 항상 하는 UserDetailsService 인터페이스를 구현한 구현체를 직접 만들어서 저 loadUserByUsername()을 구현하는 이유이다. 무슨 말인지 모르겠다면, 추후에 알게된다.
그래서 이 일련의 과정을 거쳐 비로소 Username, Password를 통해 인증/인가를 할 수 있게 된다. 그러기 위해 필요한 건 다음 절차이다.
UsernamePasswordAuthenticationFilter 활성화 (SecurityConfig에서 설정)
UserDetailsService 구현 (또는 스프링 시큐리티가 사용하는 User를 그대로 사용하거나)
보통은 서비스를 만든다면 유저 정보는 서비스마다 가지각색이다. 그렇기 때문에 스프링 시큐리티가 제공하는 User를 사용하는 프로젝트는 없다. 아마도. 그래서 내가 만든 유저 정보를 가지고 스프링 시큐리티를 사용해서 UsernamePasswordAuthenticationFilter를 통해 인증/인가를 하려면 이 필터가 사용하는 UserDetailsService의 loadUserByUsername()을 직접 구현해야 한다는 소리다.
전혀 어렵지도 않다. 아래는 내가 만든 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이다. 이 필터를 활성화 하려면 다음과 같이 하면 된다.
이렇게 원하는 유효 시간까지 지정할 수도 있다. 그리고 이걸 어떻게 사용하느냐, 지정한 로그인 경로에서 보여주는 로그인 폼에서 다음과 같이 체크박스 하나를 만들면 된다.
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 됐다.
이제는 Authentication은 UsernamePasswordAuthenticationFilter와 같은 AuthenticationFilter가 담당한다. 즉, 로그인 시에 SecurityContext에 Authentication을 넣어주는 역할도 하지만, 인증도 같이 하는것이다. 사실 그게 더 합리적이기도 하다.
그럼, Authorization은 누가하냐? AuthorizationFilter가 담당한다.
그래서 인가 허용인지 아닌지 판단한 후 인가가 되지 않는 사용자라면, 보이는 것과 같이 AccessDeniedException을 호출한다.
ExceptionTranslationFilter
앞서 본 AuthenticationException, AccessDeniedException 둘 중 하나가 발생했을 때 어떤 행동을 취해야 하는지를 결정해주는 Filter이다.
예를 들어, AuthenticationException이 발생하면 당연히 인증이 안됐기 때문에 로그인 페이지로 리다이렉트한다. 그러나, Anonymous의 AccessDeniedException이 발생하면 이 또한 로그인 페이지로 리다이렉트한다. 그 이유는 인가가 안된게 아니라 인증이 안됐으니 인가가 불명확해 로그인은 할 수 있게끔 설계한 것이다. 이런 처리를 바로 이 ExceptionTranslationFilter가 해준다.
근데 그 외 AccessDeniedException이 발생하면, 당연히 403에러를 반환한다.
이 메서드에서 보면, 예외 객체의 타입이 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)가 동작하는지도 이해했다.
이런 경우 이 TestContainer가 장점을 발휘할 수 있다. 혹자는 그냥 H2로 테스트할때만 하면 되는거 아닌가? 싶을수 있다. MySQL과 H2는 같은 데이터베이스가 아니다. 즉, 동일한 작업을 해도 둘 중 하나는 에러가 발생하는데 둘 중 하나는 발생하지 않을 수 있다. 그래서 정확한 테스트라고 할 수는 없다.
또 다른 혹자는 그럼 그냥 Mocking하면 되는거 아닌가? 싶을수 있다. 이 또한, 사실 Mocking은 데이터베이스 연동 테스트가 전혀 아니다. 비즈니스 로직의 결함을 찾는것에 더 가까운 테스트이지 데이터베이스까지 연동한 테스트는 전혀 아니다.
그리고 실제로 컨테이너를 생성해서 데이터베이스를 만들어야 하는데 나는 Redis를 사용해서 테스트해보기로 한다. 그래서 다음과 같이 필드 하나를 추가한다.
@Container
private static final GenericContainer<?> redisContainer =
new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379);
@Container 애노테이션을 붙여서 이 필드가 컨테이너가 될 것임을 알려준다.
가장 최근의 이미지인 "redis:latest"를 사용하고, Redis의 기본 포트인 6379를 Expose한다.
중요한 부분은 이 부분이다. 컨테이너를 띄울때 6379와 매핑되는 포트는 임의로 지정된다. 같은 6379:6379면 좋갰지만 그게 아니다. 그래서 컨테이너가 띄워진 후 6379와 매핑된 포트를 스프링 부트의 설정값 중 spring.data.redis.port에 지정해줘야 한다. 그래야 정상적으로 통신이 가능해질테니.
그래서, 동적으로 속성값을 설정할 수 있는 방법을 이렇게 @DynamicPropertySource 애노테이션으로 제공한다.
이 부분은 실제 테스트 코드다. 사실 이 부분은 별 게 없다. 나의 경우 getDeveloperByMemberId()는 Redis로 캐시할 수 있게 구현했다. 그래서 저 메서드를 두번 호출헀을 땐, 첫번째는 redis에 저장된 값이 없기 때문에 실제 데이터베이스에서 값을 가져올 것이고, 두번째 호출했을 땐, redis에 저장된 값이 있기 때문에 바로 캐싱이 가능해진다.
그리고, redis에 저장됐기 때문에, redisContainer.execInContainer("redis-cli", "get", "developer:xayah")를 호출하면 저장된 캐시의 value가 반환될 것이다.
그 반환된 값을 JSON으로 역직렬화를 하여 객체가 가진 값들을 비교한다.
어떻게 JSON으로 직렬화해서 저장이 바로 됐나요? → 이전 포스팅을 참고해야 한다. RedisConfig 클래스로 설정을 했다. 그리고 @SpringBootTest이므로 스프링 컨테이너가 온전히 띄워지기 때문에 설정값이 적용된 상태에서 이 테스트가 진행되는 것이다.
테스트 결과는 다음과 같이 성공이다.
참고. 컨테이너가 띄워지고 삭제되는 것을 확인
저 테스트가 실행되면, 컨테이너가 실제로 띄워진다. 그리고 테스트가 끝나면 컨테이너가 자동으로 삭제된다. 확인해보면 좋을것이다.
우선, 여러 방법으로 설정을 할 수 있는데, application.yaml 파일에서도 redis 설정을 할 수 있다. 근데 이렇게 하지 않을 것이다. 왜냐하면, 우선 첫번째 이유로는, 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을 구현하면 된다. 다음 코드처럼.
최초에는 캐시가 없기 때문에, 직접 데이터베이스에서 값을 가져오는 모습이 보인다. SELECT문이 실행됐다.
저 이후에 다시 한번 API를 날려보면, 다음과 같이 캐시데이터를 가져온다.
아예 서비스의 memberId를 보여주는 로그조차 찍히지 않았다. 즉, 캐시 데이터를 그대로 반환한 것이다.
그리고 실제로 redis-cli로 확인을 해보면 잘 저장되어 있다.
그럼 실제로 저 데이터가 어떻게 저장되어 있나 확인해보자.
우리가 알아볼 수 없는 유니코드로 보여진다. 직렬화를 하기 위해 Serializable을 구현했는데, 이게 자바 Serialization이기 때문에 사람이 알아보기가 힘들다. 그래서 사람이 알아보기 좋은 포맷이 뭘까? 바로 JSON이다. JSON으로 직렬화할 수 있겠지? 당연히 있다! 해보자!
JSON으로 직렬화 방법 바꾸기
이제 자바의 Serialization이 아닌 JSON 형태로 직렬화하기 위해 아까 빈 껍데기로 만들어 두었던 RedisConfig를 사용할 차례다.
given() 메서드는 `org.mockito.BDDMockito.given`에서 제공하는 메서드이고, 사전 조건을 정의할 수 있다. 이때 developerRepository가 가지고 있는 findByMemberId() 메서드가 어떤 값을 받을 때 반환하는 가짜 데이터를 임의로 생성할 수 있다. 저기서는 anyString() 이라는 `org.mockito.ArgumentMatchers.anyString`에서 제공하는 메서드를 사용해서 어떤 문자열이 들어와도 동일한 반환을 할 수 있게 정의했다.
findByMemberId는 반환값이 Optional이다. 그렇게 때문에 willReturn(Optional.of(...))을 사용한다.
이제 적절한 데이터를 만들어서 이 메서드가 호출되면 어떤 문자열이 들어와도 항상 코드에서 정의한 객체가 반환되도록 하였다.
@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);
}
}
DMakerController가 주입 받아야 하는 DMakerService를 Mock으로 빈 등록을 한다.
위에서 서비스 관련 테스트를 할때는 필요한 주입을 @Mock 애노테이션으로 사용했는데 여기서는 @MockBean으로 하는 이유는 이 테스트를 실행해보면 알겠지만 스프링이 띄워진다. @WebMvcTest는 스프링을 띄우긴 띄우는데 선언한 빈들만 등록하게 해서 띄우는 간단한 스프링이라고 생각하면 좋다. 그래서 스프링 Bean으로 등록될 Mock을 선언하는 @MockBean 애노테이션을 선언한다.
실제 테스트 코드는 다음과 같다. 주입 받은 MockMvc를 사용해서 HTTP 요청을 날린다. 이때 Content-Type은 JSON으로 지정하기 위해 필드로 선언한 `contentType`을 사용한다. 그리고 그 응답 결과를 우리가 만든 가짜 데이터가 나올 것으로 예상한 테스트 코드를 작성한다.
andDo(print())는 이 요청에 대한 요청-응답 정보를 출력하는 메서드이다.
실행 결과
정리를 하자면
이렇게 간단하게 데이터베이스가 필요한 서비스지만, 데이터베이스를 사용하고 싶지 않을 때 Mockito를 사용해서 가짜로 데이터를 만들어 내는 방법을 배워봤다. 간단한 단위 테스트를 작성할 때 Mockito를 사용하면 코드 자체적인 문제를 잘 찾아낼 수 있을 것 같다.
인텔리제이가 단축키 좋은게 정말 많다(진짜 찾아보면 좋은거 너무 많다). 근데 여기서 문제가 너무 많아서 단축키를 까먹는 경우가 더러 생긴다.
그 중에 내가 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 이 두가지만 남겨두고 다 지웠다. 그리고 이제 내가 매핑한 단축키를 누르면 저 두액션이 한번에 실행된다. 매우 매우 편리하다.
우선 프록시라는 단어는 매개체, 대리자이다. 그래서 nginx도 reverse 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 서비스에 가야하는구나를 알까?
worker_connections 1024; 는 각 워커 프로세스가 동시에 처리할 수 있는 최대 클라이언트 연결 수를 1024로 지정한다는 의미이다. 그래서 만약, nginx가 4개의 워커 프로세스를 사용하고 있다면 최대 4 * 1024 = 4096개의 동시 연결을 처리할 수 있게 된다.
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으로 설정하면 nginx는 sendfile 시스템 호출을 사용하여 파일 전송을 효율적으로 처리한다. 이 방식은 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-compose는 alias같은 것이다.
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.yml 파일이 있는 경로에서
docker-compose up -d
잘 띄워졌다. 그럼 한번 브라우저에서 접속해볼까?
어라? nginx가 띄워지긴 한 모양인데 404가 떴다. 즉, 우리가 원하는 파일을 못 찾은것이다. 왜 그럴까?
우선 접속 경로는 `/nginx/`이다. 그럼 nginx 프록시 서버 설정에 의해서 docker-nginx가 지정한 nginx 서버로 요청을 전달할 것이다. 근데, 이 전달받은 서버는 nginx이다. 즉, 이 내부에 있는 nginx 서버는 어떤 경로를 받냐면 `/nginx/`를 그대로 받는데 이 내부에 있는 nginx 서버는 해당 경로를 모른다. 왜? 우리가 설정한 적이 없으니까!
무슨 말인지 말로만 하면 이해가 안가니까, 내부 nginx 컨테이너로 들어가보자.
들어가기 위해 내부 nginx 컨테이너 이름을 확인해보자. 확인했으면 다음 명령어를 실행한다.
위 예시처럼 찾았으면 해당 파일을 열어보면, 아래와 같이 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 라는 옵션이 추가됐음을 확인할 수 있다. 그리고 정규 표현식이 나오고 뭐 등등 복잡하다.
표현식은 이렇다.
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 더해주면 된다.
웹 서버는 HTTP 요청을 받아서 해당 요청에 대한 응답을 해주는 프로그램이다. 대표적인 웹 서버로 Apache, Nginx가 있는데 요즘 점점 더 뜨고 있는 웹 서버가 Nginx인것 같아 공부해 보려고 한다. 웹 서버는 프로그램을 서버상에 설치해서, 특정 HTTP 요청에 따라 서비스를 제공해주는 방식으로 구현된다.
왜 Nginx가 Apache보다 점점 추세가 좋아질까? 난 아래 구동 방식의 차이에서 나온다고 생각한다.
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가 요즘 더 뜨는 추세가 아닌가 싶다.
간단하게 Apache와 Nginx의 구동방식을 이해해 보았다. 이제 Nginx를 직접 설정해보자.
여기까지 진행하면 해당 컨테이너의 쉘로 들어오게 된다. 이 안에서 다음 명령어를 실행하자.
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 파일에서 include를 sites-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;
#}
}
어떤 포트를 잡을지를 의미한다. 이 서버는 80포트와 매핑한다. 이 포트를 변경하면 해당 포트로 이 서버를 매핑한다.
nginx는 http 블록안에 여러개의 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)에 대해 어떻게 처리할지를 정의하는 부분이다. 예를 들어 아래와 같이 작성할 수가 있다.
그럼 요청이 `/blog/xxx`로 들어오면 저 location /blog {...} 로 처리하는 것이고, `/flask/xxx`로 들어오면 저 location /flask {...}로 처리하는 것이다.
try_files $uri $uri/ =404; 이 부분은 try_files부터 살펴보면, 이 try_files는 nginx가 요청된 리소스를 찾을 때 여러 경로를 시도하도록 지시하는 명령어다.
$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 파일로 만들자.
자바는 두가지 객체 소멸자를 제공한다. 그 중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있다. 아예 사용하지 않아야 한다. 그래서 자바9부터는 아예 Deprecated됐다. 또 다른 하나로는 cleaner가 있다. 이 cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고 역시나 일반적으로 불필요하다.
자바 언어 명세에서 finalizer, cleaner는 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다. 그러니까 제때 실행되어야 하는 작업은 저 두가지로 절대 할 수 없다.
그럼 자바에서 적절하게 객체를 소멸하는 방법은 뭐가 있을까?
→ AutoCloseable을 구현한 객체를 try-with-resource 구문으로 처리하는 것이다.
(클라이언트에서 인스턴스를 다 쓰고 나면 AutoCloseable이 구현해야 하는 메서드인 close 메서드를 호출해도 되지만, 일반적으로 예외가 발생해도 제대로 종료되도록 try-with-resource 구문을 사용해야 한다)
AutoCloseable을 구현한 객체를try-with-resource구문으로 처리할 때, 구체적으로 알아두면 좋을 내용이 있다.
→ 각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋다. 다시 말해, close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고, 다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것이다.
그럼 finalizer는 Deprecated 됐다고 치면, 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(); // 명시적으로 클리닝 작업 실행 가능
}
}
AutoCloseable과 Cleaner를 같이 사용한다. Cleaner만으로는 확신을 가질 수 없다. 그래서 AutoCloseable의 close 메서드안에 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();
}
}