기능이 하나씩 추가될 때마다 반복되는 mapping 로직이 많아졌다. 우선 코드가 얼마나 지저분해졌는지 FacilityMapperService의 전체 코드를 확인해 보자.

//...

@Slf4j
@RequiredArgsConstructor
@Service
public class FacilityMapperService {

    public static final Map<String, String> REGION_CODE = new HashMap<>(); // <ADDRESS, REGION_CODE>

    private final ResourceLoader resourceLoader;

    // TODO : 아래 코드를 추상화할 해결책은?

    public FacilityDto toFacilityDto(CreateFacilityRequestDto dto) {
        return FacilityDto.builder()
                .address(createAddress(dto))
                .category(dto.getCategory())
                .title(dto.getTitle())
                .description(dto.getDescription())
                .build();
    }

    public FacilityDto toFacilityDto(UpdateFacilityRequestDto dto) {
        return FacilityDto.builder()
                .id(dto.getId())
                .address(createAddress(dto))
                .category(dto.getCategory())
                .title(dto.getTitle())
                .description(dto.getDescription())
                .build();
    }

    public FindFacilityDto toFindFacilityDto(Map<String, String> findFacilityParam) {
        Map<String, String> validParam = new HashMap<>();
        String[] keys = new String[]{"category", "latitude", "longitude", "distance"};
        Arrays.stream(keys)
                .forEach(k -> {
                    validParam.put(k, null);
                });
        validParam.putAll(findFacilityParam);
        validParam.entrySet()
                .forEach(e -> validate(e));
        return FindFacilityDto.builder()
                .category(FacilityType.valueOf(validParam.get("category")))
                .latitude(Double.valueOf(validParam.get("latitude")))
                .longitude(Double.valueOf(validParam.get("longitude")))
                .distance(Integer.valueOf(validParam.get("distance")))
                .build();
    }

    public CreateFacilityResponseDto toCreateResponseDto(Facility entity) {
        return CreateFacilityResponseDto.builder()
                .address(entity.getAddress())
                .category(entity.getCategory())
                .title(entity.getTitle())
                .description(entity.getDescription())
                .build();
    }

    public FacilityResponseDto toResponseDto(Facility entity) {
        return FacilityResponseDto.builder()
                .id(entity.getId())
                .address(entity.getAddress())
                .category(entity.getCategory())
                .title(entity.getTitle())
                .description(entity.getDescription())
                .build();
    }

    public UpdateFacilityResponseDto toUpdateResponseDto(Facility entity) {
        return UpdateFacilityResponseDto.builder()
                .address(entity.getAddress())
                .category(entity.getCategory())
                .title(entity.getTitle())
                .description(entity.getDescription())
                .build();
    }

    private Address createAddress(CreateFacilityRequestDto dto) {
        return Address.builder()
                .regionCode(getRegionCode(dto.getJibunAddress()))
                .roadAddress(dto.getRoadAddress())
                .jibunAddress(dto.getJibunAddress())
                .latitude(dto.getLatitude())
                .longitude(dto.getLongitude())
                .build();
    }

    private Address createAddress(UpdateFacilityRequestDto dto) {
        return Address.builder()
                .regionCode(getRegionCode(dto.getJibunAddress()))
                .roadAddress(dto.getRoadAddress())
                .jibunAddress(dto.getJibunAddress())
                .latitude(dto.getLatitude())
                .longitude(dto.getLongitude())
                .build();
    }

    private void validate(Map.Entry entry) {
        // 카테고리 값이 존재하지 않는다면 "HEALTH"를 default로서 지정한다.
        Object value = entry.getValue();
        if (entry.getKey().equals("category")) {
            if (value == null) {
                entry.setValue("HEALTH");
            } else if (!FacilityType.contains((String) value)) {
                throw new CommonException(BError.NOT_VALID, (String) entry.getKey());
            }
        } else {
            if (value == null) {
                // 다른 파라미터의 값이 존재하지 않는다면 예외를 던진다.
                throw new CommonException(BError.NOT_EXIST, (String) entry.getKey());
            }
        }
    }

