Skip to content

feat: 변경 감지 정책 도입#200

Open
goldm0ng wants to merge 8 commits intomainfrom
feat/seat-change-detection-logic
Open

feat: 변경 감지 정책 도입#200
goldm0ng wants to merge 8 commits intomainfrom
feat/seat-change-detection-logic

Conversation

@goldm0ng
Copy link
Copy Markdown
Member

@goldm0ng goldm0ng commented Aug 28, 2025

작업 내용

여석 전송 로직을 변경했습니다.

  • 기존: 조회한 모든 여석 정보를 SSE로 보냅니다.
  • 변경 후: 변경된 여석 정보만 SSE로 보냅니다. 여석의 변경 상태는 총 세가지입니다. (IN, OUT, UPDATE)

기존에 보예님께서 작업하셨던 내용을 롤백한 것이므로, 작성하셨던 PR을 기반으로 이야기하겠습니다.


** <crawler - #7 PR>의 내용 기반으로 수정한 내용입니다. **

백엔드와 서버가 단일화 되어 더이상 SSE로 여석을 전송해주던 로직은 필요 없게 되었습니다.
따라서 SSE를 사용하는 것이 아닌 자료구조에 저장하는 방식으로 변경했습니다.
백엔드는 여석 크롤링 후 저장해둔 자료구조를 통해 여석 정보를 가져오게 될 것입니다. 여석을 저장할 자료구조의 이름은 ChangedSubjectBuffer입니다.
변경된 여석 정보들이 담겨 있는 ChangedSubjectBuffergetAllAndFlush()를 통해 버퍼에 저장된 것을 싹 가져가서 클라이언트에게 전송해줍니다. 그리고 전송한 과목들은 버퍼에서 내보냅니다.
또한 data transfer 비용 문제로 변경 지점만 클라이언트에게 여석 전송을 하도록 합의했었습니다.
따라서 크롤러에서 변경지점이 있을 때에만 버퍼에 저장하고, DB 저장도 변경이 있을 때에만 update하도록 수정했습니다.

추가된 각 클래스의 역할은 다음과 같습니다.

ChangeDetector

  • 이름 그대로 변경을 감지합니다.
  • isRemainSeatChanged 메서드를 통해 여석의 변경을 감지합니다.
  • saveChangeToBuffer를 통해 변경 지점을 버퍼에 저장해줍니다.
  • 교양이라면 LiveBoard에게 상태 판별을 한번 맡기고, 교양이 아닌 핀 과목이라면 무조건 UPDATE로 버퍼에 저장해줍니다.

ChangeSubjectsResponse

  • subjectId와 ChangeStatus와 여석정보를 담고 있습니다.
  • 클라이언트에게 전송할 데이터의 구조입니다.

ChangedSubjectBuffer

  • 여석이 변경 되어 클라이언트에게 전송할 과목들을 저장해두는 자료구조 입니다.
    • 따라서 ChangeSubjectsResponse들을 가지고 있습니다.
  • 동시성 제어를 위해 ConcurrentLinkedQueue를 사용합니다.
  • 백엔드에서는 해당 클래스의 getAllAndFlush() 메서드를 통해 버퍼를 싹 비워서 가져가게 됩니다.

LiveBoard

  • 전광판의 상태를 관리합니다.
  • 전광판에 진입할 과목의 status(in,out,update)를 판별하고 반환해줍니다.
  • 상태 판별은 아래 조건을 상정하여 진행 했습니다.
스크린샷 2025-08-30 오전 10 48 18

고민 지점과 리뷰 포인트

