Search

제네릭과 컴포지트 패턴을 활용한 T점수 계산기 구현

Created
2024/11/06 00:58
태그
개발
Status
Done

배경

상황

역량검사 3.0을 개발하면서 여러 항목의 역량 측정 값을 계산하는 로직을 구현해야 하는 상황이다. 계산 로직 중 겹치는 부분이 있어, 이를 제네릭과 인터페이스 추상화를 통해 일괄 처리할 수 있는 계산기를 작성하고자 한다.
이와 같이 역량검사의 각 질문에는 해당하는 문항 코드(ID)가 존재한다. 여기서 일부 항목은 상위 역량만 존재하여 단순 합을 통해 원점수를 구할 수 있지만, 일부 역량은 하위 역량 계산 이후 그 값들을 통한 상위 역량 계산하는 과정이 필요하다. 이 과정을 composite 패턴을 통해 추상화하여 계산 로직 호출을 단순화하고자 한다.
또한 특정 역량 점수 계산 시, 응시자가 질문들의 답으로 선택한 값 중 해당 역량에 속해있는 질문(코드)들의 합을 구한다.
이후 그 점수를 통해 표준화를 진행하는데, 표준화 시 점수 계산은 (원점수 - a) / b * c + d의 일관된 수식을 가진다. 여기서 뺄셈 상수인 a와 나눗셈 상수인 b는 각 하위 역량 항목 별로 점수가 다르며, c와 d는 각각 편차와 평균을 의미하고 상위 역량(인성, 메타인지 등…)별로 동일하다. 인재상FIT 점수만 제외하고 모든 역량 검사의 점수 계산 방식이 위와 동일하다.
추가적으로 일부 역량에서는 상한/하한 점수에 대해 제한을 두고, 일부 역량에서는 반올림 처리를 수행한다.
이러한 계산 로직을 한 번에 처리할 수 있고, 역량에 관계없이 받아서 계산 로직을 처리할 수 있는 T점수 계산기를 만들어보기로 했다.

구현

Composite 패턴을 통한 계산 로직 추상화

