이번 글은 내가 지난 두 달여간 교회 수련회에서 사용할 웹 어플리케이션을 만들었던 개발기이며 그 과정에서 배웠던 점들과 느꼈던 점들을 적어 보려고 한다.

지난 25년 12월 나는 한 교회 친구의 콜링을 받고, 교회 수련회에서 사용할 웹 어플리케이션을 개발하게 되었다.
12월 초에 콜링을 받았고 수련회는 1월 23일이어서 기획과 개발을 다 7주 정도의 시간 안에 해야 하는 상황이었다.
다행히 내가 혼자서 해야 하는건 아니었고 기획자 2명과 개발자 2명과 같이 총 5명이 팀으로 개발을 할 수 있는 팀이 있었다.
프로젝트 구조 : 웹 FE + BE 모노레포
프로젝트 구조는 프론트엔드와 백엔드를 하나의 레포로 관리했다. 개발을 할 때는 프론트엔드 서버와 백엔드 서버를 각각 띄워서 작업했다. 사용했던 기술스택은 다음과 같았다.
- FE : Next.js, React.js, TypeScript
- BE : Java, Spring Boot, PostgreSQL
사실 나는 자바 백엔드 개발을 할 줄 모른다. ㅎㅎ 이미 내가 팀에 합류했을 때 백엔드 개발을 하는 친구가 기술 스택을 결정하고 프로젝트 세팅도 완료한 상태였다. 어짜피 클로드 코드의 도움을 받을 거라서 잘 모르는 기술스택이지만 해보기로 했다.

배포 : 돌고 돌아 결국 GCP (Google Cloud Platform)
배포를 어떤 방식으로 해야 할지 고민이 많았다. 우리가 이를 위해 받을 수 있는 예산이 거의 없었기 때문이다.
처음에 먼저 개발을 하던 친구가 render의 무료 버전으로 배포를 이미 해 놓았다고 해서 확인해 보았다. 그런데.. render는 무료 버전에서 여러 명이 하나의 프로젝트를 볼 수가 없었다. 우리는 3명이 언제든지 들어가서 모니터링 하고 또 배포도 할 수 있으면서 비용이 무료이거나 아주 저렴한 그러한 배포 플랫폼을 찾아야 했다.

여러 플랫폼을 돌다가 결국 GCP로 결정했다. $300의 크레딧을 무료로 주고 90일 동안 쓸 수 있다는 점이 가장 매력적이었다. 그리고 아무래도 전세계 TOP 3 클라우드 플랫폼이다보니 우리에게 필요한 기능들은 왠만해서 다 제공하고 있었고 Cloud Run, Cloud Build, Cloud SQL 이렇게 3개의 도구는 러닝 커브가 거의 없이 쉽게 쓸 수 있어서 좋았다.
다만 Cloud SQL의 경우 비용이 가장 낮은 버전을 쓰더라도 Enterprise라서 최소 메모리 및 용량이 필요 이상으로 컸다. 우리는 300명 정도의 사용자가 약 3일 정도 기간동안 쓰는 서비스를 만들 예정이었고 사진이나 비디오 등의 데이터가 없었기에 이렇게까지 클 필요는 없었다. 만약 장기적으로 써야 하는 프로젝트였다면 Cloud SQL은 사용하지 못했을 것 같다.


기획 및 디자인
우리 프로젝트에서는 기획을 담당하는 친구가 1명, 그리고 그 친구를 도와주는 팀장 친구가 1명 이렇게 둘이서 주로 기획을 맡았다. 아무래도 기획이 이 둘이서 결정할 수 없는 부분들도 많았다 보니, 우리가 생각한 것보다 기획이 결정되는데 시간이 오래 걸렸다.
노션 등을 이용해서 조금 더 프로젝트 관리를 체계적으로 해 보려고 했는데, 생각만큼 잘 되지는 않았다. 아무래도 주된 소통은 카톡에서 하고 또 단톡방, 갠톡 등 채널이 여러개이다보니 노션까지 사용해서 하는 건 더 복잡성이 커지는 선택이었을 지도 모른다. 회의록 정리 및 역할 분담 정도까지는 노션으로 했는데, 이 역시 누구는 노션에서 누구는 카톡에서 누구는 피그마에서 소통을 하고 있었고 결국 카톡으로 소통이 귀결 되었던 것 같다.
기획 -> 디자인 -> 코드 로 연결되는 과정을 조금 더 AI의 도움을 받아서 갔으면 좋았겠다 하는 생각도 들었다. 찾아보니 Figma Make나 Manus 같은 도구들이 있는데 다음에는 이런 것들을 좀 더 활용해 보면 좋겠다.