1. 여석 정보 DB 저장 방안에 대한 논의가 필요합니다.

  • 기존: 조회한 모든 여석 정보를 DB에 저장했습니다.

    해당 방식 채택 근거

  1. 크롤링된 모든 데이터를 저장하기에 디버깅이 쉽다.

    로깅하는 대신에 DB에 저장한다고 생각하면 돼요. 특정 과목이 언제언제 크롤링됐는지 쿼리로 볼 수 있으니까, 로그로 눈 빠지게 보는 것보다 훨씬 편할거라 생각됩니다.

  2. 나중에 추가될 기능(여석 변동 추이 제공하는 기능)에서 반드시 필요하다.

    해당 기능을 구현하기 위해서는 크롤링할 때마다 여석을 저장해야 해요.

  3. 데이터 용량이 크지 않다.

    여석 테이블의 한 레코드는 240byte 정도 용량을 차지합니다.

    하루 7시간 동안 크롤링했을 때 필요한 용량을 계산해보면 240byte * 15(초당 15회크롤링) * 3600(1시간) * 7(하루 7시간 크롤링) = 90MB 인데 RDS가 20GB 까지 무료이기 때문에 괜찮다고 판단된다.

  • 변경: 해당 과목에 대한 여석이 존재하면 update, 존재하지 않으면 insert 합니다.

    해당 방식으로 변경한 이유

기존 방식대로 한다면,

public boolean isRemainSeatChanged(CrawlerSubject crawlerSubject, CrawlerSeat renewedCrawlerSeat) {
        LocalDate today = LocalDate.now();

        Optional<CrawlerSeat> previousSeat = crawlerSeatRepository.findByCrawlerSubjectAndCreatedDate(crawlerSubject, today);
        if (previousSeat.isEmpty()) {
            return true;
        }
        Integer previousRemainSeat = SeatUtils.getRemainSeat(previousSeat.get());
        Integer nowRemainSeat = SeatUtils.getRemainSeat(renewedCrawlerSeat);
        return !previousRemainSeat.equals(nowRemainSeat);
    }

-> 과목으로 해당 여석을 조회할 때, 여러 개가 반환된다면 오류가 발생합니다.
이를 해결하기 위해 기존 방식대로 돌려놓았습니다.

하지만, 이 방식은 단순 save 하는 방식보다 성능 측면에서 좋지 않습니다.
저장하거나 업데이트 하기 전 읽기 작업을 한 번 더 수행하기 때문입니다.
최근 DB 커넥션 문제로 여석이 밀리는 현상이 발생했기에, 더욱 신경써야할 부분이라고 생각해요.

2. 여석 조회 시점의 데이터 정합성에 대해 논의가 필요합니다.

  • 기존 정책
    • 여석 크롤링 -> 현재 시간 저장의 흐름이었기에 사용자들에게 실제 여석 조회 시점을 보여줄 수 있었습니다.
  • 변경 감지 정책
    • 여석 크롤링 -> 변경 여부 확인 -> 변경 상태 확인 -> 현재 시간 저장의 흐름입니다.
    • depth가 기존 방식에 비해 조금 더 깊어졌습니다. 이렇게 된다면, 저장되는 시간과 실제 여석 조회 시점과의 시간차가 발생할 수 있을 것 같아요. 거의 무시 가능한 수준이라고 생각은 들지만, 그래도 이 부분에 대해 의견 공유하고 싶어서 언급드립니다!

@github-actions
Copy link
Copy Markdown

github-actions bot commented Aug 28, 2025

Test Results

115 tests   115 ✅  23s ⏱️
 29 suites    0 💤
 29 files      0 ❌

Results for commit ac0f755.

♻️ This comment has been updated with latest results.

Copy link
Copy Markdown
Member

@haeyoon1 haeyoon1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다!👍👍👍 코멘트 몇개 남겼어요

Copy link
Copy Markdown
Member

@haeyoon1 haeyoon1 Aug 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

따로 현재는 IN 상태에 대한 검증 로직만 있지만, 나중에 OUT이나 UPDATE 테스트 코드도 작성하면 좋을 것 같아요!

Comment on lines +32 to +33
.filter(seat ->
Duration.between(seat.getQueryTime(), LocalDateTime.now()).getSeconds() <= LIMIT_QUERY_TIME)
Copy link
Copy Markdown
Member

@haeyoon1 haeyoon1 Aug 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사소하지만..! 매번 localdateTime.now를 불러오지 않고

