feature/36-pdf-and-excel-merge-to-application#38
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Walkthrough도메인 Status 모델이 ApplicationStatus 기반으로 변경되었고, 상태 조회/갱신 계약이 분리·확장되었습니다. User 도메인(집계/계약/역할)이 추가·정리되었습니다. 인프라에서는 gRPC/Redis 캐시를 포함한 Status 어댑터가 도입되고, PDF/Excel 생성기가 실제 도메인 데이터 기반 입력 시그니처로 대규모 개편되었습니다. 일부 gRPC DTO가 도메인 타입으로 대체되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Caller as Caller
participant Adapter as StatusPersistenceAdapter
participant Cache as StatusCacheRepository
participant gRPC as StatusGrpcClient
Caller->>Adapter: queryStatusByReceiptCode(receiptCode)
activate Adapter
Adapter->>gRPC: getStatusByReceiptCode(receiptCode)
activate gRPC
gRPC-->>Adapter: InternalStatusResponse?
deactivate gRPC
Adapter-->>Caller: Status?
deactivate Adapter
Caller->>Adapter: queryStatusByReceiptCodeInCache(receiptCode)
activate Adapter
Adapter->>Cache: findById(receiptCode)
activate Cache
Cache-->>Adapter: Optional<StatusCacheRedisEntity>
deactivate Cache
Adapter-->>Caller: StatusCache?
deactivate Adapter
Caller->>Adapter: updateExamCode(receiptCode, examCode)
activate Adapter
Adapter->>gRPC: updateExamCode(receiptCode, examCode)
deactivate Adapter
sequenceDiagram
autonumber
actor Caller as Controller/UseCase
participant Gen as IntroductionPdfGenerator
participant Conv as TemplateProcessor/PdfProcessor
participant Facade as PdfDocumentFacade
participant Merger as PdfMerger
Caller->>Gen: generate(applicationList)
loop for each Application
Gen->>Conv: template -> HTML
Conv-->>Gen: htmlString
Gen->>Conv: convertHtmlToPdf(htmlString)
Conv-->>Gen: ByteArrayOutputStream
Gen->>Facade: toPdfDocument(byteArray)
Facade-->>Gen: PdfDocument
Gen->>Merger: merge(document)
end
Gen-->>Caller: mergedPdf(ByteArray)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
✨ Finishing Touches🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 18
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (10)
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/examcode/usecase/GrantExamCodesUseCase.kt (2)
49-67: NPE 위험: 널 단언/캐스팅 제거 및 동시성 제한 권장
- application.streetAddress as String, application.applicationType!! 는 런타임 NPE/CCE 위험.
- Kakao 지오코딩을 전체 동시 실행하면 외부 API 쿼터 초과 가능.
수정 예시(diff):
- val address = application.streetAddress as String + val address = requireNotNull(application.streetAddress) { + "streetAddress is null for receiptCode=${application.receiptCode}" + } ... - ExamCodeInfo( + ExamCodeInfo( receiptCode = application.receiptCode, - applicationType = application.applicationType!!, // 전형 유형 + applicationType = requireNotNull(application.applicationType) { + "applicationType is null for receiptCode=${application.receiptCode}" + }, distance = distance )동시성 제한(예):
// dispatcher 또는 세마포어로 병렬수 제한 val dispatcher = kotlinx.coroutines.Dispatchers.IO.limitedParallelism(8) applications.map { application -> async(dispatcher) { /* 기존 로직 */ } }
91-97: 영역 내 3자리 일련번호로 수험번호 생성 로직 변경
GrantExamCodesUseCase.kt의 assignNumbersInGroup()에서 receiptCode 기반 포맷 대신 그룹 내 순차 번호(001..N)로 포맷하도록 수정하세요.수정 예시:
- private fun assignNumbersInGroup(distanceGroup: DistanceGroup) { - distanceGroup.examCodeInfoList.forEach { examCodeInfo -> - val receiptCode = String.format("%03d", examCodeInfo.receiptCode) - val examCode = "${distanceGroup.applicationType}${distanceGroup.distanceCode}$receiptCode" - examCodeInfo.examCode = examCode - } - } + private fun assignNumbersInGroup(distanceGroup: DistanceGroup) { + distanceGroup.examCodeInfoList + .sortedBy { it.receiptCode } + .forEachIndexed { idx, examCodeInfo -> + val serial = String.format("%03d", idx + 1) + val examCode = "${distanceGroup.applicationType}${distanceGroup.distanceCode}$serial" + examCodeInfo.examCode = examCode + } + }casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/user/UserGrpcClient.kt (1)
36-41: gRPC 호출에 데드라인(타임아웃) 부재 — 잠재적 영구 대기네트워크 장애 시 코루틴이 무기한 대기할 수 있습니다. stub에 데드라인을 설정하세요.
예시:
+import java.util.concurrent.TimeUnit ... - val userStub = UserServiceGrpc.newStub(channel) + val userStub = UserServiceGrpc + .newStub(channel) + .withDeadlineAfter(3, TimeUnit.SECONDS) // 필요시 구성값으로 노출casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/ConverterPropertiesCreator.kt (1)
26-37: 폰트 리소스 디렉터리 누락 확인 및 경로 검증 필요리포지토리에
src/main/resources/fonts폴더도, 폰트 파일(ttf/otf 등)도 존재하지 않습니다.
따라서fontPath에 지정된"/fonts/"가 클래스패스 내 리소스가 아니라 파일시스템 절대경로로 해석되어 런타임에 폰트를 못찾습니다.
리소스 디렉터리(resources/fonts)에 폰트 파일을 추가하거나,ClassLoader.getResourceAsStream등으로 클래스패스 내 로딩하도록 수정하세요.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/status/StatusGrpcClient.kt (2)
82-115: nullable 반환 타입 정합성현재 항상 값을 만들어 반환하므로
InternalStatusResponse?가 불필요하게 널러블입니다. 정책이 “없으면 예외”라면 널 제거, “없으면 null”이라면 NOT_FOUND만 잡아 null을 반환하도록 일관화하세요.옵션 A(예외 전파; 널 제거):
-suspend fun getStatusByReceiptCode(receiptCode: Long): InternalStatusResponse? { +suspend fun getStatusByReceiptCode(receiptCode: Long): InternalStatusResponse {옵션 B(NOT_FOUND → null):
- val response = + val response = suspendCancellableCoroutine { continuation -> statusStub.getStatusByReceiptCode( @@ } - return InternalStatusResponse( + return try { + InternalStatusResponse( id = response.status.id, applicationStatus = mapProtoApplicationStatus(response.status.applicationStatus), examCode = response.status.examCode.takeIf { it.isNotBlank() }, isFirstRoundPass = response.status.isFirstRoundPass, isSecondRoundPass = response.status.isSecondRoundPass, receiptCode = response.status.receiptCode, - ) + ) + } catch (e: io.grpc.StatusRuntimeException) { + if (e.status.code == io.grpc.Status.Code.NOT_FOUND) null else throw e + }
35-37: 외부 호출 타임아웃(Deadline) 없음gRPC 호출에 데드라인이 없어 hang 위험이 있습니다. 모든 스텁에 짧은 기본 데드라인을 설정하세요.
+import java.util.concurrent.TimeUnit @@ - val statusStub = StatusServiceGrpc.newStub(channel) + val statusStub = StatusServiceGrpc.newStub(channel).withDeadlineAfter(3, TimeUnit.SECONDS) @@ - val statusStub = StatusServiceGrpc.newStub(channel) + val statusStub = StatusServiceGrpc.newStub(channel).withDeadlineAfter(3, TimeUnit.SECONDS) @@ - val statusStub = StatusServiceGrpc.newStub(channel) + val statusStub = StatusServiceGrpc.newStub(channel).withDeadlineAfter(3, TimeUnit.SECONDS)Also applies to: 83-85, 130-131
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/presentation/PdfTestController.kt (1)
14-19: 테스트용 엔드포인트는 프로덕션에서 비활성화로컬/개발 프로파일에서만 노출되도록 가드하세요.
import org.springframework.web.bind.annotation.RestController +import org.springframework.context.annotation.Profile @@ @RestController +@Profile("local") @RequestMapping("/pdf") class PdfTestController(casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationCheckListGenerator.kt (1)
88-93: workbook.close()가 finally에서 조건부로만 호출됨현재는 outputStream.close() 예외시에만 workbook.close()가 호출되어 리소스 누수 위험이 큽니다. 항상 workbook을 닫도록 보강하세요.
위의 첫 번째 코멘트의 diff에 포함된 runCatching { workbook.close() } 적용으로 해결됩니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintAdmissionTicketGenerator.kt (2)
241-262: 더미 이미지 바이트가 유효 PNG가 아님
ByteArray(100){0}는 손상 이미지로 POI에서 예외가 나올 수 있습니다. 1x1 PNG 상수를 사용하세요.- val dummyImageBytes = ByteArray(100) { 0 } + val dummyImageBytes = java.util.Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAOaZVy8AAAAASUVORK5CYII=" + )추가:
import java.util.Base64필요.
264-275: 빈 셀에 값이 설정되지 않을 수 있음템플릿 셀 미존재 시
getCell이 null을 반환합니다. 없으면 생성하도록 보완하세요.- val r = sheet.getRow(ref.row) - if (r != null) { - val c = r.getCell(ref.col.toInt()) - c?.setCellValue(value) - } + val row = sheet.getRow(ref.row) ?: sheet.createRow(ref.row) + val cellIndex = ref.col.toInt() + val cell = row.getCell(cellIndex) ?: row.createCell(cellIndex, CellType.STRING) + cell.setCellValue(value)
🧹 Nitpick comments (72)
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicationInfo.kt (3)
8-14: KDoc 정확성 OK + 헤더 개수 검증 권고설명과 구현(0~59 인덱스, 총 60컬럼)이 일치합니다. 실수 예방을 위해 헤더 생성 후 60컬럼 여부를 검증하거나 단위테스트로 고정값을 체크해두길 권장합니다.
27-99: 헤더 UX 개선: 고정행/필터/자동 너비 적용 제안엑셀 사용성 향상을 위해 1행 고정, 자동필터, 컬럼 너비 자동조정 적용을 제안합니다.
적용 예시(diff):
fun format() { val row: Row = sheet.createRow(0) row.createCell(0).setCellValue("접수번호") ... row.createCell(59).setCellValue("수험번호") + + // UX 개선: 헤더 고정 및 필터/너비 설정 + sheet.createFreezePane(0, 1) + sheet.setAutoFilter(org.apache.poi.ss.util.CellRangeAddress(0, 0, 0, 59)) + for (i in 0..59) sheet.autoSizeColumn(i) }
16-16: 대용량 행 예상 시 SXSSFWorkbook 고려지원서 건수가 많다면 XSSFWorkbook 대신 스트리밍(SXSSFWorkbook) 전환을 검토하세요. 메모리 사용량을 크게 줄일 수 있습니다. 현재 반환 타입이 Workbook이므로 교체 영향은 최소입니다.
casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/value/UserRole.kt (1)
3-6: 도메인 계층에서 gRPC 언급 제거 권장(문서화 관점)KDoc에 gRPC 매핑 언급이 있어 계층 누수가 보입니다. 문서는 도메인 의미(권한의 역할)에 집중하고, gRPC 매핑은 인프라 어댑터 측 주석/문서로 옮기는 것을 권장합니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicantCode.kt (2)
26-31: 헤더 UX 개선: 고정행/필터/자동 너비 적용 제안ApplicationInfo와 동일하게 1행 고정 및 필터/자동 너비를 제안합니다.
fun format() { val row: Row = sheet.createRow(0) row.createCell(0).setCellValue("수험번호") row.createCell(1).setCellValue("접수번호") row.createCell(2).setCellValue("성명") + sheet.createFreezePane(0, 1) + sheet.setAutoFilter(org.apache.poi.ss.util.CellRangeAddress(0, 0, 0, 2)) + for (i in 0..2) sheet.autoSizeColumn(i) }
15-15: 스트리밍 워크북 고려대량 출력 시 메모리 사용을 줄이기 위해 SXSSFWorkbook 전환을 검토하세요.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/examcode/usecase/GrantExamCodesUseCase.kt (3)
79-88: Double 동등성 기반 그룹핑은 사실상 ‘개별값=개별그룹’이 됩니다실수 비교(distinct())는 거의 모든 거리값을 고유로 만들어 그룹 수가 과도해집니다. 요구사항이 “거리 순위 그룹”이라면 버킷팅(반올림/절삭) 키로 그룹핑하세요. 예: 100m/1km 단위 버킷.
예시:
val bucketSize = 100.0 // meter 단위 예시 val groupsByBucket = sortedInfos.groupBy { (it.distance / bucketSize).roundToInt() }
34-47: 전형 유형 커버리지 확인COMMON/SOCIAL/MEISTER 외 전형이 존재한다면 누락됩니다. 정책상 매핑 표를 단일 함수로 모아 관리하면 안전합니다.
100-106: 저장 단계의 탄력성/성능 개선 제안대량 업데이트 시 트랜잭션 경계/배치/재시도(백오프)/부분 실패 로깅을 고려하세요. 외부 저장소에 대한 순차 호출은 느릴 수 있습니다.
casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/aggregates/User.kt (1)
6-12: 도메인 무결성 보강(선택): 값 객체/검증 도입phoneNumber를 값 객체로 감싸 형식 검증/정규화, PII 로깅 방지 정책을 도메인에 녹일지 검토해 보세요. 간단한 KDoc 추가도 유지보수에 도움됩니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/TemplateFileName.kt (1)
20-21: 템플릿 네이밍 컨벤션 정합성 점검 제안 (nonsmoking).
다른 템플릿은 언더스코어 스네이크케이스인데, NON_SMOKING의 값만 "nonsmoking"으로 붙여씁니다. 의도라면 KDoc에 명시하고, 아니라면 파일/상수 중 한쪽을 "non_smoking"으로 정렬하거나, 하위 호환을 위해 alias 상수를 추가하는 방식을 고려하세요.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfData.kt (2)
14-14: toMap이 내부 MutableMap을 그대로 노출 — 캡슐화 약화외부에서 반환값을 변경하면 PdfData 내부 상태가 변합니다. 불변 Map을 반환하거나 복사본을 반환하는 편이 안전합니다.
다음처럼 반환 타입을 불변 Map으로 바꾸는 것을 제안합니다:
- fun toMap(): MutableMap<String, Any> = values + fun toMap(): Map<String, Any> = values.toMap()
16-23: Any/MutableMap 중심 API는 타입 안정성·오남용 여지 있음
- get/set 모두 Any로 열려 있어 런타임 캐스팅 오류 가능성이 있습니다.
- 템플릿 바인딩 키를 상수(예: enum/Sealed)로 한정하거나, 제네릭 getter/연산자 오버로드로 사용성을 개선할 수 있습니다.
예시:
- fun getValue(key: String): Any? = values[key] + @Suppress("UNCHECKED_CAST") + fun <T> getValue(key: String): T? = values[key] as? T + - fun setValue( + operator fun set( key: String, value: Any, ) { values[key] = value } + + operator fun get(key: String): Any? = values[key]casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/user/UserGrpcClient.kt (3)
44-59: suspendCancellableCoroutine 사용 시 타입 명시 및 취소 고려타입 파라미터를 명시하면 가독성이 좋아집니다. 또한 취소 시 로그 남기기 등 최소한의 처리 권장합니다(데드라인을 도입하면 실질 리스크는 줄어듦).
- val response = - suspendCancellableCoroutine { continuation -> + val response = + suspendCancellableCoroutine<UserServiceProto.GetUserInfoResponse> { continuation -> userStub.getUserInfoByUserId( request, object : StreamObserver<UserServiceProto.GetUserInfoResponse> { override fun onNext(value: UserServiceProto.GetUserInfoResponse) { continuation.resume(value) } override fun onError(t: Throwable) { continuation.resumeWithException(t) } override fun onCompleted() {} }, ) + // continuation.invokeOnCancellation { /* 로그/메트릭 등 */ } }
61-68: 응답 id 파싱 실패 가능성 가드UUID 포맷 불일치 시 예외가 발생합니다. gRPC 계약상 항상 유효하다면 OK이나, 방어 로직 혹은 의미 있는 예외 전환을 고려하세요.
예시:
val id = runCatching { UUID.fromString(response.id) } .getOrElse { throw IllegalArgumentException("Invalid user id: ${response.id}", it) }
76-83: UNSPECIFIED → USER 디폴트 매핑, 비즈니스 의도 확인 필요권한이 미정인 사용자를 일반 사용자로 취급하는 것이 보안·권한 모델에 부합하는지 확인 바랍니다. 안전측면에선 거부/에러가 더 낫습니다.
대안:
- UserServiceProto.UserRole.UNSPECIFIED -> UserRole.USER // 기본값으로 USER 설정 + UserServiceProto.UserRole.UNSPECIFIED -> error("Unspecified user role from user-service")casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationCommandStatusContract.kt (2)
3-5: 명령 성격상 비동기 I/O 가능 — suspend 고려영속/원격 호출을 수반한다면 서명에 suspend를 추가해 일관성을 갖추는 것을 권장합니다(동일 모듈의 Query 계약과도 정합성 확인).
-interface ApplicationCommandStatusContract { - fun updateExamCode(receiptCode: Long, examCode: String) +interface ApplicationCommandStatusContract { + suspend fun updateExamCode(receiptCode: Long, examCode: String) }
3-5: examCode 도메인 제약 명시/강제형식(길이/패턴) 제약이 있다면 value class(또는 별도 타입)로 캡슐화해 유효성 보장을 권장합니다. KDoc로 계약을 명문화해도 좋습니다.
예시:
@JvmInline value class ExamCode(val value: String)서명:
suspend fun updateExamCode(receiptCode: Long, examCode: ExamCode)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/UserContract.kt (1)
3-4: 빈 블록 제거로 detekt 경고 해결내용이 없다면 중괄호를 제거해 EmptyClassBlock 경고를 없앨 수 있습니다.
-interface UserContract : ApplicationQueryUserContract { -} +interface UserContract : ApplicationQueryUserContractcasper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/StatusContract.kt (1)
3-3: 계약 집합의 단일 진입점으로 적절Query/Command 분리를 유지하면서 통합 타입을 제공하는 접근이 명확합니다. 간단한 KDoc 추가로 사용 의도를 드러내면 더 좋습니다.
예시:
/** * 상태 조회/갱신 유스케이스의 파사드형 인터페이스. */casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/ConverterPropertiesCreator.kt (3)
9-14: KDoc 보완 제안현재 구현이 파일 시스템 경로("/fonts/") 기준으로 폰트를 로딩한다는 점과 JAR/컨테이너 배포 시 classpath 로딩으로의 전환 필요 가능성을 KDoc에 한 줄 명시하면 운영 시 혼선이 줄어듭니다.
/** * PDF 변환을 위한 변환 속성을 생성하는 클래스입니다. * * iText PDF 라이브러리에서 HTML을 PDF로 변환할 때 필요한 설정을 관리합니다. * 특히 한글 폰트 설정을 담당하여 PDF에서 한글이 정상적으로 표시되도록 합니다. + * 현재 구현은 파일 시스템 경로("/fonts/") 기준으로 폰트를 로딩합니다. JAR 배포 시 classpath 로딩으로의 전환이 필요할 수 있습니다. */
19-25: 예외 문서화 범위 확대변환 과정에서는 폰트 로딩 실패 외에도 iText 내부 예외가 발생할 수 있습니다. KDoc에 추가 명시를 권장합니다.
/** * PDF 변환을 위한 ConverterProperties를 생성합니다. * 한글 폰트 설정을 포함하여 PDF 생성 시 필요한 모든 속성을 구성합니다. * * @return 설정된 ConverterProperties 객체 * @throws IllegalStateException 폰트 파일을 찾을 수 없는 경우 + * @throws com.itextpdf.kernel.PdfException PDF 변환 중 iText 내부 예외가 발생한 경우 */
26-41: 폰트 재로딩/IO 오버헤드 제거 및 초기화 방식 개선매 변환 호출마다 폰트를 다시 읽어들입니다. 폰트는 변하지 않으므로 lazy 캐싱으로 1회만 로딩해 재사용하세요. 또한 fontPath는 불변이 적합합니다.
class ConverterPropertiesCreator { - private var fontPath: String = "/fonts/" + private val fontPath: String = "/fonts/" + + // 애플리케이션 생애주기 내 1회만 폰트를 로딩해 재사용 + private val cachedFontProvider: DefaultFontProvider by lazy { + val provider = DefaultFontProvider(false, false, false) + Font.fonts.forEach { font -> + try { + val fontProgram = FontProgramFactory.createFont("$fontPath$font") + provider.addFont(fontProgram) + } catch (e: IOException) { + throw IllegalStateException("폰트 파일을 찾을 수 없습니다: $font", e) + } + } + provider + } fun createConverterProperties(): ConverterProperties { - val properties = ConverterProperties() - val fontProvider = DefaultFontProvider(false, false, false) - - Font.fonts.forEach { font -> - try { - val fontProgram = FontProgramFactory.createFont("$fontPath$font") - fontProvider.addFont(fontProgram) - } catch (e: IOException) { - throw IllegalStateException("폰트 파일을 찾을 수 없습니다: $font", e) - } - } - - properties.fontProvider = fontProvider + val properties = ConverterProperties() + properties.fontProvider = cachedFontProvider return properties }또한 classpath 리소스에서 폰트를 읽어야 하는 배포 환경이라면 ResourcePatternResolver/ResourceLoader를 사용한 classpath 스캔으로 전환하는 것을 권장합니다(예: "classpath:/fonts/*.ttf"). 필요하시면 샘플 구현 제공 가능합니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/PdfProcessor.kt (1)
24-28: 스트리밍 API 오버로드 제안(대용량/복사 최소화)대용량 HTML 변환 시 불필요한 바이트 복사를 줄이기 위해 OutputStream을 받는 오버로드를 추가하면 좋습니다.
// 추가 메서드(신규) fun convertHtmlToPdf(html: String, out: OutputStream) { HtmlConverter.convertToPdf( html, out, converterPropertiesCreator.createConverterProperties() ) }casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/StatusCache.kt (1)
5-12: TTL 단위 명시 혹은 타입 강화(Duration 권장)ttl의 단위(초/밀리초)를 KDoc에 고정하거나 java.time.Duration/kotlin.time.Duration으로 타입 강화하면 오용을 줄일 수 있습니다.
+/** + * @param ttl TTL 단위(예: 초)를 명확히 기술하세요. Duration 사용 시 더 안전합니다. + */ data class StatusCache(casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/user/domain/UserPersistenceAdapter.kt (2)
15-23: runBlocking 사용 최소화 및 타임아웃 부여 필요동기 인터페이스를 맞추려는 의도는 이해되지만, 서버 스레드를 블로킹합니다. 최소한 gRPC 호출에 타임아웃을 걸어 장애 전파를 빠르게 하고, 가능하면 계약을 suspend로 승격해 블로킹을 제거하세요.
적용 예(블로킹 유지 + 타임아웃):
import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout @@ - override fun queryUserByUserId(userId: UUID): User = runBlocking { - userGrpcClient.getUserInfoByUserId(userId).run { - User( - id = id, - phoneNumber = phoneNumber, - name = name, - isParent = isParent, - ) - } - } + override fun queryUserByUserId(userId: UUID): User = runBlocking { + withTimeout(3_000L) { + val r = userGrpcClient.getUserInfoByUserId(userId) + User( + id = r.id, + phoneNumber = r.phoneNumber, + name = r.name, + isParent = r.isParent, + ) + } + }대안(권장, 비블로킹): 인터페이스를 suspend로 바꾸고 runBlocking 제거.
16-22: gRPC 예외 매핑 확인 필요NOT_FOUND 등 gRPC 예외가 그대로 전파됩니다. 도메인 규약에 맞는 예외 또는 null 반환 정책이 있다면 일관되게 매핑해 주세요.
원하시면 도메인 표준 예외(예: UserNotFound)로 매핑하는 패턴 코드를 제안하겠습니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/status/StatusGrpcClient.kt (1)
162-172: UNRECOGNIZED/UNSPECIFIED 분기 명시 권장생성 코드의
UNRECOGNIZED(및 존재 시UNSPECIFIED) 를 명시적으로 처리하면 의도가 더 분명합니다.원하시면 프로토 실제 enum에 맞춰 분기 코드를 제안하겠습니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/entity/StatusCacheRedisEntity.kt (1)
8-17: TTL/필드 변경 가능성 고려(선택)TTL 갱신이 필요하면
val→var로 변경하거나 저장 시 갱신 로직을 보장하세요. 엔티티를 data class로 두면 매핑·복사가 간결해집니다.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/presentation/PdfTestController.kt (2)
48-51: 민감 문서 응답에 캐시 금지 헤더 추가PDF 캐싱 방지를 위해
Cache-Control: no-store등을 추가하세요.return ResponseEntity.ok() .contentType(MediaType.APPLICATION_PDF) .header("Content-Disposition", "inline; filename=test.pdf") + .header("Cache-Control", "no-store") .body(pdfBytes) @@ return ResponseEntity.ok() .contentType(MediaType.APPLICATION_PDF) .header("Content-Disposition", "inline; filename=test-introduction.pdf") + .header("Cache-Control", "no-store") .body(pdfBytes)Also applies to: 79-82
22-45: 생성기 시그니처/도메인 의존 확인
dummyScore = Any()와IntroductionPdfGenerator.generate(List<Application>)사용이 실제 시그니처와 일치하는지 확인 바랍니다. 런타임 타입 미스매치 가능성 있습니다.원하시면 실제 도메인 조회/스코어 없이도 동작하는 작은 스텁/픽스처 유틸을 만들어 드립니다.
Also applies to: 56-76
casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationQueryStatusContract.kt (2)
6-9: 비동기 계약으로 승격 고려현재 인프라가 gRPC 비동기를 사용하므로 도메인 계약도 suspend로 승격하면 runBlocking 제거 및 전반적 비블로킹화가 가능합니다.
-interface ApplicationQueryStatusContract { - fun queryStatusByReceiptCode(receiptCode: Long): Status? - fun queryStatusByReceiptCodeInCache(receiptCode: Long): StatusCache? -} +interface ApplicationQueryStatusContract { + suspend fun queryStatusByReceiptCode(receiptCode: Long): Status? + suspend fun queryStatusByReceiptCodeInCache(receiptCode: Long): StatusCache? +}
7-8: null 의미 명세 요청null 반환이 “미존재”인지, “권한 없음”인지, “다운스트림 오류 마스킹”인지 명확히 해두세요. 호출자 처리가 달라집니다.
원하시면 KDoc 주석 템플릿을 추가해 드립니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/ApplicationPdfGenerator.kt (5)
30-36: KDoc 파라미터 설명 명확화 제안
score가 더미임을 KDoc에 남기신 건 좋습니다. 실제 도메인 도입 시 TODO 제거와 예외/검증 규약을 명시해 주세요.
51-60: Java Stream/널 단언 제거 및 메모리 사용 완화Java Stream +
!!대신 Kotlin 컬렉션/시퀀스를 쓰면 NPE 리스크를 없애고 가독성이 좋아집니다. 또한 모든 PDF를 메모리에 배열로 모으지 않고 순차 병합하면 피크 메모리를 줄일 수 있습니다.아래처럼 교체를 제안합니다:
- val outStream = - templates.stream() - .map { template -> - templateProcessor.convertTemplateIntoHtmlString(template, data.toMap()) - } - .map { html -> - pdfProcessor.convertHtmlToPdf(html) - } - .toArray { size -> arrayOfNulls<ByteArrayOutputStream>(size) } + val pdfStreams: List<ByteArrayOutputStream> = + templates.map { template -> + val html = templateProcessor.convertTemplateIntoHtmlString(template, data.toMap()) + pdfProcessor.convertHtmlToPdf(html) + } @@ - for (pdfStream in outStream) { - val pdfDoc = pdfDocumentFacade.getPdfDocument(pdfStream!!) - mergeDocument(pdfMerger, pdfDoc) - } + for (pdfStream in pdfStreams) { + val pdfDoc = pdfDocumentFacade.getPdfDocument(pdfStream) + mergeDocument(pdfMerger, pdfDoc) + }또는 더 나아가 템플릿을 순회하며 즉시 변환/병합하도록 스트리밍 처리할 수도 있습니다.
Also applies to: 66-69
61-74: Document 객체 불필요 생성 제거
PdfMerger는PdfDocument만으로 동작하므로Document를 만들지 않아도 됩니다. 간소화로 오버헤드를 줄일 수 있습니다.- val mergedDocument = PdfDocument(PdfWriter(outputStream)) - val pdfMerger = PdfMerger(mergedDocument) - val document = Document(mergedDocument) + val mergedDocument = PdfDocument(PdfWriter(outputStream)) + val pdfMerger = PdfMerger(mergedDocument) @@ - document.close() + mergedDocument.close()
76-83: 파라미터 명 가독성
mergeDocument(merger, document)의 매개변수명이 지역 변수document와 의미가 섞여 혼동 여지 있습니다.src등으로 변경 권장.- private fun mergeDocument( - merger: PdfMerger, - document: PdfDocument?, - ) { - if (document != null) { - merger.merge(document, 1, document.numberOfPages) - document.close() + private fun mergeDocument( + merger: PdfMerger, + src: PdfDocument?, + ) { + if (src != null) { + merger.merge(src, 1, src.numberOfPages) + src.close() } }
86-102: 템플릿 선택/삽입 위치 검증
RECOMMENDATION을 인덱스 2로 삽입해 INTRODUCTION 앞에 오도록 했는데, 기획상 위치가 맞는지 확인 부탁드립니다. 또한LinkedList대신 가변 리스트로 충분합니다.- val result = - LinkedList( - listOf( + val result = mutableListOf( TemplateFileName.APPLICATION_FOR_ADMISSION, TemplateFileName.PRIVACY_AGREEMENT, TemplateFileName.INTRODUCTION, TemplateFileName.NON_SMOKING, TemplateFileName.SMOKING_EXAMINE, - ), - ) + )casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicantCodesGenerator.kt (4)
22-29: 메서드 계약 명확화 제안설명대로라면 “1차 합격자”만 포함되어야 합니다. 입력은 전체 목록이어도, 내부에서 필터링 규약을 포함시키는 것이 안전합니다(아래 리팩터 참고).
43-53: 워크북 자원 해제 및 스트림 flushApache POI 워크북을 명시적으로 닫아 메모리/FD 누수를 방지하고, 응답 스트림은 flush 해 주세요.
- applicantCode.getWorkbook().write(response.outputStream) + val workbook = applicantCode.getWorkbook() + workbook.use { + it.write(response.outputStream) + response.outputStream.flush() + }
68-71: 셀 값 주입 보완 제안(정렬/표시 일관성)
- 접수번호는 숫자/문자열 혼선 방지를 위해 문자열로 고정한 점 좋습니다. 수험번호 정렬을 의도하면 위 루프 전에 정렬해 주세요.
- 이름 값에 앞/뒤 공백 트리밍 고려.
- row.createCell(2).setCellValue(application.applicantName ?: "") + row.createCell(2).setCellValue((application.applicantName ?: "").trim())
29-29: 메서드명 의미 구체화 제안
execute대신generateAndWrite혹은writeResponse가 역할을 더 잘 드러냅니다.이 변경이 외부 호출부에 영향 있는지 확인 부탁드립니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/IntroductionPdfGenerator.kt (2)
58-63: 페이지가 0인 PDF 병합 방지.빈 문서를 merge(document, 1, 0)로 호출하면 예외가 날 수 있습니다. 페이지 수 확인 후 병합하세요.
- private fun mergeDocument(merger: PdfMerger, document: PdfDocument?) { - if (document != null) { - merger.merge(document, 1, document.numberOfPages) - document.close() - } - } + private fun mergeDocument(merger: PdfMerger, document: PdfDocument?) { + if (document == null) return + val pages = document.numberOfPages + if (pages > 0) { + merger.merge(document, 1, pages) + } + document.close() + }
34-47: 대용량 병합 메모리 사용량 큼 — 스트리밍/파일 기반 병합 고려.수백 건 병합 시 ByteArray에 최종 결과를 올리는 구조는 메모리 피크가 큽니다. 임시 파일 기반 PdfWriter 또는 HTTP 스트리밍 반환(컨트롤러에서 write)로 전환을 검토하세요.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/StatusPersistenceAdapter.kt (1)
17-28: runBlocking 남용 — 비동기 경로로 노출 고려.Adapter에서 runBlocking은 스레드 점유를 유발합니다. Contract를 suspend로 바꾸고 호출측에서 비동기로 합성하는 방향을 검토해 주세요.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationInfoGenerator.kt (4)
59-65: 파일명 인코딩 개선 — filename 추가로 브라우저 호환성 강화.*현재 ISO-8859-1 변환만으로는 일부 브라우저에서 한글 파일명이 깨질 수 있습니다. filename* 파라미터를 함께 설정하세요.
- val formatFilename = "attachment;filename=\"전형자료" - val time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy년MM월dd일_HH시mm분")) - val fileName = String(("$formatFilename$time.xlsx\"").toByteArray(Charsets.UTF_8), Charsets.ISO_8859_1) - httpServletResponse.setHeader("Content-Disposition", fileName) + val time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy년MM월dd일_HH시mm분")) + val displayName = "전형자료${time}.xlsx" + val encoded = java.net.URLEncoder.encode(displayName, java.nio.charset.StandardCharsets.UTF_8) + .replace("+", "%20") + httpServletResponse.setHeader( + "Content-Disposition", + "attachment; filename=\"$displayName\"; filename*=UTF-8''$encoded" + )추가 import:
import java.net.URLEncoder import java.nio.charset.StandardCharsets
49-57: School 매핑 누락 — 항상 null로 출력됩니다.Application에 schoolCode가 없어 학교명이 항상 더미/공백으로 출력됩니다. 상위 계층에서 Application↔School을 조인해 넘기거나, 생성기 시그니처를 schoolsByCode: Map<String, School>로 받는 방향을 검토하세요.
107-121: 열 개수(60) 명세 vs 실제 셀 작성 범위 불일치 가능성.현재 0
14(15개) + 1542(28개) + 43~(16개) = 총 59개로 보입니다. 스펙이 60열이라면 범위를 재확인해 주세요. 체크 로직을 넣어 조기 검출하는 것도 좋습니다.
129-136: 알 수 없는 지원유형 처리.기본값을 ‘일반전형’으로 고정하면 데이터 문제를 숨길 수 있습니다. 미정/원본코드 노출 등으로 구분되게 하는 것을 제안합니다.
- private fun translateApplicationType(applicationType: String?): String { - return when (applicationType) { + private fun translateApplicationType(applicationType: String?): String { + return when (applicationType) { "COMMON" -> "일반전형" "MEISTER" -> "마이스터전형" "SOCIAL" -> "사회통합전형" - else -> "일반전형" + null -> "미정" + else -> applicationType // 원본 코드 노출로 데이터 이슈 파악 용이 } }casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationCheckListGenerator.kt (5)
55-57: status/user 파라미터 사용 여부 정리status/user를 조회해 전달하지만 insertDataIntoSheet에서 활용되지 않습니다. 불필요한 조회/전달 제거 또는 실제 사용 로직 추가가 필요합니다.
이 파일 내에서 status/user가 필요한 필드가 확정되면 키 매핑(receiptCode/UUID) 기준을 댓글로 알려주세요. 반영된 후 전체 호출부(Controller 포함) 일관성 검증 스크립트를 제공하겠습니다.
Also applies to: 59-67, 305-325
96-118: 미사용 createDummyApplication 제거 제안프로덕션 코드에 더미 생성 유틸이 남아 있습니다. 테스트 전용으로 이동하거나 삭제하세요.
- private fun createDummyApplication( - receiptCode: Long, - name: String, - schoolName: String, - ): Map<String, Any> { ... }
188-196: 중복 테두리 영역 정의 정리동일 영역(intArrayOf(18 + dh, 18 + dh, 6, 7))이 중복됩니다. 유지보수 혼란을 줄이기 위해 중복 제거를 권장합니다.
367-374: 전형 타입 매핑은 enum 직접 전달로 단순화현재 name 문자열로 변환 후 매핑합니다. ApplicationType?을 직접 받아 when으로 매핑하면 안전합니다.
+import hs.kr.entrydsm.domain.application.values.ApplicationType @@ - private fun translateApplicationType(applicationType: String?): String { - return when (applicationType) { + private fun translateApplicationType(applicationType: ApplicationType?): String { + return when (applicationType) { - "COMMON" -> "일반전형" - "MEISTER" -> "마이스터전형" - "SOCIAL" -> "사회통합전형" + ApplicationType.COMMON -> "일반전형" + ApplicationType.MEISTER -> "마이스터전형" + ApplicationType.SOCIAL -> "사회통합전형" else -> "일반전형" } } @@ - getCell(dh + 4, 1).setCellValue(translateApplicationType(application.applicationType?.name)) + sheet.getCell(dh + 4, 1).setCellValue(translateApplicationType(application.applicationType))
376-383: 전화번호 포맷: 숫자 이외 문자 제거 후 규칙 적용다양한 입력(공백/하이픈 포함)에 대비해 숫자만 추출 후 포맷팅하세요.
- private fun formatPhoneNumber(phoneNumber: String?): String { - if (phoneNumber.isNullOrBlank()) return "" - if (phoneNumber.length == 8) { - return phoneNumber.replace("(\\d{4})(\\d{4})".toRegex(), "$1-$2") - } - return phoneNumber.replace("(\\d{2,3})(\\d{3,4})(\\d{4})".toRegex(), "$1-$2-$3") - } + private fun formatPhoneNumber(phoneNumber: String?): String { + if (phoneNumber.isNullOrBlank()) return "" + val digits = phoneNumber.filter { it.isDigit() } + return when { + digits.length == 8 -> + digits.replace("(\\d{4})(\\d{4})".toRegex(), "$1-$2") + else -> + digits.replace("(\\d{2,3})(\\d{3,4})(\\d{4})".toRegex(), "$1-$2-$3") + } + }casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/presentation/ExcelTestController.kt (2)
35-41: 테스트용 엔드포인트는 운영에서 비활성화GET 엔드포인트로 파일 생성 노출은 보안/오용 우려가 큽니다. dev 전용 Profile/권한 보호를 적용하세요.
예시: @Profile("local", "dev") 또는 @PreAuthorize("hasRole('ADMIN')") 적용, 라우트 prefix에 관리용 네임스페이스 사용 등.
Also applies to: 45-52, 56-63, 65-76
73-76: Generator API 일관성다른 Generator는 execute(response, ...) 시그니처, CheckList만 printApplicationCheckList(..., response) 순서입니다. 호출 규약을 통일해 사용성을 높이세요.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/IntroductionPdfConverter.kt (3)
15-17: 미사용 의존성 주입(QuerySchoolContract)로 런타임 의존성 리스크현재 사용하지 않는 의존성을 강제 주입하면 빈 미구성 시 애플리케이션이 뜨지 않습니다. 사용 시점까지 제거하거나 ObjectProvider로 지연/옵셔널 주입을 권장합니다.
옵션 A(제거):
-class IntroductionPdfConverter( - private val querySchoolContract: QuerySchoolContract -) { +class IntroductionPdfConverter {옵션 B(옵셔널 주입):
class IntroductionPdfConverter( private val querySchoolContractProvider: org.springframework.beans.factory.ObjectProvider<QuerySchoolContract> ) { private fun setSchoolInfo(application: Application, values: MutableMap<String, Any>) { val qs = querySchoolContractProvider.ifAvailable // ... } }Also applies to: 43-53
66-74: 전화번호 포맷 보강(숫자만 추출 후 포맷)입력에 하이픈/공백이 섞여 있어도 안정적으로 처리되도록 숫자만 추출 후 규칙을 적용하세요.
- private fun toFormattedPhoneNumber(phoneNumber: String): String { - if (phoneNumber.length == 8) { - return phoneNumber.replace("(\\d{4})(\\d{4})".toRegex(), "$1-$2") - } - return phoneNumber.replace("(\\d{2,3})(\\d{3,4})(\\d{4})".toRegex(), "$1-$2-$3") - } + private fun toFormattedPhoneNumber(phoneNumber: String): String { + val digits = phoneNumber.filter { it.isDigit() } + return if (digits.length == 8) + digits.replace("(\\d{4})(\\d{4})".toRegex(), "$1-$2") + else + digits.replace("(\\d{2,3})(\\d{3,4})(\\d{4})".toRegex(), "$1-$2-$3") + }
80-85: examCode TODO 확정 필요PDF 템플릿에서 수험번호는 필수일 가능성이 높습니다. Status 연동 시 키(ReceiptCode/ExamCode) 기준과 조회 경로를 확정해 주세요.
원하시면 Status 연동용 인터페이스 초안과 단위테스트 템플릿을 생성해 드립니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfDataConverter.kt (8)
19-21: 미사용 의존성 정리 또는 경고 억제 필요
querySchoolContract가 현재 전혀 사용되지 않아 경고가 발생합니다. 실제 연동 전까지 경고 억제를 추가하거나 필드를 제거하세요.적용 예:
class PdfDataConverter( - private val querySchoolContract: QuerySchoolContract + @Suppress("unused") + private val querySchoolContract: QuerySchoolContract ) {
30-32:score: Any는 스키마가 불명확합니다타입 정보를 잃어 리팩터링/테스트/사용처 안전성이 떨어집니다. 최소한의 View/DTO 인터페이스를 도입하세요.
예:
interface ScoreView { val conversionScores: List<Double>; val attendance: Double; ... }
사용처(호출부) 모두가 해당 타입으로 전달하도록 점검 바랍니다.
96-98: 지역 표기 불일치(“비대전” vs “전국”)본 파일은 비대전, Excel 생성기는 전국을 사용합니다. 출력물이 혼재되지 않도록 통일하세요.
135-140: 시간 의존 로직 테스트 용이성 개선
YearMonth.now()는 테스트 불안정 요인입니다.Clock주입 또는 파라미터로 now를 전달하는 형태로 바꾸면 재현 가능성이 좋아집니다.
176-182: 표기 기호 일관성여기선 O/X(
toCircleBallotbox), 다른 곳은 체크박스(toBallotBox)를 사용합니다. 템플릿 기대값에 맞춰 통일하세요.
201-228: 불필요한 중복 할당 제거
applicationCase를 매 과목 루프마다 덮어씁니다. 루프 밖으로 이동하세요.private fun setAllSubjectScores( application: Application, values: MutableMap<String, Any>, ) { - // TODO: 성적 도메인이 없어서 더미값 사용 + // TODO: 성적 도메인이 없어서 더미값 사용 + values["applicationCase"] = "기술∙가정" val subjects = listOf("국어", "사회", "역사", "수학", "과학", "영어", "기술가정") subjects.forEach { subject -> val subjectPrefix = when (subject) { "국어" -> "korean" ... } with(values) { - put("applicationCase", "기술∙가정") put("${subjectPrefix}ThirdGradeSecondSemester", "A") ... } } }
269-277: 추천 전형 표기도 체크박스로 통일여기만
◯/""를 사용합니다.toBallotBox로 통일해 가독성과 유지보수성을 높이세요.- values["isDaejeonAndMeister"] = markIfTrue(isDaejeon && isMeister) - values["isDaejeonAndSocialMerit"] = markIfTrue(isDaejeon && isSocialMerit) - values["isNotDaejeonAndMeister"] = markIfTrue(!isDaejeon && isMeister) - values["isNotDaejeonAndSocialMerit"] = markIfTrue(!isDaejeon && isSocialMerit) + values["isDaejeonAndMeister"] = toBallotBox(isDaejeon && isMeister) + values["isDaejeonAndSocialMerit"] = toBallotBox(isDaejeon && isSocialMerit) + values["isNotDaejeonAndMeister"] = toBallotBox(!isDaejeon && isMeister) + values["isNotDaejeonAndSocialMerit"] = toBallotBox(!isDaejeon && isSocialMerit)그리고
markIfTrue는 제거 가능합니다.
87-91: 성별 표기 로직 이중화
isMale/isFemale와gender가 서로 다른 방식/기호로 관리됩니다. 동일 소스로부터 파생시키도록 정리하세요.Also applies to: 114-119
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintAdmissionTicketGenerator.kt (5)
85-88:schoolMap미사용 변수현재 매핑에 사용되지 않습니다. 제거하거나 이후 연동 시점에 활용하세요.
- val schoolMap = schools.associateBy { it.code } + // TODO: Application에 schoolCode 도입 시 사용 예정
97-97:startRowIndex파라미터가 사용되지 않습니다시그니처 단순화하거나 실제 오프셋 적용 로직을 구현하세요.
- fun fillApplicationData( - sheet: Sheet, - startRowIndex: Int, + fun fillApplicationData( + sheet: Sheet, application: Application, user: User?, school: School?, status: Status?, workbook: Workbook, )호출부:
- fillApplicationData(sourceSheet, 0, application, user, school, status, sourceWorkbook) + fillApplicationData(sourceSheet, application, user, school, status, sourceWorkbook)Also applies to: 201-209
210-216: 표기 통일 제안여기서는 비대전→“전국”으로 표기합니다. PDF 변환기(“비대전”)와 통일하세요.
277-283: 파일명 인코딩 호환성 강화(RFC 5987)한글 파일명 브라우저 호환을 위해
filename*사용을 권장합니다.- val fileName = String(("$formatFilename$time.xlsx\"").toByteArray(Charsets.UTF_8), Charsets.ISO_8859_1) - response.setHeader("Content-Disposition", fileName) + val file = "수험표$time.xlsx" + val ascii = file.replace("[^\\x20-\\x7E]".toRegex(), "_") + response.setHeader( + "Content-Disposition", + "attachment; filename=\"$ascii\"; filename*=UTF-8''${java.net.URLEncoder.encode(file, Charsets.UTF_8).replace("+", "%20")}" + )
38-41:drawing상태를 필드로 보관하는 대신 로컬 사용 고려시트마다 별도
DrawingPatriarch가 필요합니다. 현재 사용 흐름은 안전하지만, 상태 보관을 줄이면 오용 가능성을 낮출 수 있습니다.Also applies to: 248-259
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Cache: Disabled due to data retention organization setting
Knowledge Base: Disabled due to data retention organization setting
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (38)
casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/Status.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/StatusCache.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationCommandStatusContract.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationQueryStatusContract.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/StatusContract.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/values/ApplicationStatus.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/aggregates/User.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/ApplicationQueryUserContract.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/UserContract.kt(1 hunks)casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/value/UserRole.kt(2 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/examcode/usecase/GrantExamCodesUseCase.kt(2 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/StatusPersistenceAdapter.kt(0 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/StatusPersistenceAdapter.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/entity/StatusCacheRedisEntity.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/repository/StatusCacheRepository.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/user/domain/UserPersistenceAdapter.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/ConverterPropertiesCreator.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/IntroductionPdfConverter.kt(3 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfData.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfDataConverter.kt(8 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/TemplateFileName.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/ApplicationPdfGenerator.kt(3 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/IntroductionPdfGenerator.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/PdfProcessor.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/TemplateProcessor.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/presentation/PdfTestController.kt(3 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintAdmissionTicketGenerator.kt(6 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicantCodesGenerator.kt(2 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationCheckListGenerator.kt(6 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationInfoGenerator.kt(2 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicantCode.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicationInfo.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/presentation/ExcelTestController.kt(2 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/status/StatusGrpcClient.kt(2 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/user/UserGrpcClient.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/dto/status/ApplicationStatus.kt(0 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/dto/status/InternalStatusResponse.kt(1 hunks)casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/dto/user/InternalUserResponse.kt(1 hunks)
💤 Files with no reviewable changes (2)
- casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/StatusPersistenceAdapter.kt
- casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/dto/status/ApplicationStatus.kt
🧰 Additional context used
🪛 detekt (1.23.8)
casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/UserContract.kt
[warning] 3-4: The class or object UserContract is empty.
(detekt.empty-blocks.EmptyClassBlock)
🔇 Additional comments (25)
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicantCode.kt (1)
8-13: KDoc 보강 LGTM3컬럼 구조와 역할이 명확히 드러나 가독성이 좋아졌습니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/examcode/usecase/GrantExamCodesUseCase.kt (1)
14-22: 상태 계약 인터페이스 일관성 확인 필요(SaveExamCodeContract 사용)본 PR 전반에서 Status 도메인 계약이 분리된 것으로 보입니다. 이 클래스는 SaveExamCodeContract에 의존합니다. 최신 구조(예: ApplicationCommandStatusContract/StatusContract)와 일치하는지 확인이 필요합니다. 불일치 시 컴파일 실패/빈 주입 실패가 발생합니다.
대안 예시(diff):
-import hs.kr.entrydsm.domain.status.interfaces.SaveExamCodeContract +import hs.kr.entrydsm.domain.status.interfaces.ApplicationCommandStatusContract ... - private val saveExamCodeContract: SaveExamCodeContract, + private val statusCommand: ApplicationCommandStatusContract,casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/aggregates/User.kt (1)
6-12: 경량 도메인 객체 도입 LGTM불변 data class로 최소 속성만 담아 깔끔합니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/TemplateProcessor.kt (1)
7-13: 설명 추가 좋습니다.
컴포넌트 역할과 흐름이 명확해졌습니다.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/dto/user/InternalUserResponse.kt (1)
3-3: 도메인 UserRole로의 import 정리, 적절합니다.
레이어 간 타입 정합성이 개선되었습니다.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/dto/status/InternalStatusResponse.kt (1)
3-4: 도메인 ApplicationStatus 의존으로 통일된 점 👍
gRPC DTO와 도메인 상태 매핑 일관성이 향상되었습니다.casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/values/ApplicationStatus.kt (2)
3-6: KDoc 보강으로 의미가 분명해졌습니다.
상태 의도 및 사용 맥락이 명확합니다.
8-41: ordinal 의존성 없음 확인
코드베이스 내에ApplicationStatus.ordinal()또는ApplicationStatus.values()[i]사용 흔적이 없어, enum 순서 변경 리스크를 유발할 외부 매핑 의존이 없습니다.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfData.kt (1)
3-10: KDoc 보강 굿설명이 명확해졌습니다. 템플릿 관점의 의도를 잘 드러냅니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/user/UserGrpcClient.kt (1)
4-4: 도메인 UserRole로의 전환 적절함gRPC DTO 의존을 줄이고 도메인 모델 일관성을 높입니다.
casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/ApplicationQueryUserContract.kt (1)
6-8: 조회 실패 시 처리 방식 명시 필요:queryUserByUserId가 사용자 미존재 상황에서 예외를 던지는지(StatusRuntimeException(NotFound)등) 또는null을 반환하는지 인터페이스 KDoc에 명확히 기술하세요.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/PdfProcessor.kt (1)
8-13: KDoc 보강 LGTM역할과 한글 폰트 적용 지점이 명확해졌습니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/repository/StatusCacheRepository.kt (1)
6-7: @timetolive 및 키 타입 일치 확인됨 엔티티에 @timetolive이 선언되어 TTL이 적용되며, CrudRepository의 키 타입(Long)도 @id 타입(Long)과 일치합니다. @repository 애노테이션 추가는 선택사항입니다.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/status/StatusGrpcClient.kt (1)
132-137: 메시지 타입이 올바르게 사용됨
프로토 정의에 updateExamCode RPC가 GetExamCodeRequest를 인자로 사용하도록 선언되어 있어, 클라이언트 코드의 요청 메시지 타입이 정확합니다.Likely an incorrect or invalid review comment.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/ApplicationPdfGenerator.kt (4)
10-11: 도메인 타입으로의 전환 적절PDF 생성기의 퍼블릭 API가 도메인
Application,ApplicationType을 직접 받도록 바뀐 점 좋습니다. 타입 안정성과 내부 변환 제거에 도움이 됩니다.
16-22: 문서화 보강 좋습니다생성물/병합 범위를 명확히 설명해 유지보수에 도움이 됩니다.
38-40: 퍼블릭 시그니처 변경에 따른 호출부 점검 필요
generate(application: Application, score: Any)로 변경되었으므로 호출부 전역 점검이 필요합니다(특히 테스트/샘플 컨트롤러).
45-47: 내부 시그니처 정렬 OK퍼블릭 변경과 일치하게 내부 메서드 타입을 정리한 점 문제 없습니다.
casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/Status.kt (1)
3-3: 도메인 상태 Enum 도입 좋습니다불리언 2개보다
ApplicationStatus단일 필드가 상태 조합 불일치를 줄입니다.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicantCodesGenerator.kt (3)
13-18: 목적/범위 문서화 좋습니다생성 파일의 컬럼 정의와 사용 주체가 명확합니다.
56-62: 주석과 계약 일치
insertCodeKDoc이 변경된 시그니처와 맞습니다. OK.
65-66: 널 허용 Status 수용 적절수험번호 미발급 케이스를 상정한 설계 좋습니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/IntroductionPdfGenerator.kt (1)
39-44: 병합 순서 명확화 필요.문서가 입력 순서로만 병합됩니다. 면접·관리자에서 필요한 정렬 기준(예: 접수번호 오름차순) 확인 부탁드립니다.
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/StatusPersistenceAdapter.kt (1)
19-26: 도메인 Status 필드 매핑 일치 확인
StatusPersistenceAdapter에서 applicationStatus, isFirstRoundPass, isSecondRoundPass를 포함해 모든 도메인 필드가 정확히 매핑되어 있습니다. 변경 불필요합니다.casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintAdmissionTicketGenerator.kt (1)
50-66: HTTP 응답 처리 흐름 👍헤더 설정 → write → finally에서 close 순서가 안전합니다.
| val isPrintsArrived: Boolean = false, | ||
| val isSubmitted: Boolean = false, | ||
| val examCode: String? = null, | ||
| val applicationStatus: ApplicationStatus, |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
마이그레이션/호환성 체크 필요
applicationStatus(디폴트 없음) 도입은 직렬화/캐시/GRPC 매핑, 생성 위치 전부의 변경을 요구합니다. 구버전 필드(isSubmitted, isPrintsArrived) 소비자가 외부에 있다면 디프리케이션 기간/변환 계층이 필요합니다.
다음 점을 확인해 주세요:
- (de)serialization 스키마 변경 반영 여부(Jackson/Proto)
- 캐시 엔티티/TTL, 조회 경로 정합성
- 상태 전이 규칙과
isFirstRoundPass/isSecondRoundPass의 일관성
검증을 위한 간단 스크립트입니다:
🏁 Script executed:
#!/bin/bash
# 변경 전 필드 사용 흔적
rg -nP -C2 '\bisSubmitted\b|\bisPrintsArrived\b'
# Status 생성부 점검(네임드 인자 미사용 호출 탐지 용)
rg -nP --type=kt -C2 '\bStatus\s*\('Length of output: 116
🏁 Script executed:
#!/bin/bash
# 이전 필드 사용 여부 확인 (KT 파일 전체 대상)
rg -nP -g '*.kt' -C2 '\bisSubmitted\b|\bisPrintsArrived\b'
# Status 생성부 탐지 (네임드 인자 미사용 호출 여부)
rg -nP -g '*.kt' -C2 '\bStatus\s*\('Length of output: 3169
Status 생성 위치에 applicationStatus 인자 추가 필요
Status생성 호출부(StatusPersistenceAdapter, ExcelTestController 등)에서applicationStatus인자를 누락해 컴파일 오류가 발생합니다. 모든 호출부에 적절한ApplicationStatus값을 전달하도록 수정하세요.- Jackson/Proto 직렬화 스키마, 캐시 엔티티·TTL, GRPC 매핑 등 변경된 스키마가 전체 시스템에 반영됐는지 검증해야 합니다.
- 기존
isSubmitted·isPrintsArrived필드 사용 흔적은 없으므로 디프리케이션 계층은 불필요합니다.
| data class StatusCache( | ||
| val receiptCode: Long, | ||
| val examCode: String?, | ||
| val applicationStatus: ApplicationStatus, | ||
| val isFirstRoundPass: Boolean, | ||
| val isSecondRoundPass: Boolean, | ||
| val ttl: Long | ||
| ) No newline at end of file |
There was a problem hiding this comment.
🛠️ Refactor suggestion
단일 소스 유지: applicationStatus와 boolean 동시 보유는 불일치 위험
중복 상태가 서로 엇갈릴 수 있습니다. 선택지:
- A) boolean 제거하고 applicationStatus에서 계산(권장)
- B) boolean 유지 시 생성 시점에 일관성 검증(require) 추가
data class StatusCache(
val receiptCode: Long,
val examCode: String?,
val applicationStatus: ApplicationStatus,
- val isFirstRoundPass: Boolean,
- val isSecondRoundPass: Boolean,
+ @Deprecated("applicationStatus에서 파생됩니다. 차기 버전에서 제거 예정.")
+ val isFirstRoundPass: Boolean,
+ @Deprecated("applicationStatus에서 파생됩니다. 차기 버전에서 제거 예정.")
+ val isSecondRoundPass: Boolean,
val ttl: Long
)원하시면 A안(계산 프로퍼티/함수로 노출)으로 마이그레이션 플랜과 코드까지 드리겠습니다.
📝 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.
| data class StatusCache( | |
| val receiptCode: Long, | |
| val examCode: String?, | |
| val applicationStatus: ApplicationStatus, | |
| val isFirstRoundPass: Boolean, | |
| val isSecondRoundPass: Boolean, | |
| val ttl: Long | |
| ) | |
| data class StatusCache( | |
| val receiptCode: Long, | |
| val examCode: String?, | |
| val applicationStatus: ApplicationStatus, | |
| @Deprecated("applicationStatus에서 파생됩니다. 차기 버전에서 제거 예정.") | |
| val isFirstRoundPass: Boolean, | |
| @Deprecated("applicationStatus에서 파생됩니다. 차기 버전에서 제거 예정.") | |
| val isSecondRoundPass: Boolean, | |
| val ttl: Long | |
| ) |
🤖 Prompt for AI Agents
In
casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/StatusCache.kt
around lines 5–12, the data class stores applicationStatus plus two booleans
(isFirstRoundPass, isSecondRoundPass) creating a duplicate source of truth;
remove the booleans and expose them via computed properties/functions derived
from applicationStatus (recommended), or if you must keep them add require
checks in the primary constructor (or init block) to validate consistency with
applicationStatus at creation; implement one approach only and remove the other
fields/logic to ensure a single source of truth.
| import org.springframework.data.annotation.Id | ||
| import org.springframework.data.redis.core.RedisHash | ||
| import org.springframework.data.redis.core.TimeToLive | ||
|
|
There was a problem hiding this comment.
누락된 import 및 TTL 단위 명시
ApplicationStatus import 가 없어 컴파일 실패합니다. 또한 TTL 단위를 명시하면 오해를 줄일 수 있습니다(기본은 seconds).
package hs.kr.entrydsm.application.domain.status.domain.entity
@@
+import hs.kr.entrydsm.domain.status.values.ApplicationStatus
import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash
import org.springframework.data.redis.core.TimeToLive
+import java.util.concurrent.TimeUnit
@@
- @TimeToLive
+ @TimeToLive(unit = TimeUnit.SECONDS)
val ttl: LongAlso applies to: 12-16
| override fun updateExamCode(receiptCode: Long, examCode: String) = runBlocking { | ||
| statusGrpcClient.updateExamCode(receiptCode, examCode) | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
캐시 일관성 깨짐 — 시험번호 갱신 후 캐시 무효화/갱신 필요.
gRPC로 examCode를 갱신해도 Redis 캐시는 그대로입니다. 이후 조회가 캐시에서 나가면 구값을 돌려줍니다. 최소한 무효화해 주세요.
- override fun updateExamCode(receiptCode: Long, examCode: String) = runBlocking {
- statusGrpcClient.updateExamCode(receiptCode, examCode)
- }
+ override fun updateExamCode(receiptCode: Long, examCode: String) = runBlocking {
+ statusGrpcClient.updateExamCode(receiptCode, examCode)
+ // 캐시 일관성 보장: 갱신 직후 캐시 무효화(또는 재적재 로직 선택)
+ statusCacheRepository.deleteById(receiptCode)
+ }📝 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.
| override fun updateExamCode(receiptCode: Long, examCode: String) = runBlocking { | |
| statusGrpcClient.updateExamCode(receiptCode, examCode) | |
| } | |
| override fun updateExamCode(receiptCode: Long, examCode: String) = runBlocking { | |
| statusGrpcClient.updateExamCode(receiptCode, examCode) | |
| // 캐시 일관성 보장: 갱신 직후 캐시 무효화(또는 재적재 로직 선택) | |
| statusCacheRepository.deleteById(receiptCode) | |
| } |
🤖 Prompt for AI Agents
In
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/StatusPersistenceAdapter.kt
around lines 44–46, the method updates examCode via gRPC but does not touch the
Redis cache, causing stale reads; after calling
statusGrpcClient.updateExamCode(receiptCode, examCode) you must invalidate (or
refresh) the corresponding Redis entry for that receiptCode (or the cache key
pattern used for Status) — either delete the cache key or read the updated
status and write it back to Redis; perform this cache eviction/refresh inside
the same runBlocking block (with error handling/logging) so cache consistency is
ensured after the gRPC update.
| values["applicantTel"] = toFormattedPhoneNumber(application.applicantTel ?: "01012345678") | ||
| values["parentTel"] = toFormattedPhoneNumber(application.parentTel ?: "01087654321") | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
더미 전화번호 주입은 위험 — 미입력 시 공란이 안전합니다
실제 문서에 가짜 번호가 들어갈 수 있습니다. null/blank면 공란으로 처리하세요.
- values["applicantTel"] = toFormattedPhoneNumber(application.applicantTel ?: "01012345678")
- values["parentTel"] = toFormattedPhoneNumber(application.parentTel ?: "01087654321")
+ values["applicantTel"] = toFormattedPhoneNumber(application.applicantTel)
+ values["parentTel"] = toFormattedPhoneNumber(application.parentTel)🤖 Prompt for AI Agents
In
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfDataConverter.kt
around lines 125 to 127, remove the hardcoded dummy phone numbers and treat
missing values as empty strings: replace the current fallback logic with a
null/blank check (e.g., if application.applicantTel isNullOrBlank then "" else
toFormattedPhoneNumber(application.applicantTel)) and do the same for parentTel
so blank inputs are preserved instead of injecting fake numbers.
| // TODO: 1차 합격자만 필터링하는 로직 필요 | ||
| val statusMap = statuses.associateBy { it.receiptCode } | ||
|
|
||
| applications.forEachIndexed { index, application -> | ||
| val status = statusMap[application.receiptCode] | ||
| val row = sheet.createRow(index + 1) | ||
| insertCode(row, examCode, receiptCode, name) | ||
| insertCode(row, application, status) | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
1차 합격자 필터링 누락 — 기능적 요구 반영 필요
현재 모든 지원자를 출력합니다. 문서/클래스 주석과 불일치합니다. isFirstRoundPass 기준으로 필터링 후 행을 생성하도록 수정해 주세요.
Apply this diff:
- // TODO: 1차 합격자만 필터링하는 로직 필요
- val statusMap = statuses.associateBy { it.receiptCode }
-
- applications.forEachIndexed { index, application ->
- val status = statusMap[application.receiptCode]
- val row = sheet.createRow(index + 1)
- insertCode(row, application, status)
- }
+ val statusMap = statuses.associateBy { it.receiptCode }
+ val passed = applications
+ .filter { app -> statusMap[app.receiptCode]?.isFirstRoundPass == true }
+ // 필요 시 수험번호 기준 정렬
+ .sortedWith(compareBy(nullsLast(String.CASE_INSENSITIVE_ORDER)) { app ->
+ statusMap[app.receiptCode]?.examCode
+ })
+
+ passed.forEachIndexed { index, application ->
+ val status = statusMap[application.receiptCode]
+ val row = sheet.createRow(index + 1)
+ insertCode(row, application, status)
+ }📝 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.
| // TODO: 1차 합격자만 필터링하는 로직 필요 | |
| val statusMap = statuses.associateBy { it.receiptCode } | |
| applications.forEachIndexed { index, application -> | |
| val status = statusMap[application.receiptCode] | |
| val row = sheet.createRow(index + 1) | |
| insertCode(row, examCode, receiptCode, name) | |
| insertCode(row, application, status) | |
| } | |
| // before: TODO for filtering first-round passers has been implemented | |
| val statusMap = statuses.associateBy { it.receiptCode } | |
| val passed = applications | |
| .filter { app -> statusMap[app.receiptCode]?.isFirstRoundPass == true } | |
| // 필요 시 수험번호(examCode) 기준 정렬 | |
| .sortedWith( | |
| compareBy( | |
| nullsLast(String.CASE_INSENSITIVE_ORDER) | |
| ) { app -> statusMap[app.receiptCode]?.examCode } | |
| ) | |
| passed.forEachIndexed { index, application -> | |
| val status = statusMap[application.receiptCode] | |
| val row = sheet.createRow(index + 1) | |
| insertCode(row, application, status) | |
| } |
🤖 Prompt for AI Agents
In
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicantCodesGenerator.kt
around lines 34 to 41, the generator currently iterates over all applications
but must only output 1st-round passers; filter the applications list by
application.isFirstRoundPass (or the appropriate property name) before building
the status map/iterating, then iterate the filtered list with correct row
indexing (starting at 1) and call insertCode for each filtered application using
the corresponding status from statuses.associateBy; ensure the status lookup
still uses receiptCode and that only filtered applications produce rows.
| private val workbook: Workbook = XSSFWorkbook() | ||
| private val sheet: Sheet = workbook.createSheet("application Check List") | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
Workbook/Sheet를 싱글톤 상태로 보관하면 동시성·리소스 누수 발생
@component 싱글톤에서 XSSFWorkbook/Sheet를 필드로 유지하면 다중 요청에서 경쟁 상태, 메모리/FD 누수, 시트 병합/스타일 중첩 문제가 생깁니다. 요청마다 Workbook/Sheet를 생성·사용·종료하는 무상태 설계로 전환하세요. 또한 finally에서 항상 workbook.close()가 호출되어야 합니다.
아래와 같이 로컬 생성 + 확장함수로 Sheet를 전달하도록 최소 수정안을 제안합니다.
- private val workbook: Workbook = XSSFWorkbook()
- private val sheet: Sheet = workbook.createSheet("application Check List")
+ // 상태 제거: 요청마다 생성
@@
- fun printApplicationCheckList(
+ fun printApplicationCheckList(
applications: List<Application>,
users: List<User>,
schools: List<School>,
statuses: List<Status>,
httpServletResponse: HttpServletResponse,
) {
- var outputStream: ServletOutputStream? = null
+ val workbook: Workbook = XSSFWorkbook()
+ val sheet: Sheet = workbook.createSheet("application Check List")
+ var outputStream: ServletOutputStream? = null
@@
- formatSheet(dh)
- insertDataIntoSheet(application, user, school, status, dh)
+ formatSheet(sheet, dh)
+ insertDataIntoSheet(application, user, school, status, sheet, dh)
dh += 20
@@
- workbook.write(outputStream)
+ workbook.write(outputStream)
@@
- try {
- outputStream?.close()
- } catch (e: Exception) {
- workbook.close()
- }
+ runCatching { outputStream?.close() }
+ runCatching { workbook.close() }Sheet 의존 메서드를 확장함수로 변경합니다.
- private fun formatSheet(dh: Int) {
- sheet.apply {
- mergeRegions(dh)
- applyBorderStyles(dh)
- setCellValues(dh)
- }
- }
+ private fun formatSheet(sheet: Sheet, dh: Int) {
+ sheet.mergeRegions(dh)
+ sheet.applyBorderStyles(dh)
+ sheet.setCellValues(dh)
+ }
- private fun setBorderStyle(
+ private fun Sheet.setBorderStyle(
regions: Array<IntArray>,
borderStyle: BorderStyle,
direction: Direction,
) {
regions.forEach { region ->
val address = CellRangeAddress(region[0], region[1], region[2], region[3])
when (direction) {
- Direction.TOP -> RegionUtil.setBorderTop(borderStyle, address, sheet)
- Direction.BOTTOM -> RegionUtil.setBorderBottom(borderStyle, address, sheet)
- Direction.LEFT -> RegionUtil.setBorderLeft(borderStyle, address, sheet)
- Direction.RIGHT -> RegionUtil.setBorderRight(borderStyle, address, sheet)
+ Direction.TOP -> RegionUtil.setBorderTop(borderStyle, address, this)
+ Direction.BOTTOM -> RegionUtil.setBorderBottom(borderStyle, address, this)
+ Direction.LEFT -> RegionUtil.setBorderLeft(borderStyle, address, this)
+ Direction.RIGHT -> RegionUtil.setBorderRight(borderStyle, address, this)
Direction.ALL -> {
- RegionUtil.setBorderTop(borderStyle, address, sheet)
- RegionUtil.setBorderBottom(borderStyle, address, sheet)
- RegionUtil.setBorderLeft(borderStyle, address, sheet)
- RegionUtil.setBorderRight(borderStyle, address, sheet)
+ RegionUtil.setBorderTop(borderStyle, address, this)
+ RegionUtil.setBorderBottom(borderStyle, address, this)
+ RegionUtil.setBorderLeft(borderStyle, address, this)
+ RegionUtil.setBorderRight(borderStyle, address, this)
}
}
}
}
- private fun getCell(rowNum: Int, cellNum: Int): Cell {
- val row: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum)
- return row.getCell(cellNum) ?: row.createCell(cellNum)
- }
+ private fun Sheet.getCell(rowNum: Int, cellNum: Int): Cell {
+ val row: Row = getRow(rowNum) ?: createRow(rowNum)
+ return row.getCell(cellNum) ?: row.createCell(cellNum)
+ }
- private fun setRowHeight(rowIndex: Int, height: Int) {
- val row: Row = sheet.getRow(rowIndex) ?: sheet.createRow(rowIndex)
- row.heightInPoints = height.toFloat()
- }
+ private fun Sheet.setRowHeight(rowIndex: Int, height: Int) {
+ val row: Row = getRow(rowIndex) ?: createRow(rowIndex)
+ row.heightInPoints = height.toFloat()
+ }
- private fun insertDataIntoSheet(
- application: Application,
- user: User?,
- school: School?,
- status: Status?,
- dh: Int,
- ) {
- getCell(dh + 1, 2).setCellValue(application.receiptCode.toString())
+ private fun insertDataIntoSheet(
+ application: Application,
+ user: User?,
+ school: School?,
+ status: Status?,
+ sheet: Sheet,
+ dh: Int,
+ ) {
+ sheet.getCell(dh + 1, 2).setCellValue(application.receiptCode.toString())
...
- setRowHeight(dh + 2, 10)
- setRowHeight(dh + 6, 10)
- setRowHeight(dh + 9, 10)
- setRowHeight(dh + 0, 71)
+ sheet.setRowHeight(dh + 2, 10)
+ sheet.setRowHeight(dh + 6, 10)
+ sheet.setRowHeight(dh + 9, 10)
+ sheet.setRowHeight(dh + 0, 71)
}Also applies to: 45-52, 65-67, 120-126, 228-266, 267-287, 289-295, 297-303, 305-325
| httpServletResponse.apply { | ||
| contentType = | ||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | ||
|
|
||
| contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | ||
| val formatFilename = "attachment;filename=\"점검표" | ||
| val time = | ||
| LocalDateTime.now() | ||
| .format(DateTimeFormatter.ofPattern("yyyy년MM월dd일_HH시mm분")) | ||
|
|
||
| val fileName = | ||
| String( | ||
| ("$formatFilename$time.xlsx\"").toByteArray(Charsets.UTF_8), | ||
| Charsets.ISO_8859_1, | ||
| ) | ||
| val time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy년MM월dd일_HH시mm분")) | ||
| val fileName = String(("$formatFilename$time.xlsx\"").toByteArray(Charsets.UTF_8), Charsets.ISO_8859_1) | ||
| setHeader("Content-Disposition", fileName) | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
파일명 인코딩 개선: RFC 6266 규격으로 Content-Disposition 설정
UTF-8 → ISO-8859-1 강제 변환은 한글 파일명 깨짐을 유발합니다. Spring ContentDisposition을 사용해 filename* 처리로 브라우저 호환성을 확보하세요.
+import org.springframework.http.ContentDisposition
+import org.springframework.http.HttpHeaders
+import java.nio.charset.StandardCharsets
@@
- httpServletResponse.apply {
- contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
- val formatFilename = "attachment;filename=\"점검표"
- val time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy년MM월dd일_HH시mm분"))
- val fileName = String(("$formatFilename$time.xlsx\"").toByteArray(Charsets.UTF_8), Charsets.ISO_8859_1)
- setHeader("Content-Disposition", fileName)
- }
+ httpServletResponse.apply {
+ contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ val time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy년MM월dd일_HH시mm분"))
+ val fileName = "점검표_${time}.xlsx"
+ val cd = ContentDisposition.attachment()
+ .filename(fileName, StandardCharsets.UTF_8)
+ .build()
+ setHeader(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
+ }🤖 Prompt for AI Agents
In
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationCheckListGenerator.kt
around lines 70 to 76, the current code forces UTF-8 → ISO-8859-1 conversion for
the Content-Disposition header which breaks Korean filenames; replace this with
RFC 6266-compliant header construction using Spring's ContentDisposition API:
build a ContentDisposition via
ContentDisposition.builder("attachment").filename(originalFileName,
StandardCharsets.UTF_8).build(), then set the header using
response.setHeader("Content-Disposition", contentDisposition.toString()); ensure
the file name string uses the UTF-8 value (no manual charset conversion) so the
header emits filename* and improves browser compatibility.
| row.createCell(0).setCellValue(application.receiptCode.toString()) | ||
| row.createCell(1).setCellValue(translateApplicationType(application.applicationType?.name)) | ||
| row.createCell(2).setCellValue(if (application.isDaejeon == true) "대전" else "전국") | ||
| row.createCell(3).setCellValue("해당없음") // TODO: 추가유형 도메인 없어서 더미값 | ||
| row.createCell(4).setCellValue(application.applicantName ?: "") | ||
| row.createCell(5).setCellValue("2005-03-15") // TODO: User 도메인에서 생일 정보 필요 | ||
| row.createCell(6).setCellValue("${application.streetAddress ?: ""} ${application.detailAddress ?: ""}") | ||
| row.createCell(7).setCellValue(application.applicantTel ?: "") | ||
| row.createCell(8).setCellValue("남") // TODO: User 도메인에서 성별 정보 필요 | ||
| row.createCell(9).setCellValue("졸업예정") // TODO: 학력구분 도메인 없어서 더미값 | ||
| row.createCell(10).setCellValue("2024") // TODO: 졸업년도 도메인 없어서 더미값 | ||
| row.createCell(11).setCellValue(school?.name ?: "더미중학교") | ||
| row.createCell(12).setCellValue("3") // TODO: 학급 정보 도메인 없어서 더미값 | ||
| row.createCell(13).setCellValue(application.parentName ?: "") | ||
| row.createCell(14).setCellValue(application.parentTel ?: "") | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
개인정보 더미값 하드코딩 제거 권장.
생년·성별·학교명에 하드코딩된 값은 오해 소지가 큽니다. 도메인 미구현이면 공백으로 두고 주석 유지가 안전합니다.
- row.createCell(5).setCellValue("2005-03-15") // TODO: User 도메인에서 생일 정보 필요
+ row.createCell(5).setCellValue("") // TODO: User 도메인에서 생일 정보 필요
- row.createCell(8).setCellValue("남") // TODO: User 도메인에서 성별 정보 필요
+ row.createCell(8).setCellValue("") // TODO: User 도메인에서 성별 정보 필요
- row.createCell(11).setCellValue(school?.name ?: "더미중학교")
+ row.createCell(11).setCellValue(school?.name ?: "")📝 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.
| row.createCell(0).setCellValue(application.receiptCode.toString()) | |
| row.createCell(1).setCellValue(translateApplicationType(application.applicationType?.name)) | |
| row.createCell(2).setCellValue(if (application.isDaejeon == true) "대전" else "전국") | |
| row.createCell(3).setCellValue("해당없음") // TODO: 추가유형 도메인 없어서 더미값 | |
| row.createCell(4).setCellValue(application.applicantName ?: "") | |
| row.createCell(5).setCellValue("2005-03-15") // TODO: User 도메인에서 생일 정보 필요 | |
| row.createCell(6).setCellValue("${application.streetAddress ?: ""} ${application.detailAddress ?: ""}") | |
| row.createCell(7).setCellValue(application.applicantTel ?: "") | |
| row.createCell(8).setCellValue("남") // TODO: User 도메인에서 성별 정보 필요 | |
| row.createCell(9).setCellValue("졸업예정") // TODO: 학력구분 도메인 없어서 더미값 | |
| row.createCell(10).setCellValue("2024") // TODO: 졸업년도 도메인 없어서 더미값 | |
| row.createCell(11).setCellValue(school?.name ?: "더미중학교") | |
| row.createCell(12).setCellValue("3") // TODO: 학급 정보 도메인 없어서 더미값 | |
| row.createCell(13).setCellValue(application.parentName ?: "") | |
| row.createCell(14).setCellValue(application.parentTel ?: "") | |
| row.createCell(0).setCellValue(application.receiptCode.toString()) | |
| row.createCell(1).setCellValue(translateApplicationType(application.applicationType?.name)) | |
| row.createCell(2).setCellValue(if (application.isDaejeon == true) "대전" else "전국") | |
| row.createCell(3).setCellValue("해당없음") // TODO: 추가유형 도메인 없어서 더미값 | |
| row.createCell(4).setCellValue(application.applicantName ?: "") | |
| row.createCell(5).setCellValue("") // TODO: User 도메인에서 생일 정보 필요 | |
| row.createCell(6).setCellValue("${application.streetAddress ?: ""} ${application.detailAddress ?: ""}") | |
| row.createCell(7).setCellValue(application.applicantTel ?: "") | |
| row.createCell(8).setCellValue("") // TODO: User 도메인에서 성별 정보 필요 | |
| row.createCell(9).setCellValue("졸업예정") // TODO: 학력구분 도메인 없어서 더미값 | |
| row.createCell(10).setCellValue("2024") // TODO: 졸업년도 도메인 없어서 더미값 | |
| row.createCell(11).setCellValue(school?.name ?: "") | |
| row.createCell(12).setCellValue("3") // TODO: 학급 정보 도메인 없어서 더미값 | |
| row.createCell(13).setCellValue(application.parentName ?: "") | |
| row.createCell(14).setCellValue(application.parentTel ?: "") |
| printApplicationCheckListGenerator.printApplicationCheckList( | ||
| dummyApplications, dummyUsers, dummySchools, dummyStatuses, response | ||
| ) | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Dummy Application.userId와 User.id 불일치로 매핑 실패
각 함수에서 서로 다른 UUID를 생성해 User 매핑이 항상 null입니다. 동일 UUID를 공유하도록 고정값 또는 사전 생성 목록을 사용하세요.
- private fun createDummyApplications(): List<Application> {
+ private fun createDummyApplications(): List<Application> {
+ val u1 = UUID.fromString("00000000-0000-0000-0000-000000000001")
+ val u2 = UUID.fromString("00000000-0000-0000-0000-000000000002")
return listOf(
Application(
receiptCode = 1001L,
@@
- userId = UUID.randomUUID(),
+ userId = u1,
veteransNumber = null
),
Application(
receiptCode = 1002L,
@@
- userId = UUID.randomUUID(),
+ userId = u2,
veteransNumber = null
)
)
}
@@
- private fun createDummyUsers(): List<User> {
+ private fun createDummyUsers(): List<User> {
+ val u1 = UUID.fromString("00000000-0000-0000-0000-000000000001")
+ val u2 = UUID.fromString("00000000-0000-0000-0000-000000000002")
return listOf(
User(
- id = UUID.randomUUID(),
+ id = u1,
phoneNumber = "010-1234-5678",
name = "홍길동",
isParent = false
),
User(
- id = UUID.randomUUID(),
+ id = u2,
phoneNumber = "010-2345-6789",
name = "김철수",
isParent = false
)
)
}Also applies to: 78-119, 121-136
🤖 Prompt for AI Agents
In
casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/presentation/ExcelTestController.kt
around lines 73-76 (and also apply same fix to ranges 78-119 and 121-136), dummy
Applications are generated with userId UUIDs that do not match the UUIDs
generated for dummy Users, causing mapping to always return null; fix by
creating a shared set of UUIDs (or fixed hard-coded UUID values) up front and
use those same UUIDs when constructing both dummyUsers and dummyApplications so
the userId in each Application matches an existing User.id; update all helper
generator calls to accept or reuse that pre-generated id list to ensure
consistent IDs across the test data.
…cel-merge-to-application
Summary by CodeRabbit