Post

Tree 서비스를 정리하며

Tree 서비스를 정리하며


이번 년도 꽤 오랜 기간 진행했던 Tree 서비스가 11월 1일에 마무리되었다. 마무리를 한 후 바로 회고록을 작성했으면 좋았겠지만, 지난 해커톤 대회 대상을 수상한 후 부상이었던 해외 연수를 다녀왔으며, 올해를 좀 더 뜻 깊게 보내기 위해 이런저런 계획을 세우다 보니 벌써 한 달이 지났다.

새로운 기술을 많이 접할 수 있었던 프로젝트인데, 시간이 갈수록 기억이 희미해져서 빨리 정리해야겠다는 생각이 들었다. 최대한 작성했던 보고서를 참고하고 기억을 더듬어 글을 써 보겠다. 또한 BJPUBLIC에서 나온 ‘스프링으로 시작하는 리액티브 프로그래밍 - Spring WebFlux를 이용한 Non-Blocking 애플리케이션 구현’ 이라는 책을 참고하여 해당 내용을 공부하였다. 아래의 글은 내가 메인으로 맡았던 backend와 database에 대한 내용에 대해서만 작성한다.

1. Tree 서비스에 대해


우선 Tree가 무엇일까? 클라우드 게이밍 시스템이다. 클라우드 게이밍은 게임을 클라이언트에 설치하지 않고 스트리밍을 통해 게임을 제공하는 방식이다. 사용자는 네트워크를 통해 서버에서 처리된 게임 영상을 전송 받아 게임을 즐길 수 있다. 정리하면 사용자가 장치 성능으로부터 받는 영향을 최소화하고 고화질, 고사양의 게임을 즐길 수 있는 것이다. Unity Render Streaming을 활용하여 서버에서 실시간으로 고화질의 게임 영상을 렌더링하고 스트리밍하였다. 또한 WebRTC를 사용하여 실시간 게임 데이터의 전송 및 스트리밍을 개선하였다.

우리는 High Definition 3D 과 FPS microgame을 서비스하였다. CPU 성능, 네트워크 지역 별 차이, 네트워크 환경 차이, 실행 브라우저 차이, 동시 접속자 수 차이, 저사양 및 고사양 게임에 대한 차이, 최대 접속자 수, 접속자 수 별 Latenct 측정, 오브젝트 수 별 접속자 수와 리소스 사용량 측정 등을 비교하고 분석해보았다.

2. Spring WebFlux 사용 이점


이번 서비스에서 Spring MVCSpring WebFlux 중 후자를 선택하였다. 왜냐하면 비동기 및 논블로킹 I/O를 지원하는 리액티브 프로그래밍을 통해 확장성과 성능을 극대화할 수 있기 때문이다.

기존의 Spring MVC는 요청마다 스레드를 생성하고 해당 스레드를 블로킹하는 동기적 처리 방식으로 동작한다. 그러나 Spring WebFlux는 논블로킹 방식으로 동작하여 더 적은 리소스로 높은 동시성을 처리할 수 있다. 이러한 특징은 고성능과 효율성이 중대한 대규모 시스템에서 유리하다.

다음은 위에 언급한 책의 내용 중 일부이다.

Spring WebFlux의 요청 처리 흐름

  1. 최초에 클라이언트로부터 요청이 들어오면 Netty 등의 서버 엔진을 거쳐 HttpHandler가 들어오는 요청을 전달받는다. 각 서버 엔진마다 주어지는 ServerHttpRequestServerHttpResponse를 포함하는 ServerWebExchange를 생성한 후, WebFilter 체인으로 전달한다.
  2. ServerWebExchangeWebFilter 체인에서 전처리 과정을 거친 후, WebHandler 인터페이스의 구현체인 DispatcherHandler에게 전달된다.
  3. Spring MVC의 DispatcherServlet과 유사한 역할을 하는 DispatcherHandler에서는 HanlderMapping List를 원본 Flux의 소스로 전달받는다.
  4. ServerWebExchange를 처리할 핸들러를 조회한다.
  5. 조회한 핸들러의 호출을 HandlerAdapter에게 위임한다.
  6. HandlerAdapterServerWebExchange를 처리할 핸들러를 호출한다.
  7. Controller 또는 HandlerFunction 형태의 핸들러에서 요청을 처리한 후, 응답 데이터를 리턴한다.
  8. 핸들러로부터 리턴 받은 응답 데이터를 처리할 HandlerResultHandler를 조회한다.
  9. 조회한 HandlerResultHandler가 응답을 적절하게 처리한 후, response로 리턴한다.

