-
Notifications
You must be signed in to change notification settings - Fork 0
✨ Feature: dataFile 읽기 추가 #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c3f0362
d882a21
81a07b8
5b8c2bf
9455d92
d641455
4376657
179442b
3c46afa
c663f22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,10 +1,48 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package simplerag.ragback.domain.document.dto | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import simplerag.ragback.domain.document.entity.DataFile | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.LocalDateTime | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data class DataFileResponseList( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val dataFilePreviewResponseList: List<DataFilePreviewResponse>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data class DataFilePreviewResponse( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val id: Long, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val sha256: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data class DataFileDetailResponseList( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val dataFileDetailResponseList: List<DataFileDetailResponse>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val cursor: Long?, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val hasNext: Boolean, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data class DataFileDetailResponse( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var id: Long?, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val title: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val type: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val lastModified: LocalDateTime, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val sizeMB: Double, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val tags: List<TagDTO>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val sha256: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| companion object { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun of(dataFile: DataFile, tags: List<TagDTO>): DataFileDetailResponse { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return DataFileDetailResponse( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dataFile.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dataFile.title, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dataFile.type, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dataFile.updatedAt, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dataFile.sizeBytes / (1024.0 * 1024.0), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tags, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dataFile.sha256, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+31
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 매직 넘버 제거: MB 변환 상수로 추출
data class DataFileDetailResponse(
@@
) {
companion object {
+ private const val BYTES_PER_MB = 1_048_576.0
fun of(dataFile: DataFile, tags: List<TagDTO>): DataFileDetailResponse {
return DataFileDetailResponse(
dataFile.id,
dataFile.title,
dataFile.type,
dataFile.updatedAt,
- dataFile.sizeBytes / (1024.0 * 1024.0),
+ dataFile.sizeBytes / BYTES_PER_MB,
tags,
dataFile.sha256,
)
}
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data class TagDTO( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val id: Long?, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val name: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,12 @@ | ||
| package simplerag.ragback.domain.document.repository | ||
|
|
||
| import org.springframework.data.domain.Pageable | ||
| import org.springframework.data.domain.Slice | ||
| import org.springframework.data.jpa.repository.JpaRepository | ||
| import simplerag.ragback.domain.document.entity.DataFile | ||
|
|
||
| interface DataFileRepository : JpaRepository<DataFile, Long> { | ||
| fun existsBySha256(sha256: String): Boolean | ||
|
|
||
| fun findByIdGreaterThanOrderById(cursorId: Long, pageable: Pageable): Slice<DataFile> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,19 @@ | ||
| package simplerag.ragback.domain.document.repository | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository | ||
| import org.springframework.data.jpa.repository.Query | ||
| import org.springframework.data.repository.query.Param | ||
| import simplerag.ragback.domain.document.entity.DataFile | ||
| import simplerag.ragback.domain.document.entity.DataFileTag | ||
|
|
||
| interface DataFileTagRepository : JpaRepository<DataFileTag, Long> { | ||
| fun existsByDataFileIdAndTagId(dataFileId: Long, tagId: Long): Boolean | ||
|
|
||
| @Query(""" | ||
| SELECT dft | ||
| FROM DataFileTag dft | ||
| JOIN FETCH dft.tag t | ||
| WHERE dft.dataFile = :dataFile | ||
| """) | ||
| fun findTagsByDataFile(@Param("dataFile") dataFile: DataFile): List<DataFileTag> | ||
|
Comment on lines
+12
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion N+1 가능성: 목록 조회 시 파일별 개별 태그 조회는 비효율적 — 배치 페치 메서드 추가 권장 페이지 사이즈만큼 아래 메서드를 추가(서비스는 @Query(
"""
SELECT dft
FROM DataFileTag dft
JOIN FETCH dft.tag t
WHERE dft.dataFile IN :dataFiles
"""
)
fun findAllByDataFileInFetchTag(@Param("dataFiles") dataFiles: List<DataFile>): List<DataFileTag>서비스 측 그룹핑 예시: val tagsByFileId = dataFileTagRepository
.findAllByDataFileInFetchTag(files)
.groupBy({ it.dataFile.id!! }, { TagDTO(it.tag.id, it.tag.name) })참고: 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,14 @@ | ||
| package simplerag.ragback.domain.document.service | ||
|
|
||
| import org.springframework.dao.DataIntegrityViolationException | ||
| import org.springframework.data.domain.PageRequest | ||
| import org.springframework.data.domain.Pageable | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
| import org.springframework.transaction.support.TransactionSynchronization | ||
| import org.springframework.transaction.support.TransactionSynchronizationManager | ||
| import org.springframework.web.multipart.MultipartFile | ||
| import simplerag.ragback.domain.document.dto.DataFileBulkCreateRequest | ||
| import simplerag.ragback.domain.document.dto.DataFilePreviewResponse | ||
| import simplerag.ragback.domain.document.dto.DataFileResponseList | ||
| import simplerag.ragback.domain.document.dto.* | ||
| import simplerag.ragback.domain.document.entity.DataFile | ||
| import simplerag.ragback.domain.document.entity.DataFileTag | ||
| import simplerag.ragback.domain.document.entity.Tag | ||
|
|
@@ -24,6 +24,7 @@ import simplerag.ragback.global.util.computeMetricsStreaming | |
| import simplerag.ragback.global.util.resolveContentType | ||
| import java.time.LocalDateTime | ||
| import java.util.* | ||
| import kotlin.collections.ArrayList | ||
|
|
||
| @Service | ||
| class DataFileService( | ||
|
|
@@ -76,6 +77,28 @@ class DataFileService( | |
| return DataFileResponseList(responses) | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| fun getDataFiles(cursor: Long, take: Int): DataFileDetailResponseList { | ||
|
|
||
| val dataSlice = dataFileRepository.findByIdGreaterThanOrderById(cursor, PageRequest.of(0, take)) | ||
|
|
||
| val dataFileList: MutableList<DataFileDetailResponse> = ArrayList() | ||
| dataSlice.forEach{ dataFile -> | ||
| val dataFileTags: List<DataFileTag> = dataFileTagRepository.findTagsByDataFile(dataFile) | ||
|
|
||
| val tagDtos: List<TagDTO> = dataFileTags.map{ | ||
| dataFileTag -> | ||
| val tag = dataFileTag.tag | ||
| TagDTO(tag.id, tag.name) | ||
| } | ||
|
|
||
| dataFileList.add(DataFileDetailResponse.of(dataFile, tagDtos)) | ||
| } | ||
|
Comment on lines
+85
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion N+1 쿼리 발생 — 태그를 다건 조회로 한 번에 가져오도록 리팩터링 권장 현재는 Slice의 각 DataFile마다 아래와 같이 서비스 코드를 변경하고, 리포지토리에 다건 조회 메서드를 추가해 주세요. 서비스 변경(해당 범위 교체): - val dataFileList: MutableList<DataFileDetailResponse> = ArrayList()
- dataSlice.forEach{ dataFile ->
- val dataFileTags: List<DataFileTag> = dataFileTagRepository.findTagsByDataFile(dataFile)
-
- val tagDtos: List<TagDTO> = dataFileTags.map{
- dataFileTag ->
- val tag = dataFileTag.tag
- TagDTO(tag.id, tag.name)
- }
-
- dataFileList.add(DataFileDetailResponse.of(dataFile, tagDtos))
- }
+ val ids = dataSlice.mapNotNull { it.id }
+ val tagMap: Map<Long, List<TagDTO>> =
+ dataFileTagRepository.findByDataFileIdIn(ids)
+ .groupBy({ requireNotNull(it.dataFile.id) }) { TagDTO(it.tag.id, it.tag.name) }
+
+ val dataFileList: List<DataFileDetailResponse> = dataSlice.map { dataFile ->
+ val tagDtos = tagMap[dataFile.id] ?: emptyList()
+ DataFileDetailResponse.of(dataFile, tagDtos)
+ }.toList()리포지토리 추가(별도 파일 수정 필요): // src/main/kotlin/simplerag/ragback/domain/document/repository/DataFileTagRepository.kt
@Query("select dft from DataFileTag dft join fetch dft.tag where dft.dataFile.id in :ids")
fun findByDataFileIdIn(@Param("ids") ids: List<Long>): List<DataFileTag>이 방식은 (데이터 파일 슬라이스 1회 + 태그 일괄 1회)로 고정되어 규모가 커져도 효율적입니다. 🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 어짜피 fetch join으로 가져오니까 상관없지 않나요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @catturtle123 좋은 질문입니다! 하지만 fetch join과 N+1 쿼리 문제는 서로 다른 개념입니다. 현재 상황 분석: 현재 코드에서는 여전히 N+1 쿼리가 발생합니다:
Fetch Join의 역할:
예시: -- 1번: DataFile 목록 조회
SELECT df.* FROM data_file df WHERE df.id > ? ORDER BY df.id LIMIT 3
-- 2번: 첫 번째 DataFile의 태그 조회 (fetch join 적용)
SELECT dft.*, t.* FROM data_file_tag dft JOIN tag t ON dft.tag_id = t.id WHERE dft.data_file_id = 1
-- 3번: 두 번째 DataFile의 태그 조회 (fetch join 적용)
SELECT dft.*, t.* FROM data_file_tag dft JOIN tag t ON dft.tag_id = t.id WHERE dft.data_file_id = 2
-- 4번: 세 번째 DataFile의 태그 조회 (fetch join 적용)
SELECT dft.*, t.* FROM data_file_tag dft JOIN tag t ON dft.tag_id = t.id WHERE dft.data_file_id = 3총 4번의 데이터베이스 라운드트립이 발생합니다. 권장 방식: -- 1번: DataFile 목록 조회
SELECT df.* FROM data_file df WHERE df.id > ? ORDER BY df.id LIMIT 3
-- 2번: 모든 DataFile의 태그를 한 번에 조회
SELECT dft.*, t.* FROM data_file_tag dft JOIN tag t ON dft.tag_id = t.id WHERE dft.data_file_id IN (1,2,3)총 2번의 데이터베이스 라운드트립으로 줄어듭니다. 데이터 양이 많아질수록 성능 차이가 크게 벌어지므로, 일괄 조회 방식으로 리팩터링하는 것을 권장드립니다. ✏️ Learnings added
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋습니다. 하지만 batch_size를 등록해놨으니 N+1까지는 아니지 않나요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @catturtle123 좋은 지적입니다! Batch Size의 동작 방식:
현재 코드의 한계: dataSlice.forEach{ dataFile ->
val dataFileTags: List<DataFileTag> = dataFileTagRepository.findTagsByDataFile(dataFile) // 명시적 메서드 호출
}
실제 쿼리 수:
진정한 해결책: val ids = dataSlice.mapNotNull { it.id }
val tagMap = dataFileTagRepository.findByDataFileIdIn(ids) // 1번의 IN 쿼리
🧠 Learnings used |
||
|
|
||
| val nextCursor: Long? = dataFileList.lastOrNull()?.id | ||
| return DataFileDetailResponseList(dataFileList, nextCursor, dataSlice.hasNext()) | ||
| } | ||
|
|
||
| private fun registerRollbackCleanup(uploadedUrls: MutableList<String>) { | ||
| if (TransactionSynchronizationManager.isSynchronizationActive()) { | ||
| TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -16,3 +16,4 @@ spring: | |||||
| hibernate: | ||||||
| dialect: org.hibernate.dialect.PostgreSQLDialect | ||||||
| format_sql: true | ||||||
| default_batch_fetch_size: 500 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) default_batch_fetch_size=500은 과도할 수 있고, 현재 조회 방식에서는 효과가 제한적입니다
권장:
다음 간단 변경으로 보수적으로 조정할 수 있습니다: - default_batch_fetch_size: 500
+ default_batch_fetch_size: 100📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| package simplerag.ragback.domain.document.service | ||
|
|
||
| import jakarta.annotation.PostConstruct | ||
| import org.junit.jupiter.api.Assertions.* | ||
| import org.junit.jupiter.api.BeforeEach | ||
| import org.junit.jupiter.api.DisplayName | ||
| import org.junit.jupiter.api.Test | ||
| import org.springframework.beans.factory.annotation.Autowired | ||
|
|
@@ -23,7 +25,6 @@ import simplerag.ragback.global.storage.FakeS3Util | |
| import simplerag.ragback.global.util.S3Type | ||
| import simplerag.ragback.global.util.sha256Hex | ||
| import java.security.MessageDigest | ||
| import java.time.LocalDateTime | ||
|
|
||
| @SpringBootTest | ||
| @ActiveProfiles("test") | ||
|
|
@@ -40,6 +41,14 @@ class DataFileServiceTest( | |
|
|
||
| private fun txTemplate() = TransactionTemplate(txManager) | ||
|
|
||
| @BeforeEach | ||
| fun clean() { | ||
| dataFileTagRepository.deleteAll() | ||
| tagRepository.deleteAll() | ||
| dataFileRepository.deleteAll() | ||
| s3Util.clear() | ||
| } | ||
|
|
||
| @Test | ||
| @Transactional | ||
| @DisplayName("업로드 시 잘 저장이 된다.") | ||
|
|
@@ -207,6 +216,57 @@ class DataFileServiceTest( | |
| assertFalse(s3Util.exists(expectedUrl), "롤백 시 S3도 보상 삭제되어야 합니다") | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("데이터 조회가 잘 된다") | ||
| @Transactional | ||
| fun getDataFilesOK() { | ||
| // given | ||
| val bytes1 = "test1".toByteArray() | ||
| val sha1 = sha256Hex(bytes1) | ||
| val bytes2 = "test2".toByteArray() | ||
| val sha2 = sha256Hex(bytes2) | ||
| dataFileRepository.saveAll( | ||
| listOf( | ||
| DataFile( | ||
| title = "exists", | ||
| type = "text/plain", | ||
| sizeBytes = 0, | ||
| sha256 = sha1, | ||
| fileUrl = "fake://original/exists.txt", | ||
| ), | ||
| DataFile( | ||
| title = "exists2", | ||
| type = "text/pdf", | ||
| sizeBytes = 0, | ||
| sha256 = sha2, | ||
| fileUrl = "fake://original/exists.txt", | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
| val cursor = 0L | ||
| val take = 2 | ||
|
|
||
| // when | ||
| val dataFiles = dataFileService.getDataFiles(cursor, take) | ||
|
|
||
| // then | ||
| val dataFileDetailResponse = dataFiles.dataFileDetailResponseList[0] | ||
| assertEquals(dataFileDetailResponse.title, "exists") | ||
| assertEquals(dataFileDetailResponse.type, "text/plain") | ||
| assertEquals(dataFileDetailResponse.sizeMB, 0.0) | ||
| assertEquals(dataFileDetailResponse.sha256, sha1) | ||
|
|
||
| val dataFileDetailResponse2 = dataFiles.dataFileDetailResponseList[1] | ||
| assertEquals(dataFileDetailResponse2.title, "exists2") | ||
| assertEquals(dataFileDetailResponse2.type, "text/pdf") | ||
| assertEquals(dataFileDetailResponse2.sizeMB, 0.0) | ||
| assertEquals(dataFileDetailResponse2.sha256, sha2) | ||
|
|
||
| assertEquals(dataFiles.cursor, dataFileDetailResponse2.id) | ||
| assertEquals(dataFiles.hasNext, false) | ||
| } | ||
|
Comment on lines
+219
to
+268
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 조회 해피패스만 검증됨 — hasNext=true 및 커서 후속 조회 케이스 테스트 추가 제안 현재 테스트는 take=2, 레코드=2로 hasNext=false와 nextCursor=마지막 id만 검증합니다. 다음도 함께 커버해 주세요:
원하시면 아래와 같이 테스트를 추가할 수 있습니다: @Test
@DisplayName("커서 기반 조회 - hasNext=true, cursor로 후속 페이지 조회")
@Transactional
fun getDataFiles_hasNext_and_followUpCursor() {
// given
val f1 = dataFileRepository.save(DataFile("t1", "text/plain", 0, "sha1", "u1"))
val f2 = dataFileRepository.save(DataFile("t2", "text/plain", 0, "sha2", "u2"))
val f3 = dataFileRepository.save(DataFile("t3", "text/plain", 0, "sha3", "u3"))
// when: 첫 페이지
val first = dataFileService.getDataFiles(cursor = 0L, take = 2)
// then
assertEquals(2, first.dataFileDetailResponseList.size)
assertTrue(first.hasNext)
val next = requireNotNull(first.cursor)
// when: 후속 페이지
val second = dataFileService.getDataFiles(cursor = next, take = 2)
// then: 남은 1건
assertEquals(1, second.dataFileDetailResponseList.size)
assertFalse(second.hasNext)
assertEquals("t3", second.dataFileDetailResponseList.first().title)
}또한 sizeMB 변환 검증 예시(반올림 정책에 맞게 기대값 조정 필요): @Test
@DisplayName("sizeBytes -> sizeMB 변환 검증")
@Transactional
fun getDataFiles_sizeMB_conversion() {
val oneAndHalfMB = 1_572_864L // 1.5 MiB
val sha = "shaX"
dataFileRepository.save(DataFile("mb", "application/octet-stream", oneAndHalfMB, sha, "u"))
val res = dataFileService.getDataFiles(cursor = 0L, take = 1)
val item = res.dataFileDetailResponseList.first()
// 예: 1.5 MB로 표현된다면 아래 기대값 조정
assertTrue(item.sizeMB in 1.49..1.51, "sizeMB 변환/반올림을 확인해 주세요")
}추가 테스트 작성이 필요하시면 전체 코드 패치로 도와드리겠습니다. |
||
|
|
||
| // ----------------------- | ||
| // helpers | ||
| // ----------------------- | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
DTO 불변성 유지: id는 가변(var)보다 불변(val)이 적절
응답 DTO에서
id가 변경될 이유가 없으므로var→val로 불변성을 유지하는 것이 안전합니다.📝 Committable suggestion
🤖 Prompt for AI Agents