[내일배움캠프] Websocket + STOMP 기반 투표 시스템

변채주·2025년 12월 8일

Spring

목록 보기
12/13

0. ERD 설계

1. API 명세 및 요구사항

- 로그인 /api/auth/login POST

- /api/votes GET

요구사항 상세

  • 투표 목록 조회 API 구현
    • GET /api/votes
      • request
        없음
      • response
        • 성공시 : HttpStatusCode 200으로 응답.
          [
            {
              "id": 1, //투표 주제 번호
              "title": "점심 메뉴 선택",  //투표 주제명
              "status": "OPEN", //상태
              "author": "너구리", //사용자 닉네임
              "authorId": "delilah", //사용자 로그인 ID 
              "createdAt": "2025-11-22T12:07:35", //투표주제 생성일시
            },
            { ... }
          ]
        • 실패시 : HttpStatusCode와 오류메시지는 상황에 맞게 반환
    • votes, users 테이블을 전체 조회하여 반환합니다.
    • votes 테이블의 상태 컬럼의 데이터는 OPEN, CLOSED 두가지 상태가 존재합니다.
      • 엔티티를 선언할 때, 상태는 enum을 갖도록 합니다.
      • @Enumerated 사용

- /api/votes POST

요구사항 상세

  • 투표 주제 생성 API 구현
    • POST /api/votes
      • request
        • payload
          {
              "title": "점심뭐먹지?", //투표 주제명
              "candidates": [ //후보 리스트
                  "한식",
                  "일식",
                  "중식",
                  "양식"
              ],
              "authorId": "delilah" //작성자 로그인ID
          }
      • response
        • 성공시 : HttpStatusCode 201 Created로 응답. 투표 주제 번호 반환
          3 // 생성 완료된 투표 주제 id
        • 실패시 : HttpStatusCode와 오류메시지는 상황에 맞게 반환
    • votes, candidates 테이블에 데이터 저장합니다.

- /api/votes/{voteId} GET

요구사항 상세

  • 투표 상세 조회 API 구현
    • GET /api/votes/{voteId}
      • request
        • path parameter : voteId (투표 주제 번호)
      • response
        • 성공시 : HttpStatusCode 200으로 응답. 결과 응답
          {
              "id": 1, //투표 주제 번호
              "title": "점심 뭐먹?", // 주제명
              "status": "OPEN", //상태
              "author": "너구리",  //사용자 닉네임
              "authorId": "delilah", //사용자 로그인 ID
              "createdAt": "2025-11-22T10:55:20", //투표 생성 일자          
               //후보 리스트
          	  "candidates": [
          	    //후보 번호, 후보명, 득표 수
          	    { "id": 1, "name": "한식", "voteCount": 5 }, 
          	    { "id": 2, "name": "중식", "voteCount": 3 },
          	    { "id": 3, "name": "일식", "voteCount": 2 },
          	    { "id": 4, "name": "양식", "voteCount": 1 }
          	  ],     	  
              "totalVotes": 11 //해당 투표 주제의 총 득표수
          }
        • 실패시 : HttpStatusCode와 오류메시지는 상황에 맞게 반환
    • 투표 수 집계가 필요합니다.
      • 후보별 득표 현황을 확인 할 수 있도록 처리합니다.
      • MySQL Query 보다는 집계를 위해 필요한 테이블을 조회 후 Stream API 를 이용해서 집계해보세요!

- /api/votes/{voteId}/vote-record GET

요구사항 상세

  • 투표 기록 조회 API 구현
    • GET /api/votes/{voteId}/vote-record
      • request
        • path parameter : voteId (투표 주제 번호)
        • request parameter : voterId (투표 기록 조회를 할 대상의 사용자 로그인ID)
        • 예시
          /api/votes/1/vote-record?voterId=delilah
      • response
        • 성공시 : HttpStatusCode 200으로 응답. 결과 리스트 응답
          • 해당 로그인ID로 투표 기록이 있을 경우
            {
            	"voteId":4, //투표 주제 번호
            	"candidateId":9, //투표 후보 번호 
            	"voterId":"delilah", //투표 진행한 사용자의 로그인ID
            	"votedAt":"2025-11-22T13:43:38" //투표일시
            }
          • 투표기록이 없을 경우 null로 반환
    • 투표 이력이 있는 사용자인지 조회합니다.
    • 클라이언트에서는 해당 API 호출을 통해 얻은 응답으로
      • 이미 투표한 이력이 있다면 투표를 변경할 수 있는 화면을 노출하고,
      • 투표 이력이 없다면 첫 투표를 할 수 있는 화면을 노출합니다.