3. Mono와 Flux의 차이점 및 사용 사례


Spring WebFlux에서 제공하는 MonoFlux는 리액티브 스트림의 두 가지 주요 타입이다. 우선 Mono는 0개 또는 1개의 데이터를 비동기적으로 반환하는 Publisher이다. 따라서 단일 데이터를 반환하는 경우에 Mono를 사용하였다. 다음으로 Flux는 0개 이상의 데이터를 비동기적으로 처리하는 Publisher이다. 다수의 데이터를 실시간으로 스트리밍하거나 처리할 때 사용된다. 본 서비스에서 실시간 랭킹을 반환할 때 Flux가 사용되었다.

RankingController의 랭킹 조회 코드 일부

1
2
3
4
5
6
7
@GetMapping
@ResponseStatus(HttpStatus.OK)
public Flux<RankingResponse> getAllRankings(
		@RequestParam(defaultValue = "0") @PositiveOrZero final int page,
		@RequestParam(defaultValue = "10") @PositiveOrZero final int size) {
	return rankingService.getAllRankings(page, size);
}

RankingService의 랭킹 조회 코드 일부

1
2
3
4
public Flux<RankingResponse> getAllRankings(final int page, final int size) {
	return rankingRepository.findAllByOrderByRankNumberAsc(PageRequest.of(page, size))
						.flatMap(this::mapToRankingResponse);
}

4. Spring Security를 통한 인증 및 인가


Spring MVC는 표준 서블릿 필터를 사용하는 Spring Security가 서블릿 컨테이너와 통합되는 반면 Spring WebFlux는 WebFilter를 이용해서 Spring Security를 Spring WebFlux에서 사용한다. Tree 서비스의 로그인은 Spring Security를 통해 사용자의 인증 정보를 검증하고 성공적으로 인증된 사용자에게 JWT 토큰을 발급한다. 이 토큰은 이후 요청에서 사용자 식별 및 권한 검정에 사용된다. 회원가입은 Spring Security의 ‘PasswordEncoder’를 사용하여 안전하게 처리되고 데이터베이스에 저장된다. 이는 사용자의 비밀번호 보안을 강화하고 추후 공격으로부터 사용자를 보호할 수 있다.

5. JPA 대신 R2DBC를 사용한 이유


Spring WebFlux를 사용하는 환경에서는 R2DBC를 활용하여 비동기 데이터베이스 접근을 최적화하는 것이 적절하다. Spring MVC는 Blocking I/O 방식인 Spring Data JPA, Spring Data JDBC와 같은 데이터 액세스 기술을 사용한다. 반면 Spring WebFlux는 데이터 액세스 계층까지 완벽하게 Non Blocking I/O를 지원할 수 있도록 Spring Data R2DBC를 사용한다. 이는 데이터베이스 요청을 처리하는 동안 서버 리소스를 블로킹 하지 않기 때문에 대규모 요청 처리에 적합하다.

또한 진행 중반에 데이터베이스를 MySQL에서 PostgreSQL로 변경하였다. 처음에는 익숙한 MySQL을 사용하려 하였다. 하지만 정확하게 기억은 나지 않지만… 그 당시 PostgreSQL이 복잡한 연산에 더욱 좋다는 이야기를 들었다. 또한 비슷한 서비스를 개발했던 선배님들도 PostgreSQL을 사용한 것을 보았다. 그래서 일단 바꿔보았다. 새로운 DB를 사용해보는 것도 좋다고 생각했기 때문이다.