public List<SeatDto> getGeneralSeats() {
    Collection<SeatDto> seatsValue = seats.values();
    LocalDateTime now = LocalDateTime.now(); // 분리
    return seatsValue.stream()
        .filter(seat -> seat.getSubject().isNonMajor())
        .filter(seat ->
            Duration.between(seat.getQueryTime(), now).getSeconds() <= LIMIT_QUERY_TIME)
        .sorted(Comparator.comparingInt(SeatDto::getSeatCount))
        .toList();
}

stream 돌기 전으로 따로 빼놓으면 연산이 조금은 줄 수 있지 않을까요?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그리고 해당 filter로직은 왜 추가된것인지 궁금합니다!

Copy link
Copy Markdown
Member Author

@goldm0ng goldm0ng Aug 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

최신 여석 정보만 필터링 하려는 의도입니다!
즉, 여석이 저장된 시간으로부터 3초 이내의 여석 정보만 가져오게 되는 거죠.
1-2분 전에 저장된 여석의 정보를 보내는 건 실시간 여석 전송에 의미가 없어지는 거니까요!

그리고 저번 변경 감지 정책을 최초로 도입했을 때, LIMIT_QUERY_TIME에 대한 논의가 이루어졌었는데 그때 당시 3초로 합의했었습니다!

Copy link
Copy Markdown
Member

@boyekim boyekim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

꼼꼼히 되살려주셨네여 감사함다
저는 좀 고민인게.,,.. ChangedSubjectBuffer 에서 getAllAndFlush를 실행할때 sjptProperties.getRequestPerSecondCount()를 제한숫자로 두거든요?? 왜냐면 buffer에 add도 되고 있으니까 무한정 가져오면서 죠기 갇혀버릴까봐 제한을 뒀어요... 이 생각이 틀렸는지부터 고민이고, sjptProperties.getRequestPerSecondCount() 이렇게 strict하게 두면 점점 옛날 정보가 쌓이고 그걸 계속 flush하게 될까봐 걱정이에오ㅠㅠ 그럼 결국 주기가 안맞아서 LIMIT_QUERY_TIME에도 필터링되어서 정보 갱신이 안될까봐 걱정이네요... 이걸 sjptProperties.getRequestPerSecondCount() 두배수 정도로 늘려야할지...흠

@goldm0ng
Copy link
Copy Markdown
Member Author

goldm0ng commented Nov 29, 2025

언급해주신 내용에 대한 논의가 예전에도 한 번 있었어서 같이 공유드립니다.
https://github.com/allcll/allcll-crawler/pull/49 해당 PR에서도 같은 맥락으로 이야기해주셨고,
그때 기존 정책에 사용되던 AllSeatBuffer의 flush 제한 수를 15 → 100으로 완화했었습니다.

이후 해당 변경이 반영된 상태로 서비스를 운영했을 때,
이후 이 변경 사항으로 서비스를 했었고, 보예님께서 말씀하신 <flush 로직이 무한 루프로 도는 문제>는 발생하지 않았다고 판단했습니다!

위의 말은, 서비스 시에 큐 사이즈가 100개가 넘어가지 않았다라고 말할 수 있을 것 같습니다!
현재 코드 구조를 기준으로 간단히 계산해보면 이 부분이 어느 정도 설명되는 것 같습니다.

크롤러는 1초에 최대 15회 크롤링하고, 변경된 과목만 ChangedSubjectBuffer에 add되기 때문에 버퍼에 쌓일 수 있는 데이터는 초당 최대 15개 이하입니다. 반면 getAllAndFlush()는 1초마다 실행되며 한 번에 최대 100개까지 flush 하도록 되어 있습니다.

실제 7시간(25,200초) 운영을 가정하면,

  • 버퍼 최대 유입량: 15 × 25,200 = 378,000개
  • 버퍼 최대 처리량: 100 × 25,200 = 2,520,000개

