소개팅 주선 서비스에서 프로필을 입력하는 기능(이하 프로필 설문)이 있다.
다른 도메인보다 프로필을 입력하는 질문 수가 많을 수 밖에 없다
페이스북은 사용자가 자기소개를 작성하고 사진과 신상정보를 추가하도록 하면서 사용자가 참여하도록 하고 심리적인 보상을 제공한다 페이스북에서 자기소개를 만드느라 몇 시간을 보내 본 사람이라면 여기에 대단히 만족스러운 몰입 상태를 느끼게 하는 어떤 것이 존재한다는 점을 알 것이다
출처: 션 엘리스, 그로스 해킹: 스타트업을 위한 실전 마케팅 전략 2020.
설문을 조금 딱딱하지 않게, 뭔가 신상 캐는 느낌이 나지 않게 하기 위해 구글폼 보다는 우리 서비스에 임베드 시킬 수 있으면서 부드러운 ux/ui를 제공하는 타입폼을 사용하기로 했다
하지만 타입폼에서 생각보다 많은 유저 이탈이 있어서 생각해 봤는데
유저 입장에서 프로필 질문이 50개 정도 있는데 한번에 다 입력하기가 쉽지가 않다.
예를들어 오전에 출근시간에 30번 질문까지 답변하고 오후 퇴근시간에 30번부터 질문을 입력하는 상황이 있다면 이게 1번 질문부터 다시 시작하는게 이유였다
이걸 해결 할 방법으로 여러가지가 있지만, 다른 파트에서 병목이 생겨 내가 시간이 좀 남기도 하고 타입폼은 유료 서비스라 돈도 무시할 수 없고 해서 타입폼을 자체 구현 해보겠다 했다
질문내용은 추후에 엄청나게 바뀔 가능성이 있다. 굳이 개발자한테 "이 질문 추가해주세요" "이 질문 삭제해주세요" "이 질문 문구 수정해주세요" 할 필요 없이 어드민 페이지에서

이런 좋은 관리자 ui에서 수정할 수 있게 해야 한다
위 사진처럼 엄청나게 많은 분기가 있는데 정확한 분기 처리와 유지보수가 용이하게 짜야 한다
유저가 답변을 하다가 나중에 다시 들어오더라도 직전에 답변하던 질문으로 돌아올 수 있어야 한다
create table male_profile_survey_question
(
ID binary(16) not null primary key,
STEP_NUMBER int not null,
COLUMN_TYPE varchar(50) not null,
TYPE varchar(50) not null,
TITLE text not null,
SUB_TITLE text not null
);
create table male_profile_survey_answer
(
ID binary(16) not null primary key,
NEXT_QUESTION_ID binary(16) null,
QUESTION_ID binary(16) not null,
LABEL int not null,
CONTENT text
);
이렇게 질문과 답변을 데이터베이스 화 해봤다
우리 서비스는 남성 여성에 따라 설문 내용이 아예 달라져서 두개를 따로 했다 하지만 남성 여성의 설문에 대한 테이블 스펙은 동일하다
question은 질문내용, answer은 해당 질문의 답변할 수 있는 답변 내용들 이다
answer 테이블에에 next_question_id 칼럼을 넣어서 이 answer의 다음 question이 뭔지 알 수 있게 하였다
또한 유저 테이블에 question_id를 넣어서 이 유저가 해야 할 다음 question이 뭔지 알게 하였다
현재 나의 설문 단계 조회 구현은 이렇게 하였다 지금 현재는 좀 더티코드긴한데
if (userProfile.getGender() == Gender.MALE) {
MaleProfileSurveyQuestion question = maleProfileSurveyQuestionReader.readById(userProfile.getMaleProfileSurveyQuestionId());
List<MaleProfileSurveyAnswer> answers = maleProfileSurveyAnswerReader.readAllByQuestionId(question.getId());
List<SurveyAnswerHttpResponse> answerContents = answers.stream()
.map(answer -> new SurveyAnswerHttpResponse(answer.getId(), answer.getContent()))
.collect(Collectors.toList());
return new GetProfileSurveyHttpResponse(question, answerContents);
}
이렇게 해당 userProfile에서 현재 설문지 question_id를 가져와서 질문을 읽어온다
그리고 그 질문에 해당되는 답변들 리스트를 가져와서 반환해준다
그럼 답변은 어떻게 해야 할까?
public void answerProfileSurvey(UpdateProfileSurveyAnswerRequest request, UUID userId) {
UserProfile userProfile = userProfileReader.readByUserId(userId);
if (request.isListType()) {
List<String> contents = request.getListContentByType();
MaleProfileSurveyAnswer answer = maleProfileSurveyAnswerReader.readById(request.getAnswerId());
userProfileWriter.handleUserProfileListSurvey(request.getColumnType(), contents, userProfile, answer.getNextQuestionId());
return;
}
String content = request.getSingleContentByType();
MaleProfileSurveyAnswer answer = maleProfileSurveyAnswerReader.readById(request.getAnswerId());
userProfileWriter.handleUserProfileSurvey(request.getColumnType(), content, userProfile, answer.getNextQuestionId());
}
코드가 더럽긴하다
일단 답변이 List(다중 선택), String(단일 선택)인지 파악한다\
그 답변을 저장하고
그리고 클라이언트가 주는 answer_id(나의 설문 단계 조회에서 알 수 있다) -> 유저가 답변 한 답변 id가 온다 그럼 그걸로 답변을 조회하고
public void surveyName(UserName name, UUID nextQuestionId) {
this.name = name;
updateProfileSurveyQuestionId(nextQuestionId);
}
public void updateProfileSurveyQuestionId(UUID nextQuestionId) {
if (this.gender == Gender.MALE) {
this.maleProfileSurveyQuestionId = nextQuestionId;
return;
}
this.femaleProfileSurveyQuestionId = nextQuestionId;
}
해당 답변의 다음 question_id를 유저 프로필에 저장해준다
그럼 유저는 다음 질문으로 넘어간다
public void handleUserProfileSurvey(ProfileColumnType columnType, String content, UserProfile userProfile,
UUID nextQuestionId) {
switch (columnType) {
case NAME -> userProfile.surveyName(new UserName(content), nextQuestionId);
case AGE -> userProfile.surveyAge(Age.fromCompactDateFormat(content), nextQuestionId);
case PHONE_NUMBER -> userProfile.surveyPhoneNumber(new PhoneNumber(content), nextQuestionId);
... etc
default -> throw new ProfileSurveyAnswerTypeMismatchException(
ErrorCode.PROFILE_SURVEY_ANSWER_TYPE_MISMATCH_ERROR,
ErrorCode.PROFILE_SURVEY_ANSWER_TYPE_MISMATCH_ERROR.getStatusMessage()
);
}
userProfileRepository.save(userProfile);
}
물론 설문 내용을 디비에 저장하는건 유지보수가 쉽지 않다.... 사실 타입폼으로 한다 해도 없던 설문 내용이 추가된다하면 결국엔 user_profile 테이블에 칼럼을 추가해야되고 코드가 추가되는건 어쩔 수 없긴 하다
하지만 여기서 큰 문제가 발생한다
오키 그렇다면 유저가 뒤로가기는 어떻게 하지?
이게 진짜 큰 문제인게

