chore: sync local workspace changes

This commit is contained in:
2026-05-07 11:03:47 +09:00
parent 2df0ba30cb
commit 82c0d8a197
217 changed files with 44873 additions and 1678 deletions

View File

@@ -0,0 +1,863 @@
.cbt-play-app {
--cbt-bg: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
--cbt-surface: rgba(255, 255, 255, 0.92);
--cbt-surface-soft: rgba(244, 248, 253, 0.82);
--cbt-border: rgba(74, 105, 143, 0.14);
--cbt-accent: #3d7cf1;
--cbt-accent-strong: #2a5fc4;
--cbt-ink: #1f2f45;
--cbt-muted: #5f7088;
--cbt-success: #2f8a56;
--cbt-warn: #bf6f26;
min-height: 0;
height: 100%;
max-height: 100dvh;
padding: 14px 14px calc(18px + env(safe-area-inset-bottom, 0px));
background:
radial-gradient(circle at top right, rgba(130, 190, 255, 0.2), transparent 34%),
radial-gradient(circle at top left, rgba(220, 238, 255, 0.86), transparent 28%),
var(--cbt-bg);
color: var(--cbt-ink);
overflow: hidden;
}
.cbt-play-app--immersive {
min-height: 0;
height: 100%;
}
.cbt-play-app__viewport-shell {
display: flex;
flex-direction: column;
max-width: 1120px;
height: 100%;
min-height: 0;
gap: 10px;
margin: 0 auto;
}
.cbt-play-app__topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--cbt-border);
border-radius: 22px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 16px 36px rgba(46, 86, 138, 0.08);
}
.cbt-play-app__topbar-group,
.cbt-play-app__topbar-status,
.cbt-play-app__section-actions,
.cbt-play-app__result-switch {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.cbt-play-app__content-card {
flex: 1 1 auto;
min-height: 0;
padding: 18px;
border: 1px solid var(--cbt-border);
background: var(--cbt-surface);
border-radius: 28px;
box-shadow: 0 16px 40px rgba(46, 86, 138, 0.08);
overflow: hidden;
}
.cbt-play-app--immersive .cbt-play-app__content-card,
.cbt-play-app--immersive .cbt-play-app__stack--fill {
display: flex;
flex-direction: column;
}
.cbt-play-app__action-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.cbt-play-app__action-card {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 18px;
border: 1px solid rgba(74, 105, 143, 0.12);
border-radius: 24px;
background: rgba(247, 250, 255, 0.92);
text-align: left;
cursor: pointer;
transition:
transform 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease;
}
.cbt-play-app__action-card:hover {
transform: translateY(-1px);
border-color: rgba(61, 124, 241, 0.28);
box-shadow: 0 16px 32px rgba(46, 86, 138, 0.08);
}
.cbt-play-app__action-card--primary {
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98), rgba(248, 251, 255, 0.98));
border-color: rgba(61, 124, 241, 0.2);
}
.cbt-play-app__action-card-copy {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.cbt-play-app__hero--compact {
padding: 20px;
}
.cbt-play-app__panel--section-head {
padding: 14px 16px;
}
.cbt-play-app__stack--results {
min-height: 100%;
}
.cbt-play-app__card-scroll {
height: 100%;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.cbt-play-app__stack {
display: flex;
flex-direction: column;
gap: 16px;
min-height: min-content;
}
.cbt-play-app__hero,
.cbt-play-app__panel {
border: 1px solid var(--cbt-border);
background: var(--cbt-surface);
border-radius: 28px;
box-shadow: 0 16px 40px rgba(46, 86, 138, 0.08);
}
.cbt-play-app__hero {
padding: 24px;
}
.cbt-play-app__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(61, 124, 241, 0.1);
color: var(--cbt-accent-strong);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cbt-play-app__hero-grid {
display: grid;
grid-template-columns: minmax(0, 1.8fr) minmax(280px, 1fr);
gap: 18px;
margin-top: 18px;
}
.cbt-play-app__workspace {
padding: 18px;
border-radius: 32px;
border: 1px solid rgba(74, 105, 143, 0.12);
background: rgba(251, 253, 255, 0.92);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92);
}
.cbt-play-app__workspace-body {
margin-top: 14px;
}
.cbt-play-app__solver-scroll {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.cbt-play-app__hero-copy {
display: flex;
flex-direction: column;
gap: 12px;
}
.cbt-play-app__hero-copy h2.ant-typography {
margin: 0;
color: var(--cbt-ink);
font-size: clamp(28px, 4vw, 42px);
line-height: 1.08;
}
.cbt-play-app__hero-copy .ant-typography {
color: var(--cbt-muted);
}
.cbt-play-app__hero-points {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cbt-play-app__hero-points .ant-tag {
margin: 0;
border-radius: 999px;
padding-inline: 12px;
border-color: rgba(61, 124, 241, 0.14);
color: var(--cbt-accent-strong);
background: rgba(239, 246, 255, 0.95);
}
.cbt-play-app__hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.cbt-play-app__metric {
padding: 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(241, 247, 255, 0.94));
border: 1px solid rgba(74, 105, 143, 0.1);
}
.cbt-play-app__metric-value {
display: block;
font-size: 24px;
font-weight: 800;
color: var(--cbt-ink);
}
.cbt-play-app__metric-label {
display: block;
margin-top: 4px;
color: var(--cbt-muted);
font-size: 12px;
}
.cbt-play-app__option-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
border-top: 1px solid rgba(74, 105, 143, 0.12);
}
.cbt-play-app__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.cbt-play-app__field {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.cbt-play-app__field--full {
grid-column: 1 / -1;
}
.cbt-play-app__field-label {
color: var(--cbt-muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
}
.cbt-play-app__toolbar,
.cbt-play-app__option-line,
.cbt-play-app__summary-row,
.cbt-play-app__history-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.cbt-play-app__summary-row {
justify-content: space-between;
}
.cbt-play-app__solver-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.cbt-play-app__subject-picker {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.cbt-play-app__resume-banner {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 0 0 18px;
margin-bottom: 18px;
border-bottom: 1px solid rgba(74, 105, 143, 0.12);
}
.cbt-play-app__resume-section {
padding-bottom: 18px;
margin-bottom: 18px;
border-bottom: 1px solid rgba(74, 105, 143, 0.12);
}
.cbt-play-app__resume-list {
display: grid;
gap: 12px;
}
.cbt-play-app__resume-card {
padding: 14px 16px;
border-radius: 20px;
border: 1px solid rgba(74, 105, 143, 0.12);
background: rgba(247, 250, 255, 0.92);
}
.cbt-play-app__resume-meta {
margin-top: 4px;
}
.cbt-play-app__subject-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
align-items: center;
}
.cbt-play-app__subject-actions .ant-typography {
margin: 0;
word-break: keep-all;
overflow-wrap: anywhere;
}
.cbt-play-app__sheet,
.cbt-play-app__question,
.cbt-play-app__history-card,
.cbt-play-app__wrong-card {
padding: 18px 0;
border-radius: 0;
border: 0;
border-top: 1px solid rgba(74, 105, 143, 0.12);
background: transparent;
box-shadow: none;
}
.cbt-play-app__stack > :first-child.cbt-play-app__sheet,
.cbt-play-app__stack > :first-child.cbt-play-app__panel,
.cbt-play-app__stack > :first-child.cbt-play-app__question,
.cbt-play-app__stack > :first-child.cbt-play-app__history-card,
.cbt-play-app__stack > :first-child.cbt-play-app__wrong-card {
border-top: 0;
padding-top: 0;
}
.cbt-play-app__question {
padding-bottom: 6px;
}
.cbt-play-app__question-head {
display: flex;
flex-direction: column;
gap: 10px;
}
.cbt-play-app__question-body {
margin-top: 18px;
font-size: 20px;
font-weight: 700;
line-height: 1.55;
color: var(--cbt-ink);
word-break: keep-all;
overflow-wrap: anywhere;
}
.cbt-play-app__choice-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 18px;
}
.cbt-play-app__choice-button {
justify-content: flex-start;
min-height: 64px;
white-space: normal;
text-align: left;
border-radius: 20px;
border-width: 1px;
color: var(--cbt-ink);
font-weight: 600;
padding: 14px 16px;
}
.cbt-play-app__choice-button > span:last-child {
flex: 1;
min-width: 0;
word-break: keep-all;
overflow-wrap: anywhere;
}
.cbt-play-app__choice-button.ant-btn-default {
border-color: rgba(74, 105, 143, 0.14);
background: rgba(255, 255, 255, 0.96);
}
.cbt-play-app__choice-button.ant-btn-primary {
border-color: transparent;
background: linear-gradient(135deg, #3d7cf1, #6aa5ff);
}
.cbt-play-app__choice-prefix {
display: inline-flex;
width: 28px;
color: var(--cbt-accent);
font-weight: 800;
}
.cbt-play-app__meta-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cbt-play-app__meta-strip .ant-tag {
margin: 0;
border-radius: 999px;
}
.cbt-play-app__question-feedback {
margin-top: 18px;
padding: 16px;
border-radius: 18px;
background: rgba(242, 247, 255, 0.95);
border: 1px solid rgba(61, 124, 241, 0.14);
}
.cbt-play-app__subject-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.cbt-play-app__subject-stat {
padding: 14px 0 10px;
border-radius: 0;
border: 0;
border-bottom: 1px solid rgba(74, 105, 143, 0.1);
background: transparent;
display: flex;
flex-direction: column;
gap: 12px;
}
.cbt-play-app__subject-stat-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: baseline;
margin-bottom: 8px;
}
.cbt-play-app__subject-stat-head .ant-typography {
min-width: 0;
word-break: keep-all;
overflow-wrap: anywhere;
}
.cbt-play-app__subject-stat-rate {
font-size: 26px;
font-weight: 800;
color: var(--cbt-accent-strong);
line-height: 1;
}
.cbt-play-app__subject-start {
margin-top: auto;
}
.cbt-play-app__wrong-card,
.cbt-play-app__history-card {
padding: 16px;
}
.cbt-play-app__sticky-bar {
position: sticky;
bottom: 12px;
z-index: 4;
}
.cbt-play-app__sticky-inner {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding: 12px;
border-radius: 24px;
border: 1px solid rgba(74, 105, 143, 0.14);
background: rgba(248, 251, 255, 0.98);
backdrop-filter: blur(14px);
box-shadow: 0 18px 40px rgba(46, 86, 138, 0.12);
}
.cbt-play-app__sticky-inner .ant-btn {
min-height: 46px;
border-radius: 16px;
}
.cbt-play-app__empty {
padding: 32px 16px;
text-align: center;
color: var(--cbt-muted);
}
.cbt-play-app__empty--fill {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.cbt-play-app .ant-tag,
.cbt-play-app .ant-btn,
.cbt-play-app .ant-segmented-item-label,
.cbt-play-app .ant-typography,
.cbt-play-app .ant-alert-message,
.cbt-play-app .ant-alert-description {
white-space: normal;
word-break: keep-all;
overflow-wrap: anywhere;
}
.cbt-play-app__select {
min-width: 0;
}
.cbt-play-app__select .ant-select-selector {
min-height: 44px !important;
padding: 6px 12px !important;
align-items: center;
border-radius: 16px !important;
border-color: rgba(74, 105, 143, 0.16) !important;
background: rgba(255, 255, 255, 0.96) !important;
box-shadow: none !important;
}
.cbt-play-app__select.ant-select-single {
min-height: 44px;
}
.cbt-play-app__select.ant-select-single .ant-select-selector {
display: flex;
}
.cbt-play-app__select.ant-select-single .ant-select-selection-wrap,
.cbt-play-app__select.ant-select-single .ant-select-selection-search,
.cbt-play-app__select.ant-select-single .ant-select-selection-item,
.cbt-play-app__select.ant-select-single .ant-select-selection-placeholder {
display: flex;
align-items: center;
}
.cbt-play-app__select.ant-select-single .ant-select-selection-item,
.cbt-play-app__select.ant-select-single .ant-select-selection-placeholder {
min-width: 0;
line-height: 1.35 !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cbt-play-app__select.ant-select-multiple .ant-select-selector {
padding-block: 7px !important;
}
.cbt-play-app__select.ant-select-multiple .ant-select-selection-overflow {
flex-wrap: wrap;
gap: 6px;
}
.cbt-play-app__select.ant-select-multiple .ant-select-selection-overflow-item {
max-width: 100%;
}
.cbt-play-app__select.ant-select-multiple .ant-select-selection-item {
display: inline-flex;
align-items: center;
max-width: 100%;
border-radius: 999px !important;
background: rgba(239, 246, 255, 0.95) !important;
border: 1px solid rgba(61, 124, 241, 0.12) !important;
color: var(--cbt-accent-strong) !important;
}
.cbt-play-app__select.ant-select-multiple .ant-select-selection-item-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cbt-play-app__select.ant-select-multiple .ant-select-selection-placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cbt-play-app .ant-btn-primary {
background: linear-gradient(135deg, #3d7cf1, #6aa5ff);
border-color: transparent;
box-shadow: none;
}
.cbt-play-app .ant-space,
.cbt-play-app .ant-space-item {
min-width: 0;
}
.cbt-play-app__stack--fill {
height: 100%;
min-height: 0;
}
@media (max-width: 920px) {
.cbt-play-app__hero-grid,
.cbt-play-app__grid,
.cbt-play-app__subject-grid,
.cbt-play-app__action-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 768px) {
.cbt-play-app {
padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
}
.cbt-play-app--immersive {
height: 100%;
padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
overflow: hidden;
}
.cbt-play-app__viewport-shell,
.cbt-play-app__content-card {
height: 100%;
min-height: 0;
}
.cbt-play-app__topbar {
align-items: flex-start;
padding: 8px;
border-radius: 18px;
}
.cbt-play-app__topbar-group {
overflow-x: auto;
flex-wrap: nowrap;
width: 100%;
padding-bottom: 2px;
}
.cbt-play-app__topbar-status {
width: 100%;
}
.cbt-play-app__content-card {
padding: 12px;
border-radius: 24px;
}
.cbt-play-app--immersive .cbt-play-app__stack,
.cbt-play-app__tab-card > .cbt-play-app__card-scroll > .cbt-play-app__stack {
gap: 10px;
}
.cbt-play-app__hero,
.cbt-play-app__panel,
.cbt-play-app__workspace,
.cbt-play-app__sheet,
.cbt-play-app__question,
.cbt-play-app__history-card,
.cbt-play-app__wrong-card {
border-radius: 24px;
}
.cbt-play-app__hero {
padding: 18px;
}
.cbt-play-app__workspace,
.cbt-play-app__panel {
padding: 16px;
}
.cbt-play-app--immersive .cbt-play-app__workspace {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
padding: 12px;
border-radius: 28px;
}
.cbt-play-app--immersive .cbt-play-app__workspace-body,
.cbt-play-app--immersive .cbt-play-app__workspace-body > .cbt-play-app__stack {
display: flex;
flex: 1;
min-height: 0;
}
.cbt-play-app--immersive .cbt-play-app__workspace-body > .cbt-play-app__stack {
flex-direction: column;
gap: 10px;
}
.cbt-play-app--immersive .cbt-play-app__solver-scroll {
padding-bottom: 12px;
}
.cbt-play-app__sheet,
.cbt-play-app__question,
.cbt-play-app__history-card,
.cbt-play-app__wrong-card {
padding: 14px 0;
border-radius: 0;
}
.cbt-play-app__question-body {
font-size: 18px;
}
.cbt-play-app--immersive .cbt-play-app__panel {
flex: 0 0 auto;
padding: 12px 14px;
border-radius: 22px;
}
.cbt-play-app--immersive .cbt-play-app__question {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid rgba(74, 105, 143, 0.1);
border-radius: 22px;
background: rgba(255, 255, 255, 0.99);
position: relative;
z-index: 1;
}
.cbt-play-app--immersive .cbt-play-app__question-head {
gap: 8px;
}
.cbt-play-app--immersive .cbt-play-app__meta-strip {
display: none;
}
.cbt-play-app--immersive .cbt-play-app__question-body {
margin-top: 0;
font-size: 17px;
line-height: 1.42;
}
.cbt-play-app--immersive .cbt-play-app__choice-list {
gap: 8px;
margin-top: 12px;
}
.cbt-play-app--immersive .cbt-play-app__choice-button {
min-height: 52px;
padding: 10px 12px;
border-radius: 16px;
font-size: 14px;
background: rgba(255, 255, 255, 1);
}
.cbt-play-app--immersive .cbt-play-app__choice-prefix {
width: 24px;
}
.cbt-play-app--immersive .cbt-play-app__question-feedback {
margin-top: 0;
padding: 12px;
border-radius: 16px;
background: rgba(245, 249, 255, 1);
}
.cbt-play-app--immersive .cbt-play-app__sticky-bar {
flex: 0 0 auto;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.cbt-play-app--immersive .cbt-play-app__sticky-inner {
padding: 10px;
border-radius: 20px;
gap: 6px;
}
.cbt-play-app--immersive .cbt-play-app__sticky-inner .ant-btn {
min-height: 42px;
}
.cbt-play-app__summary-row,
.cbt-play-app__subject-actions,
.cbt-play-app__solver-actions {
align-items: flex-start;
}
.cbt-play-app__solver-actions {
width: 100%;
justify-content: space-between;
}
.cbt-play-app__resume-banner {
align-items: flex-start;
}
.cbt-play-app__resume-banner .ant-btn {
width: 100%;
}
.cbt-play-app__sticky-inner {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding-bottom: 12px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,377 @@
type BonusQuestionSeed = {
id: string;
examId?: string;
subjectId: string;
setId: string;
body: string;
answerValue: string;
explanation: string;
difficulty: 'easy' | 'medium' | 'hard';
tags: string[];
correctRate: number;
choices: [string, string, string, string];
sourceLabel?: string;
};
export const CBT_BONUS_QUESTION_SEEDS: BonusQuestionSeed[] = [
{
id: 'algo-core-7',
subjectId: 'algo',
setId: 'algo-core',
body: '요구사항 추적표(Traceability Matrix)를 유지하는 주된 목적은 무엇인가요?',
answerValue: '2',
explanation: '요구사항이 설계, 구현, 테스트까지 빠짐없이 연결됐는지 확인하려고 사용합니다.',
difficulty: 'medium',
tags: ['요구사항', '관리'],
correctRate: 0.64,
choices: ['배포 서버를 자동 증설하려고', '요구사항과 산출물의 연결 상태를 검증하려고', '소스 코드를 난독화하려고', '화면 시안을 압축하려고'],
},
{
id: 'algo-core-8',
subjectId: 'algo',
setId: 'algo-core',
body: '타당성 검토에서 기술적 타당성을 확인할 때 가장 먼저 보는 관점은 무엇인가요?',
answerValue: '3',
explanation: '현 기술 스택과 인력으로 요구 기능을 구현 가능한지 확인하는 것이 기술적 타당성의 핵심입니다.',
difficulty: 'easy',
tags: ['타당성', '분석'],
correctRate: 0.71,
choices: ['사무실 좌석 배치를 바꾸는 비용', '광고 문구의 완성도', '현재 기술과 인력으로 구현 가능한지 여부', '배경 이미지 해상도'],
},
{
id: 'algo-pattern-7',
subjectId: 'algo',
setId: 'algo-pattern',
body: '기존 인터페이스와 호환되지 않는 클래스를 현재 구조에 맞게 연결할 때 적절한 패턴은 무엇인가요?',
answerValue: '1',
explanation: 'Adapter 패턴은 기존 클래스를 원하는 인터페이스로 감싸 호환되게 만듭니다.',
difficulty: 'medium',
tags: ['패턴', '설계'],
correctRate: 0.59,
choices: ['Adapter', 'Iterator', 'Memento', 'Visitor'],
},
{
id: 'algo-pattern-8',
subjectId: 'algo',
setId: 'algo-pattern',
body: '부분-전체 계층 구조를 동일한 방식으로 다뤄야 할 때 적절한 패턴은 무엇인가요?',
answerValue: '4',
explanation: 'Composite 패턴은 개별 객체와 복합 객체를 같은 인터페이스로 다룰 수 있게 합니다.',
difficulty: 'hard',
tags: ['패턴', '구조'],
correctRate: 0.47,
choices: ['Proxy', 'State', 'Interpreter', 'Composite'],
},
{
id: 'algo-quality-7',
subjectId: 'algo',
setId: 'algo-quality',
body: '기능적 관련 요소가 하나의 모듈에 모여 있는 상태를 설명하는 응집도는 무엇인가요?',
answerValue: '2',
explanation: '기능적 응집도는 모듈이 하나의 명확한 기능만 수행하도록 묶인 가장 바람직한 형태입니다.',
difficulty: 'medium',
tags: ['품질', '응집도'],
correctRate: 0.56,
choices: ['우연적 응집도', '기능적 응집도', '시간적 응집도', '논리적 응집도'],
},
{
id: 'algo-quality-8',
subjectId: 'algo',
setId: 'algo-quality',
body: '가용성(Availability) 품질 속성을 수치로 확인할 때 가장 직접적인 지표는 무엇인가요?',
answerValue: '3',
explanation: '정상 서비스 시간 비율이나 SLA 충족률은 가용성을 대표하는 지표입니다.',
difficulty: 'medium',
tags: ['품질', '가용성'],
correctRate: 0.63,
choices: ['화면 전환 애니메이션 수', '개발자 수', '정상 서비스 시간 비율', 'ERD 컬럼 수'],
},
{
id: 'dev-core-7',
subjectId: 'dev',
setId: 'dev-core',
body: '나선형(Spiral) 모델의 핵심 특징으로 가장 적절한 것은 무엇인가요?',
answerValue: '1',
explanation: '반복 개발과 위험 분석을 함께 수행하는 것이 나선형 모델의 핵심입니다.',
difficulty: 'medium',
tags: ['프로세스', '개발방법론'],
correctRate: 0.62,
choices: ['반복 개발 단계마다 위험을 분석한다', '요구사항 변경을 절대 허용하지 않는다', '테스트를 배포 후에만 수행한다', '모든 산출물을 한 번에 작성한다'],
},
{
id: 'dev-core-8',
subjectId: 'dev',
setId: 'dev-core',
body: 'DevOps 도입의 직접적인 목표로 가장 적절한 것은 무엇인가요?',
answerValue: '4',
explanation: '개발과 운영 협업을 강화해 배포 속도와 안정성을 함께 높이는 것이 목적입니다.',
difficulty: 'easy',
tags: ['DevOps', '협업'],
correctRate: 0.74,
choices: ['운영팀을 없앤다', '테스트를 생략한다', '문서를 금지한다', '개발과 운영의 피드백 주기를 단축한다'],
},
{
id: 'dev-test-7',
subjectId: 'dev',
setId: 'dev-test',
body: '기존 기능 수정 후 주변 기능이 깨지지 않았는지 확인하는 테스트는 무엇인가요?',
answerValue: '2',
explanation: '회귀 테스트는 변경 이후 기존 기능의 정상 동작을 다시 확인하는 테스트입니다.',
difficulty: 'easy',
tags: ['테스트', '회귀'],
correctRate: 0.77,
choices: ['인수 테스트', '회귀 테스트', '알파 테스트', '베타 테스트'],
},
{
id: 'dev-test-8',
subjectId: 'dev',
setId: 'dev-test',
body: '입력 구간의 경계값에서 오류가 자주 발생한다는 점에 착안한 테스트 기법은 무엇인가요?',
answerValue: '3',
explanation: '경계값 분석은 최소값, 최대값, 바로 인접한 값에서 결함을 찾는 기법입니다.',
difficulty: 'medium',
tags: ['테스트', '기법'],
correctRate: 0.68,
choices: ['원인-결과 그래프', '오류 추정', '경계값 분석', '상태 전이 테스트'],
},
{
id: 'dev-release-7',
subjectId: 'dev',
setId: 'dev-release',
body: 'Blue-Green 배포의 장점으로 가장 적절한 것은 무엇인가요?',
answerValue: '1',
explanation: '신구 환경을 분리해 두고 전환하므로 롤백이 빠르고 서비스 중단을 줄일 수 있습니다.',
difficulty: 'medium',
tags: ['배포', '운영'],
correctRate: 0.58,
choices: ['문제 발생 시 빠르게 이전 환경으로 전환할 수 있다', '데이터베이스가 자동 정규화된다', '보안 취약점이 모두 사라진다', '테스트 코드가 필요 없어진다'],
},
{
id: 'dev-release-8',
subjectId: 'dev',
setId: 'dev-release',
body: '시맨틱 버저닝에서 `2.4.1`의 마지막 숫자가 증가하는 일반적인 경우는 무엇인가요?',
answerValue: '4',
explanation: '패치 버전은 하위 호환 가능한 버그 수정이 있을 때 증가합니다.',
difficulty: 'easy',
tags: ['배포', '버전관리'],
correctRate: 0.72,
choices: ['대규모 구조 개편이 있을 때', '하위 호환이 깨지는 변경일 때', '새로운 주요 기능 묶음을 추가할 때', '하위 호환 가능한 버그 수정일 때'],
},
{
id: 'db-core-7',
subjectId: 'db',
setId: 'db-core',
body: '제2정규형(2NF)을 만족시키기 위해 제거해야 하는 대표적 문제는 무엇인가요?',
answerValue: '2',
explanation: '부분 함수 종속을 제거해야 제2정규형을 만족합니다.',
difficulty: 'medium',
tags: ['정규화', '모델링'],
correctRate: 0.57,
choices: ['이행 함수 종속', '부분 함수 종속', '후보키 중복 정의', '색인 누락'],
},
{
id: 'db-core-8',
subjectId: 'db',
setId: 'db-core',
body: '약한 엔터티가 강한 엔터티의 기본키 일부를 상속받아 식별될 때 필요한 관계는 무엇인가요?',
answerValue: '3',
explanation: '식별 관계는 부모 엔터티의 키가 자식 엔터티 식별자에 포함되는 관계입니다.',
difficulty: 'hard',
tags: ['ERD', '모델링'],
correctRate: 0.45,
choices: ['비식별 관계', '순환 관계', '식별 관계', '자기 관계'],
},
{
id: 'db-sql-7',
subjectId: 'db',
setId: 'db-sql',
body: '집계 결과에 조건을 적용할 때 `WHERE` 대신 주로 사용하는 절은 무엇인가요?',
answerValue: '4',
explanation: '집계 이후의 그룹 조건은 HAVING 절에서 처리합니다.',
difficulty: 'easy',
tags: ['SQL', '집계'],
correctRate: 0.79,
choices: ['ORDER BY', 'LIMIT', 'GROUP SETS', 'HAVING'],
},
{
id: 'db-sql-8',
subjectId: 'db',
setId: 'db-sql',
body: '공통 키가 있는 행만 결과로 가져오려면 어떤 조인을 사용해야 하나요?',
answerValue: '1',
explanation: 'INNER JOIN은 양쪽 테이블에서 조건이 일치하는 행만 반환합니다.',
difficulty: 'easy',
tags: ['SQL', '조인'],
correctRate: 0.81,
choices: ['INNER JOIN', 'LEFT OUTER JOIN', 'CROSS JOIN', 'SELF JOIN'],
},
{
id: 'db-ops-7',
subjectId: 'db',
setId: 'db-ops',
body: '다른 트랜잭션이 아직 커밋하지 않은 값을 읽는 현상은 무엇인가요?',
answerValue: '2',
explanation: 'Dirty Read는 미커밋 데이터를 읽는 이상 현상입니다.',
difficulty: 'medium',
tags: ['트랜잭션', '격리수준'],
correctRate: 0.61,
choices: ['Phantom Read', 'Dirty Read', 'Deadlock', 'Lost Update'],
},
{
id: 'db-ops-8',
subjectId: 'db',
setId: 'db-ops',
body: '교착상태(Deadlock)를 줄이기 위한 방법으로 가장 적절한 것은 무엇인가요?',
answerValue: '3',
explanation: '잠금 순서를 일관되게 유지하면 교착상태 가능성을 낮출 수 있습니다.',
difficulty: 'medium',
tags: ['운영', '동시성'],
correctRate: 0.55,
choices: ['모든 인덱스를 삭제한다', '트랜잭션 시간을 무조건 늘린다', '잠금 획득 순서를 일관되게 맞춘다', '커밋을 금지한다'],
},
{
id: 'programming-core-7',
subjectId: 'programming',
setId: 'programming-core',
body: '재귀 함수가 무한 호출되지 않도록 반드시 갖춰야 하는 요소는 무엇인가요?',
answerValue: '1',
explanation: '재귀 종료 조건(base case)이 있어야 반복 호출이 멈춥니다.',
difficulty: 'easy',
tags: ['기초', '재귀'],
correctRate: 0.82,
choices: ['종료 조건', '전역 변수', '배열 정렬', 'GUI 이벤트'],
},
{
id: 'programming-core-8',
subjectId: 'programming',
setId: 'programming-core',
body: '값에 의한 호출(Call by Value)의 특징으로 가장 적절한 것은 무엇인가요?',
answerValue: '4',
explanation: '매개변수에 값의 복사본이 전달되므로 원본 값은 직접 바뀌지 않습니다.',
difficulty: 'medium',
tags: ['기초', '함수'],
correctRate: 0.66,
choices: ['항상 원본 객체가 즉시 수정된다', '메모리가 전혀 사용되지 않는다', '참조형을 사용할 수 없다', '원본 대신 복사본을 전달한다'],
},
{
id: 'programming-oo-7',
subjectId: 'programming',
setId: 'programming-oo',
body: '같은 메시지 호출에 대해 객체마다 다른 동작을 수행하게 하는 객체지향 특성은 무엇인가요?',
answerValue: '2',
explanation: '다형성은 동일한 인터페이스에 대해 서로 다른 구현이 반응하도록 합니다.',
difficulty: 'medium',
tags: ['객체지향', '다형성'],
correctRate: 0.63,
choices: ['상속', '다형성', '직렬화', '토큰화'],
},
{
id: 'programming-oo-8',
subjectId: 'programming',
setId: 'programming-oo',
body: '데이터와 메서드를 하나의 단위로 묶고 외부 접근을 제한하는 개념은 무엇인가요?',
answerValue: '3',
explanation: '캡슐화는 내부 구현을 숨기고 필요한 인터페이스만 노출합니다.',
difficulty: 'easy',
tags: ['객체지향', '캡슐화'],
correctRate: 0.78,
choices: ['오버로딩', '추상화', '캡슐화', '가비지 컬렉션'],
},
{
id: 'programming-script-7',
subjectId: 'programming',
setId: 'programming-script',
body: '선입선출(FIFO) 구조로 동작하는 자료구조는 무엇인가요?',
answerValue: '2',
explanation: 'Queue는 먼저 들어온 데이터가 먼저 나가는 FIFO 구조입니다.',
difficulty: 'easy',
tags: ['자료구조', '큐'],
correctRate: 0.8,
choices: ['Stack', 'Queue', 'Tree', 'Graph'],
},
{
id: 'programming-script-8',
subjectId: 'programming',
setId: 'programming-script',
body: '해시 테이블에서 서로 다른 키가 같은 해시 값을 갖는 현상을 무엇이라고 하나요?',
answerValue: '1',
explanation: 'Collision은 해시 함수 결과가 겹치는 현상이며 체이닝, 개방 주소법 등으로 대응합니다.',
difficulty: 'medium',
tags: ['자료구조', '해시'],
correctRate: 0.6,
choices: ['충돌(Collision)', '정렬(Sort)', '병합(Merge)', '순회(Traversal)'],
},
{
id: 'system-core-7',
subjectId: 'system',
setId: 'system-core',
body: '변경관리(Change Management)의 핵심 목적은 무엇인가요?',
answerValue: '4',
explanation: '변경 요청을 통제해 서비스 영향과 위험을 줄이려는 것이 핵심입니다.',
difficulty: 'easy',
tags: ['운영', '변경관리'],
correctRate: 0.73,
choices: ['문서 작성을 금지한다', '모든 변경을 즉시 반영한다', '개발 서버만 유지한다', '변경 영향과 승인 절차를 관리한다'],
},
{
id: 'system-core-8',
subjectId: 'system',
setId: 'system-core',
body: '재해복구 계획에서 RTO가 의미하는 것은 무엇인가요?',
answerValue: '2',
explanation: 'RTO는 장애 후 서비스를 복구해야 하는 목표 시간입니다.',
difficulty: 'medium',
tags: ['DR', '복구'],
correctRate: 0.58,
choices: ['허용 가능한 데이터 손실량', '목표 복구 시간', '백업 파일 크기', '암호화 키 길이'],
},
{
id: 'system-security-7',
subjectId: 'system',
setId: 'system-security',
body: '최소 권한 원칙(Principle of Least Privilege)의 설명으로 가장 적절한 것은 무엇인가요?',
answerValue: '1',
explanation: '업무 수행에 필요한 최소한의 권한만 부여해야 보안 위험을 줄일 수 있습니다.',
difficulty: 'easy',
tags: ['보안', '권한'],
correctRate: 0.76,
choices: ['필요한 최소 권한만 부여한다', '관리자 권한을 기본값으로 준다', '모든 로그를 삭제한다', '암호를 화면에 표시한다'],
},
{
id: 'system-security-8',
subjectId: 'system',
setId: 'system-security',
body: '비밀번호 해시 저장 시 솔트(salt)를 함께 사용하는 주된 이유는 무엇인가요?',
answerValue: '3',
explanation: '같은 비밀번호라도 서로 다른 해시가 되게 해 사전 계산 공격을 어렵게 만듭니다.',
difficulty: 'medium',
tags: ['보안', '인증'],
correctRate: 0.62,
choices: ['패스워드를 평문으로 복구하려고', '로그인을 생략하려고', '레인보우 테이블 공격을 어렵게 하려고', '세션 쿠키를 없애려고'],
},
{
id: 'system-infra-7',
subjectId: 'system',
setId: 'system-infra',
body: '로드밸런서의 헬스 체크(Health Check)를 두는 가장 직접적인 이유는 무엇인가요?',
answerValue: '2',
explanation: '정상 응답하지 않는 인스턴스를 트래픽 분산 대상에서 제외하기 위해 사용합니다.',
difficulty: 'medium',
tags: ['인프라', '로드밸런서'],
correctRate: 0.64,
choices: ['CPU 모델명을 확인하려고', '비정상 서버로 트래픽이 가지 않게 하려고', '소스 코드를 압축하려고', '브라우저 캐시를 비우려고'],
},
{
id: 'system-infra-8',
subjectId: 'system',
setId: 'system-infra',
body: '카나리 배포(Canary Release)의 장점으로 가장 적절한 것은 무엇인가요?',
answerValue: '4',
explanation: '일부 사용자에게만 먼저 배포해 위험을 제한한 채 반응을 확인할 수 있습니다.',
difficulty: 'medium',
tags: ['배포', '인프라'],
correctRate: 0.57,
choices: ['항상 전체 서버를 동시에 교체한다', '데이터베이스 스키마를 자동 복구한다', '장애를 완전히 없앤다', '소수 트래픽으로 먼저 검증 후 점진 확장할 수 있다'],
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
import type { BookmarkItem, CbtSessionRecord, CbtStorageState, ProgressStat, QuestionRecord, WrongNoteItem } from './cbtTypes';
const STORAGE_KEY = 'play-cbt-storage-v1';
function getDefaultState(): CbtStorageState {
return {
sessions: [],
wrongNotes: [],
bookmarks: [],
progressStats: [],
activeSessionId: null,
};
}
export function loadCbtStorageState(): CbtStorageState {
if (typeof window === 'undefined') {
return getDefaultState();
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return getDefaultState();
}
const parsed = JSON.parse(raw) as Partial<CbtStorageState>;
return {
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
wrongNotes: Array.isArray(parsed.wrongNotes) ? parsed.wrongNotes : [],
bookmarks: Array.isArray(parsed.bookmarks) ? parsed.bookmarks : [],
progressStats: Array.isArray(parsed.progressStats) ? parsed.progressStats : [],
activeSessionId: typeof parsed.activeSessionId === 'string' ? parsed.activeSessionId : null,
};
} catch {
return getDefaultState();
}
}
export function saveCbtStorageState(state: CbtStorageState) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
export function upsertSession(sessions: CbtSessionRecord[], session: CbtSessionRecord) {
const nextSessions = sessions.filter((item) => item.id !== session.id);
nextSessions.unshift(session);
return nextSessions.slice(0, 40);
}
export function mergeWrongNotes(
previous: WrongNoteItem[],
session: CbtSessionRecord,
questionMap: Map<string, QuestionRecord>,
) {
const next = new Map(previous.map((item) => [item.questionId, item]));
session.answers.forEach((answer) => {
const question = questionMap.get(answer.questionId);
if (!question || !answer.selectedValue || answer.selectedValue === question.answerValue) {
return;
}
const current = next.get(answer.questionId);
next.set(answer.questionId, {
questionId: answer.questionId,
createdAt: current?.createdAt ?? session.submittedAt ?? session.startedAt,
subjectId: question.subjectId,
examId: question.examId,
wrongCount: (current?.wrongCount ?? 0) + 1,
lastSessionId: session.id,
});
});
return Array.from(next.values()).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
export function mergeBookmarks(previous: BookmarkItem[], session: CbtSessionRecord) {
const next = new Map(previous.map((item) => [item.questionId, item]));
session.answers.forEach((answer) => {
const current = next.get(answer.questionId);
if (answer.isBookmarked) {
const timestamp = answer.answeredAt ?? session.submittedAt ?? session.startedAt;
next.set(answer.questionId, {
questionId: answer.questionId,
createdAt: current?.createdAt ?? timestamp,
updatedAt: timestamp,
});
return;
}
if (current) {
next.delete(answer.questionId);
}
});
return Array.from(next.values()).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
export function buildProgressStats(sessions: CbtSessionRecord[], questionMap: Map<string, QuestionRecord>): ProgressStat[] {
const subjectMap = new Map<string, ProgressStat>();
sessions
.filter((session) => session.submittedAt)
.forEach((session) => {
session.answers.forEach((answer) => {
if (!answer.selectedValue) {
return;
}
const question = questionMap.get(answer.questionId);
if (!question) {
return;
}
const current = subjectMap.get(question.subjectId) ?? {
subjectId: question.subjectId,
solvedCount: 0,
correctCount: 0,
wrongCount: 0,
accuracyRate: 0,
};
current.solvedCount += 1;
if (answer.selectedValue === question.answerValue) {
current.correctCount += 1;
} else {
current.wrongCount += 1;
}
current.accuracyRate = current.solvedCount > 0 ? Math.round((current.correctCount / current.solvedCount) * 100) : 0;
subjectMap.set(question.subjectId, current);
});
});
return Array.from(subjectMap.values()).sort((left, right) => right.solvedCount - left.solvedCount);
}

View File

@@ -0,0 +1,100 @@
export type CbtMode = 'sequential' | 'random' | 'wrong-only' | 'bookmarks-only';
export type ExamCategory = {
id: string;
label: string;
};
export type Subject = {
id: string;
examId: string;
label: string;
};
export type QuestionSet = {
id: string;
examId: string;
subjectId: string;
label: string;
sourceLabel: string;
};
export type QuestionChoice = {
value: string;
label: string;
};
export type QuestionRecord = {
id: string;
examId: string;
subjectId: string;
setId: string;
type: 'multiple-choice';
body: string;
answerValue: string;
explanation: string;
difficulty: 'easy' | 'medium' | 'hard';
sourceLabel: string;
tags: string[];
correctRate: number;
choices: QuestionChoice[];
isActive: boolean;
};
export type SessionAnswer = {
questionId: string;
selectedValue: string | null;
isCorrect?: boolean;
isMarked: boolean;
isBookmarked: boolean;
answeredAt: string | null;
};
export type CbtSessionRecord = {
id: string;
examId: string;
subjectIds: string[];
questionSetId: string | null;
mode: CbtMode;
questionIds: string[];
questionCount: number;
startedAt: string;
submittedAt: string | null;
score: number | null;
correctCount: number;
wrongCount: number;
currentQuestionIndex: number;
revealedExplanationQuestionIds: string[];
answers: SessionAnswer[];
};
export type WrongNoteItem = {
questionId: string;
createdAt: string;
subjectId: string;
examId: string;
wrongCount: number;
lastSessionId: string;
};
export type BookmarkItem = {
questionId: string;
createdAt: string;
updatedAt: string;
};
export type ProgressStat = {
subjectId: string;
solvedCount: number;
correctCount: number;
wrongCount: number;
accuracyRate: number;
};
export type CbtStorageState = {
sessions: CbtSessionRecord[];
wrongNotes: WrongNoteItem[];
bookmarks: BookmarkItem[];
progressStats: ProgressStat[];
activeSessionId: string | null;
};

View File

@@ -0,0 +1,89 @@
.test-play-app {
height: 100%;
min-height: 100%;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 32px;
background:
radial-gradient(circle at top left, rgba(255, 211, 105, 0.24), transparent 28%),
linear-gradient(160deg, #f4efe2 0%, #fcfaf4 48%, #eef3f8 100%);
}
.test-play-app__hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
gap: 24px;
margin-bottom: 24px;
}
.test-play-app__hero-copy,
.test-play-app__spotlight,
.test-play-app__feature-card {
border-radius: 28px;
border: 1px solid rgba(34, 49, 63, 0.08);
box-shadow: 0 22px 60px rgba(63, 79, 92, 0.08);
}
.test-play-app__hero-copy {
padding: 32px;
background: rgba(255, 252, 244, 0.82);
backdrop-filter: blur(14px);
}
.test-play-app__hero-copy .ant-typography h2 {
margin-top: 14px;
margin-bottom: 14px;
font-size: clamp(2rem, 3vw, 3.2rem);
line-height: 1.04;
}
.test-play-app__hero-copy .ant-typography {
max-width: 640px;
}
.test-play-app__spotlight {
background: linear-gradient(180deg, #1e2b31 0%, #263b45 100%);
}
.test-play-app__spotlight .ant-card-body {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 100%;
color: #f5f6f8;
}
.test-play-app__spotlight .ant-typography,
.test-play-app__spotlight .ant-typography-copy,
.test-play-app__spotlight .ant-typography-secondary {
color: inherit;
}
.test-play-app__feature-card {
height: 100%;
background: rgba(255, 255, 255, 0.84);
}
.test-play-app__feature-label {
display: inline-block;
margin-bottom: 8px;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7d5b1f;
}
@media (max-width: 900px) {
.test-play-app {
padding: 20px;
}
.test-play-app__hero {
grid-template-columns: minmax(0, 1fr);
}
.test-play-app__hero-copy {
padding: 24px;
}
}

View File

@@ -0,0 +1,60 @@
import { Button, Card, Col, Input, Row, Space, Tag, Typography } from 'antd';
import './TestPlayAppView.css';
const { Paragraph, Text, Title } = Typography;
const featureCards = [
{
title: 'Isolated Entry',
description: '기존 Layout Editor 상태와 분리된 전용 play 앱 진입점입니다.',
},
{
title: 'Scoped Styling',
description: '이 화면은 `TestPlayAppView.css`만 사용하도록 분리해 독립 스타일 실험이 가능합니다.',
},
{
title: 'Next Extension',
description: '이후 라우트, 상태, API 연결을 현재 앱과 분리된 구조로 계속 확장할 수 있습니다.',
},
];
export function TestPlayAppView() {
return (
<div className="test-play-app">
<section className="test-play-app__hero">
<div className="test-play-app__hero-copy">
<Tag color="gold">Apps / Test</Tag>
<Title level={2}> test </Title>
<Paragraph>
Play <Text strong>Apps</Text> . layout editor와
, , .
</Paragraph>
<Space wrap>
<Button type="primary">Primary Action</Button>
<Button ghost>Secondary</Button>
</Space>
</div>
<Card className="test-play-app__spotlight" bordered={false}>
<Text type="secondary">test app shell</Text>
<Title level={4}> index CSS CSS </Title>
<Paragraph>
React , CSS로 .
</Paragraph>
<Input placeholder="다음 단계에서 앱 전용 입력/상태를 붙일 수 있습니다." />
</Card>
</section>
<Row gutter={[20, 20]}>
{featureCards.map((card) => (
<Col key={card.title} xs={24} md={8}>
<Card className="test-play-app__feature-card" bordered={false}>
<Text className="test-play-app__feature-label">{card.title}</Text>
<Paragraph>{card.description}</Paragraph>
</Card>
</Col>
))}
</Row>
</div>
);
}