하지만 결과적으로 크게 장점을 체감하지는 못했다. 두 개의 데이터베이스의 차이를 체감할 만큼 복잡한 연산을 수행하지 않았기 때문일까. 오히려 PostgreSQL에서 enum 타입을 다룰 때 값을 수정하거나 삭제하는 것이 제한적이어서 불편함을 느꼈다. 그래도 확장성과 기획을 고려해보면 데이터베이스의 변경이 옳았던 선택이었던 것 같다.

6. MSA와 Spring에서의 다중 데이터베이스 연결 문제와 해결


Tree 서비스는 MSA를 기반으로 설계되었으며 두 개의 데이터베이스를 사용한다. MSA에서의 각각의 서비스는 독립적인 데이터베이스를 가질 수 있다. 이를 통해 서비스 간의 결합도를 낮추고 서비스의 독립성과 확장성을 극대화할 수 있다.

하지만 Spring Framework는 기본적으로 하나의 데이터베이스 연결만 지원한다. 따라서 두 개의 데이터베이스를 연결하는 문제를 해결하기 위해서 추가적인 설정이 필요하다. 이를 해결하기 위해 PlayerDBConfig, RankingDBConfig, R2dbcConfig 클래스를 작성하여 각 데이터베이스에 대해 개별적으로 설정을 구성하고 @Qulifier를 사용하여 데이터베이스를 명시적으로 구분하였다.

PlayerDBConfig 코드 일부

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* import 생략 */

@Configuration
@EnableR2dbcRepositories(
	basePackages = "com.tree.tree.player.repository",
	entityOperationsRef = "playerR2dbcEntityTemplate"
)
public class PlayerDBConfig {
/* DB 정보 구성 생략 */
	private final R2dbcConfig r2dbcConfig;
	
	public PlayerDBConfig(R2dbcConfig r2dbcConfig) {
		this.r2dbcConfig = r2dbcConfig;
	}
	
@Bean
public ConnectionFactory playerDataSource() {
	return r2dbcConfig.createConnectionFactory(host, port, database, username, password, maxSize, initialSize);
}

@Bean
public R2dbcEntityTemplate
playerR2dbcEntityTemplate(@Qualifier("playerDataSource") ConnectionFactory
playerDataSource) {
		return new R2dbcEntityTemplate(playerDataSource);
	}
}

R2dbcConfig 코드 일부

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* import 생략 */
@Component
public class R2dbcConfig {
	public ConnectionFactory createConnectionFactory(String host, int port,
		String database,
		String username, String password, int maxSize, int initialSize) {

		ConnectionFactoryOptions baseOptions = ConnectionFactoryOptions.builder()
			.option(DRIVER, "postgresql")
			.option(HOST, host)
			.option(PORT, port)
			.option(DATABASE, database)
			.option(USER, username)
			.option(PASSWORD, password)
			.build();
			
		ConnectionFactory connectionFactory = ConnectionFactories.get(baseOptions);
		ConnectionPoolConfiguration poolConfig = ConnectionPoolConfiguration.builder(connectionFactory)
					.maxIdleTime(Duration.ofMinutes(30))
					.maxSize(maxSize)
					.initialSize(initialSize)
					.build();
			return new ConnectionPool(poolConfig);
		}
}

7. 기타


아래의 구성도는 Tree 서비스의 시스템 구성도이다.

시스템 구성도

image

8. 주요(일부) 실험 결과 및 추가적인 고민


a. CPU 성능 확인

 Local 환경Chrome 환경
CPU Utiliization (%)80.0822.41

로컬에서 직접 게임을 실행했을 경우에 비해 클라우드에서 게임을 실행했을 경우, CPU 사용률이 75% 줄어든 것을 확인할 수 있다.