- /api/votes/{voteId}/vote POST

요구사항 상세

  • 투표 API
    • POST /api/votes/{voteId}/vote
    • request
      • path parameter : voteId (투표 주제 번호)
      • payload
        {
            "candidateId": 4, //투표 후보 번호
            "voterId": "delilah" //투표한 사람의 로그인ID 
        }
    • response
      • 성공시 : HttpStatusCode 200으로 응답. 결과 응답
        {
            "voteId": 1, //투표 주제 번호
            "candidateId": 4, //투표 후보 번호
            "voterId": "delilah", //투표한 사람의 로그인ID
            "votedAt": "2025-11-22T12:20:35.472445" //투표일시
        }
      • 실패시 : HttpStatusCode와 오류메시지는 상황에 맞게 반환
    • 이미 종료된 투표(votes.status == ‘CLOSED’) 라면 오류 메시지 발생시킵니다.
    • 투표 내용을 vote_records테이블에 저장합니다.
    • 선택한 투표 후보 변경이 가능합니다.
      • 따라서 이미 저장된 투표내용이 있다면 insert가 아닌 update를 해야합니다.

- /api/votes/{voteId}/close PATCH

요구사항 상세

  • 투표 종료 API 구현
    • PATCH /api/votes/{voteId}/close
      • request
        • path parameter : voteId (투표 주제 번호)
        • payload
          {
              "authorId": "delilah" //투표 종료한 사용자의 로그인ID
          }
      • response
        • 성공시 : HttpStatusCode 200으로 응답. 아래 예시 구조로 응답
          {
            "id": 1, //투표 주제번호
            "status": "CLOSED" //상태
          }
        • 실패시 : 실패시 : HttpStatusCode와 오류메시지는 상황에 맞게 반환
    • votes 테이블의 상태를 ‘CLOSED’로 변경합니다.
    • 투표를 생성한 사용자의 로그인ID로 요청이 들어올 경우에만, 투표 종료가 가능합니다.
      • 투표를 생성한 유저가 아니라면 오류메시지를 반환합니다.

2. 구현하기

상세 코드는 GitHub 참조 (live-voting)

트러블슈팅

‼️ Front Code와 응답 형태 불일치