기능
우리가 만든 주요 기능들은 다음과 같다
- 로그인 / 홈 화면 : 계정은 우리가 접수를 받으면 만들어 드리고, 만들어드린 계정으로 로그인 하면 홈 화면으로 이동. 내 조와 숙소 정보도 여기서 확인할 수 있음
- 중보기도 : 중보기도를 작성하고 싶은 경우 작성할 수 있고 다른 이들의 기도제목도 볼 수 있는 페이지
- 아이스브레이킹 : 각자 본인의 키워드를 3개씩 적어서 내고 그 키워드를 조별로 하나씩 랜덤으로 뽑으면서 누군지 맞추는 게임
- 체육대회 점수 계산 : 조별로 체육대회를 하면 점수를 얻는 팀이 있는데, 점수를 합산해 주는 기능
- 오프토픽(라이어 게임) : 여러 조 안에서 몇몇 조가 라이어로 심어져서 누가 라이어인지 맞추는 게임. 키워드를 다르게 주고 그 키워드에 대한 조별 생각을 서로 보면서 누가 라이어인지 조별로 나눔을 하면서 유추하기
개인적으로는 수련회 접수 및 회비 납부하는 순간부터 계정 생성까지 자동화 하면 좋을 것 같다는 생각이 들었다. 물론 이 부분은 다른 팀과 같이 조율해 나가야 하는 영역이라 우리끼리 결정할 수는 없었지만 말이다. 원래는 카풀 기능도 만들어 볼려고 했는데 리소스 부족&타팀과 역할 분담을 하면서 빠지게 되었다.
로그인과 홈화면 기능, 중보기도는 그냥 기본적인 CRUD와 JWT 기반 인증으로 작업하면 되어서 어렵지는 않았다. 아이스브레이킹의 경우는 다른 팀원들이 맡아서 개발을 했었고, 체육대회 점수 계산도 그냥 User, Group, Game 등의 엔티티 설계만 잘 해주면 계산기 정도의 기능이었다. 문제는 오프토픽이었다. 내가 담당한 기능이었어서 그런지 관련해서 트러블 슈팅을 해야 하는 영역이 많았다. 대표적인 두 가지 정도만 적어보려고 한다.
트러블 슈팅 : 1. 동시성 문제
오프토픽은 점수가 두 번씩 카운트가 되는 문제가 있었다. 그런데 문제는 이게 항상 발생하는게 아니고 열 번에 한 두 번 정도 10~20% 정도의 확률로 발생한다는 점이었다. 수련회 가기 일주일 전 마지막 테스트를 할 때 이 문제가 확인되었다.
기존에 게임이 완료가 될 때 completeGame() 이 메서드가 실행이 되고 여기에서 점수를 계산하는 로직을 수행하고 있었다.
@Transactional
public void completeGame(OffTopicGame game) {
game.setStatus(OffTopicGame.GameStatus.COMPLETED);
game.setEndedAt(LocalDateTime.now());
gameRepository.save(game);
// 점수 계산... (중복 호출 시 점수 중복 부여!)
}
그런데 게임을 여러 명이 동시에 하다 보니, 그리고 한 조에서 여러 명이 게임에 참여하다 보니 이 completeGame() 메서드가 하나의 점수를 계산하는 그룹에서 중복해서 호출되는 문제가 발생하고 있는 것이었다.
그래서 해당 메서드에서 바로 점수를 추가해 주는 것이 아닌, 먼저 최신 DB 상태를 조회하고 이미 완료가 된 게임인지 체크하는 로직을 추가해서 중복해서 호출이 되더라도 점수 계산은 중복해서 하지 않도록 개선했다.
@Transactional
public synchronized void completeGame(OffTopicGame game) {
// 1️⃣ DB에서 최신 상태 다시 조회 (동시 요청 시 최신 상태 확인)
OffTopicGame latestGame = gameRepository.findById(game.getId()).orElse(game);
// 2️⃣ 이미 완료된 게임이면 중복 처리 방지
if (latestGame.getStatus() == OffTopicGame.GameStatus.COMPLETED) {
log.warn("게임이 이미 완료 상태입니다. 중복 호출 무시: code={}", game.getGameCode());
return; // 조기 리턴으로 중복 점수 계산 방지
}
latestGame.setStatus(OffTopicGame.GameStatus.COMPLETED);
latestGame.setEndedAt(LocalDateTime.now());
gameRepository.save(latestGame);
// 점수 계산...
}
그리고 점수를 처리하는 로직이 조별로 정답을 제출한 submitGuess() 메서드와 시간 타이머가 만료된 onTimerExpired() 두 군데에서 각각 실행이 되고 있었다. 이 부분도 중복되는 로직을 공통 메서드로 빼내고 그 안에서 이미 처리된 경우 중복 실행을 방지하는 로직을 추가해 주었다.
트러블 슈팅 : 2. 트래픽 증가시 과부하 문제
우리가 수련회를 가기 전에는 15명 정도 테스트를 했는데, 실제 수련회 장소에서는 150명이 한 번에 게임에 참여했다. 그런데 타이머가 제대로 동작을 안 하고, 중간에 너무 느리거나 오류가 발생하는 상황이 발생했다. 처음에는 인스턴스의 스펙이 작아서 생기는 문제라고 생각했는데 모니터링 해 본 결과 CPU나 메모리는 아주아주 넉넉함을 알 수 있었다.









