Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ dependencies {

//타임리프
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

//pdf
implementation 'org.apache.pdfbox:pdfbox:3.0.5'
implementation 'org.apache.pdfbox:fontbox:3.0.5'
}

configurations.configureEach {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package life.mosu.mosuserver.application.admin;

import jakarta.transaction.Transactional;
import life.mosu.mosuserver.application.admin.dto.ApplicationCsvInfo;
import life.mosu.mosuserver.application.admin.dto.ImportResultDto;
import life.mosu.mosuserver.application.admin.processor.ApplyGuestStepProcessor;
import life.mosu.mosuserver.application.admin.processor.ChangeTestPaperCheckedStepProcessor;
import life.mosu.mosuserver.application.admin.processor.GetApplicationGuestRequestStepProcessor;
import life.mosu.mosuserver.application.admin.processor.RegisterVirtualAccountStepProcessor;
import life.mosu.mosuserver.application.admin.util.CsvReader;
import life.mosu.mosuserver.presentation.application.dto.ApplicationGuestRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class AdminApplicationImportService {

private final GetApplicationGuestRequestStepProcessor getApplicationGuestRequestStepProcessor;
private final ApplyGuestStepProcessor applyGuestStepProcessor;
private final ChangeTestPaperCheckedStepProcessor changeTestPaperCheckedStepProcessor;
private final RegisterVirtualAccountStepProcessor registerVirtualAccountStepProcessor;
private final CsvReader csvReader;

@Transactional
public ImportResultDto importGuestApplications(MultipartFile file) {
List<ApplicationCsvInfo> rows = csvReader.read(file);

int total = rows.size();
int success = 0;

int lineNo = 1;
for (ApplicationCsvInfo row : rows) {
lineNo++;
try {
processGuestRow(row);
success++;
} catch (Exception e) {
log.error("게스트 신청 CSV 행 {} 처리 실패: {}", lineNo, e.getMessage(), e);
throw new RuntimeException("CSV 일괄 처리 중 오류 발생");
}
Comment on lines +37 to +46
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not abort the entire import on the first row error.

The catch block rethrows a RuntimeException on the first failing row, so the loop stops immediately, the transaction rolls back, and the summary logic (including the failure count) is never reached. As a result, a single bad row prevents all other valid applications from being registered—defeating the purpose of batch import and the success/fail statistics you’re computing.

Handle the exception per row (log it, continue, and let the failure counter increase) instead of propagating it so that the rest of the CSV can still be processed. For example:

-            } catch (Exception e) {
-                log.error("게스트 신청 CSV 행 {} 처리 실패: {}", lineNo, e.getMessage(), e);
-                throw new RuntimeException("CSV 일괄 처리 중 오류 발생");
-            }
+            } catch (Exception e) {
+                log.error("게스트 신청 CSV 행 {} 처리 실패: {}", lineNo, e.getMessage(), e);
+            }

This keeps the loop running, allowing fail = total - success to reflect actual results while leaving successful rows committed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
int lineNo = 1;
for (ApplicationCsvInfo row : rows) {
lineNo++;
try {
processGuestRow(row);
success++;
} catch (Exception e) {
log.error("게스트 신청 CSV 행 {} 처리 실패: {}", lineNo, e.getMessage(), e);
throw new RuntimeException("CSV 일괄 처리 중 오류 발생");
}
int lineNo = 1;
for (ApplicationCsvInfo row : rows) {
lineNo++;
try {
processGuestRow(row);
success++;
} catch (Exception e) {
log.error("게스트 신청 CSV 행 {} 처리 실패: {}", lineNo, e.getMessage(), e);
}
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/admin/AdminApplicationImportService.java
around lines 37 to 46, the catch currently rethrows a RuntimeException which
aborts the whole import and prevents the failure/success summary from being
produced; instead remove the rethrow, log the full exception, increment a
per-row failure counter (or keep track of failedRows), and continue the loop so
remaining rows are processed; if transactions currently wrap the entire import,
ensure each row is processed in a way that a single row failure does not roll
back successful rows (e.g., use a separate transactional boundary for
processGuestRow or catch and swallow exceptions per row), and preserve existing
success/fail summary logic so fail = total - success (or use an explicit fail++
when catching).

}

int fail = total - success;
log.info("CSV Import 완료 - 총 {}건, 성공 {}, 실패 {}", total, success, fail);
return new ImportResultDto(total, success, fail);
}
Comment on lines +30 to +52

Choose a reason for hiding this comment

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

high

importGuestApplications 메서드의 오류 처리 방식에 개선이 필요해 보입니다. 현재 구조에서는 CSV 처리 중 오류가 발생하면 RuntimeException을 발생시켜 트랜잭션을 롤백합니다. 이는 데이터 정합성 측면에서는 좋은 방법이지만, 예외가 컨트롤러까지 전파되어 사용자에게는 일반적인 오류 페이지만 보이게 됩니다. import-result.html에 구현된 실패 결과 화면을 사용자에게 보여주려면 서비스 계층에서 예외를 던지는 대신, 트랜잭션 롤백을 수동으로 설정하고 실패 정보를 담은 ImportResultDto를 반환하는 방식이 더 적합합니다.

또한, Spring 환경에서는 jakarta.transaction.Transactional보다 Spring이 제공하는 @Transactional 어노테이션을 사용하는 것이 일관성 및 기능 활용(TransactionAspectSupport 등) 측면에서 더 좋습니다.

    @Transactional
    public ImportResultDto importGuestApplications(MultipartFile file) {
        List<ApplicationCsvInfo> rows = csvReader.read(file);

        int total = rows.size();

        int lineNo = 1;
        for (ApplicationCsvInfo row : rows) {
            lineNo++;
            try {
                processGuestRow(row);
            } catch (Exception e) {
                log.error("게스트 신청 CSV 행 {} 처리 실패: {}", lineNo, e.getMessage(), e);
                TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
                log.info("CSV Import 실패로 인해 롤백됩니다.");
                return new ImportResultDto(total, 0, total);
            }
        }

        log.info("CSV Import 완료 - 총 {}건, 성공 {}, 실패 {}", total, total, 0);
        return new ImportResultDto(total, total, 0);
    }



private void processGuestRow(ApplicationCsvInfo csvInfo) {
ApplicationGuestRequest applicationGuestRequest = getApplicationGuestRequestStepProcessor.process(csvInfo);

Long applicationId = applyGuestStepProcessor.process(applicationGuestRequest);

if (Boolean.TRUE.equals(csvInfo.isTestPaperChecked())) {
changeTestPaperCheckedStepProcessor.process(applicationId);
}

registerVirtualAccountStepProcessor.process(applicationId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package life.mosu.mosuserver.application.admin.dto;

import java.time.LocalDate;
import java.time.format.DateTimeParseException;

public record ApplicationCsvInfo(
String createdAt,
String name,
String gender,
LocalDate birth,
String phoneNumber,
LocalDate examDate,
String examSchool,
Boolean isLunchChecked,
Boolean isTestPaperChecked,
String subject1,
String subject2
) {
public static ApplicationCsvInfo of(
String createdAt,
String name,
String gender,
LocalDate birth,
String phoneNumber,
LocalDate examDate,
String examSchool,
Boolean isLunchChecked,
Boolean isTestPaperChecked,
String subject1,
String subject2
) {
return new ApplicationCsvInfo(createdAt, name, gender, birth, phoneNumber, examDate, examSchool, isLunchChecked, isTestPaperChecked, subject1, subject2);
}

public static ApplicationCsvInfo of(String[] values) {
if (values.length < 11) {
throw new IllegalArgumentException("CSV 행이 필수 컬럼 수(11개)보다 부족합니다. 실제 컬럼 수: " + values.length);
}
Comment on lines +36 to +38

Choose a reason for hiding this comment

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

medium

of(String[] values) 메서드에서 CSV 컬럼 수를 나타내는 11이 하드코딩되어 있습니다. 이 값을 private static final int REQUIRED_COLUMN_COUNT = 11;과 같이 상수로 추출하면 코드의 가독성과 유지보수성이 향상됩니다.

return new ApplicationCsvInfo(
values[0], // createdAt
values[1], // name
values[2], // gender
parseDate(values[3]), // birth
values[4], // phoneNumber
parseDate(values[5]), // examDate
values[6], // examSchool
parseBoolean(values[7]), // isLunchChecked
parseBoolean(values[8]), // isTestPaperChecked
values[9], // subject1
values[10] // subject2
);
}

private static LocalDate parseDate(String dateStr) {
try {
return LocalDate.parse(dateStr.trim());
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다");

Choose a reason for hiding this comment

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

medium

parseDate 메서드에서 예외 발생 시 어떤 값 때문에 오류가 났는지 알 수 있도록 예외 메시지에 입력값을 포함하는 것이 디버깅에 더 용이합니다.

Suggested change
throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다");
throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다: " + dateStr);

}
}
Comment on lines +54 to +60
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Improve error message to include the invalid value.

The error message "날짜 형식이 올바르지 않습니다" (Date format is incorrect) doesn't indicate which value failed or what format is expected. This makes debugging difficult for users.

Apply this diff to improve the error message:

 private static LocalDate parseDate(String dateStr) {
     try {
         return LocalDate.parse(dateStr.trim());
     } catch (DateTimeParseException e) {
-        throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다");
+        throw new IllegalArgumentException(
+            "날짜 형식이 올바르지 않습니다. 입력값: '" + dateStr + "', 예상 형식: yyyy-MM-dd", e);
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private static LocalDate parseDate(String dateStr) {
try {
return LocalDate.parse(dateStr.trim());
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다");
}
}
private static LocalDate parseDate(String dateStr) {
try {
return LocalDate.parse(dateStr.trim());
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(
"날짜 형식이 올바르지 않습니다. 입력값: '" + dateStr + "', 예상 형식: yyyy-MM-dd", e);
}
}
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/admin/dto/ApplicationCsvInfo.java
around lines 54 to 60, the parseDate method throws a generic
IllegalArgumentException without showing the offending value or expected format;
update the catch block to include the trimmed input value and the expected
format (e.g., ISO yyyy-MM-dd) in the exception message (optionally appending the
original DateTimeParseException message) so callers know which value failed and
why.


private static Boolean parseBoolean(String bool) {
if (bool == null || bool.isBlank()) {
return false;
}
String trimmedValue = bool.trim();
return Boolean.parseBoolean(trimmedValue);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package life.mosu.mosuserver.application.admin.dto;

public record ImportResultDto(
int totalProcessed,
int totalSuccess,
int totalFailure
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package life.mosu.mosuserver.application.admin.processor;

import life.mosu.mosuserver.application.application.ApplicationService;
import life.mosu.mosuserver.domain.application.entity.ApplicationJpaEntity;
import life.mosu.mosuserver.domain.application.entity.ApplicationStatus;
import life.mosu.mosuserver.domain.application.repository.ApplicationJpaRepository;
import life.mosu.mosuserver.global.processor.StepProcessor;
import life.mosu.mosuserver.presentation.application.dto.ApplicationGuestRequest;
import life.mosu.mosuserver.presentation.application.dto.CreateApplicationResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ApplyGuestStepProcessor implements StepProcessor<ApplicationGuestRequest, Long> {

private final ApplicationService applicationService;
private final ApplicationJpaRepository applicationJpaRepository;

@Override
public Long process(ApplicationGuestRequest request) {
CreateApplicationResponse response = applicationService.applyByGuest(request);

Long applicationId = response.applicationId();
ApplicationJpaEntity application = applicationJpaRepository.findById(applicationId)
.orElseThrow(() -> new IllegalArgumentException("신청을 찾을 수 없습니다. id=" + applicationId));
Comment on lines +22 to +26
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Inefficient fetch immediately after creation.

The code fetches the application entity from the database immediately after creating it via applyByGuest(). This introduces unnecessary database round-trips and latency.

Consider one of the following approaches:

  1. Preferred: Modify applyByGuest() to return the created entity or allow setting the status during creation, eliminating the need for a separate fetch and update.

  2. Alternative: If the fetch-and-update pattern is required for architectural reasons (e.g., audit logging, event triggering in changeStatus()), add a comment explaining why the fetch is necessary.

For approach 1, you would refactor the service method to accept the status as a parameter or return the entity directly:

ApplicationJpaEntity application = applicationService.applyByGuestAndReturn(request, ApplicationStatus.APPROVED);
return application.getId();

This reduces database operations and improves performance for bulk imports.

🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/admin/processor/ApplyGuestStepProcessor.java
around lines 22 to 26, the code does an unnecessary DB fetch right after calling
applicationService.applyByGuest(), causing extra round-trips; either change
applyByGuest() to return the created ApplicationJpaEntity (or accept a desired
status so it can be set on creation) and use that returned entity directly, or,
if fetch is required, add a concise comment explaining why the separate
findById() is necessary (e.g., for audit/event reasons) so future readers
understand the intentional extra query.


application.changeStatus(ApplicationStatus.APPROVED);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Status change is not persisted to the database.

Line 28 calls changeStatus() on the entity, but the modified entity is never saved back to the repository. The status change will be lost when the transaction commits (or the persistence context is cleared).

Apply this diff to persist the status change:

 application.changeStatus(ApplicationStatus.APPROVED);
+applicationJpaRepository.save(application);
 return applicationId;

Alternatively, if changeStatus() is designed to work within a managed transaction, ensure this method is annotated with @Transactional at the class or method level.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
application.changeStatus(ApplicationStatus.APPROVED);
application.changeStatus(ApplicationStatus.APPROVED);
applicationJpaRepository.save(application);
return applicationId;
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/admin/processor/ApplyGuestStepProcessor.java
around line 28, the code calls
application.changeStatus(ApplicationStatus.APPROVED) but does not persist the
updated entity; either call the appropriate repository.save(application) (or
repository.saveAndFlush(application) if immediate DB write is required) after
the change to persist the new status, or ensure the surrounding method/class is
annotated with @Transactional so the persistence context will automatically
flush the status change before commit.

return applicationId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package life.mosu.mosuserver.application.admin.processor;

import life.mosu.mosuserver.domain.examapplication.entity.ExamApplicationJpaEntity;
import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository;
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import life.mosu.mosuserver.global.processor.StepProcessor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class ChangeTestPaperCheckedStepProcessor implements StepProcessor<Long, List<Long>> {

private final ExamApplicationJpaRepository examApplicationJpaRepository;

@Override
public List<Long> process(Long applicationId) {

List<ExamApplicationJpaEntity> examApplications = examApplicationJpaRepository.findByApplicationId(applicationId);
if (examApplications.isEmpty()) {
throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND);
}

examApplications.forEach(ExamApplicationJpaEntity::setTestPaperChecked);

return examApplications.stream()
.map(ExamApplicationJpaEntity::getId)
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package life.mosu.mosuserver.application.admin.processor;

import life.mosu.mosuserver.application.admin.dto.ApplicationCsvInfo;
import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity;
import life.mosu.mosuserver.domain.exam.entity.ExamJpaRepository;
import life.mosu.mosuserver.global.processor.StepProcessor;
import life.mosu.mosuserver.presentation.application.dto.ApplicationGuestRequest;
import life.mosu.mosuserver.presentation.application.dto.ExamApplicationRequest;
import life.mosu.mosuserver.presentation.common.FileRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.Set;

@Component
@RequiredArgsConstructor
public class GetApplicationGuestRequestStepProcessor implements StepProcessor<ApplicationCsvInfo, ApplicationGuestRequest> {

private final ExamJpaRepository examJpaRepository;
private static final String ORG_NAME = "모수사전예약";

@Override
public ApplicationGuestRequest process(ApplicationCsvInfo csvInfo) {

LocalDate examDate = csvInfo.examDate();
String schoolName = csvInfo.examSchool();
ExamJpaEntity exam = examJpaRepository.findByExamDateAndSchoolName(examDate, schoolName);

Choose a reason for hiding this comment

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

critical

examJpaRepository.findByExamDateAndSchoolName(examDate, schoolName) 메서드가 null을 반환할 경우, 다음 줄에서 exam.getId()를 호출할 때 NullPointerException이 발생할 수 있습니다. 데이터베이스에 해당 시험 정보가 없는 경우를 대비하여 null 체크 로직을 추가해야 합니다.

        ExamJpaEntity exam = examJpaRepository.findByExamDateAndSchoolName(examDate, schoolName);
        if (exam == null) {
            throw new IllegalArgumentException("해당 시험 정보가 존재하지 않습니다. examDate=" + examDate + ", schoolName=" + schoolName);
        }


ExamApplicationRequest examApplicationRequest = new ExamApplicationRequest(
exam.getId(),
csvInfo.isLunchChecked()
);
Comment on lines +28 to +33
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard against missing exam lookups.

findByExamDateAndSchoolName returns null when no exam matches the CSV row. We immediately dereference exam.getId(), so the CSV import blows up with an NPE instead of a controlled failure. Please fail fast with a domain exception (e.g., CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND) or similar) before building the request. A minimal fix is to wrap the lookup in Optional.ofNullable(...).orElseThrow(...).

+import life.mosu.mosuserver.global.exception.CustomRuntimeException;
+import life.mosu.mosuserver.global.exception.ErrorCode;
+import java.util.Optional;
@@
-        ExamJpaEntity exam = examJpaRepository.findByExamDateAndSchoolName(examDate, schoolName);
+        ExamJpaEntity exam = Optional.ofNullable(
+                examJpaRepository.findByExamDateAndSchoolName(examDate, schoolName))
+                .orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ExamJpaEntity exam = examJpaRepository.findByExamDateAndSchoolName(examDate, schoolName);
ExamApplicationRequest examApplicationRequest = new ExamApplicationRequest(
exam.getId(),
csvInfo.isLunchChecked()
);
// at the top of the file, alongside the other imports
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import java.util.Optional;
// around lines 28–33 in GetApplicationGuestRequestStepProcessor.java
ExamJpaEntity exam = Optional.ofNullable(
examJpaRepository.findByExamDateAndSchoolName(examDate, schoolName))
.orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND));
ExamApplicationRequest examApplicationRequest = new ExamApplicationRequest(
exam.getId(),
csvInfo.isLunchChecked()
);
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/admin/processor/GetApplicationGuestRequestStepProcessor.java
around lines 28 to 33, the code dereferences exam returned from
examJpaRepository.findByExamDateAndSchoolName(...) which can be null; wrap the
lookup with Optional.ofNullable(...).orElseThrow(...) and throw a domain
exception such as new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND) (or the
project’s equivalent) so the import fails fast with a controlled error before
constructing ExamApplicationRequest.

FileRequest admissionTicket = new FileRequest("", "");
Set<String> subjects = Set.of(csvInfo.subject1(), csvInfo.subject2());

return new ApplicationGuestRequest(
ORG_NAME,
csvInfo.gender(),
csvInfo.name(),
csvInfo.birth(),
csvInfo.phoneNumber(),
examApplicationRequest,
subjects,
admissionTicket
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package life.mosu.mosuserver.application.admin.processor;

import life.mosu.mosuserver.application.virtualaccount.VirtualAccountOrderIdGenerator;
import life.mosu.mosuserver.domain.virtualaccount.VirtualAccountLogJpaEntity;
import life.mosu.mosuserver.domain.virtualaccount.VirtualAccountLogJpaRepository;
import life.mosu.mosuserver.global.processor.StepProcessor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class RegisterVirtualAccountStepProcessor implements StepProcessor<Long, Long> {

private final VirtualAccountLogJpaRepository virtualAccountLogJpaRepository;
private final VirtualAccountOrderIdGenerator orderIdGenerator;

@Override
public Long process(Long applicationId) {
String orderId = orderIdGenerator.generate();
VirtualAccountLogJpaEntity virtualAccountLog = VirtualAccountLogJpaEntity.create(applicationId, orderId, null, null, null, null);
virtualAccountLog.setDepositSuccess();
VirtualAccountLogJpaEntity saved = virtualAccountLogJpaRepository.save(virtualAccountLog);

return saved.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package life.mosu.mosuserver.application.admin.util;

import life.mosu.mosuserver.application.admin.dto.ApplicationCsvInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Component
public class CsvReader {

private static final String CSV_DELIMITER = ",";

public List<ApplicationCsvInfo> read(MultipartFile file) {
List<ApplicationCsvInfo> results = new ArrayList<>();

if (file == null || file.isEmpty()) {
log.warn("업로드된 파일이 비어있습니다.");
return results;
}

try (BufferedReader reader =
new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {

String line;
boolean isHeader = true;
int lineNumber = 0;

while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.isBlank()) continue;
if (isHeader) {
isHeader = false;
continue;
}

try {
String[] values = line.split(CSV_DELIMITER, -1);
results.add(ApplicationCsvInfo.of(values));
} catch (Exception e) {
log.error("CSV 파싱 실패 (Line {}): {}", lineNumber, e.getMessage());
throw new RuntimeException("CSV 파싱 오류");

Comment on lines +46 to +49
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Chain the original exception as cause for better debugging.

Lines 48 and 54 throw new RuntimeException instances without including the original exception as the cause. This loses the stack trace and makes debugging difficult.

Apply this diff:

 } catch (Exception e) {
     log.error("CSV 파싱 실패 (Line {}): {}", lineNumber, e.getMessage());
-    throw new RuntimeException("CSV 파싱 오류");
+    throw new RuntimeException("CSV 파싱 오류 at line " + lineNumber, e);
 }
 } catch (Exception e) {
     log.error("파일 읽기 실패: {}", e.getMessage(), e);
-    throw new RuntimeException("CSV 읽기 오류");
+    throw new RuntimeException("CSV 읽기 오류", e);
 }

Also applies to: 52-55

🤖 Prompt for AI Agents
In src/main/java/life/mosu/mosuserver/application/admin/util/CsvReader.java
around lines 46-49 (and similarly lines 52-55), the catch blocks log the
exception but rethrow a new RuntimeException without chaining the original
exception; update the throws to include the caught exception as the cause (e.g.,
throw new RuntimeException("CSV 파싱 오류", e)) so the original stack trace is
preserved for debugging, and keep the existing log statements unchanged.

}
}
} catch (Exception e) {
log.error("파일 읽기 실패: {}", e.getMessage(), e);
throw new RuntimeException("CSV 읽기 오류");
}
Comment on lines +43 to +55
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve exception information when rethrowing.

The current code catches generic Exception and rethrows as a new RuntimeException with a generic message, losing the original exception type and stack trace. This makes debugging difficult.

Apply this fix to preserve exception context:

                 try {
                     String[] values = line.split(CSV_DELIMITER, -1);
                     results.add(ApplicationCsvInfo.of(values));
                 } catch (Exception e) {
                     log.error("CSV 파싱 실패 (Line {}): {}", lineNumber, e.getMessage());
-                    throw new RuntimeException("CSV 파싱 오류");
+                    throw new RuntimeException("CSV 파싱 오류 (Line " + lineNumber + ")", e);
                 }
             }
         } catch (Exception e) {
             log.error("파일 읽기 실패: {}", e.getMessage(), e);
-            throw new RuntimeException("CSV 읽기 오류");
+            throw new RuntimeException("CSV 읽기 오류", e);
         }
🤖 Prompt for AI Agents
In src/main/java/life/mosu/mosuserver/application/admin/util/CsvReader.java
around lines 43 to 55, the code catches Exception and rethrows a new
RuntimeException without preserving the original exception, losing stacktrace
and cause; change both rethrows to pass the caught exception as the cause (e.g.,
new RuntimeException("CSV 파싱 오류", e) and new RuntimeException("CSV 읽기 오류", e))
so the original exception type and stacktrace are preserved (optionally narrow
the caught exception types instead of catching Exception).


return results;
}
Comment on lines +20 to +58
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Use a proper CSV parsing library instead of String.split().

The current implementation uses String.split(CSV_DELIMITER, -1) (line 44), which is inadequate for production CSV parsing because it doesn't handle:

  • Quoted fields containing commas (e.g., "Seoul, Korea")
  • Escaped quotes within fields
  • Multi-line fields
  • Different line endings (CRLF vs LF)
  • Encoding issues beyond UTF-8

Consider using a battle-tested CSV library like Apache Commons CSV or OpenCSV:

Using Apache Commons CSV:

Add dependency:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-csv</artifactId>
    <version>1.12.0</version>
</dependency>

Refactor the read method:

import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;

public List<ApplicationCsvInfo> read(MultipartFile file) {
    List<ApplicationCsvInfo> results = new ArrayList<>();

    if (file == null || file.isEmpty()) {
        log.warn("업로드된 파일이 비어있습니다.");
        return results;
    }

    try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8));
         CSVParser csvParser = new CSVParser(reader, 
            CSVFormat.DEFAULT.withFirstRecordAsHeader().withIgnoreEmptyLines())) {

        for (CSVRecord record : csvParser) {
            try {
                // Convert CSVRecord to String[] if needed, or adapt ApplicationCsvInfo.of()
                results.add(ApplicationCsvInfo.of(record));
            } catch (Exception e) {
                log.error("CSV 파싱 실패 (Line {}): {}", record.getRecordNumber(), e.getMessage());
                throw new RuntimeException("CSV 파싱 오류 at line " + record.getRecordNumber(), e);
            }
        }
    } catch (IOException e) {
        log.error("파일 읽기 실패: {}", e.getMessage(), e);
        throw new RuntimeException("CSV 읽기 오류", e);
    }

    return results;
}

This provides proper CSV parsing with quoted field support and better error context.

🤖 Prompt for AI Agents
In src/main/java/life/mosu/mosuserver/application/admin/util/CsvReader.java
around lines 20 to 58, the code uses String.split(...) to parse CSV which fails
for quoted fields, escaped quotes, multi-line fields, etc.; replace the manual
split with a proper CSV library (e.g., Apache Commons CSV or OpenCSV): add the
chosen dependency to pom.xml, create a CSVParser (for Commons CSV use
CSVFormat.DEFAULT.withFirstRecordAsHeader().withIgnoreEmptyLines()), iterate
CSVRecord objects instead of reading lines/splitting, map each CSVRecord to
ApplicationCsvInfo (adapt ApplicationCsvInfo.of to accept CSVRecord or convert
record fields to String[]), log parsing errors with the record number and
include the original exception as the cause, and catch IOExceptions for file
reading while closing resources via try-with-resources.

⚠️ Potential issue | 🟠 Major

Add file size and row count limits to prevent DoS.

The current implementation has no limits on:

  1. File size: Could cause OutOfMemoryError with very large files
  2. Number of rows: Loading all rows into memory could exhaust heap space

Apply this diff to add safety limits:

+private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+private static final int MAX_ROWS = 10000;
+
 public List<ApplicationCsvInfo> read(MultipartFile file) {
     List<ApplicationCsvInfo> results = new ArrayList<>();

     if (file == null || file.isEmpty()) {
         log.warn("업로드된 파일이 비어있습니다.");
         return results;
     }

+    if (file.getSize() > MAX_FILE_SIZE) {
+        throw new IllegalArgumentException("파일 크기가 너무 큽니다. 최대 10MB까지 가능합니다.");
+    }
+
     try (BufferedReader reader =
                  new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {

         String line;
         boolean isHeader = true;
         int lineNumber = 0;

         while ((line = reader.readLine()) != null) {
             lineNumber++;
+            if (lineNumber > MAX_ROWS + 1) { // +1 for header
+                throw new IllegalArgumentException("파일 행 수가 너무 많습니다. 최대 " + MAX_ROWS + "행까지 가능합니다.");
+            }
             if (line.isBlank()) continue;

Adjust the limits based on your production requirements.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public List<ApplicationCsvInfo> read(MultipartFile file) {
List<ApplicationCsvInfo> results = new ArrayList<>();
if (file == null || file.isEmpty()) {
log.warn("업로드된 파일이 비어있습니다.");
return results;
}
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String line;
boolean isHeader = true;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.isBlank()) continue;
if (isHeader) {
isHeader = false;
continue;
}
try {
String[] values = line.split(CSV_DELIMITER, -1);
results.add(ApplicationCsvInfo.of(values));
} catch (Exception e) {
log.error("CSV 파싱 실패 (Line {}): {}", lineNumber, e.getMessage());
throw new RuntimeException("CSV 파싱 오류");
}
}
} catch (Exception e) {
log.error("파일 읽기 실패: {}", e.getMessage(), e);
throw new RuntimeException("CSV 읽기 오류");
}
return results;
}
// Add at the top of CsvReader class, before the read(…) method
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static final int MAX_ROWS = 10000;
public List<ApplicationCsvInfo> read(MultipartFile file) {
List<ApplicationCsvInfo> results = new ArrayList<>();
if (file == null || file.isEmpty()) {
log.warn("업로드된 파일이 비어있습니다.");
return results;
}
// Enforce file size limit
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException("파일 크기가 너무 큽니다. 최대 10MB까지 가능합니다.");
}
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String line;
boolean isHeader = true;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
// Enforce row count limit (+1 to account for header row)
if (lineNumber > MAX_ROWS + 1) {
throw new IllegalArgumentException(
"파일 행 수가 너무 많습니다. 최대 " + MAX_ROWS + "행까지 가능합니다.");
}
if (line.isBlank()) continue;
if (isHeader) {
isHeader = false;
continue;
}
try {
String[] values = line.split(CSV_DELIMITER, -1);
results.add(ApplicationCsvInfo.of(values));
} catch (Exception e) {
log.error("CSV 파싱 실패 (Line {}): {}", lineNumber, e.getMessage());
throw new RuntimeException("CSV 파싱 오류");
}
}
} catch (Exception e) {
log.error("파일 읽기 실패: {}", e.getMessage(), e);
throw new RuntimeException("CSV 읽기 오류");
}
return results;
}
🤖 Prompt for AI Agents
In src/main/java/life/mosu/mosuserver/application/admin/util/CsvReader.java
around lines 20 to 58, add safeguards to prevent DoS by enforcing a maximum file
size and maximum row count: before opening the stream check
MultipartFile.getSize() against a configurable MAX_FILE_SIZE and reject with a
clear RuntimeException/log if exceeded; while reading, maintain a row counter
and stop/throw once a configurable MAX_ROWS limit is hit to avoid unbounded
memory use; also consider reading and validating rows incrementally (or
streaming) rather than accumulating unlimited results, and surface specific
error messages when limits are exceeded.

}
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
package life.mosu.mosuserver.application.application;

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import life.mosu.mosuserver.domain.application.entity.ApplicationJpaEntity;
import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity;
import life.mosu.mosuserver.domain.examapplication.entity.ExamApplicationJpaEntity;
Expand All @@ -17,6 +10,10 @@
import life.mosu.mosuserver.presentation.application.dto.ExamApplicationResponse;
import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationWithStatus;

import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

public record ApplicationContext(
List<ApplicationJpaEntity> applications,
List<ExamApplicationWithStatus> examApplications,
Expand Down Expand Up @@ -130,6 +127,7 @@ private Map.Entry<Long, ExamApplicationResponse> createExamApplicationResponse(
exam.getExamDate(),
subjects,
lunchName
// examApp.getIsTestPaperChecked()

Choose a reason for hiding this comment

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

medium

주석 처리된 코드가 남아있습니다. 사용하지 않는 코드는 가독성을 위해 제거하는 것이 좋습니다.

);

return Map.entry(examApp.getApplicationId(), response);
Expand Down
Loading
Loading