(발생 현상)
로그인 성공 후 투표 목록 조회(http://localhost:3000/votes) 페이지로 이어진다. 그런데 위 이미지와 같이 제공된 프론트 프레임이 아니라 오류가 있다는 안내문이 보인다. Network를 확인했으나 HTTP 응답 코드는 200 OK로 정상 동작하는 상태.
(원인 조사)
프론트 코드가 워낙 세세하게 나눠져있어 프론트 쪽을 조사하기엔 어려움이 느껴졌다. 그래서 과제 코드를 한 번씩 검수하셨을 튜터님께 직진해서 도움을 요청했다.

원인은 이번 과제에 포함되지 않았던 Common Response 응답 형태!를 도입한 것

공통 응답에서 아래와 같이 응답을 보내고 있는데

{ 
  "data": [
    {
      "id": Long,
      "title": String,
...
    }
  ]
}

프론트 코드에선 data라는 형태를 응답으로 받고 있지 않으니 응답 형태 불일치로 이런 이슈가 발생한 것이다.

왜 공통 응답으로 구현했냐고 물어본다면... 전역 예외처리를 하는 김에 공통 응답도 시도해보고 싶었다. 결론적으론 시키는 것만 잘 해내자! 라는 교훈을 얻었지만...다음 개인적인 프로젝트나 요구사항에 공통 응답이 들어있는 과제가 나온다면 다시 도전하기로 했다.

(해결 방법)
공통 응답형태ResponseEntity<CommonResponse>를 모두 각 해당되는 응답 DtoResponseEntity<VoteToResponse>를 반환하도록 수정해서 해결했다.

‼️ 투표 선택지 변경한 내역이 투표 결과 조회에 반영되지 않음


(발생 현상)
투표 선택지를 이미 양식으로 투표한 상황에서 새로운 선택지(ex. 일식)로 '투표하기' 요청을 보내면 투표 결과가 바뀌어야 한다. 그러나 요청은 정상적으로 들어갔으나 막상 투표 결과에서는 예전 결과 그대로 나타나고 있었다.
(원인 조사)
원인은 짐작가는게 있었다.
영속성 컨텍스트와 변경 감지(Dirty-Checking)

코드를 작성하면서 헷갈린 부분이라 찾아봤었는데, 연관관계에 있는 컬럼 내용이 변경될 경우(여기에선 Candidate가 해당된다) 이를 save()를 사용해서 다시 저장하지 않아도 Vote와 VoteRecord, Candidate DB 테이블에 변경 사항이 반영되는가?

투표하기의 비즈니스 로직은 아래와 같이 구성되어 있는 상태다.

public VotedResponse voteTo(Long voteId, VoteToRequest request) {

        Vote vote = findVote(voteId);

        Users user = findUser(request.getVoterId());

        Candidate candidate = candidateService.findCandidate(request.getCandidateId());

        if(voteRecordRepository.existsByVoteAndUser(vote, user)) {
            VoteRecord updateVoteRecord = voteRecordRepository.findByVoteAndUser(vote, user).orElseThrow(
                    () -> new BusinessException(ErrorCode.USER_NOT_FOUND));

            updateVoteRecord.setCandidate(candidate);

            String message = user.getNickname() + "님이 " + vote.getTitle() + " 투표에 다시 참여하셨습니다.";
            simpMessagingTemplate.convertAndSend("/topic/announcements", message);
            System.out.println(message);

            return VotedResponse.from(updateVoteRecord);
        }

        VoteRecord voteRecord = new VoteRecord(user, vote, candidate);
        VoteRecord savedRecord = voteRecordRepository.save(voteRecord);

        String message = user.getNickname() + "님이 " + vote.getTitle() + " 투표에 참여하셨습니다.";
        simpMessagingTemplate.convertAndSend("/topic/announcements", message);

        return VotedResponse.from(savedRecord);
    }

영속성 컨텍스트에서 관리되는 엔티티의 내용이 변경되면 이를 감지하고 자동으로 UPDATE 쿼리를 처리해주는 Dirty-Checking 기능이 있기 때문에, 굳이 .save()를 사용하지 않아도 된다는 검색 결과를 봤다.
그래서 save를 사용하지 않고 Setter 메서드로 VoteRecord 엔티티의 Candidate만 변경해주고 응답을 반환했더니 UPDATE가 제대로 이뤄지지 않은 것이다.

(해결방법)
Setter 메서드 아래에 voteRecordRepository.save(updateVoteRecord);를 추가해 변경사항을 저장해줬더니 간단히 해결되었다.

그러나 여전히 왜 더티 체킹이 동작하지 않은 것인지는 개념과 동작 원리를 확인할 필요가 있다. 이 내용은 과제를 제출한 뒤 여유있게 살펴보고자 한다.

⬇️

💡 Dirty-Checking은 하나의 트랜잭션 범위 내에서만 동작한다!

3. 프로젝트 결과

  1. 로그인


가입된 멤버를 목록에서 선택한 뒤 로그인 버튼을 눌러 로그인 요청 처리에 성공하면 투표 목록 페이지로 이어진다.

  1. 투표 목록 조회

    종료 버튼은 투표를 생성한 작성자에게만 활성화된다.

  2. 투표하기 & 투표 상세 조회

    선택지를 누르고 투표하기를 누르명 아래와 같이 현재 투표 현황(결과)이 나타난다.

  3. Websocket+STOMP 메시지 처리
    3번에서 투표하기가 처리되면 다른 사용자에게 메시지가 나타나게 된다.

프로젝트 회고

  • 실시간 통신 방식에 대해 배울 수 있는 좋은 기회였으나 겉핥기로 간단한 형태만 경험했다는 감상을 버릴 수가 없다. 아무래도 API 설계 + STOMP 메시지 조합으로 구성되어 있으니 과제를 진행하는 동안 websocket과 STOMP를 탐구하기 보단 API 설계 과정이 비중이 많았다. 이번에는 다른 사용자 전체에게 메시지를 전송하는 걸 구현했지만 이후에는 실시간으로 1:1 전송이나 1:다 전송방식도 도전하면 좋겠다.
  • B2B, B2C 서비스에는 필수라고 할 수 있는 영역이다 보니 깊게 들어가자면 심해까지 갈 수 있을 것 같다. (모든 지식이나 영역?도 그렇겠지만) 집중하고 싶은 분야를 확실히 정할 필요성을 느꼈다.
profile
우당탕탕얼레벌레 개발 일지

0개의 댓글