컨트롤러의 WebMvcTest의 entityManagerFactory 의존성 문제가 발생한 것이었습니다.
1. @WebMvcTest를 사용한 테스트
@ActiveProfiles("test")
@AutoConfigureMockMvc
@WebMvcTest(controllers = [ReviewGroupUpsertController::class])
class ReviewGroupUpsertControllerTestV1(
private val mockMvc: MockMvc,
) : FunSpec() {
override fun extensions(): List<Extension> = listOf(SpringExtension)
@MockkBean
private lateinit var userVerifyService: UserVerifyService
@MockkBean
private lateinit var courseGroupService: CourseGroupService
@MockkBean
private lateinit var possibleReviewCourseFindService: PossibleReviewCourseFindService
@MockkBean
private lateinit var reviewGroupCommandUseCase: ReviewGroupCommandUseCase
Description: Parameter 0 of constructor in com.content.adapter.course.CourseCommandAdapter required a bean named 'entityManagerFactory' that could not be found.
Action: Consider defining a bean named 'entityManagerFactory' in your configuration.
@WebMvcTest는 스프링 부트의 슬라이스 테스트 중 하나로, 웹 계층에 대한 테스트를 위해 사용이 됩니다.
이 어노테이션을 사용하면, 스프링 테스트 컨텍스트는 웹 계층의 빈들만 로드하여, 테스트를 가볍게 실행할 수 있습니다.
하지만, 이 어노테이션은 @Controller, @ControllerAdvice, @JsonComponent, WebMvcConfigurer 등과 같은 웹 계층 관련 구성 요소만 스캔하고, 나머지 요소는 스캔하지 않습니다.
이 문제로 인해, JPA에 사용되는 entityManagerFactory 등과 같은 의존성 모듈이 스캔되지 않는 문제가 발생하였습니다.
이에 대한 의존성을 TestConfiguration화 하여 사용할 수 있지만,
연쇄적으로 물리는 객체의 의존성으로 인해 이를 제어하는 것도 쉬운 일은 아니었습니다.
따라서, mockMvc를 활용하여 컨트롤러의 응답을 테스트하기 위해서는 컨트롤러의 생성자 주입에 필요한 클래스들만 스텁 화하여
단위테스트 형태로 작성하는 로직이 필요하였습니다.
2. 특정 컨트롤러에 대한 단위 테스트
이를 위한 해결책으로 @WebMvcTest 관련 어노테이션을 제거하고,
mockMvc에 대한 인스턴스를 만들어 사용하는 과정을 수행하였습니다.
@ActiveProfiles("test")
class ReviewGroupUpsertControllerMockMvcTest : FunSpec() {
override fun extensions(): List<Extension> = listOf(SpringExtension)
@MockkBean
private lateinit var userVerifyService: UserVerifyService
@MockkBean
private lateinit var courseGroupService: CourseGroupService
@MockkBean
private lateinit var possibleReviewCourseFindService: PossibleReviewCourseFindService
@MockkBean
private lateinit var reviewGroupCommandUseCase: ReviewGroupCommandUseCase
private lateinit var mockMvc: MockMvc
override suspend fun beforeSpec(spec: Spec) {
val reviewGroupUpsertController = ReviewGroupUpsertController(
userVerifyService = userVerifyService,
courseGroupService = courseGroupService,
possibleReviewCourseFindService = possibleReviewCourseFindService,
reviewGroupCommandUseCase = reviewGroupCommandUseCase
)
mockMvc = MockMvcBuilders.standaloneSetup(reviewGroupUpsertController).build()
}
어노테이션은 어떠한 프로파일에서 실행할 것인지를 명시하는 ActiveProfiles()를 제외하고 제거하였습니다.
각 MockkBean으로 설정한 Stub을 테스트하고자 하는 컨트롤러에 생성자 주입하여 객체를 만들고
mockMvc를 만들기 위한 빌드를 진행하였습니다.
3. 구체적인 코드 작성하기
a. FunSpec
kotest는 다양한 스타일의 테스팅을 지원합니다 (FunSpec, BehaviorSpec 등 다양한 스타일의 테스팅을 지원)
이 중 FunSpec은 scalaTest 방식을 채택하고 있습니다.
kotest를 구성할 때, 전통적인 OOP 방식으로 추상 클래스를 상속하는 방법이 있습니다.
반면 FunSpec({ /**/ }) 내부에 작성하는 DSL 스타일로 FunSpec의 생성자에 Lambda를 전달하는 방법도 있습니다.
저는 이 중 OOP 방식으로 작성하는 코드로 작성하였습니다. (두 기능 모두 정상적으로 잘 동작합니다)
class ReviewGroupUpsertControllerMockMvcTest : FunSpec() {
/* 중략 */
}
b. extentions()
Kotest에서 extentions() 함수는 테스트 실행 중에 추가적인 기능을 제공하는 데 사용됩니다.
SpringExtension은 Kotestdml Spring TestContext Framework와 통합하는 확장입니다.
이 확장을 사용하면 Spring의 DI 기능을 테스트에서 활용할 수 있습니다.
SpringExtension을 사용할 때 장점은 다음과 같습니다.
SpringContext 통합
스프링 테스트 어노테이션 지원
캐시 된 컨텍스트 지원
트랜잭션 관리
저는 MockkBean을 사용하고 있기 때문에, SpringExtension을 사용하였습니다.
override fun extensions(): List<Extension> = listOf(SpringExtension)
c. @MockkBean
스프링은 MockBean을 사용할 수 있지만, ninja 라이브러리에서 제공하는 MockkBean을 사용하였습니다.
스프링 부트는 source 서버와 replica 서버의 데이터베이스 커넥션을 각각 설정해야 하므로
driver-class-name, jdbc-url, username, password를 각각 설정하였습니다.
참고한 블로그에서 master - slave라는 표현은 윤리적인 문제로 최근에는 Source - Replica를 사용한다고 설명해 주셨습니다.
따라서 저도 master: source, slave: replica로 표현하였습니다.
2. Configuration 작성하기
전체 소스를 먼저 작성한 후, 하나씩 주요 메서드 및 기능을 정리하도록 하겠습니다.
@Slf4j
public class RoutingDataSource extends AbstractRoutingDataSource {
@Nullable
@Override
protected Object determineCurrentLookupKey() {
String lookupKey = TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "replica" : "source";
log.info("Current DataSource is {}", lookupKey);
return lookupKey;
}
}
@Slf4j
@Configuration
public class DataSourceConfig {
private static final String SOURCE_SERVER = "source";
private static final String REPLICA_SERVER = "replica";
@Bean
@Qualifier(SOURCE_SERVER)
@ConfigurationProperties("spring.datasource.source")
public DataSource masterDataSource() {
log.info("source register");
return DataSourceBuilder.create().build();
}
@Bean
@Qualifier(REPLICA_SERVER)
@ConfigurationProperties("spring.datasource.replica")
public DataSource replicaDataSource() {
log.info("replica register");
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource(@Qualifier(SOURCE_SERVER) DataSource masterDataSource,
@Qualifier(REPLICA_SERVER) DataSource slaveDataSource) {
RoutingDataSource routingDataSource = new RoutingDataSource();
HashMap<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put(SOURCE_SERVER, masterDataSource);
dataSourceMap.put(REPLICA_SERVER, slaveDataSource);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(masterDataSource);
return routingDataSource;
}
@Bean
@Primary
public DataSource dataSource() {
DataSource determinedDataSource = routingDataSource(masterDataSource(), replicaDataSource());
return new LazyConnectionDataSourceProxy(determinedDataSource);
}
}
sourceDataSource() 메서드는 spring.datasource.source로 시작하는 설정을 이용하여 DataSource 인스턴스를 생성하고 반환하는 역할을 합니다.
@ConfigurationProperties 어노테이션은 스프링 부트에서 제공하는 기능으로 외부 구성 파일에 정의된 값을 활용하여 특정 클래스의 필드와 자동으로 바인딩하도록 돕습니다.
replicaDataSource()는 replica 서버에 대한 DataSource 인스턴스를 생성하고 반환하는 역할을 수행합니다.
routingDataSource() 메서드에 각각 정의한 Datasource를 Qualifier를 통해 인자로 주입한 후, RoutingDataSource 객체를 생성합니다.
RoutingDataSource는 위에서 추상클래스인 AbstractRoutingDataSource를 확장한 클래스입니다.
AbStractRoutingDataSource는 데이터 소스의 라우팅을 처리하는 Spring의 클래스로서, 다중 데이터베이스에 대한 접근을 동적으로 제어하는 역할을 수행합니다. 이를 활용하여 트랜잭션 당 하나의 데이터베이스만 사용하도록 설정할 수 있습니다.
이 클래스를 활용함으로써 읽기와 쓰기를 분리할 수 있습니다.
구현해야 하는 추상 메서드는 determineCurrentLookupKey()입니다. 설명에 따르면 현재 데이터베이스의 키 타입에 따라 매칭시킬 수 있도록 하는 역할을 수행합니다. 현재 데이터베이스 연결이 읽기 전용인지 아닌지 판단하여 source와 replica를 반환합니다.
서비스 로직에서 자주 사용하는 @Transactional 어노테이션에 readOnly를 설정할 수 있습니다. 이 값의 설정 여부에 따라, 호출된 현재 트랜잭션이 읽기 전용인지 아닌지 판단할 수 있는 로직을 구현한 것입니다.
setDefaultTargetDataSource는 기본 데이터소스를 설정할 수 있습니다. 일반적인 쓰기 작업 등은 source 서버에서 진행해야 하므로 기존 값을 설정하였습니다.
마지막으로 datasource() 메서드에서 LazyConnectionDataSourceProxy를 반환하는 것을 확인할 수 있습니다. 이 프록시는 실제 데이터베이스 연결이 필요할 때까지 실제 데이터베이스 연결의 생성을 지연시킵니다. 이 부분에 대해 이해가 되지 않아서 지연 연결 생성 방식에 대한 정리를 해보았습니다.
제가 기존에 알고 있는 DBCP는 미리 데이터베이스의 연결을 생성한 후 필요에 따라 그 연결을 제공합니다. 하지만 LazyConnectionDataSourceProxy는 데이터베이스 연결이 필요할 때까지 실제 데이터베이스 연결의 생성을 지연시킨다는 것에 혼란이 왔습니다.
두 기능은 기본적으로 차이가 존재합니다. DBCP는 연결 생성의 오버헤드를 줄이기 위한 역할이라면, LazyConnectionDayaSourceProxy는 연결 사용 시점을 지연하는 것입니다. 즉, DBCP로 미리 연결을 만들어 놓았지만 실제 SQL 작업이 필요한 시점까지 그 연결을 사용하지 않도록 하는 것이 LazyConnectionDataSourceProxy입니다.
예를 들어 methodA()가 있다고 가정하겠습니다.
void methodA() {
// for 문 등 기타 로직
// 문자열 파싱 등 기타 로직
memberRepository.findById(id);
}
여기서 for문이나 문자열 파싱 작업은 SQL 작업을 요구하지 않기 때문에 즉각적으로 커넥션을 여는 것이 아니라 memberRepsitory.findyId와 같이 실제 SQL 작업을 요하는 작업이 도달할 때까지 지연하는 것이 LazyConnectionDayaSourceProxy의 역할입니다.
이 객체를 활용함으로써 다음의 장점을 얻을 수 있습니다.
1. 자원 최적화: 데이터베이스 연결은 자원을 많이 요구하는 작업이다. 모든 요청에 대해 연결을 즉시 만들지 않고, 필요한 상황에만 연결을 하도록 최대한 지연시키는 것은 자원 사용률을 줄이는데 도움을 줍니다.
2. 트랜잭션 관리의 향상: 트랜잭션 범위 내에서 실제 SQL 작업이 발생할 때 까지 데이터베이스 연결을 얻지 않음으로써 트랜잭션 시간을 줄일 수 있습니다.
3. 읽기/쓰기 분산 시나리오 지원: 읽기와 쓰기 작업을 분리하는 시나리오에 도움을 주는 역할을 수행합니다. 예를 들어 트랜잭션이 시작되는 지점에는 해당 트랜잭션이 읽기 혹은 쓰기 인지 알 수 없습니다. LazyConnectionDataSourceProxy로 연결을 지연한다면 SQL 요청이 요하는 시점에 어떤 연결이 필요한지 파악할 수 있습니다.
3. 테스트로 읽기 쓰기 테스트 확인하기
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Long save(MemberDto memberDto) {
return memberRepository.save(Member.builder().name(memberDto.getName()).build()).getId();
}
@Transactional(readOnly = true)
public Member findById(Long id) {
return memberRepository.findById(id).orElseThrow();
}
@Transactional(readOnly = true)
public Member findByName(String name) {
return memberRepository.findByName(name).orElseThrow();
}
}
@SpringBootTest
class MemberServiceTest {
@Autowired MemberService memberService;
@Autowired
private MemberRepository memberRepository;
@AfterEach
void clear() {
memberRepository.deleteAll();
}
@Test
@DisplayName("저장 및 조회하기")
public void save_find() throws Exception {
//given
String memberName = "gose";
//when
Long memberId = memberService.save(new MemberDto(memberName));
Member member = memberService.findById(memberId);
//then
Assertions.assertThat(member.getName()).isEqualTo(memberName);
}
}
테스트 과정에서 중요한 사항은 @Transactional 어노테이션을 사용하지 않고 @AfterEach를 사용하여 데이터를 지우는 작업을 수행하였습니다. 테스트에서 Transactional 어노테이션이 메서드 단위를 하나의 트랜잭션으로 묶어버리는 역할을 수행할 수 있기 때문에 readOnly가 적용되지 않을 수도 있습니다. 따라서, @AfterEach를 활용하여 롤백하는 작업을 처리하였습니다.
Hibernate:
insert
into
member
(name,member_id)
values
(?,?)
2023-06-11T00:04:31.111+09:00 INFO 108103 --- : Current DataSource is master
Hibernate:
select
m1_0.member_id,
m1_0.name
from
member m1_0
where
m1_0.member_id=?
2023-06-11T00:04:31.157+09:00 INFO 108103 --- : Current DataSource is slave
로그를 확인해 보면 insert 로직은 source 서버에서, select는 replica 서버에서 진행하는 것을 확인할 수 있습니다.
이번 포스팅은 의존성 주입과 profile 설정으로 filter의 설정 정보를 다르게 적용하는 과정을 정리하도록 하겠습니다.
1. 문제 상황
MSA 아키텍처에서 Gateway와 Member 서버는 서로 같은 Redis 서버 (Aws ElasticCache)를 사용하고 있습니다. 다른 서버는 Gateway로부터 라우팅을 수행하지만, 공통의 Redis를 사용하지 않기 때문에 컨트롤러 혹은 핸들러 API 테스트를 진행하는 과정에서 Gateway에서 헤더 정보 인증이 안될 수 있습니다.
gateway-server는 redis 6379 port에 연결되어 있습니다. gateway는 AuthorizationHeaderFilter에서 다음과 같이 request를 파싱 하여 jwt의 유효성 검사를 진행하고, 실제 6379 redis 서버에 jwt가 존재하고, 로그인 세션이 등록된 유저인지 판단합니다.
하지만, 문제가 되는 다른 서버들은 gateway와 다른 6380 port의 다른 redis 서버를 사용하고 있으므로, 토큰을 공유할 수 없습니다. 즉, 비즈니스 테스트 과정에서 임의의 jwt 토큰을 발급하여 헤더 정보로 보내더라도 gateway에서는 유효성을 검사한 후 실제 redis 서버에 존재하는지 파악하기 때문에, 인증 에러가 발생하여 비즈니스 로직 테스트를 할 수 없습니다.
매번 gateway에 토큰을 추가하기 위해 member 서버를 기동하여 토큰을 추가하는 방법이나, 혹은 Gateway에서 역으로 member에 대한 토큰을 발급하는 방법이 있는데 이 방법 모두 비효율적이며, 테스트를 위해 다른 부가로직을 추가하는 것은 좋지 못한 방법이라고 생각하였습니다.
따라서, 로컬 환경에서 테스트를 위해 Gateway를 기동할 때는 jwt의 유효성만 검사하고, 통과시키는 filter를 적용할 필요성이 있었습니다.
2. 문제 파악하기
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
--- 중략 ---
public static class Config {}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
if (isWhiteList(request.getURI().getPath())) return chain.filter(exchange);
validateAuthorizationHeaders(request);
try {
if (validateRequestHeader(request)) return chain.filter(exchange);
} catch (JsonProcessingException e) {
return onError(exchange, ExceptionMessage.BADREQUEST, HttpStatus.BAD_REQUEST);
}
return onError(exchange, ExceptionMessage.BADREQUEST, HttpStatus.BAD_REQUEST);
};
}
}
현재 AuthorizationHeaderFilter는 여러 가지 검증 처리와, 토큰 유효성 검사 및 실제 토큰와 로그인 세션이 Redis에 저장되어 있는지 판단합니다. 따라서, 이 네 가지 조건에 하나라도 위배된다면 인증될 수 없습니다.
AuthorizationHeaderFilter는 AbstractGatewayFilterFactory의 추상클래스를 확장한 클래스로 apply를 Override 하면 GatewayFilter를 생성하고 있습니다.
AbstractGatewayFilterFactory는 Spring Cloud Gateway에서 사용되는 필터를 생성하는 추상 클래스입니다. GatewayFilter는 Spring Cloud에서 사용하는 필터로 헤더 정보를 처리하거나 요청 및 응답을 변경하는 역할을 수행합니다.
AbstractGatewayFilterFactory는 Config 설정 정보에 따라 GatewayFilter에 대한 설정 정보를 다르게 할 수 있습니다.
여기서 Config는 특정 인터페이스가 정해져 있지 않는 제네릭 타입이므로, 사용자가 Custom하게 수정할 수 있다는 장점이 있었습니다. 따라서 저는 Config 클래스를 Custom하게 변경하여 profile에 따라 다르게 적용되도록 구현하였습니다.
3. 자바의 다형성과 의존관계 주입하기
자바의 최고 장점은 다형성인 것 같습니다. 특정 인터페이스를 구현하는 다양한 구현체가 있을 때 인터페이스로 캐스팅 할 수 있습니다. 스프링의 의존관계 주입과 함께 사용하면, 특정 상황에 따라 다른 구현체가 필요할 때 인터페이스로 의존 관계를 주입 받은 후 스프링 서버의 기동시에 구현체 빈 등록을 스프링에게 위임할 수 있습니다.
따라서, AuthorizationConfig라는 인터페이스를 선언한 후 공통 클래스를 추상 클래스로 선언한 후, AuthorizationDefaultConfig와 AuthorizationDevConfig로 AbstractAuthorizationConfig 클래스를 상속한다면, AuthorizationConfig로 의존관계를 주입 받을 수 있습니다. 스프링은 AuthoziationConfig에 대한 구현체가 하나라면 바로 스프링 빈으로 등록하여 구현체가 적용되도록 해줍니다.
장점은 이렇게 서로 다른 구현체를 모두 스프링 빈을 등록하면, 스프링은 어떤 구현체를 선택해야할 지 선택할 수 없기 때문에 서버 기동시에 개발자에게 에러를 발생시킵니다. (정말 엄청난 기술입니다 ㅠㅠ) 이러한 에러를 바탕으로 복수의 구현체가 등록된 것을 확인하고 에러를 바로 잡을 수 있습니다.
그렇다면 이제 복수의 구현체가 등록되는 문제를 해결하기 위해 Profile을 설정하는 과정을 코드로 함께 정리하도록 하겠습니다.
4. 코드 수정하기
먼저 AuthorizationConfig 인터페이스를 선언합니다.
이 인터페이스의 역할은 AuthorizationHeader를 검증하고, 토큰 유효성 및 존재 여부를 판별합니다.
친구가 메세지를 보냈는데 새로고침 하기 전까지 메세지가 전송되지 않는다면 해당 어플은 사용하지 않을 것입니다.
이를 방지하기 위해 사용하는 개념이 '소켓'입니다.
이번 포스팅은 MSA 아키텍처의 Liar-Game의 실시간 대기실 역할을 수행한 wait-server 프로젝트를 정리하며 웹소켓 연결 과정과 예외처리에 대해서 자세하게 정리하도록 하겠습니다. 제 스프링 부트 버전은 3.0.2로 최신 버전입니다.
(따라서, 최신 3.x.x에 호환되는 기능으로, 아직 레퍼런스가 많이 부족하여 오픈 소스로 참고하시면 좋습니다!!!!)
1. Socket과 SpringWebSocket
소켓은 컴퓨터 네트워크를 통해 다른 컴퓨터나 프로세스와 통신하기 위한 엔드포인트로 서로 다른 시스템 간에 데이터를 교환할 수 있도록 돕는 전송 계층의 프로토콜입니다.
소켓은 TCP 소켓과 UDP 소켓으로 구분할 수 있습니다. TCP 소켓은 연결형 통신으로 신뢰성 있는 데이터 전송을 보장하여 전송 순서와 오류 수정을 처리할 수 있습니다 대표적으로 웹 서버와 브라우저 간의 통신이 있습니다.
UDP 소켓은 데이터의 신뢰성을 보장하지 않지만 낮은 지연 시간을 갖기 때문에 실시간 게임과 같은 스트리밍 서비스에 적합합니다.
스프링의 Websocket 모듈은 웹 소켓 프로토콜을 지원하며 서버와 클라이언트 사이의 양방향 통신을 제공하여 빠른 데이터 전달을 가능하도록 합니다.
Spring WeboSocket은 WebSocket 서버 및 클라이언트를 개발하는 데 필요한 다양한 기능을 제공하며, 세션관리, 메시지 매핑으로 클라이언트에게 수신된 메시지를 빠르게 처리할 수 있습니다.
(저는 HTTP/1.1에서 사용하는 WebSocket을 적용하였지만 추후 모든 개발이 완성작업에 들어갈 때, SSL/TLS을 적용하여 HTTP/2를 도입할 예정입니다. 이 글 다음 편으로는 HTTP/1.1과 HTTP/2 사이의 Websocket 성능을 분석하도록 하겠습니다.)
2. Wait-Server 비즈니스 로직 및 WebSocket 적용 부분 정리하기
Wait-Server의 역할은 클라이언트가 Liar-Game을 수행하기 전에 게임 대기실 역할을 수행하는 장소입니다. 제가 중학생 때 좋아했던 스타크래프트나 혹은 카트라이더 등은 게임 대기실을 제공하는 서버로 많은 트래픽을 유발할 수 있는 곳입니다. 실시간으로 입장이 가능해야 하고, 클라이언트가 방을 개설하면 방의 리스트가 제공되어야 하며, 같은 대기실에 있는 사람들과 다른 대기실에 존재하는 인원들은 격리된 공간을 제공해야 합니다.
뿐만 아니라, 게임 대기실은 인원 제한도 제공해야 합니다. 보통 게임을 생각하면 4명 혹은 5명 인원 제한을 둘 수 있습니다.
따라서, 소켓으로 특정 대기실에 접속한 인원을 저장하고 인원 대기실의 만원 처리등을 수행해야 하며, 게임 대기실 호스트가 만약 방을 나가게 되면 방 전체를 없애는 핵심 기능도 수행해야 합니다.
따라서, Wait-Server의 핵심 비즈니스 로직을 다음과 같이 정리할 수 있습니다.
- 개별적인 게임 대기실은 격리된 공간에서 제공되어야 합니다. 다른 대기실은 서로 데이터를 공유할 수 없습니다.
- 유저(호스트 포함)는 동일 시간에 단 하나의 게임 대기실에만 입장할 수 있습니다. 만약 다른 대기실에 입장 요청을 하면 기존에 있던 게임 대기실에서 자동 퇴장 조치되어야 합니다.
- 게임 대기실은 인원 제한이 있으며 클라이언트가 입장 및 퇴장을 할 때, 빠른 입출력으로 대기실 현황을 업데이트해야 합니다.
- 만약 호스트가 대기실 퇴장을 요청하면 현재 대기실에 존재하는 모든 유저는 대기실에서 퇴장 조치 됩니다.
- 호스트가 게임 가능 최저 인원을 달성하면 게임을 시작할 수 있으며, wait-server에서 game-server로 요청이 위임되어야 합니다.
여기서 Controller는 게임 대기실의 리스트를 보여주는 역할을 수행하고 WebSocketHandler는 게임 대기실 내부의 현황을 처리하는 데 사용하였습니다.
두 기능을 분리한 이유는 게임 대기실 리스트를 보여주는 역할은 상대적으로 실시간성이 보장되지 않아도 된다고 판단하였습니다.
만약 게임 입장하려고 했는데, 만원이 된 경우 요청이 거절되고 다시 리스트업이 될 수 있습니다. 하지만, 게임 대기실의 경우 입장한 A 클라이언트는 다른 유저가 이 방에 접속했는지 실시간으로 확인할 수 있어야 합니다. 따라서, 원활한 게임 대기실의 역할을 수행하기 위해 양방향성을 제공하는 WebSocket을 활용하고, 비교적 리스트업의 역할을 수행해야 하는 게임 대기실 목록은 Controller로 적용하였습니다. 정리하면, Wait-Server는 게임 대기실 목록은 Controller, 대기실에 입장하는 순간부터는 외부와 격리된 대기실을 제공해야 하므로 WebSocket으로 비즈니스 로직을 구현하였습니다.
이후, Configuration을 추가하여 Websocket의 messageBroker를 설정합니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebsocketSecurityInterceptor websocketSecurityInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/wait-service/wait-websocket")
.addInterceptors(new CustomHandshakeInterceptor())
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/wait-service/waitroom/sub", "/queue");
registry.setApplicationDestinationPrefixes("/wait-service");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(websocketSecurityInterceptor);
}
}
WebSocketMessageBrokerConfigurer는 WebSocket에서 사용할 MessageBroker를 설정하는 인터페이스입니다.
위임 메서드는 다음과 같습니다.
- configureMessageBroker : 메시지 브로커 옵션 설정하는 메서드로 메세지 핸들러의 라우팅 설정 및 브로커가 사용할 목적지 접두사를 정의합니다.
- registerStompEndpoints: 클라이언트가 WebSocket 서버에 연결하기 위한 엔드포인트입니다.
- confgirueWebSocketTransport: WebSocket 전송에 대한 구성을 제공합니다.
- configureClientInboudChannetl: 클라이언트로부터 수신한 메시지를 처리하는 데 사용되는 채널에 대한 설정입니다.
- configureClientOutBoundChannel: 서버에서 클라이언트로 보내는 메시지를 처리하는데 사용되는 채널을 구성합니다.
그렇다면, setApplicationDestinationPrefixes와 enableSimpleBroker는 무슨 차이일까요?
webSocket의 메세지 브로커는 발행 - 구독 시스템을 따릅니다.
따라서, applicationDestinationPrefixes로 설정되어 있는 접두사로 특정 요청을 보내면, 서버는 내부 핸들로를 통해 서버에서 매칭한 구독 접두사와 연결하여 메시지를 구독하고 있는 클라이언트에게 전달합니다.
저는 Controller와 Mapping Uri를 동일하게 하기 위해 /wait-service를 prefix로 설정하였고, broker는 /wait-service/waitroom/sub으로 설정하였습니다. /queue가 하는 역할은 클라이언트의 요청에 대한 에러를 처리하기 위해 사용하는 브로커로 클라이언트에게 예외 메시지를 전달하기 위해 사용됩니다.
핵심 비즈니스 로직은 waitRoom을 생성하면, 호스트는 waitRoom에 참여하고 다른 유저는 대기실 목록을 클릭하면 waitRoom에 참여 가능한지 파악하고 waitRoom에 입장 허가 하거나 불가 정책을 수행합니다.
(해당 비즈니스 로직은 WebSocket 조금만 정리하도록 하겠습니다.!)
@Getter
@RedisHash("WaitRoom")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class WaitRoom extends BaseRedisTemplateEntity {
@Id
private String id;
@Indexed
private String roomName;
@Indexed
private String hostId;
@Indexed
private String hostName;
private int limitMembers;
private boolean waiting;
private List<String> members = new CopyOnWriteArrayList<>();
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime createdAt;
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime modifiedAt;
--- 중략 ---
/**
* 호스트가 방을 만들 경우, WaitRoom에서는 JoinMember(host)를 생성
*/
public JoinMember createJoinMember() {
return new JoinMember(this.getHostId(), this.getId());
}
}
WaitRoom 객체는 대기실의 기타 정보와 참여한 유저를 등록하고, 입장과 퇴장, 입장 가능 여부의 역할이 있습니다.
따라서, WaitRoom에 관련한 참여 유저 추가, 제거, 호스트의 JoinMember 생성 등의 책임을 부여하였습니다.
/**
* waitRoom을 저장
* createWaitRoomDto로 waitRoom의 정보를 얻고, userId로 hostName 불러오기
* waitRoom을 redis에 저장하고, joinMembers를 생성하여 저장한다.
*/
@Override
public String saveWaitRoomByHost(CreateWaitRoomRequest createWaitRoomRequest) {
waitRoomJoinPolicyService.createWaitRoomPolicy(createWaitRoomRequest.getUserId());
MemberNameOnly username = memberService.findUsernameById(createWaitRoomRequest.getUserId());
WaitRoom waitRoom = saveWaitRoomAndStatusJoin(createWaitRoomRequest, username);
return waitRoom.getId();
}
/**
* 호스트가 아닌 다른 유저 대기방 요청 승인
* 게임이 진행 중이거나 현재 게임 중인 유저인 경우, 현재 게임에 참여할 수 없음.
*/
@Override
public boolean addMembers(CommonWaitRoomRequest dto) {
if (!validateNotPlaying(dto.getRoomId(), dto.getUserId())) throw new BadRequestException();
waitRoomJoinPolicyService.joinWaitRoomPolicy(dto.getUserId());
WaitRoom waitRoom = findById(dto.getRoomId());
if (isEnableJoinMembers(dto, waitRoom)) {
return saveWaitRoomAndStatusJoin(dto, waitRoom);
}
throw new BadRequestException();
}
FacadeService에서는 WaitRoom을 생성하면 Redis에 waitRoom을 저장합니다. 이때 유저 추가가 가능하다면 waitRoom에 userId를 추가하고, 더 이상 참여가 불가하면 userId를 저장하지 않습니다.
먼저 waitRoom을 생성하는 구문은 Controller로 작성하였습니다.
@RestController
@RequestMapping("/wait-service")
@RequiredArgsConstructor
public class WaitRoomController {
private final WaitRoomFacadeService waitRoomFacadeService;
@PostMapping("/waitroom/create")
public ResponseEntity createWaitRoom(@Valid @RequestBody CreateWaitRoomRequest dto) {
String waitRoomId = waitRoomFacadeService.saveWaitRoomByHost(dto);
return ResponseEntity.ok().body(SendSuccessBody.of(waitRoomId));
}
클라이언트는 생성된 waitRoomId를 바탕으로 collback으로 uri를 이동한 후 소켓에 연결하여 waitRoom에 join 합니다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class WaitRoomSocketHandler {
private final WaitRoomFacadeService waitRoomFacadeService;
private final SessionManagingWebSocketHandler sessionManagingWebSocketHandler;
/**
* StompHeaderAccessor의 필수 헤더
* {@code @Header} Authorization: 인증 accessToken
* {@code @Header} RefreshToken: 인증 refreshToken
* {@code @Header} UserId: 요청 userId
* {@code @Header} WaitRoomId: 요청 waitRoomId
*/
@MessageMapping("/waitroom/pub/{waitRoomId}/join")
@SendTo("/wait-service/waitroom/sub/{waitRoomId}/join")
public ChatMessageResponse joinMember(@Valid @RequestBody CommonWaitRoomRequest dto,
@DestinationVariable String waitRoomId,
@Header("UserId") String userId,
StompHeaderAccessor stompHeaderAccessor) {
log.info("ChatMessageResponse1 message = {}", dto.getUserId());
if (!userId.equals(dto.getUserId())) throw new WebsocketSecurityException();
waitRoomFacadeService.addMembers(dto);
log.info("ChatMessageResponse2 message = {}", dto.getUserId());
return ChatMessageResponse.of(dto.getUserId(), JOIN);
}
@MessageMapping()은 앞 서 정의한 prefixes를 포함하여 destination으로 매핑하는 역할을 수행합니다.
유저는 실시간으로 게임 대기실의 참여 인원을 확인하고 입장하고 퇴장하는 인원을 실시간으로 볼 수 있습니다.
4. Websocket 보안 Interceptor 등록하기
여기서부터 정말 어려워지는 구간이었습니다.! 이 비즈니스 로직에서는 반드시 필요한 stomp 헤더 정보가 없거나, join 한 유저가 아닌 다른 클라이언트가 접근을 시도한다면 대기실 입장을 거부하는 인터셉터 작성이 필요했습니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class WebsocketSecurityInterceptor implements ChannelInterceptor {
private final WaitRoomFacadeService waitRoomFacadeService;
private final AntPathMatcher antPathMatcher;
private final TokenProviderPolicy tokenProviderPolicy;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(headerAccessor.getCommand())) {
isValidateWaitRoomIdAndJoinMember(headerAccessor);
}
return message;
}
private void isValidateWaitRoomIdAndJoinMember(StompHeaderAccessor headerAccessor) {
String accessToken = headerAccessor.getFirstNativeHeader("Authorization");
String refreshToken = headerAccessor.getFirstNativeHeader("RefreshToken");
String waitRoomId = headerAccessor.getFirstNativeHeader("WaitRoomId");
String userId = headerAccessor.getFirstNativeHeader("UserId");
if (accessToken == null || refreshToken == null || waitRoomId == null || userId == null)
throw new WebsocketSecurityException();
log.info("validateUserAccessor >>");
validateUserAccessor(validateTokenAccessor(accessToken, refreshToken), userId);
String destination = headerAccessor.getDestination();
log.info("destination = {}", destination);
log.info("destination >>");
if (destination == null) throw new WebsocketSecurityException();
if (isApplyUri(destination)) {
log.info("isJoinedMember >>");
waitRoomFacadeService.isJoinedMember(waitRoomId, userId);
}
}
private String validateTokenAccessor(String accessToken, String refreshToken) {
try {
String userIdFromAccess = tokenProviderPolicy.getUserIdFromToken(tokenProviderPolicy.removeType(accessToken));
String userIdFromRefresh = tokenProviderPolicy.getUserIdFromToken(refreshToken);
if (!userIdFromAccess.equals(userIdFromRefresh)) throw new WebsocketSecurityException();
return userIdFromAccess;
} catch (Exception e) {
throw new WebsocketSecurityException();
}
}
private boolean isApplyUri(String destination) {
return !antPathMatcher.match("/wait-service/waitroom/**/**/join", destination);
}
private void validateUserAccessor(String parseUserId, String headerUserId) {
if (!parseUserId.equals(headerUserId)) throw new WebsocketSecurityException();
}
}
STOMP는 WebSocket과 같은 전송계층 프로토콜 위에서 동작하며 양방향 통신을 쉽게 구현할 수 있도록 하는 Simple Text Orientated Messaging Protocol로 텍스트 기반의 메시징 프로토콜입니다.
StompHeaderAccessor를 활용하면 STOMP 프로토콜에 사용하는 Header 등의 정보를 담아 인증 처리 등을 구현할 수 있습니다.
<여기서 잠깐! Websocket과 Stomp 추가 정리하기>
WebSocket은 데이터를 전송하는 저수준의 프로토콜입니다. 따라서, STOMP와 같은 상위 프로토콜을 함께 사용하여 메시지 전송 및 라우팅 관리에 필요한 고수준의 기능을 수행할 수 있습니다.
STOMP가 WebSocket을 돕는 고수준의 기능은 다음과 같습니다.
- 메시지 교환 패턴 정의: STOMP는 pub-sub와 point-to-point와 같은 메시지 교환 패턴을 정의합니다.
- 메시지 라우팅: STOMP는 메시지 라우팅을 위한 명시적인 목적지를 제공합니다
- 메시지 형식화: STOMP는 메시지를 전송하는데 필요한 명령어, 헤더, 페이로드를 포함합니다.
- 구독관리: STOMP는 클라이언트가 특정 목적에 대해 구독 여부를 설정할 수 있도록 돕습니다.
따라서, Websocket Congiruation에서 설정한 엔드포인트로 MessageMapping기능, 즉 라우팅 기능을 수행할 수 있는 이유도 이처럼 Stomp가 작동하여 WebSocket이 메세지를 발행 구독 할 수 있는 기능을 돕기 때문입니다.
5. WebSocket에서 발생하는 Exception 처리 로직 작성하기
일부 커넥션을 유지해야 하는 예외가 있는 반면, 반드시 커넥션을 종료시켜야 하는 예외가 있습니다. 가령 클라이언트의 실수와 같은 예외는 단순한 예외 메시지 전송으로 처리 가능하지만, 헤더 정보를 임의로 바꾸거나 대기실 접속 가능한 유저가 아닌 클라이언트가 접속하는 경우 커넥션을 제거해야 합니다.
먼저 소스를 정리한 후 이어서 설명을 진행하도록 하겠습니다.!
@Slf4j
@Component
public class CustomWebSocketHandlerDecorator extends WebSocketHandlerDecorator {
private final ConcurrentHashMap<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
public CustomWebSocketHandlerDecorator(@Qualifier("customWebSocketHandlerDecorator") WebSocketHandler delegate) {
super(delegate);
}
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
sessionMap.put(session.getId(), session);
super.afterConnectionEstablished(session);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
sessionMap.remove(session.getId());
super.afterConnectionClosed(session, closeStatus);
}
public void closeSession(String sessionId) throws IOException {
WebSocketSession session = sessionMap.get(sessionId);
if (session != null && session.isOpen()) {
session.close();
}
}
}
WebSocketHandlerDecorator는 Websocket 핸들러 중 하나로 커넥션을 열고 닫고 하는 과정에서 데코레이터 패턴을 구현하며 추가적인 동작을 수행할 수 있도록 돕습니다.
CustomWebSocketHanlderDecorator는 WebSocketHandlerDecorator을 상속하는 클래스로 커넥션을 sessionMap에 등록하고, 커넥션을 강제로 종료하기 위해 closeSession을 호출하여 session.close()를 처리하는 로직을 구현하였습니다.
그런데, CustomWebSocketHanlderDecorator에서 WebSocketSession을 제거하는 것이 어떠한 의미를 가지고 있을까요?
6. WebSocketSession 처리과정 확인하기
기본적으로 Websocket은 hanShakeHandler로 DefaultHandShakeHandler를 기본값으로 설정하고 있습니다.
DefaultHandShakeHandler는 추상 클래스인 AbstractHandShakeHandler를 상속하고 있으며 해당 클래스에서 WebsocketHandler를 파라미터로 받고 있습니다.
스프링은 WebSocketHandler의 구현체로 stomp를 포함한 여러 프로토콜을 처리하는 역할을 수행하는 SubProtocolWebSocketHandler를 제공하고 있습니다.
여기서 WebSocketSession이 등록되고 삭제되는 로직이 수행되고 있습니다.
이는 곧 SubProtocolWebSocketHandler가 WebSession을 파라미터로 받고 있기 때문에, decorator로 감싸서 부가 기능을 수행한다면 exception이 발생했을 때 특정 session을 강제 종료 시킬 수 있음을 의미하였습니다.
여기서 확인할 수 있는 것은 WebsocketSession은 추상 클래스를 포함하더라도 18개가 구현되어 있습니다.
자바의 다형성을 활용하면, WebsocketSession을 구현하고 있는 다양한 구현체를 WebSocketSession으로 캐스팅할 수 있습니다. 따라서, 인터페이스를 타입으로 캐스팅하여 호출한 후 해당 인스턴스를 제거하면, 그 주소값이 의미를 잃게 되므로, 다른 곳에서도 특정 WebSocketSession을 사용할 수 없게 되는 것입니다. (정말 자바 스프링은 최고입니다....!)
이전에 등록한 WebsocketConfig에 제가 정의한 CustomWebsocketHandlerDecorator를 빈으로 등록한 후 decorator로 추가하겠습니다.
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.addDecoratorFactory(this::customWebSocketHandlerDecorator);
}
@Bean
public WebSocketHandlerDecorator customWebSocketHandlerDecorator(WebSocketHandler webSocketHandler) {
return new CustomWebSocketHandlerDecorator(webSocketHandler);
}
이제 마지막으로 Exception이 발생했을 때 ControllerAdvice처럼 예외 메시지를 처리해 줄 핸들러 매핑을 정의합니다.
저는 WebsocketSecurityException 종류는 가장 큰 문제라 판단하여 세션을 종료하는 로직을 수행하도록 하였고, 그 외의 CommonException 종류에는 메세지를 전송하는 정도로 마치고 나머지는 전부 세션을 끝내는 것으로 하였습니다.
(세션은 종료하면 메시지가 전송되지 않는데 이 부분은 추후 다시 발전해 나가겠습니다.)
7. CORS / Security 설정
CORS는 Cross - Origin - Resource Sharing으로 웹 브라우저에서 실행되는 스크립트에서 다른 출처의 자원에 접근할 때 보안적인 문제를 다루는 메커니즘입니다. 만약 도메인이 다른 경우 CORS 문제로 인해 통신에 제약이 있을 수 있습니다. 따라서 API 서버 역할을 수행하는 백엔드는 CORS 설정으로 요청 가능한 도메인을 설정해야 합니다.
만약 외부 클라이언트의 직접 접근을 처리해야 한다면 allowedOriginPatterns 에 "*"와 같은 와일드카드를 생성하여 모든 도메인에 접근을 허용해야 합니다.
하지만 이는 보안에 취약점을 줄 수 있으므로 클라이언트의 직접 접근을 막고 중계 역할을 수행할 수 있는 프론트 서버를 중간에 두었습니다.
@Configuration
public class WebCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:8000", "http://localhost:3000", ---- 중략 ----)
.allowedMethods("*")
.allowedHeaders("*")
.exposedHeaders("Access-Control-Allow-Origin")
.allowCredentials(true);
}
}
로컬에서 gateway 역할을 수행하는 서버는 8000 포트, 프론트 서버는 3000번을 활용할 것이기 때문에 allowedOriginPatterns에 해당 주소를 입력하였습니다. 현재 OriginPatterns에는 제가 연결하고자 하는 로컬 서버와 AWS 서버의 도메인만 기록되어 있기 때문에 다른 Method (Get, Post 등), Header는 모두 와일드 카드로 설정하였고, exposeHeaders에 Access-Control-Allow-Origin을 추가하였습니다.
Spring Seurity는 정말 다루기 어렵지만 그만큼 뛰어난 보안성을 가지고 있습니다.
여기서 문제가 되는 부분은 소켓은 먼저 클라이언트 (여기서는 프론트 서버)와 백엔드 서버가 서로 3 - HandShake를 거친 후 응답이 처리되면 소켓 연결을 진행합니다. 이후 메시징 역할을 수행하는 STOMP 프로토콜로 애플리케이션 단에서 데이터를 처리합니다.
정리하면 Tcp(Http) -> Tcp(Socket) + Application(Stomp) 순서로 기동 되는 것입니다.
따라서, Spring Security에서 최초 연결 시에 Http를 처리할 수 있지만 이 부분은 다루기 복잡하다고 판단하여 websocket을 필요로 하는 엔드포인트에 대한 요청은 모두 통과시킨 후, 앞서 정의한 interceptor에서 애플리케이션 계층의 stomp 헤더를 검증하는 것으로 처리하였습니다. (이 부분은 추후 다시 다루도록 하겠습니다.)
public static final List<String> AuthenticationGivenFilterWhitelist = Arrays.asList(
"/",
"/static/**",
"/favicon.ico",
"/wait-service/wait-websocket/**"
);
antPathMatcher를 활용하여 wait-websocket으로 커넥션 요청이 들어오는 http는 모두 통과시키도록 처리하였습니다.
8. 테스트
길었던 Websocket 관련 로직 작성을 마쳤습니다. 언제나 그렇듯 가장 중요한 것은 작성이 아니라 테스트인 것 같습니다.
Websocket을 테스트하는 과정은 굉장히 까다롭습니다. 세션이 유지되는 것과 제거되는 점, 클라이언트에 전송되는 메시지 등을 판단해야 하기 때문입니다. 스프링에서 단위 테스트를 진행할 수 있지만, 프론트 서버 -> 게이트웨이 -> 백엔드 연결되는 과정을 눈으로 직접 확인해보기 위해 간단하게 vue.js로 프론트 서버를 구축하고 테스트를 진행하도록 하겠습니다.
먼저, 현재 프로젝트는 MSA의 wait-server를 담당하고 있지만 인증 토큰 생성은 member-server에서 생성하고 있습니다.
테스트를 위해 member-server를 매번 기동하여 토큰 발급 처리하기 어려움으로 스프링에서 InitDB 클래스를 생성한 후 더미 데이터를 추가하였습니다. @PostConstruct는 모든 스프링빈이 등록되고 의존관계 주입을 마친 후에 해당 메서드가 실행될 수 있도록 처리해 주는 어노테이션입니다.
@Component
@RequiredArgsConstructor
public class InitDb {
private final InitService initService;
@PostConstruct
public void init() {
initService.dbInit();
}
@Slf4j
@Component
@Transactional
@RequiredArgsConstructor
static class InitService {
private final EntityManager em;
private final WaitRoomFacadeService waitRoomFacadeService;
--- 중략 ---
저는 프론트 서버는 테스트용 정도밖에 다뤄본 경험이 없어서 최대한 로직은 간단하게 Vue.js로 작성하였습니다.
<index.js>
module.exports = {
dev: {
// Paths
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/api': {
target: 'http://localhost:8000',
// changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
},
host: 'localhost', // can be overwritten by process.env.HOST
port: 3000, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
autoOpenBrowser: false,
errorOverlay: true,
notifyOnErrors: true,
proxyTable에 등록된 target은 '/api'라는 prefix 요청에 대해 해당 타깃으로 보내겠다는 의미입니다.
저는 client -> front 중계 -> 백엔드 스프링 클라우드 gateway 중계 -> 실제 서비스를 호출하는 방식으로 구현하고 있어서 gateway 서버를 target으로 설정하였습니다.
거의 30시간 가까이 에러를 맞이하며 수정하고 디버깅하고 다시 해결하고 에러나고의 반복이었던 것 같습니다.
이전에 webSocket을 연결하는 간단한 실습을 한 적이 있었는데, 그 당시에는 여러 비즈니스 로직이 없는
간단한 서버사이드 렌더링으로 채팅방을 구현하는 것이었습니다.
이번에는 SpringSecurity, Interceptor, 예외처리를 추가하고 스프링 부트의 최신 버전으로 적용하다보니 정말 많은 에러를 맞이할 수 있었습니다. 에러가 발생하니 이전에 쉽게 지나갔던 부분을 다시 검토할 수 있게 되었고 정말 깊게 스프링의 내부 구조와 우원리를 다시 한번 볼 수 있게 된 계기가 되었습니다.
이번 Websocket에 여러 가지 기능을 붙이며, 소켓이 어떻게 전송이 되는지부터 시작해서 Spring이 어떻게 websocket을 지원하고 있는지, 그리고 에러가 생기면 어떻게 세션을 종료하는지 등을 정말 깊게 공부할 수 있었던 것 같습니다.
또한, 정말 자바 스프링의 위대함을 다시 한번 느낄 수 있었습니다.
혹시 "WebsocketSession 인터페이스를 활용하는 구현체를 찾아서 custom 하게 바꾼 후 빈을 등록하면 되지 않을까?"
라는 생각을 가지고 접근을 하니 이러한 생각을 현실로 만들 수 있도록 모든 것을 제공해 주는 스프링에 대해서
다시 한번 감사함을 느낄 수 있었습니다.
정말 스프링 프레임워크가 발전하고 많은 레퍼런스를 볼 수 있도록 힘 써주신 선임 개발자님들 감사드립니다 ㅠㅠㅠㅠㅠㅠㅠ
스프링은 다양한 데이터 접근 기술을 제공합니다. 주로 사용하는 기술은 jpa, jdbc, mybatis 등이 있습니다. jpa를 사용하는 간단한 crud를 빠르게 사용할 수 있는 장점이 존재하지만 동적 쿼리를 해결하는 데는 많은 어려움이 있습니다.
스프링은 동적 쿼리를 해결하기 위한 수단으로 다양한 기능을 제공합니다. 가장 대표적인 기술은 queryDsl입니다.
queryDsl은 자바 언어로 SQL을 작성할 수 있도록 제공하여 컴파일 시점에 에러를 잡을 수 있고, 다양한 동적 쿼리를 빌더 형태로 작성할 수 있습니다.
이번 글은 제가 작성했던 queryDsl 조회 로직을 살펴보고 100만개의 더미데이터에서 성능을 분석하는 시간을 가져보도록 하겠습니다.
1. ERD 스키마와 Result-Server의 역할 정리하기
인텔리제이에서는 데이터베이스 스키마를 효과적으로 볼 수 있습니다. 인텔리 제이로 스키마를 분석해 보겠습니다.
Result-Server의 주된 비즈니스 로직은 AWS SQS로 보내진 메시지를 구독하여 데이터베이스에 값을 저장하고, 조회 로직을 구현하는 것입니다.
필요한 테이블은 member, authority, player, player_result, game_result, topic입니다.
MSA 아키텍처에서 member, authority는 member-server의 테이블에서 반드시 필요한 필드로 result-server만의 테이블을 사용하도록 구현하였습니다.
player는 member와 일 대 일 관계로, 한 명의 회원은 한 개의 플레이어를 선정한다라는 관계를 설정하였습니다.
player_result는 member와 다 대 일관계로, 한 명의 회원은 다수의 게임 결과를 가질 수 있습니다.
game_result는 player_result와 일 대 다 관계로, 하나의 게임 결과는 다수의 플레이어 결과를 가질 수 있습니다.
topic은 game_result와 일 대 다의 관계로 하나의 토픽은 다수의 game_result에서 사용될 수 있습니다.
저는 game-server에서 AWS SQS로 메시지를 발행하면 result-server가 이를 구독하여, 전송된 게임 결과, 투표 결과 등을 조합하여 결과를 저장하는 시나리오를 생각했습니다. 따라서, game-server는 redis를 사용하여 저장된 다양한 게임 결과를 result-server로 위임하여 result-server에서 모든 결과를 저장하고 클라이언트의 결과 조회나 랭킹 조회 등의 결과 조회 서비스를 담당하는 역할을 부여하였습니다.
2. 100만 개의 더미 데이터 생성하기 (MySQL)
약 100만개의 데이터를 자바 코드로 INSERT문을 txt로 작성하였고, LOAD DATA INFILE로 생성된 데이터를 입력하였습니다.
- member 1만 명: member_id (vachar(255)) pk, userId (vachar(255)) (인덱스)
- topic 1만 개: 게임에 사용될 주제 topic_id (BigInt) pk
- game_result 10만 건: 게임 결과 저장 game_result_id (vachar(255)) pk, topic_id fk
- player_result 약 100만 건: 플레이어의 게임 결과 player_result_id (varchar(255)) pk, game_result_id fk, member_id fk
LOAD DATA INFILE '/var/lib/mysql-files/insert_player_result.txt'
INTO TABLE player_result
FIELDS TERMINATED BY ', '
LINES starting by '(' TERMINATED BY '),\n'
IGNORE 1 LINES;
select count(*) from player_result;
각 더미데이터는 최대한 현실과 비슷하게 작성하기 위해, UUID로 생성된 pk가 겹치지 않고, 실제 제가 저장하는 방식 대로 데이터를 입력하였습니다. player_result의 경우 LOAD DATA INFILE로 더미 데이터를 입력하는데 약 30분 정도 소요되었습니다.
3. 현재 비즈니스 로직 분석하기
회원은 최근 자신이 참여한 게임 결과를 조회할 수 있습니다.
참여한 gameId(게임 Id: 추가 조회용), gameName(게임 이름), topicName(라이어 게임 주제), winner (승리한 역할), totalUsers (참여한 총 유저수), myRole (게임에서 내 역할), answer (라이어 투표에서 내 투표의 정답 여부)
조회한 내용을 클라이언트로 전달하기 위해 Dto를 선언하였습니다.
여기서 QueryProjection의 역할은 Q 타입은 기본적으로 Entity로 선언된 class가 Q타입으로 컴파일됩니다.
이때, Dto로 선언된 class로 조회 로직을 작성해야 하는 경우 QueryProjection으로 선언된 Dto는 Q타입을 사용할 수 있게 됩니다.
다음은 Slice에서 페이징 역할을 수행하는 count 로직입니다. 두 로직을 searchGameName()의 여부에 따라 분리한 이유는 불필요한 조인을 줄이기 위함입니다.
gameName은 gameResult에서 받아오고 있습니다. 이 경우 playerResult와 gameResult 간의 조인 연산과 where 연산이 필요합니다. 하지만, 이긴 게임 혹은 진 게임을 조회하는 경우는 직접적인 game_result에 대한 접근이 필요하지 않습니다. 왜냐하면, player_result 테이블은 game_result 테이블에 대한 외래키 제약조건이 걸려있습니다. 즉, 외래키가 존재하기 때문에 player_result는 game_result가 존재하는 한 유효한 row를 가질 수 있습니다.
카운트 쿼리는 실제 값의 여부가 아닌 페이징을 위한 row의 개수가 중요하므로, 불필요한 조인을 줄일 수 있는 상황이라면 줄이는 것이 성능에 유리하다고 판단하였습니다,
count(*) 개수는 '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5'라는 userId를 가지고 있는 더미 회원의 개수 player_result 개수는 84개입니다.
explain
select sql_no_cache * from player_result p
join game_result gr on p.game_result_id = gr.game_result_id
join member m on m.member_id = p.member_id
join topic t on gr.topic_id = t.topic_id
where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5';
실행 계획을 살펴보면, index, fk, pk, pk로 해당 조건이 수행되어 연산이 계획되어 있습니다. 타입을 확인하면 ref , eq_ref로 인덱스 혹은 fk와 pk를 사용할 때 적용되는 결과가 작성되어 있습니다.
성능은 63ms로 기록되었습니다.
다음은 최신 순 조회입니다.
explain
select sql_no_cache * from player_result p
join game_result gr on p.game_result_id = gr.game_result_id
join member m on m.member_id = p.member_id
join topic t on gr.topic_id = t.topic_id
where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' order by gr.created_at desc;
여기서 한 가지 의문이 발생한 점이 있습니다. 분명 game_result는 created_at으로 인덱스를 생성했는데 order by 문에서 인덱스가 설정되지 않은 점입니다. 역시 데이터베이스는 정말 어려운 것 같습니다...!
(이 부분은 추후 다른 데이터를 토대로 다른 글을 작성하도록 하겠습니다.)
성능은 76ms가 나왔습니다.
동적 쿼리 부분에서 많은 문제를 야기할 수 있는 Like연산을 살펴보겠습니다.
like 연산은 많은 부하를 가할 수 있는 기능입니다. keyword%의 경우 인덱스를 활용할 수 있다는 장점이 있지만 와일드카드가 앞단에 위치하는 경우 인덱스를 적용할 수 없습니다.현재의 경우 userId와 각각의 pk로 필터링되는 개수가 많으므로 적은 데이터가 남았지만, 만약 게임 이름으로 검색하는 비즈니스 로직이 추가된다면 많은 성능적 이슈를 발생시킬 수 있습니다.
이를 해결하는 방법으로 역 인덱스, 혹은 n-gram 인덱스 등을 활용할 수 있습니다.
역 인덱스는 입력되는 문자열을 기반으로 인덱스를 저장하는 검색 엔진입니다. 형태소 분석기 등과 함께 사용할 경우, 띄어쓰기 기준 혹은 특정 문자열 기준으로파싱 한 문자열을 인덱스로 저장할 수 있습니다. 이 경우 검색 조건에서 like '%안녕%' 과 같은 데이터가 입력될 때 효율적으로 검색할 수 있습니다.
n-gram 인덱스는 '안녕하세요'와 같은 매칭하기 어려운 문자열을 n 단위로 잘라서 인덱스화하는 기법입니다.
'안녕' ,'녕하 ', '하세', '세요'와 같이 2 단어로 잘라서 인덱스로 저장하기 때문에 단위별 검색 엔진을 구축할 때 효율적입니다.
하지만 꼭 장점만이 있는 것은 아닙니다. 만약 중복되는 이름의 인덱스가 많다면 상황에 따라서는 오히려 최적화가 되지 않을 수도 있습니다.
5. n_gram 인덱스 적용하여 성능 비교하기
저는, 추후 생길 수 있는 게임 검색 조건등을 고려하여, 2-gram 인덱스 적용을 고려해 보았습니다.
다음과 같이 'ㄱㄱ'이 있는 game_name을 검색할 수 있습니다.
이제 쿼리로 like 연산과 math 연산 간 유의미한 성능 차이가 있는지 파악해 보겠습니다.
explain
select sql_no_cache * from player_result p
join game_result gr on p.game_result_id = gr.game_result_id
join member m on m.member_id = p.member_id
join topic t on gr.topic_id = t.topic_id
where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' and
gr.game_name like '%ㄱㄱ%';
explain
select sql_no_cache * from player_result p
join game_result gr on p.game_result_id = gr.game_result_id
join member m on m.member_id = p.member_id
join topic t on gr.topic_id = t.topic_id
where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' and
match (gr.game_name) against ('ㄱㄱ' in boolean mode);
오히려 이 경우 n_gram 인덱스를 사용했을 때, 2초 945로 성능이 더 하락한 것을 볼 수 있습니다.
실제, 실행계획을 살펴보면 eq_ref로 game_result 테이블에 대한 pk로 연산이 수행되는 것이 아니라 full_text 인덱스로 검색이 수행되고 있습니다. 즉 player_result의 game_result_id의 fk로 game_result의 pk를 찾아서 먼저 매칭한 후 개수를 줄인 것이 아니라, full text로 'ㄱㄱ'를 검색 후 pk를 찾아서 매칭한 결과입니다.
만약 ngram_token이 서로 다른 경우에는 이러한 검색이 효과적일 수 있지만, 중복 값이 많은 경우에는 성능이 더 하락하였습니다.
따라서, n_gram을 적용한다고 해서 성능 향상을 할 수 있었던 것은 아니었습니다. "게임 이름으로 검색"만 수행한다면 유의미한 결과를 도출할 수 있지만 조인이 복잡하게 연결된 상태에서는 성능 보장이 어려웠습니다.
6. QueryDsl 실행 쿼리 확인하고 성능 파악하기
이제 spring QueryDsl로 작성된 코드에서 test 환경으로 변경 후, 실제 쿼리의 동작을 확인하겠습니다.
먼저 like연산이 없는 최신순 조회 로직입니다.
@Test
@DisplayName("fetchMyDetailGameResult의 onlyLastViews를 테스트 한다.")
public void fetchMyDetailGameResult_onlyLastViews() throws Exception {
//given
Pageable page = PageRequest.of(0, 10);
//when
MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, true, null,
null, null);
long before = System.currentTimeMillis();
Slice<MyDetailGameResultDto> myDetailGameResultDtos =
myDetailGameResultQueryDslRepository.fetchMyDetailGameResult(cond, page);
long after = System.currentTimeMillis();
//then
assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
System.out.println("Total Query Time = " + (after - before));
}
쿼리가 정상적으로 의도한 결과대로 작성되었습니다.
성능도 springBootTest를 기동하고 기타 작업이 수행된 과정으로 인해 741ms가 나왔지만 n_gram_idx를 사용했을 때보다 훨씬 빠른 성능을 보였습니다. 실제 커넥션 얻은 후 쿼리 작동 결과는 279ms입니다. 카운트 쿼리까지 작동이 되었음에도 279ms가 기록된 것은 괜찮은 성능이 유지된 것을 확인할 수 있습니다.
콘솔에서 확인하면, 84개의 행과 최신순 조회가 올바르게 설정된 것을 확인할 수 있었습니다.
다음은 like 연산에 대한 Spring QueryDsl의 결과입니다.
@Test
@DisplayName("fetchMyDetailGameResult의 searchGameName을 테스트 한다.")
public void fetchMyDetailGameResult_searchGameName() throws Exception {
//given
Pageable page = PageRequest.of(0, 10);
//when
MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, null, null,
null, "ㄱㄱ");
long before = System.currentTimeMillis();
Slice<MyDetailGameResultDto> myDetailGameResultDtos = myDetailGameResultQueryDslRepository
.fetchMyDetailGameResult(cond, page);
long after = System.currentTimeMillis();
//then
assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
System.out.println("Total Query Time = " + (after - before));
}
성능이 338ms로 2s 보다는 더 빠른 성능이 유지되었습니다.
그리고, 앞서 카운트 쿼리에서 game_name가 cond에 설정되어 있는지 유무에 따라 다른 페이징 쿼리를 작성하였었습니다.
@Test
@DisplayName("fetchMyDetailGameResult의 onlyLose을 테스트 한다.")
public void fetchMyDetailGameResult_onlyLose() throws Exception {
//given
Pageable page = PageRequest.of(0, 10);
//when
long before = System.currentTimeMillis();
MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, null, null,
true, null);
Slice<MyDetailGameResultDto> myDetailGameResultDtos = myDetailGameResultQueryDslRepository.fetchMyDetailGameResult(cond, page);
long after = System.currentTimeMillis();
//then
assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
System.out.println("Total Query Time = " + (after - before) + "ms");
}
약 100ms 차이가 있었지만 사실 다이나믹한 차이는 보이지 않아서 머쓱했습니다.. ㅎ!! 결과는 상황에 따라 바뀌는데 이 부분은 추후 다시 더미 대용량 데이터를 확보하여 재 테스트 해보겠습니다.!
+ 2023 11/1 추가 !
해당 테스트는 기본적으로 "원하는 검색어%" 형태의 결과를 체크하였습니다.
만약 "%원하는 검색어%"를 처리한다면, 다른 결과가 도출될 수 있습니다.
7. 정리하며...
평소에 스프링과 데이터베이스를 다루는 것을 좋아하기 때문에, 이렇게 QueryDsl로 작성된 코드의 실행 계획을 분석하는 것은 정말 즐거운 시간인 것 같습니다.
쿼리 실행 계획을 분석하며 제가 했던 계획이 올바르게 실행되지 않은 점도 확인할 수 있었습니다. created_at의 경우 인덱스로 설정하였지만, 인덱스가 활용되지 않은 이유가 쿼리의 문제인지 혹은 데이터 개수 타입, 분포 등의 문제인지 확실하지가 않아서 이 부분은 추후 다시 공부를 진행해야 할 것 같습니다.
또한, 언제든지 데이터의 특성에 따라 쿼리 실행 계획 변경은 불가피할 수 있습니다. 꾸준히 계속 테스트하며 상황에 맞는 최적의 쿼리가 설정될 수 있도록 공부하도록 하겠습니다.
"ERD에 인덱스로 설정된 gameId의 경우 SQS로 받을 때, uuid로 설정된 gameId를 그대로 uuid로 받아야 할까? atomicLong으로 변환 후 정수로 클러스터드 인덱스를 설정하면 추후 발생할 수 있는 검색 조건에 더 좋은 쿼리 향상을 할 수 있지 않을까?" 등입니다.
다음 편에서는 더욱 어려운 QueryDsl 쿼리를 작성하고 성능 분석하는 시간을 가지도록 하겠습니다.
d. ARP와 게이트웨이 라우팅 기능으로 실제 원하는 수신지 ip까지 도달하게 된다면 송신지의 ip정보를 확인할 수 있습니다.
저의 프로젝트에서는 client -> gateway server -> member server 순서로 요청이 위임되기 때문에, 송신지 ip가 바뀔 수 있습니다. 즉 전달되는 데이터는 같을지라도 ip에 대한 정보가 바뀌므로 이를 인지할 수 있도록 헤더를 추가하는 과정이 필요했습니다.
이때, 사용되는 헤더는 Fowarded와 X-Forwarded-For 입니다. RFC 7239 표준에 의하면, Forwarded 방식이 표준으로 정의되어 있습니다.
a. 표준화 - Forwarded: 2014년에 발표된 RFC 7239에 정의된 공식 표준입니다. - X-Forwarded-For: 비공식 확장 헤더이지만 널리 사용되고 있는 헤더입니다.
b. 구조 및 포맷 - Forwarded: 이 헤더는 구조화된 포맷을 사용하는데 for, by, proto, host 등을 세미콜론으로 구분합니다. - X-Forwarded-For: 각 전송과정에서 사용된 IP 목록을 쉼표로 구분합니다.
현재 이 프로젝트는 로그인 Ip 정보만 요청받는 것이 주목적이고, 추후 SSL/TLS를 적용하더라도 보안상 허점이 존재하므로 보다 많은 정보를 얻는 것은 위험할 수 있다고 판단하였습니다. 따라서 저는 X-Forwarded-For 방식을 사용하기로 결정하였습니다.
(물론, X-forwared-For에서 받은 ip가 실제 client의 ip가 아닐 수 있습니다. 프록시 우회를 사용할 경우 ip가 X-forwared-For 헤더에 실제 ip주소를 담는다 하더라도 변조될 가능성이 있기 때문에 클라이언트의 ip라고 확신할 수는 없습니다. 이 부분은 추후 보안해나가겠습니다.)
X-Forwarded-For 방식을 사용하면, 최종 백엔드로 도착하기까지 ip에 대한 정보를 X-Forwarded-For 헤더에 아래와 같은 방식으로 담기게 됩니다.
LoginSessionPolicy는 로그인 세션을 어떻게 활용할 것인가에 대한 비즈니스 로직을 작성하였습니다.
앞 서 정리한 계획대로 로그인 세션을 등록하고 중복 로그인이라면 저장된 세션정보와 인증 토큰을 전부 제거합니다.
그리고 새로운 이용자의 세션 정보와 인증 토큰을 저장합니다.
여기서 로그인 세션 등록과 토큰 발급은 객체 지향적으로 분리하는 게 맞는지 아니면 하나의 로직으로 수행해야 하는지 트레이드오프가 발생하였습니다. 이 과정은 단일 트랜젝션 내에서 처리되어야 하므로 코드상 아쉬움이 남지만, TokenPolicy를 의존성 주입받은 LoginSessionPolicy에서 처리하는 것으로 결정하였습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginSessionPolicyImpl implements LoginSessionPolicy {
private final LoginSessionRepository loginSessionRepository;
private final TokenPolicy tokenPolicy;
@Value("${jwt.access-expiration-time}")
private Long expiration;
@Override
public AuthTokenDto loginNewSession(Member member, List<Authority> authorities, String remoteAddr)
throws JsonProcessingException {
if (isDoubleLogin(member.getUserId())) {
logoutSession(member.getUserId());
tokenPolicy.saveLogoutTokensAndDeleteSavedTokens(member.getUserId());
}
loginSession(member.getUserId(), remoteAddr);
return tokenPolicy.createAuthToken(member.getUserId(), authorities);
}
private boolean isDoubleLogin(String userId) {
return loginSessionRepository.existLoginSession(userId);
}
private void loginSession(String userId, String remoteAddr) throws JsonProcessingException {
loginSessionRepository.saveLoginSession(LoginSession
.of(userId, remoteAddr, expiration + new Date().getTime()));
}
private void logoutSession(String userId) {
loginSessionRepository.deleteLoginSession(userId);
}
}
<FacadeSerivce.java>
public AuthTokenDto login(LoginFacadeRequest request) throws JsonProcessingException {
Member member = memberPolicy.findMemberByEmailOrThrow(RequestMapperFactory.mapper(request));
List<Authority> authorities = memberPolicy.findAuthorityByUserOrThrow(member);
return loginSessionPolicy.loginNewSession(member, authorities, request.getRemoteAddr());
}
faceService는 부가 로직 수행 후, loginSessioPolicy.loginNewSession을 호출하고, AuthTokenDto를 리턴합니다.
7. 서비스 로직 코드 테스트 하기
차례대로 LogoutToken 관련 서비스, LoginSessionPolicy, FacadeService 순서로 테스트를 진행하였습니다.
일반적인 순차적 테스트에서는 테스트 성공하였고, 하단은 동시성 테스트 코드입니다.
고려해야 할 사항이 많으므로 Assertions 코드가 지저분하지만 확실하게 검증해야 하는 부분이므로 전부 작성하였습니다.
먼저 같은 ip로 몇 초 이내에 반복 요청을 보낸다면 필터가 예외를 발생시키므로 컨트롤러에서 검증을 진행할 수 있습니다.
하지만, 만약 예기치 않는 문제가 발생할 수도 있으므로 비즈니스 로직에서도 동시성 문제를 테스트하였습니다.
검증해야 하는 로직은, 마지막(물리적으로 마지막)에 수행되는 익명의 스레드만 로그인 세션을 유지하고 유효한 인증 토큰으로 저장합니다. 물리적으로 이전에 저장된 토큰은 모두 로그아웃된 토큰으로 저장해야 합니다.
/**
* 동시에 로그인 요청이 올 때 (필터에서 처리되지 않는 제약 사항을 넘어서 요청이 수행되었다고 가정)
* 같은 email, password, 다른 remoteAddr
* 마지막에 실행되는 익명의 스레드의 로그인 세션만 유지하고 다른 스레드의 로그인 세션은 제거한다.
* 유효 한인증 토큰은 하나만 유지되고 나머지 토큰은 모두 LogoutSessionToken으로 저장된다.
*/
@Test
@DisplayName("로그인이 여러 번 발생하는 문제에 대해 동시성을 체크한다.")
public void loginNewSession_success_beforeLoginSessionExist_mt() throws Exception {
//given
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(count);
AuthTokenDto[] authTokens = new AuthTokenDto[count];
//when
for (int i = 0; i < count; i++) {
int fIdx = i;
executorService.submit(() -> {
try {
authTokens[fIdx] = loginSessionPolicy
.loginNewSession(member, authorities, "127.0.0." + fIdx);
}
catch (Exception e) {
e.printStackTrace();
}
finally {
latch.countDown();
}
});
}
latch.await();
LoginSession nowLoginSession = loginSessionPolicy.findLoginSessionByUserId(member.getUserId());
//then
int validRemoteAddr = parseInt(nowLoginSession.getRemoteAddr().split("\\.")[3]);
System.out.println("validRemoteAddr = " + validRemoteAddr);
for (int i = 0; i < count; i++) {
if (i != validRemoteAddr) {
assertThat(tokenRepository
.findTokenByKey(authTokens[i].getAccessToken(), AccessToken.class)).isNull();
assertThat(tokenRepository
.findTokenByKey(authTokens[i].getRefreshToken(), RefreshToken.class)).isNull();
assertThat(tokenRepository
.findTokenByKey(authTokens[i].getAccessToken(), LogoutSessionAccessToken.class)).isNotNull();
assertThat(tokenRepository
.findTokenByKey(authTokens[i].getRefreshToken(), LogoutSessionRefreshToken.class)).isNotNull();
}
else {
Token accessToken = tokenRepository.findTokenByIdx(member.getUserId(), AccessToken.class);
Token refreshToken = tokenRepository.findTokenByIdx(member.getUserId(), RefreshToken.class);
assertThat(tokenRepository.findTokenByKey(accessToken.getId(), AccessToken.class)).isNotNull();
assertThat(tokenRepository.findTokenByKey(refreshToken.getId(), RefreshToken.class)).isNotNull();
}
}
}
비즈니스 로직에는 문제가 없었지만, 실제 토큰을 발급하는 유저아이디와 발급시간이 동일하면 동일한 키가 생성되는 문제가 발생하였습니다. 즉, 이전 요청에 대한 토큰을 제거하고 로그아웃 토큰을 생성하고 새로운 요청에 대한 토큰이 생성될 때 동일한 시간으로 인해 토큰이 겹치게 된 것입니다.
이 문제를 극복하기 위해 토큰 발급 시간을 기존에 정해둔 이외에 추가로 0 ~ 10만 사이의 숫자를 발급할 수 있도록 설정하였습니다.
그 결과 정상적으로 중복 로그인 세션 및 토큰이 될 때, 토큰이 겹치는 문제를 해결할 수 있었습니다.
10만으로 설정한 이유는 토큰 유효 시간이 AccessToken, RefreshToken이 각각 3일, 7일입니다. 100000 / 1000 수행하면 100초로 실제 정의한 목표 토큰 유효시간에 100초 미만 차이를 주면서 유효한 랜덤 발생을 지킬 수 있다고 생각하였습니다.
마지막 LoginSessionPolicy를 의존 관계 주입받는 FacadeService에서도 테스트를 진행하였고 성공하였습니다.
8. LoginSessionFilter 등록하기
이제 거의 마지막 단계입니다.! 실제 요청을 수행할 때 같은 ip 혹은 같은 email로 여러 번 요청하는 로그인에 대해 필터에서 요청을 차단하는 로직을 작성해야 했습니다. 인터셉터가 아닌 필터를 선택한 이유는, Http 요청에 대해 앞단에서 filter가 수행되기 때문에 Controller를 거치지 않고 차단을 수행할 수 있기 때문입니다.
필터에서 고려해야 할 사항은 서버의 성능을 고려하여 최대 얼마의 시간 동안 몇 번의 요청을 차단할 것인지 문제입니다.
또한, 헤더 정보만 파싱 할 것인지 아니면 body를 복사하여 inputStream을 만든 후 다시 body를 생성해서 보내는 작업을 수행할지에 대한 선택이 필요했습니다.
먼저, 헤더 정보만 파싱 하면 10초 동안 같은 ip에서 3번의 요청이 오면 차단하는 기능을 생각하였습니다. ip는 헤더 정보로 필터에서 비교적 간단하게 차단 로직을 수행할 수 있습니다.
반면 이메일로 중복 요청을 검사하는 로직은 많은 입출력 비용이 발생합니다. HttpServletRequest의 request는 Consumable로 작동합니다. 즉, 한 번 값을 읽으면 body의 스트림이 소비되므로 filter에서 이메일 중복 요청을 수행하려면 값을 복사해서 사용한 후 다시 inputStream을 생성하여 controller로 보내야 합니다.
현재 가용 가능한 서버의 성능을 생각하면 I/O를 생성하는 것은 서버에 많은 부담을 줄 수 있을 것이라 판단하여 요청 본문 처리를 컨트롤러 혹은 서비스 단에서 수행하도록 하고 filter에서는 헤더 정보만 파싱 하도록 처리하는 것으로 결정했습니다.
코드는 아래와 같습니다. 하나씩 분석해 보겠습니다.
@Slf4j
public class LoginSessionFilter extends OncePerRequestFilter {
private final ConcurrentHashMap<String, LoginSessionCheck> sessionFilter = new ConcurrentHashMap<>();
@Qualifier("defaultObjectMapper")
private final ObjectMapper defaultObjectMapper;
public LoginSessionFilter(@Qualifier("defaultObjectMapper") ObjectMapper defaultObjectMapper) {
this.defaultObjectMapper = defaultObjectMapper;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String xForHeader = request.getHeader("X-Forwarded-For");
if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();
String remoteAddr = xForHeader.split(",")[0].trim();
clearCheckStatus();
LocalDateTime now = LocalDateTime.now();
LoginSessionCheck addrCheck = sessionFilter.get(remoteAddr);
if (addrCheck != null) {
setCheckStatus(remoteAddr, addrCheck, now);
} else {
sessionFilter.put(remoteAddr, new LoginSessionCheck(now));
}
filterChain.doFilter(request, response);
}
private void setCheckStatus(String key, LoginSessionCheck addrCheck, LocalDateTime now) {
if (addrCheck.isBlock()) {
if (between(addrCheck.getLast(), now).toSeconds() > 20) { // 차단 해제 조건
addrCheck.setInit();
} else {
throw new TooManyRequestException(); // 차단 유지
}
}
if (addrCheck.getFirst() == null) { // first가 null 인경우
addrCheck.setFirst(now);
addrCheck.setLast(now);
}
else {
if (addrCheck.getCount() > 3) {
addrCheck.setBlock(true);
throw new TooManyRequestException();
}
if (between(addrCheck.getLast(), now).toSeconds() > 10) { //마지막 요청 후 10초가 지났다면 메모리 낭비를 제거하기 위해 체크 제거
sessionFilter.remove(key);
} else {
addrCheck.incrementAndGetCount();
addrCheck.setLast(now);
}
}
}
private void clearCheckStatus() {
if (sessionFilter.size() > 300) {
sessionFilter.entrySet().removeIf(entry -> !entry.getValue().isBlock());
}
}
@Setter
@Getter
@NoArgsConstructor
static class LoginSessionCheck {
private LocalDateTime first;
private LocalDateTime last;
private AtomicInteger count = new AtomicInteger(0);
private volatile boolean block;
public LoginSessionCheck(LocalDateTime now) {
this.first = now;
this.last = now;
}
public int incrementAndGetCount() {
return count.incrementAndGet();
}
public int getCount() {
return count.get();
}
public void setInit() {
this.first = null;
this.last = null;
this.count = new AtomicInteger(0);
this.block = false;
}
}
}
ConcurrentHashMap은 스레드에 안전한 hashMap을 제공하는 기능입니다.
ConcurrentHashMap에서 static class인 LoginSessionCheck인스턴스를 저장하고 있습니다. LoginSessionCheck는 first, last, count, block으로 구성되어 있습니다.
first: 최초 요청 시간
last: 마지막 요청 시간
count: 인스턴스가 유효하게 저장되는 기간 동안 요청 수
block: 특정 클라이언트의 ip에 대한 차단 여부
입니다.
앞 서 비즈니스 로직에서 정리한 X-Forwarded-For의 헤더로부터 remoteAddr을 가져옵니다. 만약 이 값이 null이라면 제가 정의한 BadRequestException()을 발생시켜, ControllerAdvice에서 예외를 처리합니다.
String xForHeader = request.getHeader("X-Forwarded-For");
if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();
String remoteAddr = xForHeader.split(",")[0].trim();
clearCheckStatus();
LocalDateTime now = LocalDateTime.now();
LoginSessionCheck addrCheck = sessionFilter.get(remoteAddr);
if (addrCheck != null) {
setCheckStatus(remoteAddr, addrCheck, now);
} else {
sessionFilter.put(remoteAddr, new LoginSessionCheck(now));
}
이 로직은, setCheckStatus를 적용하여, ip에 대한 유효성을 체크합니다.
private void setCheckStatus(String key, LoginSessionCheck addrCheck, LocalDateTime now) {
if (addrCheck.isBlock()) {
if (between(addrCheck.getLast(), now).toSeconds() > 20) { // 차단 해제 조건
addrCheck.setInit();
} else {
throw new TooManyRequestException(); // 차단 유지
}
}
if (addrCheck.getFirst() == null) { // first가 null 인경우
addrCheck.setFirst(now);
addrCheck.setLast(now);
}
else {
if (addrCheck.getCount() > 3) {
addrCheck.setBlock(true);
throw new TooManyRequestException();
}
if (between(addrCheck.getLast(), now).toSeconds() > 10) { //마지막 요청 후 10초가 지났다면 메모리 낭비를 제거하기 위해 체크 제거
sessionFilter.remove(key);
} else {
addrCheck.incrementAndGetCount();
addrCheck.setLast(now);
}
}
}
저는 10초 이내에 요청이 4번 이상 발생하면 addr에 대해 block을 설정하고 TooManyRequestException()을 발생시켰습니다. 그리고 그 이외의 요청에 대해서, 마지막(last) 요청보다 시간이 10초 지난 이후에 다시 요청이 온 경우에는 sessionFilter를 remove 하였습니다. 그 이외의 경우에는 count를 증가시키고, 마지막 요청 시간을 현재로 설정하였습니다.
만약, block 된 사용자가 접근할 때, 블락당한 지 20초가 안 지났다면 블락을 유효화하고 TooManyRequestException()을 발생시킵니다. 마지막 요청 기준 20초가 지났다면 블락을 해제하고 값을 초기화합니다.
중간에 있는 clearCheckStatus()는 key가 300개 이상 초과하면, block 되지 않은 entry를 제거하여 메모리 관리할 수 있도록 하였습니다.
이 코드를 사용하기 위해 Configuration에서는 필터를 등록하여야 합니다. 필터를 FilterRegistrationBean<LoginSessionFilter>로 빈을 등록하는 이유는 생성자 주입에 필요한 objectMapper의 Qualifier를 명시하여, 여러 개가 bean으로 등록되어 있는 objectMapper에서 defaultObjectMapper를 선택할 수 있도록 하였습니다.
또한, FilterRegistrationBean을 활용하면 특정 uri에 매핑되어야 하는 필터를 선정할 때 효율적입니다.
@Configuration
public class FilterRegisterConfig {
@Qualifier("defaultObjectMapper")
private final ObjectMapper objectMapper;
public FilterRegisterConfig(@Qualifier("defaultObjectMapper") ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Bean
public FilterRegistrationBean<LoginSessionFilter> loginSessionFilter(@Qualifier("defaultObjectMapper") ObjectMapper objectMapper) {
FilterRegistrationBean<LoginSessionFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new LoginSessionFilter(objectMapper));
registrationBean.addUrlPatterns("/member-service/login/**");
return registrationBean;
}
}
만약 필터 내부에 uri패턴을 매칭시키려면 PatternMatchUtils를 사용해야 합니다.
하지만 이 경우 비즈니스로직에 uri 패턴 매칭 코드로 if - else를 추가해야 하므로 복잡성이 가해질 수 있습니다.
또한, 서버의 안정화를 위해 filter에서 ip를 차단하되 생길 수 있는 ip 우회를 방지하기 위해 X-Forwarded-For 헤더를 추가하여 ip 차단 기능을 추가하였습니다.
String xForHeader = request.getHeader("X-Forwarded-For");
if (xForHeader == null || xForHeader.isEmpty()) throw new BadRequestException();
String remoteAddr = xForHeader.split(",")[0].trim();
filter를 설정하며 request의 Consumable 문제를 해결하며 이메일까지 검증을 할지, 아니면 이 부분은 다른 필터를 적용하거나 컨트롤러에서 처리할 지 많은 고민을 하였습니다. 그 과정에서 Controller가 값을 받지 못하는 에러도 발생하였고 어떻게 처리해야할 지 수많은 코드를 쓰고 지웠던 것 같습니다.
filter를 등록할 때, 이전 필터는 PathMatcher 방식을 사용했는데, 스프링이 제공해주는 FilterRegsitrationBean 기능으로 보다 기능을 분리하며 가독성 좋게 작성할 수 있었습니다.
남은 부분도 꾸준히 수정하고 문제를 검토하고 고민하고 코드로 작성하며 해결하도록 하겠습니다.
이번 포스팅은 API 개발의 마지막 관문이라고 할 수 있는 SpringRestDocs를 발급하는 과정을 정리하려고 합니다.
API 문서를 개발하는 과정은 까다로운 점들이 있습니다. 먼저 백엔드에서 개발한 api 명세 규칙을 프론트 개발자 혹은 api를 활용하는 클라이언트가 명확하게 이해하여 활용할 수 있도록 작성되어야 합니다. api의 uri, 전달 방식, 헤더 필수 정보, 필요한 파라미터 와 응답 객체 등 많은 내용을 정확하게 전달해야 합니다.
스프링에서 api docs를 개발하도록 돕는 기술은 여러 가지가 있는데, 제가 소개드릴 수 있는 기능은 Swagger와 SpringRestDocs입니다. 두 가지 방법은 각각 장단점이 존재하기 때문에 간단하게 정리한 후, 제가 선택한 SpringRestDocs 위주로 작성 방법과 공통 기능 분리하기, 테스트 진행 후 docs 발급 등을 작성하도록 하겠습니다.
1. Swagger vs SpringRestDocs
Swagger는 api 문서를 자동으로 생성하여 Swagger UI를 사용하여 생성된 문서를 확인할 수 있습니다. 다른 방법에 비해 어노테이션으로 api를 개발할 수 있기 때문에, 간편한 설정으로 빠른 개발이 가능합니다.
<Swagger>
@Slf4j
@Api(value = "auth api")
@RestController
@RequestMapping("/member-service")
@RequiredArgsConstructor
public class AuthController {
private final FacadeService facadeService;
/**
* 회원 가입 요청
*/
@ApiOperation(value = "Register", response = ResponseEntity.class)
@PostMapping("/register")
public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
facadeService.register(request);
return ResponseEntity.ok(SendSuccess.of());
}
<SpringRestDocs>
SpringRestDocs는 테스트 기반으로 api 명세를 발급하는 기술입니다. JUnit 기반 테스트를 진행하여, 클라이언트의 mock 요청에 대해 비즈니스 로직을 수행하여 문제가 없다면 테스트를 성공하고 테스트 결과물로 api 명세를 작성하는 방식입니다.
저는 백엔드 개발에 TDD 설계가 정말 중요하다고 생각합니다. 저는 기술적으로 굉장히 많이 부족한 상태이고 많이 배워가야 하는 상황에서 제 코드를 맹신한다는 것은 과오임이 분명합니다. 따라서, 제가 작성한 코드가 어느 부분에서든 에러가 발생할 수 있는데, 테스트 없이 api를 발급하는 과정은 리스크가 있다고 생각하였습니다. 또한 , 소비자에게 테스트로 검증되지 않은 api 명세를 제공하는 것은 신뢰도 측면에서 악영향을 미칠 수 있다고 판단하였습니다.
SpringRestDocs는 Slice 혹은 Page 처럼 페이징 쿼리가 적용되는 코드에 대해서는 실제 페이징 응답을 전부 제공해야 하는 점에서 개발자의 피로도를 늘릴 수 있습니다. 하지만 역시 스프링은 위대한 기술임을 증명하듯, 이러한 과정을 하나의 유틸성 클래스를 작성함으로써 해결할 수 있습니다.
따라서, SpringRestDocs를 개발하는 과정이 보다 복잡해보이지만 상속 기능을 통해 최대한 중복되는 코드를 줄이고,
다음에도 활용 가능한 코드로 구현함으로써 신뢰도와 개발 편리성을 모두 지키는 방식으로 코드를 작성해 보는 시도를 진행하겠습니다.
혹시 제 코드를 활용하시더라도 SpringRestDocs가 버전 문제로 인해 진행이 안되실 수 도 있습니다. 이 경우는 구글링이나 Chat GPT의 도움을 받으시면 버전에 맞는 라이브러리를 받을 수 있습니다.
(해당 프로젝트에 QueryDsl을 적용하거나 기타 다른 라이브러리를 활용하게 되면 Build.gradle에서 많은 에러가 발생할 수 있습니다. 저는 최종 jar로 빌드하는 과정에서 많은 에러가 발생했습니다. 따라서 제가 적용했을 때 에러가 없었던 환경으로 공유드리기 위해 필요하다고 생각되는 설정으로 제시하였습니다.!)
// <-- docs 추가 asciidoctor 이 부분 --> //
plugins {
id 'java'
id 'org.springframework.boot' version '3.0.2'
id 'io.spring.dependency-management' version '1.1.0'
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
group = 'liar'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
// <-- docs 추가 asciidoctorExt 이 부분 --> //
configurations {
compileOnly {
extendsFrom annotationProcessor
}
asciidoctorExt
}
// <-- docs 추가 snippetsDir --> //
ext {
snippetsDir = file('build/generated-snippets') // restdocs
set('springCloudVersion', "2022.0.1")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
// json
implementation 'com.google.code.gson:gson:2.10.1'
// mockito
testImplementation 'org.mockito:mockito-core:4.11.0'
testImplementation 'org.mockito:mockito-junit-jupiter:4.11.0'
// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
// <-- docs 시작 --> //
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
// <-- docs 끝 --> //
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
//test lombok
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
// <-- docs 시작 --> //
tasks.named('test') {
useJUnitPlatform()
}
sourceSets {
main {
java {
srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
}
}
}
clean {
delete file('src/main/generated')
}
test {
outputs.dir snippetsDir
useJUnitPlatform()
systemProperty 'spring.config.name', 'application-test'
systemProperty 'spring.cloud.bootstrap.name', 'bootstrap'
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}
bootJar {
dependsOn asciidoctor
copy {
from asciidoctor.outputDir
into "src/main/resources/static/docs"
}
}
jar {
enabled = false
}
tasks.withType(JavaCompile) {
options.release = 17
}
// <-- docs 시작 --> //
4. 공통 코드 작성하기
자바는 상속을 활용할 수 있으므로 공통 로직은 부모 클래스에서 작성한 후, 하위 자식 클래스에서 해당 클래스를 상속하여 공통 로직을 줄이는 방식을 사용하였습니다.
@SpringBootTest: SpringBoot를 Test환경에서 실행하도록 돕는 어노테이션입니다. application을 컨텍스트에 로드하며, 필요한 빈을 주입하여 통합 테스트 환경을 제공해 줍니다.
@AutoConfigureWebMvc: MockMvc를 사용하는데 필요한 설정정보를 자동으로 제공하는 역할을 수행합니다. HadlerMapping 혹은 HandlerAdapter 등의 구성요소를 자동으로 주입해 주기 때문에 복잡한 Mvc 관련 코드를 생략할 수 있습니다.
(SpringMvc로 적용되지만 실제 RestController(RestFul)로 작성한 경우도 모두 포함됩니다. SpringMvc라고 표현되는 이유는 SpringWebFlux와 구분하기 위함입니다.)
@AutoConfigureRestDocs: SpringRestDocs를 자동으로 구성하여 api 문서화를 돕는 어노테이션입니다. springRestDocs에 필요한 설정 정보를 제공하며, api의 문서를 생성하는 역할을 수행합니다.
@ExtendWith(RestDocumentationExtension.class): Junit5 확장 모델을 사용하여 RestFul api 문서화를 돕는 어노테이션입니다.
MockMvc: MockMvc는 SpringMvc를 테스트할 때 사용하는 기술로, 스프링은 DispatcherServlet을 사용하여 Http요청을 처리하는데, MockMvc는 이러한 과정을 Mock(가짜) 객체로 일련의 과정을 처리해 줍니다. 따라서, 테스트 환경에서 보다 간편하게 코드를 작성하는데 도움을 줄 수 있습니다.
@BeforeEach의 init() : Spring Mvc application의 Web Context로 앞서 @ExtendWith(RestDocumentationExtension.class) 어노테이션으로 자동 설정받은 restDocumentationContextProvider를 WebApplicationContext에 적용하여 셋업 함으로써, Mock 환경에서 restDocument를 작성할 수 있는 환경을 만들고 build 하여 인스턴스를 생성한 후 mockMvc에 적용하는 과정입니다.
5. SpringRestDocs 적용을 위한 실제 ControllerTest
제가 테스트하고자 하는 RestController의 메서드는 다음과 같습니다.
/**
* 회원 가입 요청
*/
@PostMapping("/register")
public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
facadeService.register(request);
return ResponseEntity.ok(SendSuccess.of());
}
perform 객체의 andExpect()가 의미하는 바는, 응답 코드와 ResponseEntitiy 응답 객체의 값입니다. 저는 응답 결과로, 정형화된 응답 값을 제공하고자 하였습니다. Body가 필요한 경우는 T 타입이 추가된 SendSuccessBody 클래스 인스턴스를 제공하지만, 현재 응답은 크게 T body가 필요하지 않으므로, SendSuccess.of()를 응답 객체로 제시하였습니다.
@NoArgsConstructor
@AllArgsConstructor
public class SendSuccess {
public String code;
public String message;
public static SendSuccess of() {
return new SendSuccess(SuccessCode.OK, SuccessMessage.OK);
}
}
하단의. andDo()는 document의 이름을 명시할 수 있고 출력 결과를 이쁘게 보여주는 prettyPrint()를 추가할 수 있습니다.
이후, responseFields()에서 응답 결과로 제공하는 필드명과 설명을 제공할 수 있습니다.
fieldWithPath().type().description() 결과는 해당 필드 이름, 제공 타입, 필드의 설명을 각각 작성하는 부분입니다.
.andDo(document("member-register",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
responseFields(
fieldWithPath("code").type(STRING).description("응답 상태 코드"),
fieldWithPath("message").type(STRING).description("상태 메세지")
)));
이후 테스트를 진행하면, 테스트에 성공한 경우 성공 메시지가 나오며, 패키지의 build/generated-snippets 폴더에 adoc 파일이 생성됩니다. 만약 생성되지 않는다면, Intellij의 캐시 무효화 버튼으로 모든 캐시를 제거 후 다시 시작하면 generated-snippets에 정상적으로 adoc 파일이 추가된 것을 확인할 수 있습니다.
6. 중복 코드 제거하기
SpringRestDocs는 중복되는 코드가 많이 발생할 수 있습니다. mockMvc.perform()을 수행하는 과정에 적용되는 Application_Json, andDo()의 preprocessingRequest, preprocessingResponse 등이 그 예에 속합니다.
따라서, 위에서 작성한 코드를 리팩토링하여 반복되는 코드를 정리하였습니다.
public <T> MockHttpServletRequestBuilder customPost(String uri, T t) {
return post(uri)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(new Gson().toJson(t));
}
public <T> ResultActions mockMvcPerformPost(String uri, T t) throws Exception {
return mockMvc.perform(customPost(uri, t));
}
mockMvc.perform()을 수행하는 코드에서 accept, contentType, conent는 공통적으로 사용하므로 제네릭 메소드를 활용하여 request타입으로 받아서 MockHttpServletRequestBuilder를 리턴합니다.
이후 mockMvcPerformPost()도 제네릭 메소드로 선언하여 ResultActions를 리턴하도록 하였습니다.
두 메서드를 분리하여 각각 적용하도록 한 이유는 상황에 따라, MockHttpServletRequestBuilder 타입에 추가로 헤더 정보등을 넣어야 할 수도 있습니다. 이 경우 두 타입을 분리하여 상황에 맞게 적용하기 위함입니다.
만약 헤더 정보를 추가해야한다면 자바의 다형성을 활용하여 uri, T 타입 request를 받거나, uri, T 타입 request, AuthTokenDto로 헤더 정보를 받을 수 있습니다.
public <T> MockHttpServletRequestBuilder customPost(String uri, T t) {
return post(uri)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(new Gson().toJson(t));
}
public <T> ResultActions mockMvcPerformPost(String uri, T t) throws Exception {
return mockMvc.perform(customPost(uri, t));
}
public <T> MockHttpServletRequestBuilder customPost(String uri, T t, AuthTokenDto auth) {
return post(uri)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(new Gson().toJson(t))
.header("Authorization", auth.getAccessToken())
.header("RefreshToken", auth.getRefreshToken())
.header("UserId", auth.getUserId());
}
public <T> ResultActions mockMvcPerformPost(String uri, T t, AuthTokenDto auth) throws Exception {
return mockMvc.perform(customPost(uri, t, auth));
}
SpringRestDocs에서 가장 복잡한 부분은 페이징 쿼리를 응답을 작성하는 부분입니다. 만약 QueryDsl로 Slice<>, Page<> 형태의 객체를 응답 Body로 제공할 때, SpringRestDocs 테스트를 성공하려면 사용되는 페이징 쿼리를 전부 작성해야 합니다.
이 문제는 여간 쉬운 일이 아니고, 테스트마다 모두 작성하는 것은 비효율적입니다.
따라서, 이러한 코드도 앞서 Custom하게 정리했던 방식대로 페이징 쿼리 전용 메서드를 만들 수 있습니다.
이를 구현하기 위해, CommonRestDocsController에 ResponseFieldsSnippet 리턴타입의 responseFieldsSnippetPageable 메서드를 추가였고 공통적으로 사용되는 페이징 응답을 작성하였습니다.
public ResponseFieldsSnippet responseCustomFieldsPageable(FieldDescriptor... fieldDescriptors) {
FieldDescriptor[] fields = new FieldDescriptor[] {
fieldWithPath("body.pageable.offset").type(NUMBER).description("The offset of the current page"),
fieldWithPath("body.pageable.pageNumber").type(NUMBER).description("The number of the current page"),
fieldWithPath("body.pageable.pageSize").type(NUMBER).description("The size of the current page"),
fieldWithPath("body.pageable.paged").type(BOOLEAN).description("Whether the current page is paged"),
fieldWithPath("body.pageable.unpaged").type(BOOLEAN).description("Whether the current page is unpaged"),
fieldWithPath("body.sort.empty").type(BOOLEAN).description("Whether the current page is sorted"),
fieldWithPath("body.sort.sorted").type(BOOLEAN).description("Whether the current page is sorted"),
fieldWithPath("body.sort.unsorted").type(BOOLEAN).description("Whether the current page is sorted"),
fieldWithPath("body.pageable.sort.empty").type(BOOLEAN).description("Whether the current page is sorted"),
fieldWithPath("body.pageable.sort.sorted").type(BOOLEAN).description("Whether the current page is sorted"),
fieldWithPath("body.pageable.sort.unsorted").type(BOOLEAN).description("Whether the current page is sorted"),
fieldWithPath("body.totalPages").type(NUMBER).description("The total number of pages"),
fieldWithPath("body.totalElements").type(NUMBER).description("The total number of elements"),
fieldWithPath("body.last").type(BOOLEAN).description("Whether the current page is the last one"),
fieldWithPath("body.size").type(NUMBER).description("The size of the current page"),
fieldWithPath("body.number").type(NUMBER).description("The number of the current page"),
fieldWithPath("body.numberOfElements").type(NUMBER).description("The number of elements in the current page"),
fieldWithPath("body.first").type(BOOLEAN).description("Whether the current page is the first one"),
fieldWithPath("body.empty").type(BOOLEAN).description("Whether the current page is empty")
};
return responseFields(fieldDescriptors).and(fields);
}
FieldDescriptor 타입의 인자들을 받아서, reponseFields()를 생성한 후 FieldDescriptor 배열을 추가하여 ResponseFieldsSnippet의 인스턴스를 적용하는 방식입니다.
이를 활용하여, responseCustomFieldsPageable()은 commonRestDocsController에서 작성한 메서드를 받아서 추가로 필요한 custom 응답값을 추가합니다.
따라서, 많은 페이징 쿼리를 작성하더라도 커스텀 메서드를 활용하여 코드의 양을 줄일 수 있었습니다.
7. 정리하며...
Swagger나 SpringRestDocs를 사용하는 것은 많은 트레이드오프가 있습니다. 무엇이 더 좋다고 평가하기 어려울 정도로 두 기능 모두 각각 장점들이 너무 뚜렷합니다. 만약 Controller에 대한 테스트를 따로 진행한다면, swagger를 적용하여 빠르고 정확하게 개발을 진행할 수 있습니다.
저는 개인적으로 Controller에서 핵심 로직 이외에 부가 기능을 수행하는 어노테이션을 추가하는 것에 부담을 느꼈고, 실제로 아직 실력이 너무나도 부족하기 때문에 테스트를 거치지 않은 제 코드를 믿지 않는 편입니다. 직접 눈으로 확인하고, 에러를 발생시켜 보고 "왜 이건 안되지?" 생각하는 시간이 저에게 발전을 가져다주는 것 같습니다.
그러다 보니 의식적으로라도 SpringRestDocs를 활용하여 테스트를 진행한 후 api 명세를 발급하게 되는 것 같습니다. 또한, 복잡하고 중복되는 코드의 경우 메서드로 정리해놓은 후, 해당 메서드를 적용하면 되므로 개발 시간도 단축시킬 수 있었습니다.