원인을 분석해 보니 웹소켓에서 사용한 SimpleBroker 가 메모리 측면에서 문제를 일으키고 있었다.
// WebSocketConfig.java:16-21
config.enableSimpleBroker("/topic", "/queue")
.setTaskScheduler(taskScheduler)
.setHeartbeatValue(new long[]{10000, 10000});
// 메모리 제한, 연결 제한 없음!
메모리 버퍼 제한이나 연결 제한이 없어서 메시지가 누적되면 약 380MB 정도까지 쌓일 수 있는 것이었다.
계산은
- 메시지 1개: ~5KB
- 브로드캐스트 빈도: 10회/초
- 클라이언트가 처리 못하고 50초간 누적 시: 5KB × 10 × 50 = 2.5MB
- 150명 인원 : 150 * 2.5MB = 380MB
다음과 같이 했다.
그리고 이 뿐만 아니라 브로드캐스트를 할 때 마다 쿼리가 수십 개씩 이뤄지는데, 브로드캐스트 주기가 10회/초 였기 때문에 초당 수백 회의 쿼리가 발생했다.
타이머 스케줄러 역시 과부하가 있었다.
// OffTopicTimerScheduler.java:26-59
@Scheduled(fixedRate = 1000) // 매초 실행!
public void checkExpiredTimers() {
List<OffTopicGame> activeGames = gameRepository.findByStatusIn(...);
for (OffTopicGame game : activeGames) {
offTopicService.nextPhase(game.getGameCode());
offTopicController.broadcastGameState(gameCode); // 25 쿼리
offTopicController.broadcastAdminGameState(gameCode); // 25 쿼리
}
}
// 매초 50+ 추가 쿼리 발생
여러 부분에서 병목이 있었다. 정리하면
- 150명이 동시 접속하면
- 웹소켓 SimpleBroker에 메모리가 누적되고
- 브로드캐스트가 10회/초 발생하며 매 초당 수백 회의 쿼리를 발생하고
- DB에서 결국 캐싱도 없이 이 트래픽을 받다 보니 과부하가 온 것이었다.
이를 해결하기 위해서는
- Redis/RabbitMQ 외부 브로커를 도입하여 메모리를 분산시키고
- 배치 쿼리와 Caffeine 캐싱을 통해 쿼리 부하를 줄여서 DB에서 받는 트래픽을 줄이며
- 전체 브로드캐스트 하는 방식을 변경된 데이터만 전송하는 식으로 바꾸어야 한다.
추후에 비슷한 기능을 개발할 일이 생긴다면 참고해야겠다.
후기
약 두 달 동안 친구들과 늦게까지 개발하고 또 안 풀리는 문제를 붙잡고 씨름하느라 힘들긴 했지만 그래도 재미있었다. 백엔드 개발에 대한 경험을 쌓을 수 있어서 그 점이 좋았고, 실제 약 300여 명의 사용자가 발생하는 서비스를 만들었다는 점이 뿌듯하기도 했다.
다 지나고 나서 보니 기획부터 개발하는 과정에서 아쉬움이 참 많이 남는다. AI를 너무 챗봇 형태로만 쓴 건 아닌지, 에이전트 모드를 더 잘 활용하지 못한 점이 아쉬웠다. 기획 -> 디자인 -> 개발 과정에서 AI를 조금 더 잘 쓸 수 있었다면 팀의 친구들이 덜 고생했었을 것 같다는 생각도 들었다.
이 모든 일을 허락하신 하나님께 감사하다.
'Dev. Life' 카테고리의 다른 글
| [TIR, Today I Read] W41 (10/5 ~ 10/8) (1) | 2021.10.08 |
|---|---|
| [TIR, Today I Read] W40 (9/27 ~ 10/1) (0) | 2021.09.30 |
| [TIR, Today I Read] W38, 39 (9/13 ~ 9/24) (0) | 2021.09.24 |
| [TIR, Today I Read] W37 (9/6 ~ 9/10) (0) | 2021.09.12 |
| [TIR, Today I Read] W36 (8/30 ~ 9/3) (0) | 2021.09.03 |