    private String getRegionCode(String jibunAddress) {
        String[] addressArr = jibunAddress.split(" ");
        StringJoiner validAddress = new StringJoiner(" ");
        IntStream.range(0, addressArr.length - 1)
                .forEach(i -> {
                    validAddress.add(addressArr[i]);
                });
        return REGION_CODE.get(String.valueOf(validAddress));
    }

    @PostConstruct
    void initRegionCode() throws IOException {
        String filePath = "static/region-code.csv";
        Resource resource = resourceLoader.getResource("classpath:" + filePath);
        InputStream inputStream = resource.getInputStream();
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
        String currentLine = reader.readLine(); // 첫 줄은 column명이므로 skip
        while ((currentLine = reader.readLine()) != null) {
            String[] splitLine = currentLine.split(",");
            String regionCode = splitLine[0];
            String area = String.join(" ",
                    splitLine[1], splitLine[2], splitLine[3], splitLine[4]).trim();
            REGION_CODE.put(area, regionCode);
        }
        reader.close();
    }
}

언뜻 봐도 반복되는 코드가 눈에 띈다. 그리고 초기화 작업을 하는 로직도 같은 클래스 안에 범벅이 되어 있다. 리팩토링을 하지 않을 수가 없다. 반복되는 코드를 추상화할 해결책을 고민하다가 프로젝트 초기에 후보에 있었던 mapstruct 라이브러리를 다시 꺼냈다. 이 라이브러리를 쓰지 않았던 이유는 dto에서 엔티티로 변환할 때 엔티티에 setter를 열어줘야 한다는 한계 때문이었다. 하지만 현재 mapping 로직에서는 dto에서 다른 dto로 변환되는 로직 밖에 없으므로 mapstruct를 사용해도 엔티티에 setter를 열어주는 위험을 감수하지 않아도 된다. 그래서 단순한 mapping 로직은 mapstruct를 사용해서 구현하기로 했다.

//...

@Component
@Mapper(componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.IGNORE,
        unmappedSourcePolicy = ReportingPolicy.IGNORE)
public interface FacilityMapper {

    FacilityMapper facilityMapper = Mappers.getMapper(FacilityMapper.class);

    FacilityDto toFacilityDto(CreateFacilityRequestDto dto);
    FacilityDto toFacilityDto(UpdateFacilityRequestDto dto);
    FacilityResponseDto toResponseDto(Facility entity);

}

대략 위와 같이 만들면 될 것 같다. 하지만 문제가 있다. FacilityDto의 필드 중에는 address 라는 필드가 있는데, 이 필드는 value object이다. 게다가 mapping 할 때 address의 지역코드를 생성해주는 추가 작업이 필요하기 때문에 단순히 FacilityMapper를 사용해서 mapping을 할 수가 없다. 물론 interface 안에 default 메서드를 만들어서 코드를 추가하면 되기는 하나 interface 안에 default 메서드를 무분별하게 사용하는 방법은 지저분해 보인다.

그래서 단순 mapping은 mapstruct에게 맡기고, 추가적으로 필요한 코드는 다른 클래스에서 만들기로 했다. 그런 다음에 추가로 만든 클래스에서 mapper를 주입받아서 mapping 로직을 조립하는 방법을 선택했다. 주의해야 할 점은 mapper 인터페이스는 package-private하게 만들었다는 점이다. 이렇게 설계한 이유는 다른 사람이 mapping 작업을 하기 위해 converter와 mapper를 이리저리 섞어서 쓰는 상황을 방지하기 위해서다. 다시 말해 mapper는 같은 패키지에 있는 converter만 사용할 수 있고, 개발자는 mapping을 하려면 converter만 사용해야 한다.

//...

/**
 * package-private
 * (주의) FacilityMapper는 FacilityConverter를 통해 사용해야 한다.
 */
@Component
@Mapper(componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.IGNORE,
        unmappedSourcePolicy = ReportingPolicy.IGNORE)
interface FacilityMapper {

    FacilityMapper facilityMapper = Mappers.getMapper(FacilityMapper.class);

    FacilityDto preprocess(CreateFacilityRequestDto dto);
    FacilityDto preprocess(UpdateFacilityRequestDto dto);
    FacilityResponseDto toResponseDto(Facility entity);

}
//...

@Component
@RequiredArgsConstructor
public class FacilityConverter {

    public static final Map<String, String> REGION_CODE = new HashMap<>(); // <ADDRESS, REGION_CODE>

