토이 프로젝트에서는 외부 API 연동이 드물어 Entity와 DTO를 구분해야 한다는 말에 공감하지 못할 때가 많다.
익숙한 형태의 Request를 요구하면 별 공수없이 우리 서비스에서 사용할 수 있기 때문이다.
나는 이번에 차원이 다른 Request를 경험하고 VO와 책임 분리에 대한 깊은 필요성을 체감했다.
마주했던 복잡한 데이터 변환 문제, 해결 과정, 그리고 이를 통해 얻은 설계 원칙에 대한 깨달음을 기록하고
공유(되면 좋겠다🥹)하기 위해 글을 작성한다.
CRAYON의 새로운 기능을 소개합니다
구글폼 응답 연동 기능
구글폼으로 받은 응답을 구글 스프레드 시트에서 볼 수 있는데, 많은 양의 응답을 검토하기에는 비효율적이고 가독성이 떨어진다. 🤕
크레용팀은 위 페인포인트를 중점으로,
구글폼으로 응답받은 데이터들을 CRAYON과 연동해
깔끔한 UI로 관리하는 기능을 제공하고자 한다.
그럼 크레용 서버팀이 해야하는 일은 ?
프론트가 구글API로부터 받아와 넘겨주는 스프레드 시트 데이터를 CRAYON 형식에 맞추어 저장해야 한다!
구글폼 API로부터 받은 데이터 형식은 Application Entity(이하 Application), Answers Document(이하 Answers)와 크게 달랐다
Application: 지원자 기본 정보 저장
Answers: 문항과 응답 데이터를 저장
cols
는 구글폼 문항, rows
는 구글폼의 지원자별 응답이다.
label
은 문항 제목, type
은 문항 데이터 타입으로 이해하면 되겠다.
c
는 지원자별 응답들, v
는 각 문항에 대한 응답을 지칭한다.
Application, Answers와 전-혀 다른 Request에서 CRAYON에서 활용할 수 있는 형태로 변환할 필요가 있었다.
데이터 변환을 위해 크게 3가지 제약조건이 존재했다.
Request Json에서 볼 수 있듯이 문항과 응답이 1:1로 매핑된 것이 아닌, 문항과 응답은 순서가 같을 때만 유효한 쌍으로 볼 수 있다.
따라서 반드시 순서를 기준으로 문항과 응답을 매핑해야 하는 제약조건이 있다.
지원자의 이름, 전화번호, 이메일 주소는 Answers가 아닌 Application에 저장해야 한다.
그리고 이런 지원자 정보를 구분하기 위해서는 ‘Label(문항 제목)’을 이용해야 한다.
구글 API로부터 받는 날짜는 아래와 같다.
문항의 type이 datetime
인 경우에는 yyyy-MM-dd HH:mm
형식으로 포맷팅 해야한다.
( 원래는 Date(2025,1,3,22,41,7)
이지만, 간단한 예시를 위해 Date
와 ()
는 생략해 설명하겠다 )
Request를 CRAYON에서 사용할 수 있는 형태로 만들어 저장해보자
ApplicationImportUseCase.java
public void importApplications(UUID recruitmentId, ApplicationImportRequest request) {
List<QuestionRequest> questions = request.cols();
List<RespondentRequest> respondents = request.rows();
...
for (RespondentRequest respondent : respondents) { // 1) 지원자 순회
ApplicationBuilder applicationBuilder = Application.builder();
List<Item> items = new ArrayList<>();
List<DataRequest> answers = respondent.c(); // 2) 지원자의 문항별 응답
if (questions.size() != answers.size()) { // 3) 문항 - 응답 매핑 예외
throw new QuestionReplySizeMismatchException();
}
for (int j = 0; j < questions.size(); j++) { // 4) 문항 순회
QuestionRequest question = questions.get(j); // 5) 문항
String questionLabel = question.label();
String questionType = question.type();
String answer = answers.get(j).v(); // 6) 응답
// 7) 지원자 정보 추출
if (questionLabel.contains("이름")) {
applicationBuilder.userName(answer);
} else if (questionLabel.contains("전화번호")) {
applicationBuilder.tel(answer);
} else if (
...
// 8) 날짜 포맷팅
if (!answer.isEmpty() && "datetime".equals(questionType)) {
String[] split = answer.split(",");
...
answer = String.format("%04d-%02d-%02d %02d:%02d", args);
}
items.add(itemFactory.createItem(questionLabel, answer));
}
applications.add(applicationBuilder.build());
itemsPerApplication.add(items);
}
// 지원(Application) 저장
// 응답(Item) 저장
}
일단은(ㅋㅋ) 돌아가는 코드이다.
그럼 여기서 세 가지 질문을 해보자.
String
의 모든 기능이 필요로 하는가?나의 답은 모두 아니오
이다
문항-응답 매핑은 비즈니스 로직인가?
→ No. 도메인 영역인 ‘지원’ 처리를 위해 단순히 구조를 변경하는 것.
Request가 변경되어도 재사용 가능한가?
→ No. Request의 형태가 그대로 드러나 Request가 변경되었을 때 재사용 불가
문항과 응답은 String
의 모든 기능을 필요로 하는가?
→ No. 문항과 응답을 임의로 수정해서는 안됨.
위 대답이 모두 아니오
라는 것은 캡슐화 없이 책임이 불분명해 유지보수가 어려울 것이라는 문제를 암시한다. 그리고 일단 누가봐도 가독성이 좋지 않다.
나는 위 문제를 해결하는 방안으로 VO를 도입했다.
📎 VO 특성
이 특성으로 인해 VO는 직관적이고 안전한 비교가 가능하다. 또한 자가 유효성 검사를 통해 데이터를 사용하는 시점에서 무결성을 확보할 수 있다.
VO의 특성과 아까 세 가지 답으로부터 VO를 활용했을 때 얻을 수 있는 이점을 살펴보자
즉, VO를 활용함으로써 비즈니스 로직과 도메인 로직을 분리해 SRP를 보장할 수 있다.
설계를 바탕으로 Request를 VO로 변환해보자
지원자 정보와 그 외 문항&응답을 가진 ApplicationReply는 비즈니스 로직에서 의미를 갖는다.
그럼 외부에서 들어온 Request를 우리 서비스에서 의미있는 ApplicationReply로 변환하겠다.
public record ApplicationImportRequest(
List<QuestionRequest> cols,
List<RespondentRequest> rows
) {
public List<ApplicationReply> toApplicationReplies() {
List<Question> questions = toQuestions();
List<Replies> replies = toReplies();
return replies.stream()
.map(reply -> toQuestionReplyList(questions, reply))
.map(ApplicationReply::of)
.toList();
}
...
private List<QuestionReply> toQuestionReplyList(List<Question> questions, Replies replies) {
if (questions.size() != replies.size()) {
throw new QuestionReplySizeMismatchException();
}
return IntStream.range(0, questions.size())
.mapToObj(i -> QuestionReply.of(questions.get(i), replies.get(i)))
.toList();
}
}
세 줄 요약
복잡했던 지원자 정보 추출은 List<QuestionReply>
→ ApplicationReply
변환 과정에 있다.
단순 문항&응답 쌍이었던 QuestionReply를 ApplicationReply로 변환하기 위해서는 지원자 정보를 추출하는 과정을 거친다.
지원자 정보/그 외 정보를 구분하기 위해 각 label을 파악해야 한다.
위 과정을 거쳐 APPLICANT_INFO와 FORM 유형으로 구분할 수 있다.
하지만 아직 지원자 정보라는 것만 판단했고 이름, 전화번호, 이메일 중 어떤 항목인지 알 수 없다.
이를 구분하는 책임은 Applicant에게 맡기겠다.
Applicant를 생성할 때, ApplicantInfo으로부터 QuestionReply가 어떤 항목인지 찾도록 한다.
public enum ApplicantInfo {
NAME("이름"),
PHONE("전화번호"),
EMAIL("메일");
private final String keyword;
public static ApplicantInfo find(QuestionReply questionReply) {
return Arrays.stream(values())
.filter(info -> questionReply.match(info.keyword))
.findFirst()
.orElseThrow(InvalidApplicantInfoException::new);
}
...
}
처음과 비교해보면 복잡했던 if-else문에서 새로운 정보 기준이 추가되더라도 분기 과정은 수정할 필요없는 코드가 되었다.
만약 생년월일
이라는 지원자 정보가 추가되어도 ApplicantInfo에 BIRTH(”생년월일”)
만 추가하면 될 것이다.
DataType enum에 포맷팅 함수를 연결하여 format(input) 호출만으로 처리하겠다.
public enum DataType {
STRING("string", Function.identity()),
DATE("date", DateFormatter::format),
DATE_TIME("datetime", DateFormatter::format),
private final String type;
private final Function<String, String> function;
public static DataType match(String type) {
return Arrays.stream(values())
.filter(v -> v.type.equals(type.toLowerCase()))
.findFirst()
.orElseThrow(InvalidDataType::new);
}
public String format(String input) {
return function.apply(input);
}
}
DataType을 가져오는 것은 아까 지원자정보를 가져오는 방식과 비슷하다.
DataType에 따라 포맷팅하는 행위(Function<String, String> function
)를 알고있다
이 과정들을 거쳐 마침내 ApplicationReply를 완성했다
그리고 ApplicationReply를 사용해 Application를 만들 수 있게 되었다
public class ApplicationReply {
private final Applicant applicant;
private final List<QuestionReply> formQuestionReplies;
public Application toApplication(UUID recruitmentId, Process process) {
return Application.builder()
.userName(applicant.getName())
.tel(applicant.getPhone())
.email(applicant.getEmail())
.recruitmentId(recruitmentId)
.process(process)
.build();
}
}
마지막으로 Request을 ApplicationReply로 변환하고 저장하는 과정의 비즈니스 로직이다.
약 40줄 이었던 비즈니스 로직을 단 3줄로 간소화했다.
public void importApplications(UUID recruitmentId, ApplicationImportRequest request) {
List<ApplicationReply> applicationReplies = request.toApplicationReplies();
List<Application> applications = applicationSaveService.saveAll(recruitmentId, applicationReplies);
answerSaveService.save(applicationReplies, applications);
}
이 기능을 모두 구현했을 때 들은 충격 소식
후론후🗣️ :저번에 말했던 형식으로 받아오는 구글폼 API를 못찾겠는데 다른 형식으로 전달해도 될까?
아니요
다행히 API를 (내가) 찾아 기존 형식으로 받아올 수 있었지만
변경되었다면 얼마나 공수가 있을까? 하고 혼자 해봤다(??)
public record TempImportRequest(
List<Map<String, String>> data
) {
public List<ApplicationReply> toApplicationReplies() {
return data.stream()
.map(this::toQuestionReplyList)
.map(ApplicationReply::of)
.toList();
}
private List<QuestionReply> toQuestionReplyList(Map<String, String> map) {
return map.entrySet().stream()
.map(this::toQuestionReply)
.toList();
}
private QuestionReply toQuestionReply(Entry<String, String> entry) {
Question question = new Question(entry.getKey(), entry.getValue());
Reply reply = new Reply(entry.getValue());
return QuestionReply.of(question, reply);
}
}
.
.
.
어라 ? 도메인 로직은 수정하지 않고 Request만 변경하면 되네 ?
10분만에 뚝딱 해결해버리기
SRP(단일 책임 원칙)를 지키기 위한 클래스 분리가 왜 중요한지 체감했다.
SRP의 여러가지 장점이 있다고 하지만, 내가 체감한 것들 중 최고는 가독성이다.
코드가 분산되어 읽기 어렵다는 입장도 있지만, 나는 클래스명과 메서드명이 주는 적절한 표현이 가독성에 좋은 영향을 준다고 생각한다.
클래스와 메서드를 명확히 구분하여 역할을 한정하면, 읽는 사람은 세부 구현을 몰라도 클래스명과 메서드명만으로 코드의 의도를 빠르게 파악할 수 있다.
(빨간색, 달콤함) → (사과)
처럼 작은 개념으로부터 큰 개념을 추론하는 것은 어렵지만, (사과) → (빨간색, 달콤함)
처럼 큰 개념을 알고 세부 개념을 이해하는 것은 비교적 쉽기 때문이다.
다들 객체지향 하세요 ~