b. 네트워크 지역별 차이 비교

 영국, 런던 (14Mbps)한국, 부산 (27Mbps)
평균 미디어 전송률 (kbps)7487.608175.40
평균 프레임레이트 (fps)44.9643.14

두 지역 모두 네트워크 속도가 미디어 전송률을 충분히 감당할 수 있기 때문에 두 지역 간 비디오 품질에는 큰 차이가 없었다.

c. 네트워크 환경 차이 비교

 Wi-FiLTE3GBad Network
평균 미디어 전송률 (kbps)8284.738073.79373.11410.07
평균 프레임레이트 (fps)43.4342.8119.5177.18

Wi-Fi와 LTE 환경은 제공된 대역폭이 측정된 미디어 전송률을 상회하는 수준이기 때문에 비디오 품질에는 유의미한 차이가 나타나지 않았다.

d. 실행 브라우저 차이 비교

 ChromeFirefox
평균 미디어 전송률 (kbps)8942.608175.40
평균 프레임레이트 (fps)43.1943.14

e. 저사양, 고사양 게임 비교

 FPS microgame (저사양)High Definition 3D (고사양)
평균 미디어 전송률 (kbps)8561.768175.40
평균 프레임레이트 (fps)59.7743.14

f. 오브젝트 수 별 접속자 수와 리소스 사용량 측정 및 비교

 오브젝트 수 1 ~100오브젝트 수 200오브젝트 수 400
지원 인원 (명)181410
CPU utilization (%)42 ~ 556378

위의 결과들은 간략한 결과이다. (실험 환경, 분석 결과 등 자세한 내용가 궁금하다면 개인적으로 연락주세요!)

이 개발을 진행하며 2가지의 추가적인 생각을 하였다.

우선 적절한 HTTP Methods를 사용하지 않았다. 처음 설계하고 개발했을 당시에는 알맞게 개발하였다. 하지만 frontend 담당자로부터 put(랭킹 업데이트)과 post(랭킹 등록)를 하나의 api에 합쳐 달라는 요청을 받았다. 고민을 많이 했다. 가능은 하지만 원칙에는 어긋나기 때문이다. 하지만 frontend 담당자가 Unity 개발을 동시에 맡았고, 게임 개발에 익숙하지 않아 frontend를 많이 신경 쓸 수 없었던 상황의 특수성을 고려하여 두 개의 api를 합치는 방향으로 결정했다. 하지만 이 결정이 옳은 결정인가에 대해 계속 의문이 든다.

다음으로 non-blocking, 고성능, 효율적이라는 이유로 Spring WebFlux를 사용하였지만, ‘얼마나’에 대한 실험을 하지 않았던 것이 마음에 걸린다. 여유가 된다면 같은 서비스를 Spring MVC와 Spring WebFlux로 각각 개발하여 차이점을 실제로 확인해보고 싶다.

9. 정리하며..


의도하지 않았지만 초반에 잘못된 방향을 가지고, WebGL을 사용한 적도 있었다. 하지만 이는 클라이언트 측에서 렌더링을 진행하는 방식이며 ‘클라이언트 장치 성능과 상관없이 고품질의 게임을 제공한다.’ 라는 우리의 목표에 맞지 않았다. 하지만 여러가지를 시도해보며 장단점과 특징을 자세히 알 수 있었던 경험이 되었다.

이 개발을 진행하며…. 블로그에는 적을 수 없는 많은 일이 있었다…. 그래도 어떠한 상황에서도 내가 얻어갈 부분은 있다고 생각한다. 내가 경험한 모든 경험이 언젠가 의미 있는 방식으로 활용되길 바란다.

(아.. 이 tree 서버비 진짜 많이 나온다… 로깅, 모니터링까지 붙여서 진짜 무지막지하게 나왔다.. 정말 깜짝 놀랬다..)

This post is licensed under CC BY 4.0 by the author.