즉, 설계상으로는 버퍼 처리 속도가 버퍼 유입 속도의 약 6배 정도 빠르기 때문에 아무리 지연이 발생하더라도
스케줄러 주기가 정상적으로 유지된다는 전제 하에서는 큐 사이즈가 100개를 넘어서 지속적으로 밀리는 상황이 발생하기는 어렵다고 생각합니다!

오히려 여석 밀림 현상이 없어지고 서비스가 정상화 되었어요.

실시간 여석 밀림 현상이 해결된 원인은 두가지로 유추해볼 수 있었는데요.

  1. DB 커넥션 부족 문제 - 활성화된 pin 과목 조회
    참고 PR: feat: 활성화된 pin 과목 조회 로직 수정 #194
  2. flush 제한 수 15에서 100으로 완화

여석 밀림 현상은 수강신청 기간에 발생했던 크리티컬한 문제였습니다. 따라서 위 두 작업이 거의 동시에 merge 되고 빠르게 서비스되어 둘 중 정확한 문제 원인이 무엇이었는지 확정하기 어려웠습니다.

다만, 당시 경험 상 너무 strict한 flush 제한 수가 밀림 현상에 일정 부분 영향을 줬을 가능성은 있다고 보고 있고,
그래서 이번에도 동일한 맥락에서 ChangedSubjectBuffer 쪽 제한을 완화해 보는 방향으로 먼저 시도해보면 좋겠다고 판단했습니다!
가능하면 AllSeatBuffer와 일관성을 위해 제한 개수를 맞추면 좋을 것 같은데, 100개는 너무 클까요..?


지표 수집

추가적으로, 서비스 운영 시 해당 여석 갱신이 잘 되는지 여부를 확인하려면 직접 학사정보시스템의 여석과 비교해보는 것 외에는 마땅한 방법이 없었습니다. 그래서 여석 갱신 상태를 관찰할 수 있는 로그나 지표를 남기는 것이 좋겠다고 생각했습니다. 운영하는 입장에서도 좋고, 문제가 발생했을 때 어느 구간에서 병목이 생겼는지 트래킹하기도 훨씬 쉬울 것 같아서요!
-> 그런 의미에서, ChangedSubjectBufferSeatStorage 같은 중간 매개체에서 지표를 수집해보면 어떨까 싶습니다!

예를 들면,

  • 일정 주기마다 ChangedSubjectBuffer의 큐 사이즈를 수집하고
  • SeatStorage에서는 새로 갱신되는 과목 수, 갱신 지연 시간(크롤링 시점과 SSE 전송 시점의 차이) 등을 수집하는 것이죠!

이런 식으로 지표를 쌓아두면, 여석이 실제로 잘 갱신되고 있는지를 정량적으로 확인하기도 쉽고 다시 밀림 현상이 생기더라도 어느 레이어에서 문제가 발생했는지 빠르게 추적할 수 있을 것 같아요!!

이에 대해 어떻게 생각하시는지 다른 분들의 의견이 궁금합니다!

@boyekim
Copy link
Copy Markdown
Member

boyekim commented Nov 29, 2025

flush 제한 수

50개 정도도 좋을 것 같습니다! 그때 금서주스랑 해윤주스가 실제로 지표 찍어봤을 때 최대 30개(두배수)정도로 보였다고 말해줬던 것 같아서요

지표 수집 관련

지표 수집 저도 말하고싶었어요
ChangedSubjectBuffer queue 사이즈 좋아요
그리고 쿼리시간(여석이 저장된 시간부터 지금까지 몇초차이인지, LIMIT_QUERY_TIME으로 3초 이내이긴하지만 그 차이가 몇초인지)도 보면 좋을까여?
우선순위 큐들이 사이즈가 몇개인지도 보면 좋을 것 같구요.... 우선순위큐들에 어떤 과목이 담겨있는지도 확인하면 좋을 것 같긴한데 흠...