먼저 일부 항목은 상위 역량만을 통해 계산이 가능하지만, 일부 항목들은 상위 역량과 하위 역량으로 나뉘어 계산 과정을 복잡하게 만든다. 이를 composite 패턴을 통해 상위 역량 Enum 내부에 계산에 필요한 하위 역량의 Enum을 넣어, 상위 역량만 알면 원점수 계산을 할 수 있도록 만들어보자.
public interface CapabilityTScoreCalculateType { List<QuestionNewCode> getOriginScoreComponents(); List<QuestionNewCode> getReverseScoreComponents(); }
Java
복사
타입 선언 시 필요한 Enum에 필드와 메서드를 갖도록 인터페이스에서 구현하도록 강제한다. 각각의 getter를 통해 강제하는 메서드는 다음을 의미한다.
getOriginScoreComponents : 정방향 계산 항목의 질문 코드 리스트 반환(ex. 나는 ~~한 사람이다)
getReverseScoreComponents : 역방향 계산 항목의 질문 코드 리스트 반환(ex. 나는 ~~한 사람이 아니다)
@Getter @RequiredArgsConstructor public enum CapabiltyAType implements CapabilityEnumType { A( List.of(CODE_A1, CODE_A2, CODE_A3, CODE_A4, CODE_A5, CODE_A6, CODE_A7), List.of(), ), B( List.of(CODE_B1, CODE_B2, CODE_B4, CODE_B5, CODE_B6), List.of(CODE_B3), ), ... ; private final List<QuestionCode> originScoreComponents; private final List<QuestionCode> reverseScoreComponents; }
Java
복사
이와 같이 상위 역량만으로 계산이 가능한 역량 항목에 대해서는 Enum의 필드값으로 해당 속성을 계산 시 필요한 질문 코드값을 넣어준다.
일부 역량 중 하위 역량이 있고 하위 역량들을 묶어 상위 역량의 점수를 계산하는 로직의 경우에는
@Getter @RequiredArgsConstructor public enum CapabilityBLowerType { EXP( List.of(PRE_B1, PRE_B2, PRE_B3, PRE_B4), List.of() ), PER( List.of(PRE_B5, PRE_B6, PRE_B7, PRE_B8), List.of() ), ... ; private final List<QuestionCode> originScoreComponents; private final List<QuestionCode> reverseScoreComponents; public static List<QuestionCode> extractQuestionCode(List<CapabilityBLowerType> lowerTypes) { return lowerTypes.stream() .flatMap(lowerType -> lowerType.getOriginScoreComponents().stream()) .toList(); } public static List<QuestionCode> extractReverseQuestionCode(List<PersonalityLowerType> lowerTypes) { return lowerTypes.stream() .flatMap(lowerType -> lowerType.getReverseScoreComponents().stream()) .toList(); } }
Java
복사
이처럼 하위 역량의 Enum 타입에서 계산에 필요한 질문 코드를 묶은 후,
@Getter public enum CapabilityBUpperType implements CapabilityEnumType { PRE( List.of(EXP, PER), ), TMW( List.of(COO, REC), ), ... ; private final List<QuestionCode> originScoreComponents; private final List<QuestionCode> reverseScoreComponents; CapabilityBUpperType( final List<CapabilityBLowerType> lowerComponents, ) { this.originScoreComponents = CapabilityBLowerType.extractQuestionCode(lowerComponents); this.reverseScoreComponents = CapabilityBLowerType.extractReverseQuestionCode(lowerComponents); } }
Java
복사
상위 역량에서 포함하는 하위 역량을 묶도록 한다. 그 후 생성자 호출 시점에 속해있는 하위 역량에서 계산에 포함되는 모든 질문 코드를 받아와 저장하도록 처리한다.
/** * 원점수 계산 */ public <T extends Enum<T> & CapabilityTScoreCalculateType> Map<T, Integer> calculateOriginScore( final Map<QuestionNewCode, Integer> newCodeAnswerValueMap, final Class<T> translateType ) { EnumMap<T, Integer> originScoreMap = new EnumMap<>(translateType); for (T type : translateType.getEnumConstants()) { Integer positiveSum = type.getOriginScoreComponents().stream() .mapToInt(newCode -> newCodeAnswerValueMap.getOrDefault(newCode, 0)) .sum(); Integer reverseSum = type.getReverseScoreComponents().stream() .mapToInt(newCode -> 7 - newCodeAnswerValueMap.getOrDefault(newCode, 0)) .sum(); originScoreMap.put(type, positiveSum + reverseSum); } return originScoreMap; }
Java
복사
구현되어있는 getOriginScoreComponents 메서드와 getReverseScoreComponents 메서드를 통해, 정방향 점수 합과 역방향 점수 합을 구하여 원점수를 반환한다. 그 과정에서 제네릭 타입으로 Enum 속성이고 CapabilityEnumType을 구현한 타입만 받도록 제한을 추가하여 타입 안정성을 확보한다.

공통된 계산 로직 처리

