6인 팀 프로젝트 / 게임 플레이어 매칭 서비스
원신이라는 게임의 플레이어들이 온라인에서 멀티플레이 파티를 쉽게 구성할 수 있도록 지원하는 매칭 웹 애플리케이션입니다.
친구와 게임을 하던 중 원하는 유저와 멀티플레이를 하기가 불편하다는 점을 개선하고자 기획하게 되었습니다.
백엔드 개발자 2명, 프론트엔드 개발자 2명, 디자이너 2명으로 구성된 팀 프로젝트에서 저는 백엔드 개발자로서 아래와 같은 역할을 담당했습니다.
- API 및 데이터베이스 설계, ERD 작성
- Git 파이프라인을 활용한 CI/CD 배포 자동화
- 도메인 주도 설계 기반 시스템 개발
- 로그인 및 회원가입 등 사용자 인증 및 보안 기능 구현
- 이메일 인증 코드 발송 및 검증 기능 개발
- JWT와 Redis를 활용한 사용자 인증 및 권한 관리
- 원신 유저 데이터 API 호출 및 데이터 처리 기능 개발
- 사용자 제재 및 계정 정지와 같은 고객 관리 API 개발
- 유저 경고 및 제재 기록 확인 API 개발
- 비회원과 회원 구분에 따른 게시물 CRUD 기능 개발
- 데이터베이스 쿼리 최적화를 위한 리팩토링 수행
Frameworks: Spring Boot(Web, Security, Data JPA, Validation, Mail, OAuth2)
Authentication: JWT
Server: AWS EC2, S3
Database: H2(in-memory), MySQL, Redis
API Documentation: Swagger
Utilities: Lombok, Apache Commons
Testing: JUnit, Security Test, MockServer, Database Rider
프로젝트 초기 단계에서 복잡한 비즈니스 문제를 해결하고 소프트웨어 개발의 효율성을 높이기 위해
도메인 주도 설계(DDD)를 도입하기로 결정했습니다.
이를 위해 도메인 모델, 유비쿼터스 언어, 바운디드 컨텍스트와 같은 핵심 개념을 적극적으로 적용하였습니다.
각 도메인(post, report, user)마다 독립적인 Entity와 Repository를 정의하고,
비즈니스 로직을 처리하는 DTO, Response, Mapper, Service는 application 폴더에 배치하여 유비쿼터스 언어를 반영했습니다.
또한, 비즈니스 로직을 처리하는 계층은 application에, 핵심 도메인 모델은 domain에,
인프라 관련 로직은 infrastructure 디렉토리에 분리하여 배치함으로써 각 모듈이 고유한 모델과 규칙을 가지도록 했으며,
서비스 레이어에서는 도메인 서비스와 애플리케이션 서비스를 명확히 구분하여 정의하였습니다.
이를 통해 바운디드 컨텍스트를 명확히 구분하고 모듈 간의 독립성을 유지할 수 있었습니다.
프로젝트 진행 중 코드 리뷰에서 카카오 개발자 분으로부터 메소드명과 변수명이 일관성이 없고
난해하며, 코드의 가독성이 떨어진다는 지적을 받았습니다.
당시 저는 실무에서의 협업 경험이 부족하여 클린 코드 원칙에 대한 이해가 미흡한 상태였습니다.
이를 개선하기 위해, 코드의 가독성과 네이밍 규칙을 최우선으로 고려하여 리팩토링을 진행했습니다.
메서드명은 동사나 동사구로, 클래스명은 명사나 명사구로 작성하여 역할과 의도를 명확히 드러냈으며, 코드의 간결함을 유지했습니다.
또한, 중복된 로직을 제거하고 불필요한 주석을 최소화하여 코드 자체로 이해할 수 있도록 개선하였습니다.
이 과정을 통해 협업과 유지 보수에 적합한 코드 품질을 확보할 수 있었습니다.
Refresh Token 구현 과정에서는 해당 토큰이 특정 사용자에게 부여된 것인지, 유효한 토큰인지 검증할 방법이 명확하지 않았습니다.
초기에는 만료된 Access Token을 검증하여 새 Access Token을 재발급 하는 방식을 사용했으나,
이 방식은 만료된 토큰이 탈취될 경우 공격에 악용될 가능성이 있어 보안상 문제가 있었으며,
Refresh Token의 설계 의도를 충분히 반영하지 못한 방법이었습니다.
이를 개선하기 위해 Refresh Token 검증 로직을 설계하는 과정에서 In-Memory Store, DB 저장, JWT 기반 검증을 비교 분석했습니다.
JWT 기반 검증은 토큰 무효화가 어려워 보안성이 낮다는 이유로 제외했으며,
DB 저장 방식보다는 In-Memory Store가 성능과 유연성 측면에서 유리하다고 판단했습니다.
프로젝트에 이미 이메일 인증 기능에 Redis를 활용 중이었기 때문에, Redis를 재사용하여
Refresh Token을 저장하고, 요청이 들어올 때마다 해당 토큰을 검증하도록 구현했습니다.
결과적으로, 개선된 Refresh Token 검증 방식은 보안성과 효율성을 균형 있게 유지하며, 시스템 전반의 신뢰성을 높이는 데 기여했습니다.
소스코드 보기
@Operation(
summary = "액세스 토큰 갱신",
description = "리프레시 토큰을 이용해 만료된 액세스 토큰을 갱신함."
)
@PostMapping("/refresh")
public ResponseEntity refreshAccessToken(@RequestHeader("AccessToken") String accessTokenHeader,
@RequestHeader("RefreshToken") String refreshTokenHeader) {
String accessToken = accessTokenHeader.replace("Bearer ", "");
String refreshToken = refreshTokenHeader.replace("Bearer ", "");
TokenResponse tokenResponse = authService.refreshAccessToken(accessToken, refreshToken);
return ResponseEntity.ok(tokenResponse);
}
public TokenResponse refreshAccessToken(String accessToken, String refreshToken) throws IOException {
if (!tokenProvider.validateToken(accessToken)) {
throw new BusinessLogicException(ExceptionCode.INVALID_ACCESS_TOKEN);
}
String email = tokenProvider.getUserInfoFromToken(accessToken).getSubject();
String refreshTokenFromRedis = redisRepository.getData(email);
if (refreshTokenFromRedis == null || !refreshTokenFromRedis.equals(refreshToken)) {
throw new BusinessLogicException(ExceptionCode.INVALID_REFRESH_TOKEN);
}
Authentication authentication = tokenProvider.getAuthentication(accessToken);
String newAccessToken = tokenProvider.generateAccessToken(authentication);
return new TokenResponse(newAccessToken, refreshToken);
}
회원가입 시, 원신 게임의 유저 정보 API를 호출하여 자동으로 유저 프로필에 등록되도록 구현했습니다.
초기 구현에서는 WebClient를 사용하여 외부 API를 호출했지만,
호출 시마다 block()을 사용하면서 WebClient의 비동기 처리 이점이 사라졌습니다.
또한, 매번 WebClient 인스턴스를 생성하여 불필요하게 메모리를 소모하고, 재사용 가능한 설정을 반복적으로 정의해야 하는 비효율적인 구조였습니다.
이러한 문제를 해결하기 위해 Spring Boot 3.2부터 지원하는 RestClient를 도입하고,
HttpInterface와 함께 활용하여 API 호출 성능과 코드의 효율성을 개선했습니다.
이 변경을 통해 API 호출의 성능을 최적화했으며,
단일 API 호출 시간은 4376ms에서 877ms로 약 80% 향상되었고,
20회 동시 요청 처리 시간은 15444ms에서 13222ms로 약 14% 개선되었습니다.
기존 코드 보기
public class ApiService {
private final WebClient.Builder webClientBuilder;
private final ObjectMapper objectMapper;
@Autowired
public ApiService(WebClient.Builder webClientBuilder,
ObjectMapper objectMapper) {
this.webClientBuilder = webClientBuilder;
this.objectMapper = objectMapper;
}
public Mono callExternalApi(long uid) {
return webClientBuilder.build()
.get()
.uri("https://enka.network/api/uid/" + uid + "?info")
.retrieve()
.bodyToMono(UserInfoResponse.class)
.doOnNext(response -> log.info("API Response: {}", response))
.doOnError(error -> log.error("API Error: {}", error.getMessage()));
}
}
public class MemberService {
public MemberResponse createMember(SignUpRequest signUpRequest) {
verifyExistEmail(signUpRequest.getEmail());
UserInfoResponse apiResponse = apiService.callExternalApi(signUpRequest.getUid()).block();
if (apiResponse == null || apiResponse.getPlayerInfo() == null) {
throw new RuntimeException("Failed to fetch user info from external API");
}
... 코드 생략
}
public TokenResponse authenticate(LoginRequest loginRequest) {
... 코드 생략
MemberEntity findMember = findMember(loginRequest.getEmail());
UserInfoResponse apiResponse = apiService.callExternalApi(findMember.getUid()).block();
if (apiResponse == null || apiResponse.getPlayerInfo() == null) {
throw new RuntimeException("Failed to fetch user info from external API");
}
}
}
최신 코드 보기
@Slf4j
@Configuration
public class EnkaClientConfig {
@Value("${enka.host}")
private String host;
@Bean
public HttpServiceProxyFactory enkaClientProxyFactory() {
RestClient restClient = RestClient.builder()
.baseUrl(host)
.defaultStatusHandler(new EnkaClientErrorHandler())
.build();
RestClientAdapter adapter = RestClientAdapter.create(restClient);
return HttpServiceProxyFactory.builderFor(adapter).build();
}
@Bean
public EnkaClient enkaClient(@Autowired HttpServiceProxyFactory enkaClientProxyFactory) {
try {
return enkaClientProxyFactory.createClient(EnkaClient.class);
}
catch (Exception e) {
log.error("Failed to create EnkaClient: {}", e.getMessage(), e);
throw new RuntimeException("Error creating EnkaClient", e);
}
}
}
public class EnkaService {
private final EnkaClient enkaClient;
private final ObjectMapper objectMapper;
public UserInfoResponse callExternalApi(long uid) {
try {
ResponseEntity responseEntity =
enkaClient.getUserInfo(uid, 1L);
UserInfoResponse response = responseEntity.getBody();
if (response != null) {
log.info("API Response: {}", response);
return response;
}
else {
log.warn("API Response is null");
throw new RuntimeException("API Response is null");
}
}
catch (Exception e) {
log.error("API Error: {}", e.getMessage(), e);
throw new RuntimeException("External API call failed", e);
}
}
}
public class MemberService {
public MemberResponse createMember(SignUpRequest signUpRequest) {
verifyExistEmail(signUpRequest.getEmail());
UserInfoResponse apiResponse = apiService.callExternalApi(signUpRequest.getUid());
if (apiResponse == null || apiResponse.getPlayerInfo() == null) {
throw new RuntimeException("Failed to fetch user info from external API");
}
... 코드 생략
}
public TokenResponse authenticate(LoginRequest loginRequest) {
... 코드 생략
MemberEntity findMember = findMember(loginRequest.getEmail());
UserInfoResponse apiResponse = apiService.callExternalApi(findMember.getUid());
if (apiResponse == null || apiResponse.getPlayerInfo() == null) {
throw new RuntimeException("Failed to fetch user info from external API");
}
}
}
@Slf4j
public class EnkaClientErrorHandler extends DefaultResponseErrorHandler {
@Override
public void handleError(ClientHttpResponse response) throws IOException {
log.error("Error occurred while calling external API: HTTP Status Code {}, Message: {}",
response.getStatusCode(), response.getStatusText());
super.handleError(response);
}
}