Comment on lines 43 to 46
if (canOut(crawlerSubject, remainSeat)) {
liveBoardSubjects.remove(crawlerSubject);
return List.of(ChangeSubjectsResponse.of(crawlerSubject.getId(), ChangeStatus.OUT, remainSeat));
return List.of(ChangeSubjectsResponse.of(crawlerSubject.getId(), ChangeStatus.OUT, remainSeat, LocalDateTime.now()));
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out 된 경우에는 전광판의 과목의 개수가 19개가 되니 21번째 전광판 과목이 20번째로 올라와야할 것 같은데 그 로직이 빠져있는 것 같아요!
out이 될 경우 in이 함께 동반되어야 할 것 같습니다. 어차피 몇 초 뒤면 새로운 과목들이 크롤링 되고, 과목이 20개가 채워질 수 도 있지만 새로운 과목이 나타나기 전까지는 19개만 전광판에 보일 것 같은데, liveboard 사이즈를 20보다 조금 크게 22-25 정도로 해두고 20개만 보이게 하는 것은 어떤가요?

Copy link
Copy Markdown
Member

@haeyoon1 haeyoon1 Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

또한 checkStatus 메서드에 대해

 public List<ChangeSubjectsResponse> checkStatus(CrawlerSubject crawlerSubject, Integer remainSeat) {
        if (isRemainSeatBecomeEmpty(remainSeat)) { // 크롤링 한 과목의 여석이 0인 경우
            if (canOut(crawlerSubject, remainSeat)) {
                liveBoardSubjects.remove(crawlerSubject);
                return List.of(ChangeSubjectsResponse.of(crawlerSubject.getId(), ChangeStatus.OUT, remainSeat,
                    LocalDateTime.now()));
            }
        } else { // 크롤링 한 과목의 여석이 0이 아닌 경우
            if (canOnlyIn(crawlerSubject, remainSeat)) {
                liveBoardSubjects.put(crawlerSubject, remainSeat);
                return List.of(ChangeSubjectsResponse.of(crawlerSubject.getId(), ChangeStatus.IN, remainSeat,
                    LocalDateTime.now()));
            }

            CrawlerSubject maxCrawlerSubject = findMaxRemainSeatSubject();
            Integer maxSubjectRemainSeat = liveBoardSubjects.get(maxCrawlerSubject);
            if (canInAndOut(crawlerSubject, maxCrawlerSubject, remainSeat)) {
                liveBoardSubjects.put(crawlerSubject, remainSeat);
                liveBoardSubjects.remove(maxCrawlerSubject);
                return List.of(
                    ChangeSubjectsResponse.of(crawlerSubject.getId(), ChangeStatus.IN, remainSeat, LocalDateTime.now()),
                    ChangeSubjectsResponse.of(maxCrawlerSubject.getId(), ChangeStatus.OUT, maxSubjectRemainSeat,
                        LocalDateTime.now())
                );
            }

            if (canUpdate(crawlerSubject, remainSeat)) {
                liveBoardSubjects.put(crawlerSubject, remainSeat);
                return List.of(ChangeSubjectsResponse.of(crawlerSubject.getId(), ChangeStatus.UPDATE, remainSeat,
                    LocalDateTime.now()));
            }
        }
        return Collections.emptyList();
    }

과 같이 초반에 isRemainSeatBecomeEmpty(크롤링 한 과목의 여석이 0인지) 를 기준으로 분기처리를 해 로직을 수정하는 것은 어떤가요? 여석이 0인 경우에도 불필요하게 많은 로직들을 거치게 되는 것 같아서요!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public void saveChangeToBuffer(CrawlerSubject crawlerSubject, CrawlerSeat renewedCrawlerSeat) {
        if (isGeneralSubject(crawlerSubject)) { // 교양 과목인 경우 -> 대시보드
            List<ChangeSubjectsResponse> response = liveBoards.checkStatus(
                crawlerSubject,
                SeatUtils.getRemainSeat(renewedCrawlerSeat)
            );
            if (!response.isEmpty()) { // 대시보드에 변화가 생긴 경우
                changedSubjectBuffer.addAll(response);
            }
        } else {  // 교양 과목이 아닌 경우 -> pin과목
            changedSubjectBuffer.add( 
                ChangeSubjectsResponse.of(crawlerSubject.getId(), ChangeStatus.UPDATE, SeatUtils.getRemainSeat(
                    renewedCrawlerSeat), LocalDateTime.now()));
        }
    }

changedSubjectBuffer.addAll(response); 에 걸리는 경우는 교양과목인 경우 중, 대시보드에 변화가 생긴 경우입니다.

else에 해당하는 changedSubjectBuffer.add()…. 가 실행되는 경우는 교양 과목이 아닌 경우 즉 pin 과목인 경우를 의도하고 작성된 부분인 것 같은데, 교양 과목인데 pin 과목인 경우에는 어느곳에도 해당하지 않아 여석이 업데이트 되지 않을 것 같습니다.

따라서

public void saveChangeToBuffer(CrawlerSubject crawlerSubject, CrawlerSeat renewedCrawlerSeat) {
        if (isGeneralSubject(crawlerSubject)) { // 교양 과목인 경우 -> 대시보드
            List<ChangeSubjectsResponse> response = liveBoards.checkStatus(
                crawlerSubject,
                SeatUtils.getRemainSeat(renewedCrawlerSeat)
            );
            if (!response.isEmpty()) { // 대시보드에 변화가 생긴 경우
                changedSubjectBuffer.addAll(response);
                return;
            }
        }
			  // 대시보드에 없는 교양 과목 + 교양 과목이 아닌 경우 -> pin과목
        changedSubjectBuffer.add( 
            ChangeSubjectsResponse.of(crawlerSubject.getId(), ChangeStatus.UPDATE, SeatUtils.getRemainSeat(
                renewedCrawlerSeat), LocalDateTime.now()));
        }
    }