계산된 원점수를 T점수로 변환하는 과정에서의 공통된 계산 로직을 제네릭으로 일괄 처리하도록 구현해보자.
public record TScoreStandardizationDto( double subtractConst, double divideConst, double multiplyConst, double addConst ) { } public record TScoreBoundLimitDto( BigDecimal upperLimit, BigDecimal lowerLimit, int roundScale // 소수점 아래 올림 자리수 ) { } private static final Map<ALowerType, TScoreStandardizationDto> STANDARDIZATION_CONST = new HashMap<>() {{ put(ALowerType.EXP, new TScoreStandardizationDto(1.01, 485.0, 16.67, 50.00)); put(ALowerType.PER, new TScoreStandardizationDto(39.39, 3.285, 16.67, 50.00)); put(ALowerType.COO, new TScoreStandardizationDto(7.6, 85.3, 16.67, 50.00)); put(ALowerType.REC, new TScoreStandardizationDto(931.213, 5389.2, 16.67, 50.00)); ... }}; private static final TScoreBoundLimitDto BOUND_LIMIT_CONST = new TScoreBoundLimitDto(BigDecimal.valueOf(100), BigDecimal.ZERO, 0);
Java
복사
먼저 공통된 계산 로직에 들어가는 상수들을 정의 후, 계산기를 호출하는 주체 클래스에서 상수로 정의한다.
/** * 표준화 */ public <T extends Enum<T> & CapabilityTScoreCalculateType> Map<T, BigDecimal> standardize( final Map<T, Integer> originScoreMap, final Map<T, TScoreStandardizationDto> standardizationDtos, final Class<T> translateType ) { EnumMap<T, BigDecimal> tScoreMap = new EnumMap<>(translateType); originScoreMap.forEach((type, originScore) -> { double subtracted = originScore - standardizationDtos.get(type).subtractConst(); double divided = subtracted / standardizationDtos.get(type).divideConst(); double multiplied = divided * standardizationDtos.get(type).multiplyConst(); final BigDecimal standardizedScore = BigDecimal.valueOf(multiplied + standardizationDtos.get(type).addConst()); tScoreMap.put(type, standardizedScore); }); return tScoreMap; } /** * 상한/하한 처리 */ public <T extends Enum<T>> Map<T, BigDecimal> setLimits( final Map<T, BigDecimal> tScores, final TScoreBoundLimitDto boundLimitDto, final Class<T> translateType ) { EnumMap<T, BigDecimal> limitedTScoreMap = new EnumMap<>(translateType); tScores.forEach((type, score) -> limitedTScoreMap.put( type, score.min(boundLimitDto.upperLimit()).max(boundLimitDto.lowerLimit()) ) ); return limitedTScoreMap; } /** * T 점수 반올림 처리 */ public <T extends Enum<T>> Map<T, BigDecimal> roundTScore( final Map<T, BigDecimal> tScores, final TScoreBoundLimitDto boundLimitDto, final Class<T> translateType ) { EnumMap<T, BigDecimal> roundedTScoreMap = new EnumMap<>(translateType); tScores.forEach((type, score) -> roundedTScoreMap.put(type, score.setScale(boundLimitDto.roundScale(), RoundingMode.HALF_UP)) ); return roundedTScoreMap; }
Java
복사
계산된 점수를 정의된 상수와 함께 넘겨 계산 로직을 처리한다.

사용

public CapabilityAType calculate(CapabilityV2AnswerDto answerV2Dto) { ... // 원점수 계산 final Map<ALowerType, Integer> originScores = tScoreTranslator.calculateOriginScore(answers, ALowerType.class); // 표준화(T점수 계산) final Map<ALowerType, BigDecimal> standardizedTScores = tScoreTranslator.standardize(originScores, STANDARDIZATION_CONST, ALowerType.class); // 반올림 처리 final Map<AUpperType, BigDecimal> roundedTScore = TScoreTranslator.roundTScore(jobMatchRate, BOUND_LIMIT_CONST, AUpperType.class); // 상한/하한 처리 final Map<AUpperType, BigDecimal> limitedTScore = TScoreTranslator.setLimits(roundedTScore, BOUND_LIMIT_CONST, AUpperType.class); ... }
Java
복사
이후 해당 계산기를 통해 점수 계산을 요청하면, 지정된 타입에 맞춰 응답 항목 중 필요 항목을 선택, 합 계산, 표준화하여 그 결과를 항목별로 Map에 담아 돌려준다.
/** * 사용자 응답으로부터 원점수 계산, T점수 계산, 상한/하한 처리, 반올림까지 전체 프로세스를 수행 */ public <T extends Enum<T> & CapabilityTScoreCalculateType> Map<T, BigDecimal> calculateFullProcess( final Map<QuestionNewCode, Integer> newCodeAnswerValueMap, final Map<T, TScoreStandardizationDto> standardizationDtos, final TScoreBoundLimitDto boundLimitDto, final Class<T> translateType ) { final Map<T, Integer> originScores = calculateOriginScore(newCodeAnswerValueMap, translateType); final Map<T, BigDecimal> standardizedScores = standardize(originScores, standardizationDtos, translateType); final Map<T, BigDecimal> roundedTScore = roundTScore(standardizedScores, boundLimitDto, translateType); return setLimits(roundedTScore, boundLimitDto, translateType); }
Java
복사
추가적으로 역량 중 절반 이상의 계산 과정이 원점수 계산 → 표준화 → 상한/하한 처리 → 반올림 순으로 동작하기 때문에, 이를 한번에 계산하는 편의 메서드를 만들어서 사용했다.