이번 프로젝트는 단순한 게임 구현을 넘어, 실시간성과 보안에 초점을 맞춘 **'UNO Multiplayer Game'**입니다. 2~4인이 실시간으로 방을 만들어 카드 게임을 즐길 수 있으며, Spring Boot와 React라는 현대적인 기술 스택을 활용해 안정적인 게임 환경을 구축했습니다.
2. 사전 요구사항 (Prerequisites)
이 프로젝트를 실행하기 위해 필요한 개발 환경입니다.
Backend: Java 21 이상
Frontend: Node.js 18 이상
3. 주요 핵심 기능
🌐 WebSocket(STOMP) 기반 실시간 동기화
Pub/Sub 모델을 활용해 게임 내 모든 액션(카드 내기, 드로우, 턴 변경)을 실시간으로 모든 플레이어에게 전송합니다.
🛡️ 서버 사이드 룰 엔진 (UnoRuleEngine)
클라이언트가 아닌 서버에서 모든 게임 로직을 관리합니다. 특히 손패 정보를 서버에서만 보관하여 클라이언트 변조를 통한 치트를 원천 차단했습니다.
🃏 UNO 오리지널 룰 완벽 구현
숫자 카드는 물론 SKIP, REVERSE, +2, WILD, +4 등 특수 카드를 모두 구현했습니다.
2인 플레이 시 REVERSE가 SKIP으로 작동하는 세부 룰까지 적용되어 있습니다.
🏠 로비 및 방 시스템
6자리 초대 코드를 통해 친구와 같은 방에 입장하여 게임을 시작할 수 있습니다.
4. 기술적 의사결정 및 아키텍처
Java 21 Record 활용: 카드(Card) 정보를 불변 값 객체인 record로 선언하여 데이터 무결성을 보장했습니다.
In-Memory Store: 게임 데이터의 휘발성을 고려하여 ConcurrentHashMap 기반의 메모리 저장소를 구현, Redis 없이도 멀티스레드 환경에서 안전하게 데이터를 관리합니다.
Zustand 상태 관리: 프론트엔드에서는 가벼운 Zustand를 사용해 WebSocket으로 넘어오는 실시간 게임 상태를 효율적으로 처리합니다.
5. 프로젝트 구조
Backend: UnoRuleEngine(도메인 로직), GameService(흐름 제어), STOMP Controller 등으로 분리되어 유지보수가 용이합니다.
Frontend: Lobby, WaitingRoom, GameBoard로 이어지는 직관적인 페이지 구조와 useWebSocket 커스텀 훅을 통한 통신 관리가 특징입니다.
6. 마치며
단순한 토이 프로젝트를 넘어 WebSocket 통신과 서버 측 유효성 검사 로직을 깊게 고민해 볼 수 있었던 프로젝트였습니다. 실시간 멀티플레이어 게임 개발의 메커니즘을 이해하고 싶은 분들에게 좋은 참고 사례가 될 것입니다.
단순히 아이디만 입력하는 방식은 타인의 캐릭터를 사칭할 위험이 있습니다. 이를 해결하기 위해 공식 홈페이지의 특정 게시글에 댓글을 작성하게 하고, 서버에서 Jsoup으로 이를 크롤링하여 소유권을 검증하는 로직을 구현했습니다. 외부 웹 데이터의 정적 분석을 통해 보안성을 높인 시도였습니다.
② Claude API 기반 공지사항 자동 요약 시스템
게임 공지사항은 길고 가독성이 떨어지는 경우가 많습니다. 3시간마다 크롤링한 데이터를 **Claude API (Haiku 모델)**에 전달하여 핵심 내용만 3줄로 자동 요약해 제공합니다. 최신 LLM 기술을 서비스 로직에 자연스럽게 녹여낸 기능입니다.
③ 스케줄링을 활용한 데이터 무결성 관리
게임의 정기 점검 시간(매주 수요일 오전 5시)에 맞춰 퀘스트 진행률이 초기화되어야 합니다. Spring의 @Scheduled 기능을 활용하여 서버 부하를 최소화하면서도 정확한 시점에 데이터 초기화가 이루어지도록 설계했습니다.
4. 인프라 및 운영 효율화
CI/CD 파이프라인: GitHub Actions와 AWS CodeDeploy를 연동하여 코드 수정부터 배포까지의 과정을 자동화했습니다. 이를 통해 개발 생산성을 크게 향상시켰습니다.
보안 관리: DB 접속 정보나 API 키와 같은 민감한 설정값은 application.yml에 직접 노출하지 않고, AWS SSM Parameter Store를 통해 주입받아 보안 사고를 방지했습니다.
비용 및 성능 최적화: EC2 t4g(Graviton2) 인스턴스를 활용하여 성능 대비 비용 효율을 극대화했습니다.
5. 기술적 도전과 해결
프로젝트 진행 중 CORS 문제와 OAuth2 인증 흐름에서 발생한 토큰 전달 이슈가 가장 큰 고민이었습니다.
해결: React와 Spring Boot 사이의 보안 필터를 조정하고, CloudFront를 통해 도메인을 통합하거나 적절한 Proxy 설정을 통해 문제를 해결했습니다.
또한, 백엔드 개발자로서 도메인 중심의 설계를 지향하며 각 서비스 계층이 명확한 책임(Responsibility)을 갖도록 리팩토링하는 과정에 공을 들였습니다.
6. 마무리하며
이번 프로젝트는 단순히 기능을 구현하는 것을 넘어, 사용자가 정말 필요로 하는 기능이 무엇인지 고민하고 이를 기술적으로 구현해 보는 귀중한 경험이었습니다. 특히 외부 API와의 연동 및 AWS를 활용한 안정적인 배포 경험은 실제 운영 가능한 서비스를 구축하는 데 큰 자산이 되었습니다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Member findByLoginId(String username);
}
Spring Security에서 사용자를 담아둘 클래스입니다
securty/auth/MemberPrincipalDetails
// Spring Security 에 있는 UserDetails 를 구현한 클래스,
// 이 클래스를 통해 Spring Security 에서 사용자의 정보를 담아둠
public class MemberPrincipalDetails implements UserDetails {
// member 패키지에 선언해놓은 member 엔티티를 사용하기 위해 선언
private final Member member;
public MemberPrincipalDetails(Member member) {
this.member = member;
}
// 생성자
public Member getMember() {
return member;
}
// member 계정의 권한을 담아두기위해
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(member.getRole()));
return authorities;
}
// member 계정의 비밀번호를 담아두기 위해
@Override
public String getPassword() {
return member.getPassword();
}
// member 계정의 아이디를 담아두기 위해
@Override
public String getUsername() {
return member.getLoginId();
}
// 계정이 만료되지 않았는지를 담아두기 위해 (true: 만료안됨)
@Override
public boolean isAccountNonExpired() {
return true;
}
// 계정이 잠겨있지 않았는지를 담아두기 위해 (true: 잠기지 않음)
@Override
public boolean isAccountNonLocked() {
return true;
}
// 계정의 비밀번호가 만료되지 않았는지를 담아두기 위해 (true: 만료안됨)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 계정이 활성화되어있는지를 담아두기 위해 (true: 활성화됨)
@Override
public boolean isEnabled() {
return true;
}
}
스프링 시큐리티가 로그인 요청을 가로챌때 동작하는 서비스 입니다
security/auth/MemberPrincipalDetailService
// UserDetailsService 를 구현한 클래스
// 스프링 시큐리티가 로그인 요청을 가로챌 때, username, password 변수 2개를 가로채는데
// password 부분 처리는 알아서 함
@Service
public class MemberPrincipalDetailService implements UserDetailsService {
// JPARepository 를 상속받은 인터페이스를 자동으로 DI (의존성 주입)
@Autowired
private MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 넘겨받은 id 로 DB 에서 회원 정보를 찾음
Member member = memberRepository.findByLoginId(username);
System.out.println("username : " + username);
System.out.println("member : " + member);
// 없을경우 에러 발생
if(member == null)
throw new UsernameNotFoundException(username + "을 찾을 수 없습니다.");
if(!"Y".equals(member.getIsUsed()))
throw new UsernameNotFoundException("사용할 수 없는 계정입니다.");
if(!"N".equals(member.getIsDel()))
throw new UsernameNotFoundException("삭제된 계정입니다.");
// MemberPrincipalDetails 에 Member 객체를 넘겨줌
return new MemberPrincipalDetails(member);
}
}
로그인 정보를 가로챈 후 인증버로를 반환 하기위한 클래스입니다
security/provider/MemberAuthenticationProvider
@Component
// AuthenticationProvider 를 구현한 클래스
public class MemberAuthenticatorProvider implements AuthenticationProvider {
@Autowired
private MemberPrincipalDetailService memberPrincipalDetailService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName(); // 사용자가 입력한 id
String password = (String) authentication.getCredentials(); // 사용자가 입력한 password
// 생성해둔 MemberPrincipalDetailService 에서 loadUserByUsername 메소드를 호출하여 사용자 정보를 가져온다.
MemberPrincipalDetails memberPrincipalDetails = (MemberPrincipalDetails) memberPrincipalDetailService.loadUserByUsername(username);
// ====================================== 비밀번호 비교 ======================================
// 사용자가 입력한 password 와 DB 에 저장된 password 를 비교한다.
// db 에 저장된 password
String dbPassword = memberPrincipalDetails.getPassword();
// 암호화 방식 (BCryptPasswordEncoder) 를 사용하여 비밀번호를 비교한다.
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
if(!passwordEncoder.matches(password, dbPassword)) {
System.out.println("[사용자] 비밀번호가 일치하지 않습니다.");
throw new BadCredentialsException("[사용자] 아이디 또는 비밀번호가 일치하지 않습니다.");
}
// ========================================================================================
// 사용자가 입력한 id 와 password 가 일치하면 인증이 성공한 것이다.
// 인증이 성공하면 MemberPrincipalDetails 객체를 반환한다.
Member member = memberPrincipalDetails.getMember();
if (member == null || "N".equals(member.getIsUsed())) {
System.out.println("[사용자] 사용할 수 없는 계정입니다.");
throw new BadCredentialsException("[사용자] 사용할 수 없는 계정입니다.");
}
// 인증이 성공하면 UsernamePasswordAuthenticationToken 객체를 반환한다.
// 해당 객체는 Authentication 객체로 추후 인증이 끝나고 SecurityContextHolder.getContext() 에 저장된다.
return new UsernamePasswordAuthenticationToken(memberPrincipalDetails, null, memberPrincipalDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
로그인 실패시 에러 메세지를 띄우기 위한 커스텀 핸들러 입니다
security/config/MemberAuthFailureHandler
public class MemberAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws ServletException, IOException {
// 실패 메세지를 담기 위한 세션 선언
HttpSession session = request.getSession();
// 세션에 실패 메세지 담기
session.setAttribute("loginErrorMessage", exception.getMessage());
// 로그인 실패시 이동할 페이지
setDefaultFailureUrl("/member/login/loginForm?error=true&t=h");
// 로그인 실패시 이동할 페이지로 이동
super.onAuthenticationFailure(request, response, exception);
}
}
로그인 성공시 작동하는 커스텀 핸들러 입니다 ( 해당 프로젝트에선 사용하지 않습니다 )
security/config/MemberAuthSuccessHandler
public class MemberAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 로그인 성공시 이동할 페이지
setDefaultTargetUrl("/member/main");
// 로그인 성공시 이동할 페이지로 이동
super.onAuthenticationSuccess(request, response, authentication);
}
}
위에 작성한 파일들이 사용되는 파일입니다
security/config/MemberSecurityConfig
로그인 기억하기 사용을 위한 rememberMe 도 같이 사용하였습니
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class MemberSecurityConfig {
/*
* 중요
* Spring Security 5.7.0 버전부터
* WebSecurityConfigurerAdapter가 deprecated 되기 때문에
* 이와 같은 방법으로 구현
* */
// 생성해둔 MemberAuthenticatorProvider를 주입받는다.
// 해당 클래스로 MemberPrincipalDetailsService 내부 로직을 수행하며
// 인증 처리도 같이 진행된다
@Autowired
MemberAuthenticatorProvider memberAuthenticatorProvider;
// 로그인 기억하기 사용을 위해 MemberAuthenticatorProvider 내부
// MemberPrincipalDetailsService 선언
@Autowired
MemberPrincipalDetailService memberPrincipalDetailService;
// in memory 방식으로 인증 처리를 진행 하기 위해 기존엔 Override 하여 구현했지만
// Spring Security 5.7.0 버전부터는 AuthenticationManagerBuilder를 직접 생성하여
// AuthenticationManager를 생성해야 한다.
@Autowired
public void configure (AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(memberAuthenticatorProvider);
}
// 5.7.0 부터 Override 하지 않고
// SecurityFilterChain을 직접 생성하여 구현
// 그 외 authorizeRequests 가 deprecated 되었기 때문에
// authorizeHttpRequests 로 변경
@Bean
public SecurityFilterChain memberSecurityFilterChain (HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeHttpRequests(authorize -> {
try {
authorize
.requestMatchers("/css/**", "/images/**", "/js/**", "/member/login/**", "/member/attachment/**", "/member/files/**")
.permitAll() // 해당 경로는 인증 없이 접근 가능
.requestMatchers("/member/**") // 해당 경로는 인증이 필요
.hasRole("MEMBER") // ROLE 이 MEMBER 가 포함된 경우에만 인증 가능
.and()
.formLogin()
.loginPage("/member/login/loginForm") // 로그인 페이지 설정
.loginProcessingUrl("/member/login/login") // 로그인 처리 URL 설정
.defaultSuccessUrl("/member/main") // 로그인 성공 후 이동할 페이지
// .successHandler(new MemberAuthSuccessHandler()) // 로그인 성공 후 처리할 핸들러
.failureHandler(new MemberAuthFailureHandler()) // 로그인 실패 후 처리할 핸들러
.permitAll()
.and()
.logout()
.logoutUrl("/member/login/logout") // 로그아웃 처리 URL 설정
.logoutSuccessUrl("/member/login/loginForm?logout=1") // 로그아웃 성공 후 이동할 페이지
.deleteCookies("JSESSIONID"); // 로그아웃 후 쿠키 삭제
} catch (Exception e) {
throw new RuntimeException(e);
}
});
http.rememberMe()
.key("namhyeok") // 인증 토큰 생성시 사용할 키
.tokenValiditySeconds(60 * 60 * 24 * 7) // 인증 토큰 유효 시간 (초)
.userDetailsService(memberPrincipalDetailService) // 인증 토큰 생성시 사용할 UserDetailsService
.rememberMeParameter("remember-me"); // 로그인 페이지에서 사용할 파라미터 이름
return http.build();
}
}
실행 결과입니다.
테스트 아이디 : member1 , 비밀번호 : 1111
자동로그인 체크후 로그인 시도
로그인 성공화면
자동 로그인 체크 후 remember-me 라는 쿠키가 생성, JSESSIONID를 지워도 remember-me 가 있어 다시 생성됨
로그인 성공 후 담겨있는 데이터를 model에 담은뒤 thymeleaf로 출력
다음 Spring Security 글은 DB에 저장되어있는 권한을 동적으로 부여하는 법에 대해 작성하겠습니다.