다음과 같이 로직을 변경해야하지 않나 하는 생각이 드는데 어떻게 생각하시나요?

Comment on lines +26 to +28
if (existingSeatOpt.isPresent()) {
CrawlerSeat existingCrawlerSeat = existingSeatOpt.get();
existingCrawlerSeat.merge(crawlerSeat);
Copy link
Copy Markdown
Member

@haeyoon1 haeyoon1 Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존에는 과목을 크롤링할 때 마다 계속 save만 했던 로직을, 과목 여석 정보를 Update하는 방식으로 변경했는데요,
이는 매번 db에서 특정 과목의 여석 정보를 찾아, 현재 크롤링한 여석과 비교하여 변경 여부를 판단하기때문인 것 같습니다!

하지만 이는 매초 15번 일어나는 로직이기에

  1. db에서 특정 과목의 여석 정보를 찾아

이 경우(1)

  1. 과목 여석 정보를 Update하는 방식

요 경우(2) ==

Optional<CrawlerSeat> existingSeatOpt = crawlerSeatRepository.findByCrawlerSubjectAndCreatedDate(
            crawlerSeat.getCrawlerSubject(),
            today
        );

에도 db에 부하를 너무 많이 주는 로직인 것 같습니다. 따라서 변경이 필요해보여요!

위에 링크 남겨놓은 부분이 db를 거치지 않는 방식으로 수정된다면, 해당 부분은 원래대로 save만 하는 방법으로 수정할 수 있을 것 같은데 어떻게 생각하시나요?

Optional<CrawlerSeat> previousSeat = crawlerSeatRepository.findByCrawlerSubject(crawlerSubject);
LocalDate today = LocalDate.now();

Optional<CrawlerSeat> previousSeat = crawlerSeatRepository.findByCrawlerSubjectAndCreatedDate(crawlerSubject, today);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

db에서 매번 해당 과목의 여석 정보를 가져오는 것 보다, 모든 과목의 여석 정보를 담고있는 캐시에서 가져와서 비교하는 식으로 방법을 바꾸는건 어떨까요?

매번 db를 들락날락하면 병목이 생길 수 있을 것 같은데, 관리포인트가 너무 늘어나지 않는 선에서 방법을 고민해보면 좋을 것 같아요.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants