Pyeonggang Church
평강교회 웹 플랫폼 리뉴얼은 교회 소개와 방송, 찬양 같은 콘텐츠, 수련원 소개와 예약 신청, 관리자 게시물 운영을 한 Spring Boot 서버의 템플릿으로 제공하는 프로젝트입니다. 저는 디자이너 1명이 만든 GUI를 받아 퍼블리싱과 프론트엔드 전체를 맡았고 서버에서는 방송 콘텐츠 API와 예외, 검증 계층을 만들었습니다. 서버가 그리는 HTML 위에서 페이지별 스크립트와 공통 스타일을 어떻게 나누고 연결할지가 이 프로젝트에서 계속 붙잡은 문제였습니다.
핵심 기여
| 과제 | 본인 기여 | 확인한 동작 |
|---|---|---|
| 서버 템플릿과 프론트엔드 빌드의 연결 | 초기 도구, 공통 레이아웃, 페이지 진입점 분리, 운영 빌드 설정 | 공통 main 번들은 헤더와 푸터만 초기화하고 페이지 동작은 각 진입점에서 실행 |
| 여러 사람이 쓰는 하나의 CSS | RSCSS 네이밍 규약 도입과 팀 공통 스타일 가이드 제공 | 모든 페이지가 같은 main.css를 쓰는 구조에서 클래스 이름과 적용 범위 기준 공유 |
| 콘텐츠 API와 입력 검증 | 방송 게시물 API, 결과 코드 예외, 검증 DSL 이식 | 이식한 검증 DSL을 동료들이 엔티티와 서비스 8개 파일에서 사용 |
| 유튜브 영상이 있는 상세 화면 | 영상 ID 추출, 오버레이 재생, 썸네일 대체 | 링크 하나로 임베드 주소와 대체 썸네일을 생성 |
| 조건이 이어지는 예약 신청 | 날짜와 인원, 호실, 신청자 정보의 세 단계와 모바일 캘린더 | 빈 조회 결과와 오류, 필수 입력과 동의를 단계별로 처리 |
개요
| 항목 | 내용 |
|---|---|
| 기간 | 2024.09 ~ 2024.12 |
| 역할 | 퍼블리싱과 프론트엔드, 방송 콘텐츠 API와 예외, 검증 계층 |
| 협업과 인원 | 개발자 5명, 디자이너 1명(GUI) |
| 기술 | TypeScript, Sass, RSCSS, Webpack, jQuery, Thymeleaf, Spring Boot, Spring Data JPA |
| 링크 | 평강교회 홈페이지 |
맡은 범위
- 본인 구현. ESLint와 Prettier, EditorConfig, 첫 Webpack 설정과 개발 서버 프록시, Thymeleaf 공통 레이아웃, 경로 별칭, 운영 빌드의 압축과 번들 분석. 헤더와 푸터, 홈 섹션, 방송과 찬양 상세의 영상 오버레이, 수련원 메인과 예약 신청, 캘린더, 관리자 메뉴와 게시물 관리 탭. 서버에서는 방송 게시물 등록과 수정, 삭제 API, 결과 코드 예외와 검증 계층, 유튜브 영상 ID 추출.
- 리딩과 협업. RSCSS 네이밍 규약을 도입하고 전체 팀원에게 공통 스타일 가이드를 제공했습니다. 디자이너의 GUI를 받아 퍼블리싱했고 GUI 수정 요청을 화면에 반영했습니다.
- 동료 구현. 진입점 폴더를 읽어 번들을 자동 구성하는 Webpack 설정과 이후의 설정 정리, 방송 목록의 URL 조회 조건과 부분 HTML 갱신, 예약 가능 호실 조회와 예약 저장 API, 공통 예외 처리기, 목록 썸네일 응답. 검증 DSL의 원형은 이전 프로젝트에서 동료가 만든 것입니다.
문제와 해결
코드는 homepage-application 저장소의 본인 커밋에서 발췌했습니다.
서버 템플릿 저장소에 프론트엔드 작업 환경을 세우기
저장소는 Spring Boot와 Thymeleaf로 시작했고 프론트엔드 빌드는 없었습니다. 화면은 서버가 템플릿으로 그리고 TypeScript와 Sass는 빌드해서 Spring의 정적 자원으로 내보내야 했습니다. 서버를 띄운 채로 스크립트와 스타일을 고치며 확인할 방법도 필요했습니다.
첫 커밋 e644550에서 ESLint, Prettier, EditorConfig와 Webpack을 구성했습니다. Webpack은 TypeScript를 static/js로, Sass를 하나의 main.css로 내보내고 개발 서버는 9000번 포트에서 모든 요청을 8080번의 Spring 서버로 넘겼습니다. 4e70f12에서는 Thymeleaf Layout Dialect를 넣어 공통 레이아웃을 만들었습니다. 각 페이지 템플릿은 layout:decorate로 이 레이아웃을 지정하고 본문 조각만 채웁니다. 8a7596c에서는 TypeScript와 Sass 원본을 src/main 밖의 typescript/, style/로 옮겨 빌드 원본과 서버가 내보내는 결과물을 나눴고 이후 @layouts, @page 같은 경로 별칭을 더했습니다.
flowchart LR
Source["typescript/, style/"] --> Webpack["Webpack 빌드"]
Webpack --> Static["static/js, static/css/main.css"]
Layout["Thymeleaf 공통 레이아웃"] --> Page["페이지 템플릿: layout:decorate"]
Static --> Page
Dev["개발 서버 9000"] -->|"프록시"| Spring["Spring Boot 8080"]
Spring --> Pagemode: 'development',
watch: true,
devServer: {
static: {
directory: path.join(__dirname, 'src/main/resources/static'),
},
compress: true,
port: 9000,
hot: true,
proxy: {
'/': 'http://localhost:8080',
},
},구현 코드: 헤더와 본문, 푸터를 나눈 공통 레이아웃
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title layout:fragment="title">홈페이지</title>
<link rel="stylesheet" th:href="@{/css/main.css}">
</head>
<body>
<header th:replace="~{layouts/header :: header}"></header>
<main layout:fragment="content"></main>
<footer th:replace="~{layouts/footer:: footer}"></footer>
<script th:src="@{/js/main.js}"></script>
</body>주석과 html 선언부는 생략했습니다.
이 구성으로 서버 개발자는 템플릿과 컨트롤러를, 저는 같은 저장소에서 스크립트와 스타일을 동시에 고칠 수 있었습니다. 다만 이 시점의 구조는 모든 페이지가 main.js 하나를 받는 형태였고 다음 절의 문제로 이어졌습니다.
모든 페이지 코드를 싣던 공통 번들을 페이지 진입점으로 나누기
화면이 늘면서 공통 진입점 main.ts가 헤더와 푸터뿐 아니라 홈, 소개, 방송, 수련원, 관리자 폼과 상세, 검색, 업로드, 캘린더까지 23개 모듈을 import하게 됐습니다. 어느 페이지를 열어도 다른 페이지의 코드가 함께 실리고 한 페이지의 초기화가 다른 페이지에 영향을 줄 수 있는 구조였습니다.
50b1812에서 main.ts에는 공통 스타일과 헤더, 푸터 초기화만 남기고 수련원 메인을 별도 진입점으로 분리했습니다. 수련원 레이아웃에 몰려 있던 라이브러리 CDN 태그도 정리했습니다. 같은 날 동료가 typescript/entry 폴더의 파일을 읽어 진입점을 자동으로 구성하는 Webpack 설정을 넣었고 이후 화면은 진입점 파일 하나를 추가하는 방식으로 늘었습니다.
flowchart LR
subgraph Before["분리 전"]
M1["main.ts: 23개 모듈 import"] --> All["모든 페이지가 같은 main.js"]
end
subgraph After["분리 후"]
M2["main.ts: 스타일, 헤더, 푸터"] --> Common["공통 main.js"]
E1["entry/center-home.ts"] --> P1["수련원 페이지"]
E2["entry/reservation-apply.ts"] --> P2["예약 신청 페이지"]
E3["entry/post-detail.ts"] --> P3["게시물 상세"]
endimport "@style/main.scss";
import footer from "@layouts/footer";
import header from "@layouts/header";
header.init();
footer.init();import centerHome from "@page/center-home";
centerHome.init();스크립트와 달리 스타일은 계속 main.css 하나로 묶었습니다. 동료가 진입점별로 생기는 CSS 파일을 빌드 뒤에 지우는 플러그인을 넣어 템플릿은 main.css만 참조합니다. 스크립트는 페이지별로 나뉘었지만 스타일은 전역 CSS 하나로 남았기 때문에 클래스 이름의 충돌은 빌드가 아니라 규약으로 다뤄야 했습니다. 자동 진입점 구성과 현재 Webpack 설정의 대부분은 동료가 작성했습니다.
운영 빌드에서 압축하고 번들 내용을 확인하기
초기 설정은 개발 모드 기준이라 운영 배포용 압축과 번들 구성을 확인할 수단이 없었습니다. 7abcbd3에서 운영 모드에 Terser와 CssMinimizer를 넣어 스크립트와 CSS를 압축하고 주석과 라이선스 추출 파일을 남기지 않게 했습니다. webpack-bundle-analyzer는 정적 리포트로만 만들고 빌드 때 브라우저를 열지 않도록 했습니다.
jQuery와 Swiper는 레이아웃에서 CDN으로 불러오고 Webpack에서는 externals로 빼 번들에 다시 포함되지 않게 했습니다. 같은 라이브러리를 CDN과 번들이 이중으로 싣지 않도록 레이아웃의 script 태그와 externals 목록을 한 커밋에서 함께 맞췄습니다.
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin({
extractComments: false,
terserOptions: {
format: {
comments: false,
},
},
}),
],
},
mode: "production",
externals: {
jquery: "jQuery",
swiper: "Swiper",
},이 설정은 이후 동료가 Webpack 설정을 다시 정리할 때도 유지됐고 externals에는 daterangepicker, lodash, DataTables가 더해졌습니다. 압축 전후의 번들 크기나 로딩 시간은 기록하지 않았기 때문에 개선 수치는 쓰지 않았습니다.
RSCSS 네이밍 규약과 팀 공통 스타일 가이드
첫 Webpack 설정부터 홈, 소개, 방송, 찬양, 선교, 수련원, 관리자 화면의 스타일은 모두 main.css 하나로 모였습니다. 여러 사람이 각자 화면의 클래스를 만들면 다른 페이지의 같은 이름이 서로 덮어쓸 수 있었습니다. 서버 템플릿이 클래스 이름을 그대로 쓰는 구조라 이름 자체로 적용 범위를 구분하는 규칙이 필요했습니다.
그래서 RSCSS를 팀의 네이밍 규약으로 도입했습니다. RSCSS는 두 단어 이상의 이름을 가진 컴포넌트, 한 단어 이름의 요소, 대시로 시작하는 변형으로 클래스를 나누고 요소는 직접 자식 선택자로 범위를 좁히도록 권합니다. 저는 컴포넌트와 요소, 변형별 클래스 규칙과 스타일 적용 범위를 정리한 공통 스타일 가이드를 전체 팀원에게 제공했습니다.
flowchart TD
Page["페이지 템플릿"] --> Component["컴포넌트: 두 단어 이상의 이름"]
Component --> Element["요소: 컴포넌트 안의 한 단어 이름"]
Component --> Variant["변형: -로 시작하는 수식 클래스"]
Element --> Scope["적용 범위를 컴포넌트 안으로 제한"]다만 규약 준수를 검사하는 도구는 두지 않았습니다. 지금 Sass에는 페이지나 컴포넌트 이름을 앞에 붙인 클래스와 하위 선택자 중첩이 대부분이고 직접 자식 선택자는 라이브러리 스타일을 덮는 몇 곳에만 있습니다. 규약의 도입과 가이드 공유는 저장소에 문서로 남아 있지 않아 팀 안에서 공유한 사실로만 적었고 스타일 충돌이 얼마나 줄었는지도 측정하지 않았습니다.
방송 콘텐츠 API와 결과 코드 예외, 검증 계층
서버는 게시물 종류마다 컨트롤러와 서비스, 엔티티를 동료들과 나눠 만들었습니다. 저는 205a0f7에서 방송 게시물의 등록과 수정, 삭제 API를 만들면서 팀이 함께 쓸 예외와 검증 계층을 같이 넣었습니다.
예외는 결과 코드 열거형과 그 코드를 담는 PyungkangException, 필수값 누락과 길이 초과, 형식 오류 같은 하위 예외로 구성했습니다. 직전 프로젝트인 ARTIST PROP의 API에서 쓰던 구조를 서버 렌더링 프로젝트로 옮겨 왔습니다. 검증은 validateThat(값, 이름).requiredParam().loeLength(60)처럼 값을 이어서 검사하는 DSL을 이식했습니다. 이 DSL의 원형은 그 프로젝트에서 동료가 만든 것이고 저는 패키지와 예외를 이 저장소에 맞게 옮겼습니다.
@PostMapping("/new")
public String createBroadcastPost(
@RequestAttribute("userId") Long userId,
@ModelAttribute BroadcastPostRegisterRequest registerRequest,
BindingResult result
) {
if (result.hasErrors()) {
return "pages/broadcast/broadcast-form";
}
broadcastService.registerBroadcastPost(userId, registerRequest);
return "redirect:/broadcast";
}public void remove(Long removerId) {
this.removed = true;
super.modifierId = removerId;
super.modifiedDateTime = LocalDateTime.now();
}이식한 검증 DSL은 이후 동료들이 방송, 찬양, 선교, 평강의 삶 게시물과 교역자, 메인 이미지 엔티티, 예약 서비스까지 8개 파일의 생성과 수정 경계에서 56곳에 사용했습니다. 서버 전체 설계를 제 기여로 묶지는 않았습니다. 제 코드에도 고칠 점이 있었습니다. 첫 구현은 게시물을 찾지 못했을 때 결과 코드 예외 대신 RuntimeException을 던져 같은 커밋에 넣은 예외 체계를 스스로 비껴갔습니다.
유튜브 링크 하나로 썸네일과 재생기를 만들기
방송과 찬양 게시물은 운영자가 유튜브 링크를 입력합니다. 상세 화면은 그 링크로 임베드 재생기를 띄우고 재생 전에는 디자인에 맞춘 썸네일과 재생 버튼을 덮어 보여 줘야 했습니다. 업로드한 썸네일이 없거나 깨지는 경우도 있었습니다.
01deaed에서 링크의 v= 값이나 youtu.be/ 뒤의 값을 영상 ID로 꺼내는 함수를 만들고 상세 컨트롤러가 그 ID를 모델에 담게 했습니다. 템플릿은 ID로 enablejsapi=1이 붙은 임베드 주소를 만들고 인라인 스크립트로 같은 ID를 페이지 스크립트에 넘깁니다. c023f0b에서는 방송과 찬양 상세가 하나의 진입점을 함께 쓰도록 합치고 YouTube IFrame API로 재생기를 잡아 오버레이를 누르면 재생하게 했습니다. 재생기가 아직 준비되지 않았으면 초기화한 뒤 100ms 간격으로 준비 여부를 확인해 재생했습니다. 같은 커밋에서 썸네일을 불러오지 못하면 유튜브 기본 썸네일로 바꾸게 했고 4abf0ba에서 업로드 썸네일이 빈 경우도 같은 썸네일을 쓰게 했습니다.
flowchart LR
Url["운영자가 입력한 유튜브 링크"] --> Id["서버: 영상 ID 추출"]
Id --> Embed["템플릿: 임베드 주소와 videoId"]
Embed --> Overlay["상세 진입점: 썸네일 오버레이"]
Overlay -->|"클릭"| Play["준비되면 재생, 아니면 초기화 후 대기"]
Overlay -->|"썸네일 없음, 오류"| Fallback["유튜브 기본 썸네일"]public static String extractYouTubeVideoId(String youtubeUrl) {
String videoId = "";
try {
if (youtubeUrl.contains("v=")) {
String[] parts = youtubeUrl.split("v=");
if (parts.length > 1) {
videoId = parts[1].split("&")[0];
}
} else if (youtubeUrl.contains("youtu.be/")) {
videoId = youtubeUrl.substring(youtubeUrl.lastIndexOf("/") + 1);
}
} catch (Exception e) {
e.printStackTrace();
}
return videoId;
}$(".video-overlay-thumbnail").each((_index, element) => {
const $element = $(element);
const src = $element.attr("src");
if (src === "") $element.attr("src", `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`);
});
$(".video-overlay-thumbnail").on("error", (event) => {
const target = event.currentTarget as HTMLImageElement;
target.src = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
});youtu.be 링크 뒤에 붙는 query를 잘라 내는 처리는 동료가 나중에 더했고 목록 카드의 유튜브 썸네일도 동료가 같은 함수로 만들었습니다. 쇼츠나 임베드 주소 같은 다른 형식은 빈 ID가 되고 준비 여부를 확인하는 interval에는 시간 상한이 없습니다. 지금 다시 만든다면 링크 검증 단계에서 형식을 막고 대기에 상한을 두겠습니다.
수련원 예약 신청을 세 단계로 나누기
수련원 예약은 등록 교인만 신청할 수 있다는 안내로 시작합니다. 사용자는 날짜와 인원을 고르고 그 조건에서 가능한 호실을 조회한 뒤 호실을 골라 이름, 연락처, 교구, 동의 항목을 입력합니다. 앞 단계의 선택이 뒤 단계의 조회 조건이 되고 조회 결과가 없거나 서버 오류가 나는 경우도 화면에서 설명해야 했습니다.
화면을 날짜와 인원, 호실 선택, 신청자 정보의 세 단계로 나누고 각 단계의 완료 조건이 다음 버튼을 열게 했습니다. 인원은 2명에서 12명 사이로 제한했습니다. 날짜는 화면에 보여 줄 M월 D일 형식과 API에 보낼 YYYY-MM-DD 형식을 함께 저장했습니다. 데스크톱은 daterangepicker를 쓰고 모바일은 12개월을 세로로 이어 붙인 캘린더 컴포넌트를 직접 만들었는데 두 입력 모두 같은 상태 변수에 값을 씁니다. 호실 조회 결과가 비면 조건을 다시 고르는 버튼을 보여 주고 요청이 실패하면 토스트를 띄운 뒤 첫 단계로 돌려보냈습니다.
flowchart LR
Notice["등록 교인 안내"] --> Dates["날짜와 인원: 2명에서 12명"]
Dates --> Fetch["가능한 호실 조회"]
Fetch -->|"결과 없음"| Dates
Fetch -->|"서버 오류: 토스트"| Dates
Fetch --> Room["호실 선택"]
Room --> Form["이름, 연락처, 교구, 동의"]
Form --> Submit["예약 신청과 완료 화면"]신청자 정보 단계에서는 연락처를 숫자 11자리까지만 받아 세 자리, 네 자리, 네 자리 사이에 하이픈을 넣어 정리하고 이름, 연락처, 두 가지 동의가 모두 채워졌을 때 다음 버튼을 열었습니다.
const validForm = (): void => {
const isNameFilled = ($nameInput.val() as string).trim() !== "";
const isPhoneFilled = ($phoneInput.val() as string).trim() !== "";
const isAgreeCollectPhoneChecked = $agreeCollectPhoneCheckbox.prop("checked");
const isAgreeGuidelinesChecked = $agreeGuidelines.prop("checked");
const fulfilled = isNameFilled && isPhoneFilled && isAgreeCollectPhoneChecked && isAgreeGuidelinesChecked;
if (fulfilled) {
$optionFormNextBtn.removeClass("disabled");
} else {
$optionFormNextBtn.addClass("disabled");
}
};모바일 캘린더는 첫 클릭을 시작일, 다음 클릭을 종료일로 받고 종료일이 시작일보다 앞이면 그 날짜를 새 시작일로 삼았습니다. 지난 날짜는 막았는데 셀 날짜를 현재 시각과 비교하면 오늘 0시도 지난 날짜로 판정되기 때문에 오늘 셀은 따로 다시 선택 가능하게 했습니다(4774bcb, 3eacf84).
구현 코드: 모바일 캘린더의 범위 선택
const handleDateClick = (clickedDate: Date) => {
if (!startDate || (startDate && endDate)) {
startDate = clickedDate;
endDate = null;
} else if (startDate && !endDate) {
if (clickedDate >= startDate) {
endDate = clickedDate;
} else {
startDate = clickedDate;
endDate = null;
}
}
if (startDate && endDate) {
onDateRangeSelect(startDate, endDate);
}
updateCalendarUI();
};원문의 줄 끝 주석은 생략했습니다.
예약 가능 호실 조회와 예약 저장 API는 동료가 만들었고 저는 화면의 단계 흐름과 입력을 맡았습니다. 화면의 필수값 확인을 서버의 예약 충돌 방지로 설명하지 않았습니다. 다시 읽어 보니 신청 응답의 성공 판정이 response.code === 200 || response.body !== null처럼 ||로 묶여 있어 오류 코드와 본문이 함께 오면 오류 토스트와 완료 화면이 같이 뜰 수 있었습니다. 단계 상태도 DOM 클래스와 모듈 변수에 나뉘어 있어 이전 단계로 돌아갈 때 되돌릴 표시가 많았습니다.
결과와 근거
- 서버 템플릿 저장소에 프론트엔드 빌드와 공통 레이아웃을 세우고 페이지 진입점과 운영 빌드 설정을 정리했습니다. 근거는
e644550,4e70f12,8a7596c,50b1812,7abcbd3과 현재webpack.config.js입니다. - 방송 콘텐츠 API와 결과 코드 예외, 검증 계층은
205a0f7, 검증 DSL의 사용처는 현재 코드의git blame으로 확인했습니다. - 유튜브 영상 처리는
01deaed,c023f0b,4abf0ba, 예약 신청과 캘린더는reservation-apply.ts,calendar.ts와f64ff1c,0e11435,4774bcb,3eacf84입니다. - RSCSS 규약 도입과 팀 스타일 가이드 공유는 저장소에 문서가 없어 팀에서 공유한 사실로 적었습니다.
- 이후 배포 기록은 있지만 주요 구축 기간은 2024.09 ~ 2024.12로 적었습니다. 예약 전환율이나 운영 지표는 측정한 값이 없습니다.
배운 것
- CSS 규약은 가이드로 공유하는 것만으로는 코드에 끝까지 남지 않았습니다. 이후 SK AX GAS Position Play에서는 CSS 적용 범위와 레이어 접두사를 stylelint 규칙으로 검사하도록 바꿨습니다.
- 이전 프로젝트의 예외와 검증 형태를 코드로 옮기자 팀원들이 엔티티 생성 메서드 첫 줄에 검증을 붙이는 방식으로 바로 썼습니다. 같은 프로젝트에서 문서로 공유한 CSS 규약과 비교하면 공통 규칙은 바로 호출할 수 있는 코드로 줄 때 더 잘 지켜졌습니다.
- jQuery로 만든 단계 흐름은 상태가 DOM 표시와 변수에 흩어지기 쉬웠습니다. 단계와 선택값을 하나의 상태로 모으고 화면은 그 상태를 그리게 했다면 되돌아가기와 오류 처리가 단순했을 것입니다.