Skip to content

feat: CSV 파싱 및 대화 통계 집계 구현#6

Open
oniwon wants to merge 5 commits into
mainfrom
feat/parsing-csv
Open

feat: CSV 파싱 및 대화 통계 집계 구현#6
oniwon wants to merge 5 commits into
mainfrom
feat/parsing-csv

Conversation

@oniwon

@oniwon oniwon commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

요약

카카오톡 Mac CSV 파일을 읽어 대화 통계를 만드는 파싱 파이프라인을 구현했습니다.

CSV 파싱 → 1차 스캔 → 검증 → 2차 스캔

구현 내용

CSV 파싱 (CsvMessageParser)

  • CSV 파일을 읽어 메시지 목록(RawMessage)으로 변환
  • 잘못된 파일은 사용자 입력 오류로 응답

1차 스캔 (PreScanner)

  • 누가 몇 개의 메시지를 보냈는지, 대화가 언제부터 언제까지인지 요약

검증 (PreScanValidator)

  • 분석 가능한 1:1 대화인지 검증하고 상위 2인 화자를 선택

2차 스캔 (ChatStatsAnalyzer)

  • 화자 역할별·메시지 종류별 건수, 대화 기간, 활성일 수를 집계해 최종 통계(ParseStats) 생성
  • 상위 2인이 아닌 화자는 통계에서 제외
  • 메시지 종류(텍스트/이모티콘/사진)는 MessageTypeClassifier가 분류

테스트

  • 성공·실패·전체 연결(CSV 파일 → ParseStats) 케이스 22개

@oniwon
oniwon requested a review from if-else-f July 21, 2026 12:37
@oniwon oniwon closed this Jul 21, 2026
@oniwon oniwon reopened this Jul 21, 2026
@sonarqubecloud

Copy link
Copy Markdown

public MessageType classify(String content) {
String text = content.strip();

if (text.equals(EMOTICON_MARKER)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

왜 이런 체크를 하는지 private boolean 함수를 만들면 어떨까요?
함수명에 명시해서요

private static final String EMOTICON_MARKER = "이모티콘";
private static final Pattern PHOTO = Pattern.compile("사진( \\d+장)?");

public MessageType classify(String content) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

근데 이거 MessageType에 있는게 더 응집력이 있지 않을까요?

private Reader stripBom(Reader in) throws IOException {
PushbackReader pushback = new PushbackReader(in, 1);
int first = pushback.read();
if (first != -1 && first != BOM) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이건 꼭 private 함수로 빼서 왜 이런 체크를 하는지 명시하면 좋을것 같아요

try {
String speakerName = csvRow.get("User");
String text = csvRow.get("Message");
LocalDateTime sentAt = parseSentAt(csvRow.get("Date"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

User, Message, Date 컬럼이 없으면 fail fast로 에러를 반환하는건 어떨까요?

Objects.requireNonNull(ownerName, "ownerName");
Objects.requireNonNull(partnerName, "partnerName");
if (ownerName.equals(partnerName)) {
throw new IllegalArgumentException("OWNER와 PARTNER는 서로 달라야 합니다.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

우리 exception handler 에 등록된 exception으로 전환하면 어떨까요?

}
}

public SpeakerRole roleOf(String speakerName) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이 함수도 SpeakerRole 안으로 들어가는게 더 응집력이 있을거 같아요

MessagePeriodAccumulator period = new MessagePeriodAccumulator();
Set<LocalDate> activeDays = new HashSet<>();

for (RawMessage message : messages) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이 for문에서 너무 많은 일이 일어나요 private으로 분리하는게 필요해보입니다

continue; // 소수 화자는 모든 통계에서 제외
}

switch (role) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이 기능도 SpeakerRole 안으로 들어가면 더 응집력 있는 코드가 될 것 같아요

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

private boolean isNotOneToOneChatRoom()

}

MessageType type = classifier.classify(message.text());
switch (type) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이것도 MessageType 안으로 들어가면 좋을것 같아요

long partnerCount = 0;
long textCount = 0;
long emoticonCount = 0;
long photoCount = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이렇게 따로 두는것 보다 객체를 만들어서 관리하면 더 좋을것 같아요
각각의 숫자가 어떻게 연관 있는지 흐름을 파악하는게 어려워요


import java.time.LocalDateTime;

final class MessagePeriodAccumulator {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

이름은 Accumulator 인데 하는 일은 날짜를 관리(validate)하는것 같아 보여요

period.accumulate(message.sentAt());
}

List<SpeakerCount> speakerCounts = messageCountBySpeaker.entrySet().stream()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

new SpeakerCount(entry);

로 변경하면 stream을 사용하는데 더 적합할것 같아요

.map(entry -> new SpeakerCount(entry.getKey(), entry.getValue()))
.toList();

return new PreScanSummary(speakerCounts, period.startedAt(), period.endedAt(), messages.size());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

new PreScanSummary(speakerCounts, period, messages);

가 더 객체지향적으로 보여요

.mapToLong(SpeakerCount::messageCount)
.min()
.orElse(0);
if (minCount < thresholds.minPerSpeakerMessages()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

if 문 안은 private 으로 해두는게 좋겠어요

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants