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) {

Choose a reason for hiding this comment

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

medium

현재 구현은 CSV 처리 중 첫 번째 오류가 발생하면 전체 작업을 중단하고 롤백합니다. 이는 데이터 일관성을 보장하는 안전한 방법이지만, CSV 파일에 여러 오류가 있을 경우 사용자가 한 번에 하나의 오류만 보고받게 되어 불편할 수 있습니다. 모든 행을 검사하고 모든 오류를 한 번에 보고하는 방식을 고려해볼 수 있습니다. 예를 들어, 1) 모든 행에 대한 유효성 검사를 먼저 수행하고, 2) 유효성 검사를 통과하면 모든 행을 데이터베이스에 저장하는 2단계 처리 방식을 사용할 수 있습니다. 이렇게 하면 트랜잭션의 원자성을 유지하면서 사용자 경험을 개선할 수 있습니다.

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 일괄 처리 중 오류 발생");

Choose a reason for hiding this comment

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

high

예외를 새로 생성하여 던질 때 기존 예외 정보를 포함하지 않으면 근본적인 원인을 파악하기 어려워집니다. log.error로 기록은 되지만, 호출 스택 상위에서는 원본 예외 정보를 알 수 없습니다. 디버깅 편의성을 위해 원본 예외(e)를 함께 전달하는 것이 좋습니다.

                throw new RuntimeException("CSV 일괄 처리 중 오류 발생: 행 " + lineNo, e);

}
}

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


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) {

Choose a reason for hiding this comment

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

medium

CSV 파일의 컬럼 수인 11이 매직 넘버로 사용되고 있습니다. 이 값의 의미를 명확히 하고 유지보수성을 높이기 위해 private static final int EXPECTED_COLUMN_COUNT = 11;과 같이 상수로 정의하고, 이 상수를 사용하여 검증하는 것을 권장합니다.

throw new IllegalArgumentException("CSV 행이 필수 컬럼 수(11개)보다 부족합니다. 실제 컬럼 수: " + values.length);
}
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

날짜 파싱 실패 시 예외 메시지가 너무 일반적입니다. 어떤 값 때문에 오류가 발생했는지 알 수 있도록, "날짜 형식이 올바르지 않습니다: '" + dateStr + "'"와 같이 실패한 문자열 값을 메시지에 포함하면 디버깅에 큰 도움이 됩니다.

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

}
}

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));

application.changeStatus(ApplicationStatus.APPROVED);
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);

ExamApplicationRequest examApplicationRequest = new ExamApplicationRequest(
exam.getId(),
csvInfo.isLunchChecked()
);
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);

Choose a reason for hiding this comment

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

high

String.split(",")을 사용하여 CSV를 파싱하는 방식은 필드 값에 쉼표가 포함된 경우(예: "some, text")를 제대로 처리하지 못합니다. 이는 데이터 파싱 오류를 유발할 수 있습니다. Apache Commons CSV나 OpenCSV와 같은 CSV 전문 라이브러리를 사용하여 이러한 엣지 케이스를 안정적으로 처리하는 것을 강력히 권장합니다.

results.add(ApplicationCsvInfo.of(values));
} catch (Exception e) {
log.error("CSV 파싱 실패 (Line {}): {}", lineNumber, e.getMessage());
throw new RuntimeException("CSV 파싱 오류");

Choose a reason for hiding this comment

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

high

예외 발생 시 새로운 RuntimeException을 생성할 때 원본 예외(e)를 포함하지 않아 근본 원인 추적이 어렵습니다. throw new RuntimeException("..."); 대신 `throw new RuntimeException("...

Suggested change
throw new RuntimeException("CSV 파싱 오류");
throw new RuntimeException("CSV 파싱 오류", e);


}
}
} catch (Exception e) {
log.error("파일 읽기 실패: {}", e.getMessage(), e);
throw new RuntimeException("CSV 읽기 오류");
}

return results;
}
}
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()
);

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