    private final ResourceLoader resourceLoader;
    private final FacilityMapper facilityMapper;

    //== initialization methods ==//
    @PostConstruct
    void initRegionCode() throws IOException {
        String filePath = "static/region-code.csv";
        Resource resource = resourceLoader.getResource("classpath:" + filePath);
        InputStream inputStream = resource.getInputStream();
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
        String currentLine = reader.readLine(); // 첫 줄은 column명이므로 skip
        while ((currentLine = reader.readLine()) != null) {
            String[] splitLine = currentLine.split(",");
            String regionCode = splitLine[0];
            String area = String.join(" ",
                    splitLine[1], splitLine[2], splitLine[3], splitLine[4]).trim();
            REGION_CODE.put(area, regionCode);
        }
        reader.close();
    }

    // dto -> adaptee 변환
    public FacilityDto toFacilityDto(CreateFacilityRequestDto dto) {
        FacilityDto adaptee = facilityMapper.preprocess(dto);
        adaptee.setAddress(createAddress(dto));
        return adaptee;
    }

    public FacilityDto toFacilityDto(UpdateFacilityRequestDto dto) {
        FacilityDto adaptee = facilityMapper.preprocess(dto);
        adaptee.setAddress(createAddress(dto));
        return adaptee;
    }

    // 조회용 dto 변환
    public FindFacilityDto toFindFacilityDto(Map<String, String> findFacilityParam) {
        Map<String, String> validParam = new HashMap<>();
        String[] keys = new String[]{"category", "latitude", "longitude", "distance"};
        Arrays.stream(keys)
                .forEach(k -> {
                    validParam.put(k, null);
                });
        validParam.putAll(findFacilityParam);
        validParam.entrySet()
                .forEach(e -> validate(e));
        return FindFacilityDto.builder()
                .category(FacilityType.valueOf(validParam.get("category")))
                .latitude(Double.valueOf(validParam.get("latitude")))
                .longitude(Double.valueOf(validParam.get("longitude")))
                .distance(Integer.valueOf(validParam.get("distance")))
                .build();
    }

    // 클라이언트 응답 dto 변환
    public FacilityResponseDto toResponseDto(Facility entity) {
        return facilityMapper.toResponseDto(entity);
    }

    //== private methods ==//
    private Address createAddress(CreateFacilityRequestDto dto) {
        return Address.builder()
                .regionCode(getRegionCode(dto.getJibunAddress()))
                .roadAddress(dto.getRoadAddress())
                .jibunAddress(dto.getJibunAddress())
                .latitude(dto.getLatitude())
                .longitude(dto.getLongitude())
                .build();
    }

    private Address createAddress(UpdateFacilityRequestDto dto) {
        return Address.builder()
                .regionCode(getRegionCode(dto.getJibunAddress()))
                .roadAddress(dto.getRoadAddress())
                .jibunAddress(dto.getJibunAddress())
                .latitude(dto.getLatitude())
                .longitude(dto.getLongitude())
                .build();
    }

    private void validate(Map.Entry entry) {
        // 카테고리 값이 존재하지 않는다면 "HEALTH"를 default로서 지정한다.
        Object value = entry.getValue();
        if (entry.getKey().equals("category")) {
            if (value == null) {
                entry.setValue("HEALTH");
            } else if (!FacilityType.contains((String) value)) {
                throw new CommonException(BError.NOT_VALID, (String) entry.getKey());
            }
        } else {
            if (value == null) {
                // 다른 파라미터의 값이 존재하지 않는다면 예외를 던진다.
                throw new CommonException(BError.NOT_EXIST, (String) entry.getKey());
            }
        }
    }

    private String getRegionCode(String jibunAddress) {
        String[] addressArr = jibunAddress.split(" ");
        StringJoiner validAddress = new StringJoiner(" ");
        IntStream.range(0, addressArr.length - 1)
                .forEach(i -> {
                    validAddress.add(addressArr[i]);
                });
        return REGION_CODE.get(String.valueOf(validAddress));
    }
}

코드의 길이가 확연하게 줄어들지는 않았지만 중복되는 코드는 사라졌다. 그리고 복잡한 로직과 단순한 로직을 분리함으로써 각자의 책임과 역할이 분명해졌다.