29번째 설문에서 뒤로가기를 눌러도 13번부터 28번까지 어디로 가야할지 모르는게 문제이다 answer은 다음 질문을 갖고잇는거지 question이 이전 질문을 갖고있을 순 없다 (설문의 분기가 존재해서...)
유저가 뒤로가기를 한다 해도 user profile에는 설문해야할 question_id가 있기 때문에 뒤로 못간다
정말 많은 고민과 더티 빡구현 코드를 하나하나 작성해야 하나 고민이 들었다
더티 빡구현 코드는 이제 유저 칼럼 하나하나 확인하면서 이전 답변이 뭐였는지 확인하는건데 이러면 너무 유지보수가 안된다
그러던 중 너무 좋은 생각이 났다
user_profile에 칼럼을 하나 파는거다 그리고 그 테이블과 연결하는 엔티티의 필드엔 Stack자료구조로 하는거다
그러면 stack에는 유저가 설문을 제출할 때 마다 제출한 question_id를 stack에 계속 push 하는거다 만약에 유저가 뒤로 가고싶다?
그럼 stack에서 pop하면 된다
@Convert(converter = StringStackConverter.class)
private Stack<String> profileSurveyQuestionStack;
엔티티에 이렇게 추가해준다 사실 DB와 JPA에는 자료구조를 처리할 수 있는 좋은 방법이 딱히 없다
하나 있긴 한데 @ElementCollection, @CollectionTable 을 사용하여 컬렉션의 요소를 별도 테이블에 매핑할 수 있긴 한데 테이블을 분리하기 싫어서
이렇게 했다
@Converter
public class StringStackConverter implements AttributeConverter<Stack<String>, String> {
private static final String SPLIT_CHAR = ",";
@Override
public String convertToDatabaseColumn(Stack<String> stack) {
return (stack != null && !stack.isEmpty()) ? String.join(SPLIT_CHAR, stack) : null;
}
@Override
public Stack<String> convertToEntityAttribute(String string) {
Stack<String> stack = new Stack<>();
if (string != null && !string.isEmpty()) {
List<String> items = Arrays.asList(string.split(SPLIT_CHAR));
stack.addAll(items);
}
return stack;
}
}
JPA AttributeConverter를 이용해 Stack 타입의 데이터를 데이터베이스에 String 형태로 저장하고, 다시 엔티티에 불러올 때는 Stack으로 변환하는 기능을 구현했다
StringStackConverter는 Stack 데이터를 콤마(,)로 구분된 String으로 직렬화하고, 이 직렬화된 문자열을 다시 Stack으로 복원할 수 있게 해주게 했다
Stack 데이터를 콤마(,)로 구분된 String으로 변환하여 데이터베이스에 저장한다
Stack이 null이거나 비어있다면 null을 반환하고, 데이터가 있다면 String.join()을 사용해 스택의 모든 항목을 하나의 문자열로 만들어준다
데이터베이스에서 불러온 String을 다시 Stack으로 변환하여 엔티티의 속성으로 반환
string이 null이거나 비어있지 않은 경우, split(SPLIT_CHAR)로 문자열을 분리해 항목 리스트를 만들고, 이를 Stack에 넣는다
엔티티의 profileSurveyQuestionStack 필드는 StringStackConverter를 이용해 데이터베이스에 String 형태로 저장
이 필드가 @Convert 어노테이션을 통해 StringStackConverter와 연결되므로, 데이터베이스와의 저장 및 불러오기 시 자동으로 변환
저장 예: Stack stack = ["Q1", "Q2", "Q3"] → 데이터베이스에 "Q1,Q2,Q3" 형태로 저장
불러오기 예: "Q1,Q2,Q3" → Stack stack = ["Q1", "Q2", "Q3"]
public void backwardProfileSurvey() {
if (profileSurveyQuestionStack.isEmpty()) {
throw new EmptyProfileSurveyStackException(
ErrorCode.EMPTY_PROFILE_SURVEY_STACK_ERROR,
ErrorCode.EMPTY_PROFILE_SURVEY_STACK_ERROR.getStatusMessage()
);
}
this.femaleProfileSurveyQuestionId = UUID.fromString(this.profileSurveyQuestionStack.pop());
}
뒤로 가기 구현은 이렇게 했다
물론 설문을 답변할 때도
public void updateProfileSurveyQuestionId(UUID nextQuestionId) {
if (this.gender == Gender.MALE) {
this.profileSurveyQuestionStack.push(this.maleProfileSurveyQuestionId.toString());
this.maleProfileSurveyQuestionId = nextQuestionId;
return;
}
this.profileSurveyQuestionStack.push(this.femaleProfileSurveyQuestionId.toString());
this.femaleProfileSurveyQuestionId = nextQuestionId;
}
이런식으로 다음 질문을 넣기 전에 이전 질문을 stack에 push해서 트래킹이 가능하게 했다
또한 설문을 끝까지 전부 제출하고 나면 stack은 필요없다 심지어 질문이 50개니까 제출 후엔 칼럼내부에 너무 많은 값을 차지 하고 있고 엔티티 조회할때마다 스택 내부에 너무 많은 값이 있어서 DB의 테이블 메모리와, 힙 메모리가 아깝다 때문에 설문을 제출하면
public void submitSurvey() {
this.registrationStep = RegistrationStep.findNextRegistrationStep(this.registrationStep);
if (this.profileSurveyQuestionStack != null) {
this.profileSurveyQuestionStack.clear();
}
stack 내부를 비우도록 했다
개발자한테 "이 질문 추가해주세요" "이 질문 삭제해주세요" "이 질문 문구 수정해주세요" 할 필요 없이 어드민 페이지에서 수정할 수 있게 하려고 했는데
이 질문 추가해주세요 는 못할 것 같다. 만약에 원래 자신의 키를 받지 않았는데 "내가 키가 몇인지 질문 추가해주세요" 할 필요없이 관리자 페이지에서 할 순 없을 것 같다. 왜냐하면 유저 테이블에 키 칼럼을 추가해야되고 코드를 수정해야되기 때문이다... 그래도 이건 같은 상황이면 타입폼에서도 서버 코드를 건들여야하기 때문에 일단 하지 않도록 하자
또 만들고 보니 설문중 이탈한 유저가 어디에서 이탈했는지 알기 쉬워서 어떤 질문 내용이 유저한테 부담스러운지 알 수 있게 되었다 나이스 ㅋㅋ
타입폼 안쓰게 되서 아낀 돈으로 마케팅에 투자하고 유저 많이 왔으면 좋겠다
사실상 이거 조금만 더 고도화 시키면 진짜 BtoB 프로젝트 하나짜리인데 우리 프로젝트 내부에선 기능 개발 하나인게 아쉽다... 다 만들고 나서
'task: 타입폼 자체구현' 에 완료 띡 눌렀을 때의 현타... 하지만 정말 너무 좋은 경험을 했고 Stack 구상은 지금 다시 생각해도 도파민 오버플로우... 시간이 늦었다 내일은 일찍 일어나서 더티코드 리펙토링좀 하자