Skip to content

Commit 4b3bbc7

Browse files
committed
refactor/OPS-373 : 명시적 null과 미전달 구분
1 parent 929eeef commit 4b3bbc7

File tree

6 files changed

+212
-139
lines changed

6 files changed

+212
-139
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ dependencies {
8282
// OpenAPI / Swagger UI
8383
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
8484

85+
// OpenAPI - nullable support
86+
implementation "org.openapitools:jackson-databind-nullable:0.2.6"
87+
8588
// JWT
8689
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
8790
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -178,49 +178,44 @@ public ResponseEntity<?> moveMany(
178178

179179
/**
180180
* 파일 수정
181-
* @param dataSourceId 수정할 파일 Id
182-
* @param body 수정할 내용
181+
* - 전달된 필드만 반영 (present)
182+
* - 명시적 null이면 DB에 null 저장
183+
* - 미전달(not present)이면 변경 없음
183184
*/
184185
@Operation(summary = "자료 수정", description = "내 PersonalArchive 안에 자료를 수정합니다.")
185186
@PatchMapping("/{dataSourceId}")
186187
public ResponseEntity<?> updateDataSource(
187188
@PathVariable Integer dataSourceId,
188-
@Valid @RequestBody reqBodyForUpdateDataSource body,
189+
@RequestBody reqBodyForUpdateDataSource body,
189190
@AuthenticationPrincipal CustomUserDetails userDetails
190191
) {
191-
// title, summary 둘 다 비어있으면 의미 없는 요청 → 400
192192
boolean anyPresent =
193-
(body.title() != null) ||
194-
(body.summary() != null) ||
195-
(body.sourceUrl() != null) ||
196-
(body.imageUrl() != null) ||
197-
(body.source() != null) ||
198-
(body.tags() != null) ||
199-
(body.category() != null);
193+
body.title().isPresent() ||
194+
body.summary().isPresent() ||
195+
body.sourceUrl().isPresent() ||
196+
body.imageUrl().isPresent() ||
197+
body.source().isPresent() ||
198+
body.tags().isPresent() ||
199+
body.category().isPresent();
200200

201201
if (!anyPresent) {
202202
throw new IllegalArgumentException(
203203
"변경할 값이 없습니다. title, summary, sourceUrl, imageUrl, source, tags, category 중 하나 이상을 전달하세요."
204204
);
205205
}
206206

207-
// 비즈니스 규칙: sourceUrl은 엔티티 nullable=false 이므로, 빈 문자열로 업데이트 요청 시 400
208-
if (body.sourceUrl() != null && body.sourceUrl().isBlank()) {
209-
throw new IllegalArgumentException("sourceUrl은 빈 값일 수 없습니다.");
210-
}
211-
212-
Member member = userDetails.getMember();
213-
214207
Integer updatedId = dataSourceService.updateDataSource(
215-
member.getId(),
208+
userDetails.getMember().getId(),
216209
dataSourceId,
217-
body.title(),
218-
body.summary(),
219-
body.sourceUrl(),
220-
body.imageUrl(),
221-
body.source(),
222-
body.tags(),
223-
body.category()
210+
DataSourceService.UpdateCommand.builder()
211+
.title(body.title())
212+
.summary(body.summary())
213+
.sourceUrl(body.sourceUrl())
214+
.imageUrl(body.imageUrl())
215+
.source(body.source())
216+
.tags(body.tags())
217+
.category(body.category())
218+
.build()
224219
);
225220

226221
String msg = updatedId + "번 자료가 수정됐습니다.";
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package org.tuna.zoopzoop.backend.domain.datasource.dto;
22

3-
import java.util.List;
3+
import org.openapitools.jackson.nullable.JsonNullable;
44

55
public record reqBodyForUpdateDataSource(
6-
String title,
7-
String summary,
8-
String sourceUrl,
9-
String imageUrl,
10-
String source,
11-
List<String> tags,
12-
String category
6+
JsonNullable<String> title,
7+
JsonNullable<String> summary,
8+
JsonNullable<String> sourceUrl,
9+
JsonNullable<String> imageUrl,
10+
JsonNullable<String> source,
11+
JsonNullable<java.util.List<String>> tags,
12+
JsonNullable<String> category
1313
) {}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java

Lines changed: 48 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
import jakarta.persistence.NoResultException;
44
import jakarta.transaction.Transactional;
5+
import lombok.Builder;
56
import lombok.RequiredArgsConstructor;
7+
import org.openapitools.jackson.nullable.JsonNullable;
68
import org.springframework.data.domain.Page;
79
import org.springframework.data.domain.Pageable;
810
import org.springframework.stereotype.Service;
9-
import org.springframework.util.StringUtils;
1011
import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive;
1112
import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository;
1213
import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder;
@@ -238,83 +239,48 @@ private Folder resolveTargetFolder(Integer currentMemberId, Integer targetFolder
238239
/**
239240
* 자료 수정
240241
*/
242+
@Builder
243+
public record UpdateCommand(
244+
JsonNullable<String> title,
245+
JsonNullable<String> summary,
246+
JsonNullable<String> sourceUrl,
247+
JsonNullable<String> imageUrl,
248+
JsonNullable<String> source,
249+
JsonNullable<List<String>> tags,
250+
JsonNullable<String> category
251+
) {}
252+
241253
@Transactional
242-
public Integer updateDataSource(
243-
Integer memberId,
244-
Integer dataSourceId,
245-
String newTitle,
246-
String newSummary,
247-
String newSourceUrl,
248-
String newImageUrl,
249-
String newSource,
250-
List<String> newTags,
251-
String newCategory // enum 이름 string
252-
) {
254+
public Integer updateDataSource(Integer memberId, Integer dataSourceId, UpdateCommand cmd) {
253255
DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId)
254256
.orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다."));
255257

256-
if (StringUtils.hasText(newTitle))
257-
ds.setTitle(newTitle.trim());
258-
259-
if (StringUtils.hasText(newSummary))
260-
ds.setSummary(newSummary.trim());
261-
262-
if (newSourceUrl != null) {
263-
if (!StringUtils.hasText(newSourceUrl))
264-
throw new IllegalArgumentException("sourceUrl은 빈 값일 수 없습니다.");
265-
266-
ds.setSourceUrl(newSourceUrl.trim());
267-
}
268-
269-
if (newImageUrl != null) {
270-
String v = newImageUrl.trim();
271-
ds.setImageUrl(v.isEmpty() ? null : v);
272-
}
273-
274-
if (newSource != null) {
275-
String v = newSource.trim();
276-
ds.setSource(v.isEmpty() ? null : v);
277-
}
278-
279-
if (newCategory != null) {
280-
ds.setCategory(parseCategoryOrThrow(newCategory));
281-
}
282-
283-
if (newTags != null) {
284-
replaceTags(ds, newTags);
258+
// 문자열/enum 필드들
259+
if (cmd.title().isPresent()) ds.setTitle(cmd.title().orElse(null));
260+
if (cmd.summary().isPresent()) ds.setSummary(cmd.summary().orElse(null));
261+
if (cmd.sourceUrl().isPresent()) ds.setSourceUrl(cmd.sourceUrl().orElse(null));
262+
if (cmd.imageUrl().isPresent()) ds.setImageUrl(cmd.imageUrl().orElse(null));
263+
if (cmd.source().isPresent()) ds.setSource(cmd.source().orElse(null));
264+
if (cmd.category().isPresent()) ds.setCategory(parseCategoryNullable(cmd.category().orElse(null)));
265+
266+
// 태그
267+
if (cmd.tags().isPresent()) {
268+
List<String> names = cmd.tags().orElse(null);
269+
if (names == null) {
270+
ds.getTags().clear();
271+
} else {
272+
replaceTags(ds, names);
273+
}
285274
}
286275

287276
return ds.getId();
288277
}
289278

290-
private Category parseCategoryOrThrow(String raw) {
291-
String key = raw.trim();
292-
if (key.isEmpty()) {
293-
throw new IllegalArgumentException("category는 빈 값일 수 없습니다.");
294-
}
295-
// 대소문자 관대 처리 (IT, Science, science 등)
296-
try {
297-
return Category.valueOf(key.toUpperCase(Locale.ROOT));
298-
} catch (IllegalArgumentException e) {
299-
throw new IllegalArgumentException("지원하지 않는 category 값입니다: " + raw);
300-
}
301-
}
302-
303-
private void replaceTags(DataSource ds, List<String> names) {
304-
ds.getTags().clear();
305-
306-
if (names == null) return;
307-
308-
for (String name : names) {
309-
if (name == null || name.isBlank()) continue;
310-
311-
Tag tag = Tag.builder()
312-
.tagName(name.trim())
313-
.dataSource(ds)
314-
.build();
315-
316-
ds.getTags().add(tag);
317-
}
279+
private Category parseCategoryNullable(String raw) {
280+
if (raw == null) return null;
281+
String k = raw.trim();
282+
if (k.isEmpty()) return null; // 빈문자 들어오면 null로 저장(원하면 그대로 저장하도록 바꿔도 됨)
283+
return Category.valueOf(k.toUpperCase(Locale.ROOT));
318284
}
319285

320286
/**
@@ -340,4 +306,17 @@ public Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondi
340306
}
341307

342308
public record MoveResult(Integer datasourceId, Integer folderId) {}
309+
310+
private void replaceTags(DataSource ds, List<String> names) {
311+
ds.getTags().clear();
312+
313+
for (String name : names) {
314+
if (name == null) continue;
315+
Tag tag = Tag.builder()
316+
.tagName(name)
317+
.dataSource(ds)
318+
.build();
319+
ds.getTags().add(tag);
320+
}
321+
}
343322
}

src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.fasterxml.jackson.databind.ObjectMapper;
44
import org.junit.jupiter.api.*;
55
import org.mockito.Mockito;
6+
import org.openapitools.jackson.nullable.JsonNullableModule;
67
import org.springframework.beans.factory.annotation.Autowired;
78
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
89
import org.springframework.boot.test.context.SpringBootTest;
@@ -17,10 +18,13 @@
1718
import org.springframework.transaction.annotation.Transactional;
1819
import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse;
1920
import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder;
20-
import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService;
2121
import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository;
22+
import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService;
2223
import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService;
23-
import org.tuna.zoopzoop.backend.domain.datasource.dto.*;
24+
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto;
25+
import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForCreateDataSource;
26+
import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForDeleteMany;
27+
import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForMoveDataSource;
2428
import org.tuna.zoopzoop.backend.domain.datasource.entity.Category;
2529
import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource;
2630
import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag;
@@ -37,9 +41,8 @@
3741
import static org.mockito.ArgumentMatchers.anyInt;
3842
import static org.mockito.Mockito.when;
3943
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
40-
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
41-
import static org.hamcrest.Matchers.anyOf;
42-
import static org.hamcrest.Matchers.is;
44+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
45+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
4346

4447
@ActiveProfiles("test")
4548
@SpringBootTest
@@ -95,6 +98,11 @@ TagRepository stubTagRepository() {
9598

9699
return mock;
97100
}
101+
102+
@Bean
103+
com.fasterxml.jackson.databind.Module jsonNullableModule() {
104+
return new JsonNullableModule();
105+
}
98106
}
99107

100108
@BeforeAll
@@ -501,21 +509,20 @@ void update_ok_all_fields() throws Exception {
501509

502510

503511
@Test
504-
@DisplayName("자료 수정 실패: sourceUrl가 빈 문자열400")
512+
@DisplayName("자료 수정 성공: sourceUrl가 빈문자여도 허용200")
505513
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
506-
void update_badRequest_sourceUrl_blank() throws Exception {
514+
void update_ok_sourceUrl_blank_allowed() throws Exception {
507515
String body = updateJson(null, null, " ", null, null, null, null);
508516

509517
mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1)
510518
.contentType(MediaType.APPLICATION_JSON)
511519
.content(body))
512-
.andExpect(status().isBadRequest())
513-
.andExpect(jsonPath("$.status").value(400))
514-
.andExpect(jsonPath("$.msg").exists());
520+
.andExpect(status().isOk())
521+
.andExpect(jsonPath("$.status").value(200));
515522
}
516523

517524
@Test
518-
@DisplayName("자료 수정 실패: 모든 필드 미전달(null) → 400")
525+
@DisplayName("자료 수정 실패: 모든 필드 미전달 → 400")
519526
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
520527
void update_badRequest_all_null() throws Exception {
521528
// 빈 JSON 객체 {}

0 commit comments

Comments
 (0)