fix : open api , 공지사항 반응형 퍼블리싱

This commit is contained in:
hong
2026-07-24 10:19:18 +09:00
parent 41391c8df0
commit a361cc3dec
15 changed files with 979 additions and 677 deletions
+196 -97
View File
@@ -693,7 +693,7 @@ hr {
--accent-orange: #FF6B6B; --accent-orange: #FF6B6B;
--accent-green: #6BCF7F; --accent-green: #6BCF7F;
--accent-purple: #A78BFA; --accent-purple: #A78BFA;
--accent-color:#efdcb2; --accent-color: #efdcb2;
--accent-light: #E9F9FF; --accent-light: #E9F9FF;
--text-dark: #1A1A2E; --text-dark: #1A1A2E;
--text-gray: #64748B; --text-gray: #64748B;
@@ -1490,7 +1490,7 @@ body.design-survey-active .global-header {
.nav-menu { .nav-menu {
display: flex; display: flex;
list-style: none; list-style: none;
gap: 36px; gap: 28px;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
@@ -1545,7 +1545,7 @@ body.design-survey-active .global-header {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--text-gray); color: var(--text-gray);
padding: 8px 0; padding: 8px;
position: relative; position: relative;
transition: var(--transition-smooth); transition: var(--transition-smooth);
} }
@@ -6028,7 +6028,7 @@ select.form-control {
width: 100%; width: 100%;
} }
.board-table .board-table-row .row-cell--title .notice-title-link:hover { .board-table .board-table-row .row-cell--title .notice-title-link:hover {
text-decoration: underline; font-weight: 600;
} }
.board-table .board-table-row .row-cell--title .notice-title-link .notice-number { .board-table .board-table-row .row-cell--title .notice-title-link .notice-number {
display: none; display: none;
@@ -6521,7 +6521,8 @@ select.form-control {
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
.pagination .pagination-btn i, .pagination .pagination-btn svg { .pagination .pagination-btn i,
.pagination .pagination-btn svg {
font-size: 14px; font-size: 14px;
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -10226,32 +10227,38 @@ body.index-page-body {
padding: 20px; padding: 20px;
} }
} }
@media (max-width: 768px) { .api-market-container {
.api-market-container .service-sidebar { /*
position: fixed; .service-sidebar {
left: 0; @media (max-width: 768px) {
top: 0; position: fixed;
height: 100vh; left: 0;
width: 280px; top: 0;
margin: 0; height: 100vh;
z-index: 1000; width: 280px;
transform: translateX(-100%); margin: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000;
background: #ffffff; transform: translateX(-100%);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
padding: 80px 24px 30px; background: #ffffff;
} box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
.api-market-container .service-sidebar.mobile-open { padding: 80px 24px 30px; // Clear header height if any
transform: translateX(0);
} &.mobile-open {
.api-market-container .service-sidebar .service-nav { transform: translateX(0);
flex-direction: column !important; }
gap: 16px !important;
border: none !important; .service-nav {
padding: 0 !important; flex-direction: column !important;
box-shadow: none !important; gap: 16px !important;
background: transparent !important; border: none !important;
padding: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
}
} }
*/
} }
.api-market-hero { .api-market-hero {
@@ -10471,6 +10478,7 @@ body.index-page-body {
.api-market-content { .api-market-content {
flex: 1; flex: 1;
width: 100%;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -10491,35 +10499,7 @@ body.index-page-body {
} }
.api-mobile-toggle { .api-mobile-toggle {
display: none; display: none !important;
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
color: #FFFFFF;
font-size: 24px;
cursor: pointer;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
z-index: 1051;
transition: all 0.3s ease;
background-color: #0049b4;
}
.api-mobile-toggle:hover {
transform: scale(1.05);
box-shadow: 0 16px 40px rgba(75, 155, 255, 0.2);
}
.api-mobile-toggle:active {
transform: scale(0.95);
}
@media (max-width: 768px) {
.api-mobile-toggle {
display: flex;
align-items: center;
justify-content: center;
}
} }
.api-market-header { .api-market-header {
@@ -11138,9 +11118,12 @@ body.index-page-body {
overflow-x: hidden; overflow-x: hidden;
} }
.api-market-content { .api-market-content {
max-width: 100vw; width: 100%;
overflow-x: hidden; overflow-x: hidden;
} }
.api-card {
height: 285px;
}
} }
.login-page { .login-page {
display: flex; display: flex;
@@ -18364,6 +18347,14 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #0a4ea3; color: #0a4ea3;
border: 1px solid #b3d4ff; border: 1px solid #b3d4ff;
} }
.notice-type-badge.lg {
height: 28px;
width: auto;
min-width: 50px;
padding: 0 10px;
font-size: 13px;
font-weight: 700;
}
.notice-detail-title { .notice-detail-title {
font-size: 28px; font-size: 28px;
@@ -18421,22 +18412,27 @@ input[type=checkbox]:checked + .custom-checkbox {
.notice-attachment-link .attachment-filename { .notice-attachment-link .attachment-filename {
font-size: 14px; font-size: 14px;
color: #666; color: #666;
word-break: break-all;
} }
.notice-detail-incident-info { .notice-detail-incident-info,
.notice-detail-affected-info {
margin-bottom: 20px; margin-bottom: 20px;
} }
.notice-detail-incident-info .detail-table { .notice-detail-incident-info .detail-table,
.notice-detail-affected-info .detail-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
border-top: none; border-top: none;
border-bottom: 1px solid #e3e8f0; border-bottom: 1px solid #e3e8f0;
} }
.notice-detail-incident-info .detail-table tr { .notice-detail-incident-info .detail-table tr,
.notice-detail-affected-info .detail-table tr {
border-bottom: 1px solid #e3e8f0; border-bottom: 1px solid #e3e8f0;
} }
.notice-detail-incident-info .detail-table th { .notice-detail-incident-info .detail-table th,
.notice-detail-affected-info .detail-table th {
background: #f9f9f9; background: #f9f9f9;
height: 43px; height: 43px;
font-weight: 500; font-weight: 500;
@@ -18444,12 +18440,114 @@ input[type=checkbox]:checked + .custom-checkbox {
text-align: center; text-align: center;
color: #000; color: #000;
} }
.notice-detail-incident-info .detail-table td { .notice-detail-incident-info .detail-table td,
padding-left: 19px; .notice-detail-affected-info .detail-table td {
padding: 10px 19px;
font-size: 14px; font-size: 14px;
color: #000; color: #000;
word-break: break-all; word-break: break-all;
} }
@media (max-width: 768px) {
.notice-detail-incident-info .detail-table,
.notice-detail-affected-info .detail-table {
display: block;
}
.notice-detail-incident-info .detail-table colgroup,
.notice-detail-affected-info .detail-table colgroup {
display: none;
}
.notice-detail-incident-info .detail-table tbody,
.notice-detail-incident-info .detail-table tr,
.notice-detail-incident-info .detail-table th,
.notice-detail-incident-info .detail-table td,
.notice-detail-affected-info .detail-table tbody,
.notice-detail-affected-info .detail-table tr,
.notice-detail-affected-info .detail-table th,
.notice-detail-affected-info .detail-table td {
display: block;
width: 100% !important;
box-sizing: border-box;
}
.notice-detail-incident-info .detail-table tr,
.notice-detail-affected-info .detail-table tr {
border-bottom: none;
}
.notice-detail-incident-info .detail-table th,
.notice-detail-affected-info .detail-table th {
text-align: left;
background: #f8fafc;
height: auto;
padding: 10px 16px;
border-bottom: 1px solid #e2e8f0;
}
.notice-detail-incident-info .detail-table td,
.notice-detail-affected-info .detail-table td {
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
}
.affected-apis-wrapper {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
}
.affected-apis-list {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.affected-api-badge {
display: inline-flex;
align-items: center;
padding: 6px 16px;
background-color: #f3f6fa;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #555555;
white-space: nowrap;
font-weight: 500;
}
.affected-api-badge strong {
color: #0a4ea3;
font-weight: 700;
}
.btn-affected-toggle {
font-size: 13px;
color: #ffffff;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 8px 16px;
border-radius: 4px;
background-color: #0049b4;
border: none;
transition: all 0.2s ease;
align-self: flex-end;
margin-top: 8px;
line-height: normal;
}
.btn-affected-toggle:hover {
background-color: #003685;
}
.btn-affected-toggle .arrow {
font-size: 10px;
display: inline-block;
transition: transform 0.2s ease;
}
.btn-affected-toggle.active .arrow {
transform: rotate(180deg);
}
.notice-detail-content { .notice-detail-content {
padding: 20px 0 60px 0; padding: 20px 0 60px 0;
@@ -18507,35 +18605,8 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
.btn-notice-list { .btn-notice-list {
display: flex;
align-items: center;
justify-content: center;
width: 247px; width: 247px;
height: 60px; height: 60px;
background: #2a69de;
color: #fff;
border: none;
font-size: 20px;
font-weight: 700;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s ease;
}
@media (max-width: 1024px) {
.btn-notice-list {
width: auto;
min-width: 120px;
padding: 0 20px;
height: 48px;
font-size: 16px;
}
}
.btn-notice-list:hover {
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
transform: translateY(-2px);
}
.btn-notice-list:active {
transform: scale(0.98);
} }
.notice-detail-card { .notice-detail-card {
@@ -22637,12 +22708,39 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
.service-sidebar { .service-sidebar {
width: 100%; width: 100%;
overflow: hidden;
} }
.service-nav { .service-nav {
flex-direction: row; flex-direction: row !important;
flex-wrap: wrap; flex-wrap: nowrap !important;
justify-content: center; justify-content: flex-start !important;
padding: 20px; padding: 16px 20px !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
gap: 24px !important;
border: 1px solid #e3e8f0 !important;
border-radius: 20px !important;
box-shadow: none !important;
background: #ffffff !important;
}
.service-nav::-webkit-scrollbar {
display: none;
}
.service-nav {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.service-nav .service-sidebar__profile,
.service-nav .service-sidebar__divider {
display: none !important;
}
.service-nav .service-nav__item {
flex-shrink: 0 !important;
white-space: nowrap !important;
margin: 0 !important;
padding: 0 !important;
} }
.service-hero, .service-hero,
.oauth2-2legged__hero { .oauth2-2legged__hero {
@@ -23448,6 +23546,7 @@ input[type=checkbox]:checked + .custom-checkbox {
background: #1A1A2E; background: #1A1A2E;
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
margin-top: 24px;
} }
.oauth2-2legged__code-panel::before { .oauth2-2legged__code-panel::before {
content: ""; content: "";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,5 +12,5 @@ html {
body { body {
margin: 0; margin: 0;
background: #fafafa; background: #ffffff;
} }
@@ -366,7 +366,9 @@
white-space: normal; white-space: normal;
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
&::before { display: none; } &::before {
display: none;
}
} }
.notice-title-link { .notice-title-link {
@@ -378,7 +380,7 @@
width: 100%; width: 100%;
&:hover { &:hover {
text-decoration: underline; font-weight: 600;
} }
// Mobile number prefix [1] [2] etc // Mobile number prefix [1] [2] etc
@@ -813,6 +815,7 @@
// 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션 // 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
// 검색 필드 - Figma: 335px × 40px, border-radius 8px // 검색 필드 - Figma: 335px × 40px, border-radius 8px
.search-field { .search-field {
display: flex; display: flex;
@@ -995,7 +998,8 @@
width: 20px; width: 20px;
height: 20px; height: 20px;
i, svg { i,
svg {
font-size: 14px; font-size: 14px;
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -18,7 +18,7 @@
--accent-orange: #FF6B6B; --accent-orange: #FF6B6B;
--accent-green: #6BCF7F; --accent-green: #6BCF7F;
--accent-purple: #A78BFA; --accent-purple: #A78BFA;
--accent-color:#efdcb2; --accent-color: #efdcb2;
--accent-light: #E9F9FF; --accent-light: #E9F9FF;
// Base colors // Base colors
@@ -38,7 +38,7 @@
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2); --shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
// border // border
--border-color : #E2E8F0; --border-color: #E2E8F0;
//transition //transition
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); --transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
@@ -225,19 +225,19 @@ body.design-survey-active {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
font-size:24px; font-size: 24px;
.logo-text-bold { .logo-text-bold {
font-weight: 700; font-weight: 700;
color: var(--primary-color); color: var(--primary-color);
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
.logo-text-thin { .logo-text-thin {
font-weight: 300; font-weight: 300;
color: var(--secondary-color); color: var(--secondary-color);
margin-left: 4px; margin-left: 4px;
} }
} }
@@ -999,7 +999,7 @@ body.design-survey-active {
.nav-menu { .nav-menu {
display: flex; display: flex;
list-style: none; list-style: none;
gap: 36px; gap: 28px;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -1064,7 +1064,7 @@ body.design-survey-active {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--text-gray); color: var(--text-gray);
padding: 8px 0; padding: 8px;
position: relative; position: relative;
transition: var(--transition-smooth); transition: var(--transition-smooth);
@@ -23,6 +23,8 @@
} }
// Sidebar overrides for mobile toggle menu inside market // Sidebar overrides for mobile toggle menu inside market
// Commented out to allow horizontal scrollbar on mobile screens as well
/*
.service-sidebar { .service-sidebar {
@media (max-width: 768px) { @media (max-width: 768px) {
position: fixed; position: fixed;
@@ -52,6 +54,7 @@
} }
} }
} }
*/
} }
// Hero Section // Hero Section
@@ -311,6 +314,7 @@
flex: 1; flex: 1;
// padding-left: $spacing-sm; // padding-left: $spacing-sm;
// padding-right: $spacing-sm; // padding-right: $spacing-sm;
width: 100%;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -334,37 +338,7 @@
// Mobile Menu Toggle // Mobile Menu Toggle
.api-mobile-toggle { .api-mobile-toggle {
display: none; display: none !important;
position: fixed;
bottom: $spacing-lg;
right: $spacing-lg;
width: 56px;
height: 56px;
border-radius: $border-radius-circle;
border: none;
color: $white;
font-size: 24px;
cursor: pointer;
box-shadow: $shadow-lg;
z-index: $z-index-modal + 1;
transition: $transition-base;
background-color: $primary-blue;
&:hover {
transform: scale(1.05);
box-shadow: $shadow-xl;
}
&:active {
transform: scale(0.95);
}
@media (max-width: $breakpoint-sm) {
display: flex;
align-items: center;
justify-content: center;
}
} }
// Header Section // Header Section
@@ -1097,7 +1071,11 @@
// API Market Content - Mobile (화면 폭 제한) // API Market Content - Mobile (화면 폭 제한)
.api-market-content { .api-market-content {
max-width: 100vw; width: 100%;
overflow-x: hidden; overflow-x: hidden;
} }
.api-card {
height: 285px;
}
} }
+115 -31
View File
@@ -158,6 +158,16 @@
color: #0a4ea3; color: #0a4ea3;
border: 1px solid #b3d4ff; border: 1px solid #b3d4ff;
} }
&.lg {
height: 28px;
width: auto;
min-width: 50px;
padding: 0 10px;
font-size: 13px;
font-weight: 700;
}
} }
.notice-detail-title { .notice-detail-title {
@@ -222,11 +232,13 @@
.attachment-filename { .attachment-filename {
font-size: 14px; font-size: 14px;
color: #666; color: #666;
word-break: break-all;
} }
} }
// Incident Info Table // Incident Info Table
.notice-detail-incident-info { .notice-detail-incident-info,
.notice-detail-affected-info {
margin-bottom: 20px; margin-bottom: 20px;
.detail-table { .detail-table {
@@ -250,11 +262,112 @@
} }
td { td {
padding-left: 19px; padding: 10px 19px;
font-size: 14px; font-size: 14px;
color: #000; color: #000;
word-break: break-all; word-break: break-all;
} }
@media (max-width: 768px) {
display: block;
colgroup {
display: none;
}
tbody,
tr,
th,
td {
display: block;
width: 100% !important;
box-sizing: border-box;
}
tr {
border-bottom: none;
}
th {
text-align: left;
background: #f8fafc;
height: auto;
padding: 10px 16px;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
}
}
}
// Affected APIs Styling
.affected-apis-wrapper {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
width: 100%;
}
.affected-apis-list {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.affected-api-badge {
display: inline-flex;
align-items: center;
padding: 6px 16px;
background-color: #f3f6fa;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #555555;
white-space: nowrap;
font-weight: 500;
strong {
color: #0a4ea3;
font-weight: 700;
}
}
.btn-affected-toggle {
font-size: 13px;
color: #ffffff;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 8px 16px;
border-radius: 4px;
background-color: #0049b4;
border: none;
transition: all 0.2s ease;
align-self: flex-end;
margin-top: 8px;
line-height: normal;
&:hover {
background-color: #003685;
}
.arrow {
font-size: 10px;
display: inline-block;
transition: transform 0.2s ease;
}
&.active .arrow {
transform: rotate(180deg);
} }
} }
@@ -324,37 +437,8 @@
// List Button - Figma Design // List Button - Figma Design
.btn-notice-list { .btn-notice-list {
display: flex;
align-items: center;
justify-content: center;
width: 247px; width: 247px;
height: 60px; height: 60px;
background: #2a69de;
color: #fff;
border: none;
font-size: 20px;
font-weight: 700;
border-radius: 10px;
cursor: pointer;
transition: $transition-base;
@media (max-width: $breakpoint-md) {
width: auto;
min-width: 120px;
padding: 0 20px;
height: 48px;
font-size: 16px;
}
&:hover {
background: darken(#2a69de, 10%);
transform: translateY(-2px);
}
&:active {
transform: scale(0.98);
}
} }
// Notice detail card // Notice detail card
@@ -227,13 +227,42 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
.service-sidebar { .service-sidebar {
width: 100%; width: 100%;
overflow: hidden;
} }
.service-nav { .service-nav {
flex-direction: row; flex-direction: row !important;
flex-wrap: wrap; flex-wrap: nowrap !important;
justify-content: center; justify-content: flex-start !important;
padding: 20px; padding: 16px 20px !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
gap: 24px !important;
border: 1px solid #e3e8f0 !important;
border-radius: 20px !important;
box-shadow: none !important;
background: #ffffff !important;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
.service-sidebar__profile,
.service-sidebar__divider {
display: none !important;
}
.service-nav__item {
flex-shrink: 0 !important;
white-space: nowrap !important;
margin: 0 !important;
padding: 0 !important;
}
} }
// 사이드바가 세로로 배치되면 가로 여백이 없으므로 100vw로 보정 // 사이드바가 세로로 배치되면 가로 여백이 없으므로 100vw로 보정
@@ -1244,6 +1273,7 @@ $o2leg-err-fg: #a23b3b;
background: $o2leg-text-dark; background: $o2leg-text-dark;
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
margin-top: 24px;
&::before { &::before {
content: ""; content: "";
@@ -13,31 +13,24 @@
</div> </div>
</th:block> </th:block>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="api-market-container"> <section class="container api-market-container">
<!-- Sidebar Navigation --> <!-- Sidebar Navigation -->
<aside class="api-market-sidebar" id="apiSidebar"> <aside class="service-sidebar" id="apiSidebar">
<nav class="service-nav">
<div class="api-sidebar-header">
<img th:src="@{/img/api_sidebar.png}" alt="API">
</div>
<nav class="api-sidebar-nav">
<!-- All APIs --> <!-- All APIs -->
<div class="menu-section"> <a class="service-nav__item"
<a class="menu-title" th:classappend="${selected == '-1'} ? 'active'" th:href="@{/apis}"> th:classappend="${selected == '-1' || #request.getParameter('selected') == '-1'} ? 'service-nav__item--active' : ''"
전체 th:href="@{/apis}">
</a> 전체
</div> </a>
<!-- Service Categories --> <!-- Service Categories -->
<div class="menu-section" th:each="service : ${services}"> <a class="service-nav__item" th:each="service : ${services}"
<a class="menu-title" th:classappend="${(#request.getParameter('selected') != null ? #request.getParameter('selected') : (selected != null ? selected : apiSpecInfo.service)) == service.id} ? 'service-nav__item--active' : ''"
th:classappend="${selected == service.id} ? 'active'" th:href="@{/apis(groupIds=${service.id})}">
th:href="@{/apis(groupIds=${service.id})}"> [[${service.groupName}]]
[[${service.groupName}]] </a>
</a>
</div>
</nav> </nav>
</aside> </aside>
@@ -8,8 +8,7 @@
<section class="service-hero"> <section class="service-hero">
<div class="service-hero__inner"> <div class="service-hero__inner">
<div class="service-hero__icon-wrapper"> <div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘" <img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
style="width: 100%; height: 100%; object-fit: contain;" />
</div> </div>
<div class="service-hero__content"> <div class="service-hero__content">
<div class="service-hero__badge"> <div class="service-hero__badge">
@@ -129,8 +128,11 @@
apiCards.forEach(function (card) { apiCards.forEach(function (card) {
// Click handler // Click handler
card.addEventListener('click', function () { card.addEventListener('click', function () {
const href = this.getAttribute('data-href'); let href = this.getAttribute('data-href');
if (href) { if (href) {
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
const val = selectedGroup ? selectedGroup : '-1';
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
window.location.href = href; window.location.href = href;
} }
}); });
@@ -139,8 +141,11 @@
card.addEventListener('keypress', function (e) { card.addEventListener('keypress', function (e) {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
const href = this.getAttribute('data-href'); let href = this.getAttribute('data-href');
if (href) { if (href) {
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
const val = selectedGroup ? selectedGroup : '-1';
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
window.location.href = href; window.location.href = href;
} }
} }
@@ -1,116 +1,157 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경"> <img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
<h1>공지사항</h1> <h1>공지사항</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="signup-guide-v2"> <div class="signup-guide-v2">
<!-- Hero Section --> <!-- Hero Section -->
<section class="service-hero"> <section class="service-hero">
<div class="service-hero__inner"> <div class="service-hero__inner">
<div class="service-hero__icon-wrapper"> <div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" <img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" /> style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">고객지원</span>
</div> </div>
<h1 class="service-hero__title">공지사항</h1> <div class="service-hero__content">
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해 주시기 바랍니다.</p> <div class="service-hero__badge">
</div> <span class="service-hero__badge-dot"></span>
</div> <span class="service-hero__badge-text">고객지원</span>
</section>
<!-- Main Content -->
<div class="service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
<!-- Content -->
<section class="service-content">
<div class="notice-detail-container" style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
<!-- Notice Header: Title and Date -->
<div class="notice-detail-header">
<p class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">2026.06.24</p>
<div class="notice-detail-title-wrapper">
<span class="notice-type-badge notice-type-badge--incident" th:if="${portalNotice.noticeType == '3'}">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${portalNotice.noticeType == '2'}">점검</span>
<h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">공지사항 디테일 입니다</h2>
</div> </div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
주시기 바랍니다.</p>
</div> </div>
<!-- 장애/점검 정보 영역 -->
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}">
<table class="detail-table">
<colgroup>
<col style="width: 160px;"><col><col style="width: 160px;"><col>
</colgroup>
<tbody>
<tr>
<th>시작</th>
<td th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">-</td>
<th></th>
<td th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">-</td>
</tr>
<tr>
<th>상태</th>
<td th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
<th>영향 API</th>
<td>
<th:block th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
<span th:each="api, iterStat : ${portalNotice.affectedApis}">
<strong th:text="${api.apiId}">API_ID</strong><span th:if="${api.apiName != null and !api.apiName.isEmpty()}" th:text="| - ${api.apiName}|"></span><th:block th:if="${!iterStat.last}">, </th:block>
</span>
</th:block>
<th:block th:unless="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">-</th:block>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Attachment Section -->
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
</svg>
<span class="attachment-label">첨부파일</span>
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
</a>
</div>
</div>
</div>
<!-- Content Section -->
<div class="notice-detail-content">
<div id="noticeDetail" class="notice-content-body editor-content" th:utext="${portalNotice.noticeDetail}">
공지사항 내용이 여기에 표시됩니다.
</div>
</div>
<!-- Action Buttons -->
<div class="notice-detail-actions">
<button type="button" class="btn-notice-list" th:onclick="|location.href='@{/portalnotice}'|">목록</button>
</div>
</div> </div>
</section> </section>
<!-- Main Content -->
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
<!-- Content -->
<section class="service-content">
<div class="notice-detail-container"
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
<!-- Notice Header: Title and Date -->
<div class="notice-detail-header">
<p class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">
2026.06.24</p>
<div class="notice-detail-title-wrapper">
<span class="notice-type-badge lg notice-type-badge--incident"
th:if="${portalNotice.noticeType == '3'}">장애</span>
<span class="notice-type-badge lg notice-type-badge--maintenance"
th:if="${portalNotice.noticeType == '2'}">점검</span>
<h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">공지사항 디테일 입니다</h2>
</div>
</div>
<!-- 장애/점검 정보 영역 -->
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}">
<table class="detail-table">
<colgroup>
<col style="width: 160px;">
<col>
<col style="width: 160px;">
<col>
</colgroup>
<tbody>
<tr>
<th>시작</th>
<td
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">
-</td>
<th></th>
<td
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">
-</td>
</tr>
<tr>
<th>상태</th>
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
</tr>
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
<th>영향 API</th>
<td colspan="3">
<div class="affected-apis-wrapper">
<!-- First 3 APIs (Always visible) -->
<div class="affected-apis-list">
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
th:if="${iterStat.index < 3}">
<strong th:text="${api.apiId}">API_ID</strong><span
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
th:text="| - ${api.apiName}|"></span>
</span>
<!-- Rest of APIs (Hidden by default) -->
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
<span class="affected-api-badge extra-api"
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
style="display: none;">
<strong th:text="${api.apiId}">API_ID</strong><span
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
th:text="| - ${api.apiName}|"></span>
</span>
</th:block>
</div>
<!-- Toggle Button -->
<button type="button" class="btn-affected-toggle"
th:if="${portalNotice.affectedApis.size() > 3}" onclick="toggleAffectedApis(this)">
리스트 전체보기 <span class="arrow"></span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Attachment Section -->
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'"
class="notice-attachment-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z"
fill="currentColor" />
</svg>
<span class="attachment-label">첨부파일</span>
<span
class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
</a>
</div>
</div>
</div>
<!-- Content Section -->
<div class="notice-detail-content">
<div id="noticeDetail" class="notice-content-body editor-content" th:utext="${portalNotice.noticeDetail}">
공지사항 내용이 여기에 표시됩니다.
</div>
</div>
<!-- Action Buttons -->
<div class="notice-detail-actions">
<button type="button" class="btn-action-primary btn-notice-list"
th:onclick="|location.href='@{/portalnotice}'|">목록</button>
</div>
</div>
</section>
</div>
</div> </div>
</div> </th:block>
</th:block>
</body> </body>
@@ -120,13 +161,31 @@
window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn); window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn);
} }
function toggleAffectedApis(btn) {
const wrapper = btn.closest('.affected-apis-wrapper');
const extraApis = wrapper.querySelectorAll('.extra-api');
const isExpanded = btn.classList.contains('active');
extraApis.forEach(el => {
el.style.display = isExpanded ? 'none' : 'inline-flex';
});
if (isExpanded) {
btn.classList.remove('active');
btn.innerHTML = '리스트 전체보기 <span class="arrow">▼</span>';
} else {
btn.classList.add('active');
btn.innerHTML = '리스트 접기 <span class="arrow">▲</span>';
}
}
function decodeHTMLEntities(text) { function decodeHTMLEntities(text) {
var textArea = document.createElement('textarea'); var textArea = document.createElement('textarea');
textArea.innerHTML = text; textArea.innerHTML = text;
return textArea.value; return textArea.value;
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
var element = document.getElementById('noticeDetail'); var element = document.getElementById('noticeDetail');
if (element) { if (element) {
element.innerHTML = decodeHTMLEntities(element.innerHTML); element.innerHTML = decodeHTMLEntities(element.innerHTML);
@@ -16,8 +16,7 @@
<section class="service-hero"> <section class="service-hero">
<div class="service-hero__inner"> <div class="service-hero__inner">
<div class="service-hero__icon-wrapper"> <div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" <img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
style="width: 100%; height: 100%; object-fit: contain;" />
</div> </div>
<div class="service-hero__content"> <div class="service-hero__content">
<div class="service-hero__badge"> <div class="service-hero__badge">
@@ -32,7 +31,7 @@
</section> </section>
<!-- Main Content --> <!-- Main Content -->
<div class="service-main"> <div class="container service-main">
<!-- Sidebar --> <!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
@@ -86,8 +85,10 @@
<span class="notice-number" <span class="notice-number"
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span> th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span> <span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
<span class="notice-type-badge notice-type-badge--incident" th:if="${notice.noticeType == '3'}">장애</span> <span class="notice-type-badge notice-type-badge--incident"
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${notice.noticeType == '2'}">점검</span> th:if="${notice.noticeType == '3'}">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance"
th:if="${notice.noticeType == '2'}">점검</span>
<span th:text="${notice.noticeSubject}">공지사항 제목</span> <span th:text="${notice.noticeSubject}">공지사항 제목</span>
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}"> <span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1,197 +1,224 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="oauth2-2legged"> <section class="oauth2-2legged">
<!-- Section 1: Hero --> <!-- Section 1: Hero -->
<header class="oauth2-2legged__hero"> <header class="oauth2-2legged__hero">
<div class="oauth2-2legged__hero-inner"> <div class="oauth2-2legged__hero-inner">
<div class="oauth2-2legged__hero-body"> <div class="oauth2-2legged__hero-body">
<span class="oauth2-2legged__hero-eyebrow"> <span class="oauth2-2legged__hero-eyebrow">
<span class="oauth2-2legged__hero-eyebrow-dot"></span> <span class="oauth2-2legged__hero-eyebrow-dot"></span>
개발 가이드 · Webhook · HMAC-SHA256 개발 가이드 · Webhook · HMAC-SHA256
</span> </span>
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1> <h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한</p> <p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
<p class="oauth2-2legged__hero-lead">HMAC-SHA256 서명 검증 방법을 단계별로 설명합니다.</p> 단계별로 설명합니다.</p>
<div class="oauth2-2legged__hero-chips"> <!-- <div class="oauth2-2legged__hero-chips">
<span class="oauth2-2legged__chip oauth2-2legged__chip--primary">HMAC-SHA256</span> <span class="oauth2-2legged__chip oauth2-2legged__chip--primary">HMAC-SHA256</span>
<span class="oauth2-2legged__chip">X-Webhook-Signature</span> <span class="oauth2-2legged__chip">X-Webhook-Signature</span>
<span class="oauth2-2legged__chip">Raw Body</span> <span class="oauth2-2legged__chip">Raw Body</span>
</div> -->
</div>
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img"
aria-label="Signed Webhook Delivery">
<defs>
<marker id="whsig-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
</marker>
</defs>
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF" />
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">DJBank</text>
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Webhook</text>
<rect x="170" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="213" y="114" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Your</text>
<text x="213" y="128" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Endpoint</text>
<line x1="110" y1="118" x2="170" y2="118" stroke="#0049b4" stroke-width="2"
marker-end="url(#whsig-hero-arrow)" />
<g transform="translate(58,28)">
<rect width="164" height="34" rx="6" fill="#1A1A2E" />
<text x="12" y="16" font-family="'Fira Code', monospace" font-size="9"
fill="#00D4FF">X-Webhook-Signature:</text>
<text x="12" y="28" font-family="'Fira Code', monospace" font-size="9"
fill="#FFD93D">sha256=9f86d0..</text>
</g>
<g transform="translate(133,104)">
<circle r="13" fill="#0049b4" />
<path d="M-5,-1 h10 v7 h-10 z M-3,-1 v-3 a3,3 0 0 1 6,0 v3" fill="none" stroke="#FFFFFF"
stroke-width="1.5" />
</g>
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600"
fill="#64748B">Signed Webhook Delivery</text>
</svg>
</div>
</div> </div>
</div> </header>
<div class="oauth2-2legged__hero-illust" aria-hidden="true"> <div class="container service-main">
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Signed Webhook Delivery"> <!-- Sidebar -->
<defs> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
<marker id="whsig-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/>
</marker>
</defs>
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF"/>
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4"/> <div class="service-content">
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">DJBank</text>
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Webhook</text>
<rect x="170" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4"/> <!-- Section 2: 개요 -->
<text x="213" y="114" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Your</text> <section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
<text x="213" y="128" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Endpoint</text> <span class="oauth2-2legged__eyebrow">OVERVIEW</span>
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2>
<line x1="110" y1="118" x2="170" y2="118" stroke="#0049b4" stroke-width="2" marker-end="url(#whsig-hero-arrow)"/> <div class="oauth2-2legged__prereq-grid">
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">1</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">Secret 확보</h3>
<p>[마이페이지 &gt; Webhook 관리]에서 발급된</p>
<p>Secret Key를 서버에 안전하게 보관.</p>
</div>
</article>
<g transform="translate(58,28)"> <article class="oauth2-2legged__prereq-card">
<rect width="164" height="34" rx="6" fill="#1A1A2E"/> <span class="oauth2-2legged__prereq-num">2</span>
<text x="12" y="16" font-family="'Fira Code', monospace" font-size="9" fill="#00D4FF">X-Webhook-Signature:</text> <div class="oauth2-2legged__prereq-body">
<text x="12" y="28" font-family="'Fira Code', monospace" font-size="9" fill="#FFD93D">sha256=9f86d0..</text> <h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
</g> <p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
<p>바이트 원문 그대로 서명 계산에 사용.</p>
</div>
</article>
<g transform="translate(133,104)"> <article class="oauth2-2legged__prereq-card">
<circle r="13" fill="#0049b4"/> <span class="oauth2-2legged__prereq-num">3</span>
<path d="M-5,-1 h10 v7 h-10 z M-3,-1 v-3 a3,3 0 0 1 6,0 v3" fill="none" stroke="#FFFFFF" stroke-width="1.5"/> <div class="oauth2-2legged__prereq-body">
</g> <h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3>
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p>
<p>헤더 서명과 상수 시간으로 비교.</p>
</div>
</article>
</div>
</section>
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600" fill="#64748B">Signed Webhook Delivery</text> <!-- Section 3: 전체 서명·검증 시퀀스 -->
</svg> <section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
</div> <span class="oauth2-2legged__eyebrow">SEQUENCE</span>
</div> <h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
</header>
<div class="container service-main"> <div class="oauth2-2legged__sequence-diagram">
<!-- Sidebar --> <svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img"
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block> aria-label="Webhook Signature Verification Sequence">
<defs>
<marker id="whsig-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
</marker>
<marker id="whsig-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B" />
</marker>
</defs>
<div class="service-content"> <g>
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE"
stroke="#0049b4" />
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">DJBank Webhook Sender</text>
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
</g>
<g>
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF"
stroke="#0049b4" />
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">Your Endpoint</text>
<line x1="880" y1="72" x2="880" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
</g>
<!-- Section 2: 개요 --> <text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700"
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title"> fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text>
<span class="oauth2-2legged__eyebrow">OVERVIEW</span> <text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700"
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2> fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
<div class="oauth2-2legged__prereq-grid"> <text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700"
<article class="oauth2-2legged__prereq-card"> fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=&lt;hex&gt;</text>
<span class="oauth2-2legged__prereq-num">1</span> <line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2"
<div class="oauth2-2legged__prereq-body"> marker-end="url(#whsig-arrow-primary)" />
<h3 class="oauth2-2legged__prereq-title">Secret 확보</h3>
<p>[마이페이지 &gt; Webhook 관리]에서 발급된</p>
<p>Secret Key를 서버에 안전하게 보관.</p>
</div>
</article>
<article class="oauth2-2legged__prereq-card"> <text x="880" y="198" text-anchor="middle" font-size="12" font-weight="700"
<span class="oauth2-2legged__prereq-num">2</span> fill="#64748B">④ 동일 secret으로 재계산</text>
<div class="oauth2-2legged__prereq-body"> <text x="880" y="218" text-anchor="middle" font-size="12" font-weight="700"
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3> fill="#64748B">⑤ 상수 시간 비교 (일치 여부)</text>
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
<p>바이트 원문 그대로 서명 계산에 사용.</p>
</div>
</article>
<article class="oauth2-2legged__prereq-card"> <text x="560" y="250" text-anchor="middle" font-size="12" font-weight="700"
<span class="oauth2-2legged__prereq-num">3</span> fill="#64748B">⑥ 200 OK (검증 성공 시)</text>
<div class="oauth2-2legged__prereq-body"> <line x1="880" y1="260" x2="240" y2="260" stroke="#64748B" stroke-width="2"
<h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3> marker-end="url(#whsig-arrow-gray)" />
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p> </svg>
<p>헤더 서명과 상수 시간으로 비교.</p> </div>
</div> </section>
</article>
</div>
</section>
<!-- Section 3: 전체 서명·검증 시퀀스 --> <!-- Section 4: STEP 1 — 수신 요청 형식 -->
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title"> <section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
<span class="oauth2-2legged__eyebrow">SEQUENCE</span> <span class="oauth2-2legged__eyebrow">STEP 1</span>
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2> <h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
<div class="oauth2-2legged__sequence-diagram"> <div class="oauth2-2legged__endpoint-box">
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Webhook Signature Verification Sequence"> <span class="oauth2-2legged__method">POST</span>
<defs> <code class="oauth2-2legged__endpoint-path">https://your-service.example.com/webhook</code>
<marker id="whsig-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"> <span class="oauth2-2legged__endpoint-content-type">application/json</span>
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/> </div>
</marker>
<marker id="whsig-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B"/>
</marker>
</defs>
<g> <div class="oauth2-2legged__step-grid">
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE" stroke="#0049b4"/> <div class="oauth2-2legged__panel">
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">DJBank Webhook Sender</text> <h3 class="oauth2-2legged__panel-title">요청 헤더</h3>
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4"/> <table class="oauth2-2legged__table">
</g> <thead>
<g> <tr>
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF" stroke="#0049b4"/> <th>HEADER</th>
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">Your Endpoint</text> <th>설명</th>
<line x1="880" y1="72" x2="880" y2="272" stroke="#94A3B8" stroke-dasharray="4 4"/> </tr>
</g> </thead>
<tbody>
<tr>
<td><code>X-Webhook-Signature</code></td>
<td><code>sha256=&lt;hex&gt;</code> — 본문 HMAC-SHA256 서명(소문자 hex)</td>
</tr>
<tr>
<td><code>X-Webhook-Event</code></td>
<td>이벤트 코드 (예: CONTROL_START)</td>
</tr>
<tr>
<td><code>X-Webhook-Timestamp</code></td>
<td>발송 시각 (epoch millis) — 재전송 방어용</td>
</tr>
<tr>
<td><code>Content-Type</code></td>
<td>application/json</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
</p>
</div>
<text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text> <div class="oauth2-2legged__code-panel">
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">② signature = HMAC-SHA256(secret, body)</text> <span class="oauth2-2legged__code-tag">Request Body · JSON</span>
<pre class="oauth2-2legged__code-block">{
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=&lt;hex&gt;</text>
<line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2" marker-end="url(#whsig-arrow-primary)"/>
<text x="880" y="198" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">④ 동일 secret으로 재계산</text>
<text x="880" y="218" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">⑤ 상수 시간 비교 (일치 여부)</text>
<text x="560" y="250" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">⑥ 200 OK (검증 성공 시)</text>
<line x1="880" y1="260" x2="240" y2="260" stroke="#64748B" stroke-width="2" marker-end="url(#whsig-arrow-gray)"/>
</svg>
</div>
</section>
<!-- Section 4: STEP 1 — 수신 요청 형식 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
<span class="oauth2-2legged__eyebrow">STEP 1</span>
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
<div class="oauth2-2legged__endpoint-box">
<span class="oauth2-2legged__method">POST</span>
<code class="oauth2-2legged__endpoint-path">https://your-service.example.com/webhook</code>
<span class="oauth2-2legged__endpoint-content-type">application/json</span>
</div>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">요청 헤더</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>HEADER</th>
<th>설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>X-Webhook-Signature</code></td>
<td><code>sha256=&lt;hex&gt;</code> — 본문 HMAC-SHA256 서명(소문자 hex)</td>
</tr>
<tr>
<td><code>X-Webhook-Event</code></td>
<td>이벤트 코드 (예: CONTROL_START)</td>
</tr>
<tr>
<td><code>X-Webhook-Timestamp</code></td>
<td>발송 시각 (epoch millis) — 재전송 방어용</td>
</tr>
<tr>
<td><code>Content-Type</code></td>
<td>application/json</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.</p>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Request Body · JSON</span>
<pre class="oauth2-2legged__code-block">{
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>, <span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>,
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>, <span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>, <span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
@@ -199,20 +226,20 @@
} }
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre> <span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
</div> </div>
</div> </div>
</section> </section>
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 --> <!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title"> <section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
<span class="oauth2-2legged__eyebrow">STEP 2</span> <span class="oauth2-2legged__eyebrow">STEP 2</span>
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2> <h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p> <p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
<div class="oauth2-2legged__step-grid"> <div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Verification Steps</span> <span class="oauth2-2legged__code-tag">Verification Steps</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span> <pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span>
received = header[<span class="o2leg-c">"X-Webhook-Signature"</span>] <span class="o2leg-g"># "sha256=...."</span> received = header[<span class="o2leg-c">"X-Webhook-Signature"</span>] <span class="o2leg-g"># "sha256=...."</span>
<span class="o2leg-cm"># 2) 본문 원문으로 HMAC-SHA256 재계산</span> <span class="o2leg-cm"># 2) 본문 원문으로 HMAC-SHA256 재계산</span>
@@ -221,36 +248,36 @@ expected = <span class="o2leg-y">"sha256="</span> + toHexLower(digest)
<span class="o2leg-cm"># 3) 상수 시간 비교</span> <span class="o2leg-cm"># 3) 상수 시간 비교</span>
valid = constantTimeEquals(received, expected)</pre> valid = constantTimeEquals(received, expected)</pre>
</div> </div>
<div class="oauth2-2legged__panel"> <div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3> <h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
<ul class="oauth2-2legged__tips"> <ul class="oauth2-2legged__tips">
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li> <li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li> <li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li> <li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li> <li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li> <li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
</ul> </ul>
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4> <h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
<ul class="oauth2-2legged__tips"> <ul class="oauth2-2legged__tips">
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li> <li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li> <li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
</ul> </ul>
</div> </div>
</div> </div>
</section> </section>
<!-- Section 6: STEP 3 — 언어별 예제 --> <!-- Section 6: STEP 3 — 언어별 예제 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title"> <section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
<span class="oauth2-2legged__eyebrow">STEP 3</span> <span class="oauth2-2legged__eyebrow">STEP 3</span>
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2> <h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p> <p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
<div class="oauth2-2legged__step-grid"> <div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Node.js (Express)</span> <span class="oauth2-2legged__code-tag">Node.js (Express)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">const</span> crypto = <span class="o2leg-y">require</span>(<span class="o2leg-c">'crypto'</span>); <pre class="oauth2-2legged__code-block"><span class="o2leg-p">const</span> crypto = <span class="o2leg-y">require</span>(<span class="o2leg-c">'crypto'</span>);
<span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span> <span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span>
<span class="o2leg-p">function</span> <span class="o2leg-y">verify</span>(rawBody, header, secret) { <span class="o2leg-p">function</span> <span class="o2leg-y">verify</span>(rawBody, header, secret) {
@@ -263,11 +290,11 @@ valid = constantTimeEquals(received, expected)</pre>
<span class="o2leg-p">return</span> a.length === b.length &amp;&amp; <span class="o2leg-p">return</span> a.length === b.length &amp;&amp;
crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b); crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b);
}</pre> }</pre>
</div> </div>
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Python (Flask)</span> <span class="oauth2-2legged__code-tag">Python (Flask)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib <pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib
<span class="o2leg-cm"># raw_body: request.get_data() 로 확보한 bytes</span> <span class="o2leg-cm"># raw_body: request.get_data() 로 확보한 bytes</span>
<span class="o2leg-p">def</span> <span class="o2leg-y">verify</span>(raw_body, header, secret): <span class="o2leg-p">def</span> <span class="o2leg-y">verify</span>(raw_body, header, secret):
@@ -278,13 +305,13 @@ valid = constantTimeEquals(received, expected)</pre>
).<span class="o2leg-y">hexdigest</span>() ).<span class="o2leg-y">hexdigest</span>()
expected = <span class="o2leg-c">'sha256='</span> + digest expected = <span class="o2leg-c">'sha256='</span> + digest
<span class="o2leg-p">return</span> hmac.<span class="o2leg-y">compare_digest</span>(expected, header)</pre> <span class="o2leg-p">return</span> hmac.<span class="o2leg-y">compare_digest</span>(expected, header)</pre>
</div> </div>
</div> </div>
<div class="oauth2-2legged__step-grid"> <div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Java</span> <span class="oauth2-2legged__code-tag">Java</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac; <pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac;
<span class="o2leg-p">import</span> javax.crypto.spec.SecretKeySpec; <span class="o2leg-p">import</span> javax.crypto.spec.SecretKeySpec;
<span class="o2leg-p">import</span> java.nio.charset.StandardCharsets; <span class="o2leg-p">import</span> java.nio.charset.StandardCharsets;
<span class="o2leg-p">import</span> java.security.MessageDigest; <span class="o2leg-p">import</span> java.security.MessageDigest;
@@ -301,84 +328,106 @@ valid = constantTimeEquals(received, expected)</pre>
expected.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8), expected.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8),
header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8)); header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
}</pre> }</pre>
</div> </div>
<div class="oauth2-2legged__panel"> <div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3> <h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
<table class="oauth2-2legged__table"> <table class="oauth2-2legged__table">
<thead> <thead>
<tr> <tr>
<th>CODE</th> <th>CODE</th>
<th>의미</th> <th>의미</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td><code>CONTROL_START</code></td><td>점검 시작</td></tr> <tr>
<tr><td><code>CONTROL_END</code></td><td>점검 종료</td></tr> <td><code>CONTROL_START</code></td>
<tr><td><code>DELAY_START</code></td><td>지연 시작</td></tr> <td>점검 시작</td>
<tr><td><code>DELAY_END</code></td><td>지연 종료</td></tr> </tr>
<tr><td><code>ERROR_START</code></td><td>장애 시작</td></tr> <tr>
<tr><td><code>ERROR_END</code></td><td>장애 종료</td></tr> <td><code>CONTROL_END</code></td>
</tbody> <td>점검 종료</td>
</table> </tr>
</div> <tr>
</div> <td><code>DELAY_START</code></td>
</section> <td>지연 시작</td>
</tr>
<tr>
<td><code>DELAY_END</code></td>
<td>지연 종료</td>
</tr>
<tr>
<td><code>ERROR_START</code></td>
<td>장애 시작</td>
</tr>
<tr>
<td><code>ERROR_END</code></td>
<td>장애 종료</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Section 6.5: STEP 4 — 응답 반환 규칙 --> <!-- Section 6.5: STEP 4 — 응답 반환 규칙 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title"> <section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
<span class="oauth2-2legged__eyebrow">STEP 4</span> <span class="oauth2-2legged__eyebrow">STEP 4</span>
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2> <h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p> <p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
<div class="oauth2-2legged__step-grid"> <div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel"> <div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">상태 코드별 처리</h3> <h3 class="oauth2-2legged__panel-title">상태 코드별 처리</h3>
<table class="oauth2-2legged__table"> <table class="oauth2-2legged__table">
<thead> <thead>
<tr> <tr>
<th>반환</th> <th>반환</th>
<th>상황</th> <th>상황</th>
<th>DJBank 처리</th> <th>DJBank 처리</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span></td> <td><span
<td>검증 성공 + 정상 접수</td> class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span>
<td><strong>발송 성공</strong> 기록. 재시도 없음</td> </td>
</tr> <td>검증 성공 + 정상 접수</td>
<tr> <td><strong>발송 성공</strong> 기록. 재시도 없음</td>
<td><code>400</code></td> </tr>
<td>필수 헤더 누락</td> <tr>
<td>실패 기록 + 재시도</td> <td><code>400</code></td>
</tr> <td>필수 헤더 누락</td>
<tr> <td>실패 기록 + 재시도</td>
<td><code>401</code></td> </tr>
<td>서명 불일치 / timestamp 만료</td> <tr>
<td>실패 기록 + 재시도</td> <td><code>401</code></td>
</tr> <td>서명 불일치 / timestamp 만료</td>
<tr> <td>실패 기록 + 재시도</td>
<td><code>5xx</code> · 타임아웃</td> </tr>
<td>수신 서버 일시 장애</td> <tr>
<td>실패 기록 + 재시도</td> <td><code>5xx</code> · 타임아웃</td>
</tr> <td>수신 서버 일시 장애</td>
</tbody> <td>실패 기록 + 재시도</td>
</table> </tr>
<p class="oauth2-2legged__warning">⚠ 2xx 이외 응답과 네트워크 오류는 <strong>일정 간격을 두고 재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p> </tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 2xx 이외 응답과 네트워크 오류는 <strong>일정 간격을 두고
재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4> <h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
<ul class="oauth2-2legged__tips"> <ul class="oauth2-2legged__tips">
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li> <li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li> <li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li> <li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li> <li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
</ul> </ul>
</div> </div>
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON (권장)</span> <span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span> (권장)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
HTTP/1.1 <span class="o2leg-g">200 OK</span> HTTP/1.1 <span class="o2leg-g">200 OK</span>
Content-Type: application/json Content-Type: application/json
@@ -400,77 +449,77 @@ HTTP/1.1 <span class="o2leg-g">400 Bad Request</span>
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>, <span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span> <span class="o2leg-c">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span>
}</pre> }</pre>
</div>
</div>
</section>
<!-- Section 7: 검증 실패 대표 원인 -->
<section class="oauth2-2legged__errors" aria-labelledby="whsig-errors-title">
<span class="oauth2-2legged__eyebrow">TROUBLESHOOTING</span>
<h2 class="oauth2-2legged__h2" id="whsig-errors-title">서명 불일치 대표 원인</h2>
<div class="oauth2-2legged__panel">
<table class="oauth2-2legged__table oauth2-2legged__table--errors">
<thead>
<tr>
<th>원인</th>
<th>증상</th>
<th>해결 가이드</th>
</tr>
</thead>
<tbody>
<tr>
<td>본문 재직렬화</td>
<td>JSON 파싱 후 다시 문자열화한 값으로 서명 계산</td>
<td>파싱 전 raw body(bytes)로 계산</td>
</tr>
<tr>
<td>hex 대소문자</td>
<td>대문자 hex로 비교해 불일치</td>
<td>소문자 hex 사용</td>
</tr>
<tr>
<td>접두어 처리</td>
<td><code>sha256=</code> 접두 포함/제외 불일치</td>
<td>양쪽 모두 접두 포함 후 비교</td>
</tr>
<tr>
<td>인코딩</td>
<td>Secret/본문을 UTF-8 외로 인코딩</td>
<td>키·메시지 모두 UTF-8 bytes</td>
</tr>
<tr>
<td>Secret 불일치</td>
<td>재발급 후 이전 Secret 사용</td>
<td>최신 Secret으로 교체</td>
</tr>
<tr>
<td>재전송</td>
<td>동일 이벤트 중복 수신</td>
<td>timestamp 검사 + eventId 멱등 처리</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Section 8: CTA -->
<a class="oauth2-2legged__cta" th:href="@{/webhook}">
<div class="oauth2-2legged__cta-body">
<span class="oauth2-2legged__cta-eyebrow">MANAGE</span>
<h2 class="oauth2-2legged__cta-title">Webhook 관리로 가기</h2>
<p class="oauth2-2legged__cta-desc">수신 URL·Secret·구독 이벤트를 등록하고 관리하세요.</p>
<span class="oauth2-2legged__cta-button">Webhook 관리 →</span>
</div>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
</a>
</div> </div>
</div> </div>
</section> </section>
</th:block>
<!-- Section 7: 검증 실패 대표 원인 -->
<section class="oauth2-2legged__errors" aria-labelledby="whsig-errors-title">
<span class="oauth2-2legged__eyebrow">TROUBLESHOOTING</span>
<h2 class="oauth2-2legged__h2" id="whsig-errors-title">서명 불일치 대표 원인</h2>
<div class="oauth2-2legged__panel">
<table class="oauth2-2legged__table oauth2-2legged__table--errors">
<thead>
<tr>
<th>원인</th>
<th>증상</th>
<th>해결 가이드</th>
</tr>
</thead>
<tbody>
<tr>
<td>본문 재직렬화</td>
<td>JSON 파싱 후 다시 문자열화한 값으로 서명 계산</td>
<td>파싱 전 raw body(bytes)로 계산</td>
</tr>
<tr>
<td>hex 대소문자</td>
<td>대문자 hex로 비교해 불일치</td>
<td>소문자 hex 사용</td>
</tr>
<tr>
<td>접두어 처리</td>
<td><code>sha256=</code> 접두 포함/제외 불일치</td>
<td>양쪽 모두 접두 포함 후 비교</td>
</tr>
<tr>
<td>인코딩</td>
<td>Secret/본문을 UTF-8 외로 인코딩</td>
<td>키·메시지 모두 UTF-8 bytes</td>
</tr>
<tr>
<td>Secret 불일치</td>
<td>재발급 후 이전 Secret 사용</td>
<td>최신 Secret으로 교체</td>
</tr>
<tr>
<td>재전송</td>
<td>동일 이벤트 중복 수신</td>
<td>timestamp 검사 + eventId 멱등 처리</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Section 8: CTA -->
<a class="oauth2-2legged__cta" th:href="@{/webhook}">
<div class="oauth2-2legged__cta-body">
<span class="oauth2-2legged__cta-eyebrow">MANAGE</span>
<h2 class="oauth2-2legged__cta-title">Webhook 관리로 가기</h2>
<p class="oauth2-2legged__cta-desc">수신 URL·Secret·구독 이벤트를 등록하고 관리하세요.</p>
<span class="oauth2-2legged__cta-button">Webhook 관리 →</span>
</div>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
</a>
</div>
</div>
</section>
</th:block>
</body> </body>