Good Code - 규약과 구조
클라이언트와의 합의
파일 URL 과 그 파일의 메타 정보를 함께 받는 요청이 있다.
@Data
@Builder
@JsonIgnoreProperties(ignoreUnknown = true)
public class RequestDto {
@Schema(description = "파일 URL", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String url;
@Schema(description = "업로드 할 파일의 메타 정보", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private FileMetadata metadata;
}메타데이터에는 자기가 비어 있는지 알려주는 메서드가 있다.
/**
* 메타데이터가 비어있는지 확인.
* 클라이언트에서 이미지 추출에 실패하면, 기본값을 가진 메타데이터 전송.
*/
@JsonIgnore
public boolean isEmptyMetadata() {
return width == 0 && height == 0 && filesize == 0;
}이 메서드가 있는 이유는 주석에 그대로 적혀 있다.
클라이언트가 처리할 수 없는 이미지라도 객체 자체는 보내주기로 합의했기 때문이다.
값이 없다는 사실이 null 이 아니라 0 으로 도착한다.
합의를 믿는 서비스
서비스는 합의를 믿고 아래와 같이 작성했다.
@Service
public class ClientApiService {
public ResponseDto execute(RequestDto requestDto) {
var metadata = requestDto.getMetadata();
if (metadata.isEmptyMetadata()) {
log.info("이미지 메타데이터 정보가 없어 이미지 바이트로부터 크기 계산 수행");
...
}
// 메타데이터를 꺼내서 사용
}
}- 메타데이터를 꺼낸다.
- 비어 있는지 확인한다.
- 유무에 따라 분기한다.
크게 문제 없어 보이고, 실제로 잘 돌아간다.
그런데 이 코드에는 코드에 적혀 있지 않은 제약이 하나 붙는다.
호출하는 쪽은
metadata를 반드시 채워 보내야 한다.
컨트롤러 입장에서는 합리적인 가정이다. 요청을 보내는 쪽과 합의를 했기 때문이다.
@RestController
public class ClientApiController {
public ResponseEntity<ResponseDto> request(RequestDto dto, ClientRequestDto clientRequestDto) {
log.info("신규 요청 {} | {}", dto, clientRequestDto);
var responseDto = clientApiService.execute(dto);
log.info("요청 결과 : {}", responseDto);
return ResponseEntity.ok(responseDto);
}
}문제는 서비스는 하나의 컨트롤러, 하나의 DTO 규약에 묶여 있지 않다는 점이다.
내부 SDK 용 진입점이 하나 붙는다고 가정해보자.
@RestController
public class InternalRequestApiController {
public ResponseEntity<ResponseDto> request(InternalRequestDto dto) {
log.info("내부 sdk 통한 요청: {}", dto);
var responseDto = clientApiService.execute(dto.toRequestDto());
log.info("요청 결과 : {}", responseDto);
return ResponseEntity.ok(responseDto);
}
}toRequestDto() 가 메타데이터를 채우는지는 그 변환 코드를 쓴 사람만 안다.
여기에 이런 말이 나올수도 있다.
빈 Metadata 는 보낼 필요가 없을 것 같아요?
합의는 코드와 일치하지 않는다. 합의는 컨트롤러 개수만큼, 명세 수정 횟수만큼 희석된다.
그리고 깨질 때 나는 건 의도한 에러가 아니라 NullPointerException 이다.
판단을 객체로
RequestDto 가 자기가 가진 메타데이터에 대해 답하게 만든다.
public class RequestDto {
/**
* 사용할 수 있는 메타데이터를 가지고 있는지 확인.
* 클라이언트가 아예 실어 보내지 않으면 null, 추출에 실패하면 기본값(0)을 가진 객체가 온다.
*/
@JsonIgnore
public boolean hasFileMetadata() {
return metadata != null && metadata.isUsable();
}
}if (!requestDto.hasFileMetadata()) {
log.info("이미지 메타데이터 정보가 없어 이미지 바이트로부터 크기 계산 수행");
...
}
var metadata = requestDto.getMetadata();if 는 사라지지 않았다. 그대로 있고 조건식만 짧아졌다.
얻은 건 조건식이 DTO 내부 표현의 변화에 면역이 된다는 것이다.
(나중에 filesize 만 남기든, 빈 값을 null 로 바꾸든, 판정 기준을 width > 0 하나로 줄이든)
서비스는 영향을 받지 않는다. 진입점이 몇 개로 늘어나도 규칙이 존재하는 곳은 객체 내부 한 곳이다.
has + get 은 다시 순서 규약을 만든다
그런데 위 코드에는 처음 문제에서 형태만 바꿔서 남아 있다.
has 로 먼저 확인한 다음 get 해야 한다.
이것도 코드에 적혀 있지 않은 제약이다.
다음에 hasFileMetadata() 없이 getMetadata().getWidth() 를 부르면 똑같이 NPE 가 발생한다.
판정과 꺼내기를 한 번에 끝내면 이 제약 자체가 사라진다.
public class RequestDto {
@Nullable
@Schema(description = "업로드 할 파일의 메타 정보", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@Getter(AccessLevel.NONE)
private FileMetadata metadata;
@JsonIgnore
public Optional<FileMetadata> findUsableMetadata() {
return Optional.ofNullable(metadata).filter(FileMetadata::isUsable);
}
}public class FileMetadata {
private int width;
private int height;
private long filesize;
/**
*크기 계산에 쓸 수 있는 값인지. 하나라도 비면 신뢰하지 않는다.
*/
@JsonIgnore
public boolean isUsable() {
return width > 0 && height > 0 && filesize > 0;
}
}외부에서 잘못된 사용을 막기 위해 Getter 는 제거한다.
ImageSize size = requestDto.findUsableMetadata()
.map(ImageSize::from)
.orElseGet(() -> {
log.info("이미지 메타데이터 정보가 없어 이미지 바이트로부터 크기 계산 수행");
return ImageSize.from(bytes);
});이제 잘못 쓸 수 있는 경로가 없다. 값을 얻는 유일한 문은 부재 판정을 통과한 걸 보장한다.
isEmptyMetadata(부정형)를 isUsable(긍정형)로 뒤집은 것도 같은 이유다.
!hasFileMetadata() 안에 isEmpty 가 들어가면 이중 부정으로 읽는 데 부담을 줄 수 있다.
모든 진입점을 보장하기
빈 메타데이터를 진입 지점에서 null 로 강제하면, 내부 코드는 "있다 / 없다" 두 가지만 다루면 된다.
// InternalRequestDto.toRequestDto()
return RequestDto.builder()
.url(url)
.metadata(metadata != null && metadata.isUsable() ? metadata : null)
.build();이 역시도 동작한다. 그리고 이 방식이 더 깔끔한 경우도 분명 있을것이다.
차이는 가능성이다.
- 진입점이 늘어날 가능성이 있는지
- 진입점이 늘어날 때 어떻게 행동해야 하는지, 놓치면 어떤 문제가 발생하는지
- 값을 사용할 때 규칙을 놓칠 가능성이 있는지
=> 진입점에서 막는 방식은 진입점이 늘어날 때마다 같은 규칙을 다시 지키는걸 보장해야만 한다!
마무리
당연한 이야기처럼 보인다.
하지만, Controller - Service - DTO 를 기계적으로 반복해서 짜다 보면 놓칠수 있다.
DTO 는 값을 담는 그릇으로만 취급되고, 어떻게 사용 하는지에 대한 책임은 서비스가 고려해야만 한다.
그리고, 필요한 문은 최대한 적게 만들어두자.
문이 많아질수록 '잘못 사용될 가능성', '관리할 포인트' 등이 늘어난다.
짤 수 있는 가장 좋은 코드를 고민하고 생각하자!