Navigation

Pyeonggang Church

평강교회 웹 플랫폼 리뉴얼은 교회 소개와 방송, 찬양 같은 콘텐츠, 수련원 소개와 예약 신청, 관리자 게시물 운영을 한 Spring Boot 서버의 템플릿으로 제공하는 프로젝트입니다. 저는 디자이너 1명이 만든 GUI를 받아 퍼블리싱과 프론트엔드 전체를 맡았고 서버에서는 방송 콘텐츠 API와 예외, 검증 계층을 만들었습니다. 서버가 그리는 HTML 위에서 페이지별 스크립트와 공통 스타일을 어떻게 나누고 연결할지가 이 프로젝트에서 계속 붙잡은 문제였습니다.

핵심 기여

과제본인 기여확인한 동작
서버 템플릿과 프론트엔드 빌드의 연결초기 도구, 공통 레이아웃, 페이지 진입점 분리, 운영 빌드 설정공통 main 번들은 헤더와 푸터만 초기화하고 페이지 동작은 각 진입점에서 실행
여러 사람이 쓰는 하나의 CSSRSCSS 네이밍 규약 도입과 팀 공통 스타일 가이드 제공모든 페이지가 같은 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
링크평강교회 홈페이지

맡은 범위

문제와 해결

코드는 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 --> Page
e644550: 개발 서버는 Spring 서버로 요청을 넘긴다js
mode: 'development',
watch: true,
devServer: {
  static: {
    directory: path.join(__dirname, 'src/main/resources/static'),
  },
  compress: true,
  port: 9000,
  hot: true,
  proxy: {
    '/': 'http://localhost:8080',
  },
},
구현 코드: 헤더와 본문, 푸터를 나눈 공통 레이아웃
4e70f12: layouts/default.htmlhtml
<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["게시물 상세"]
    end
50b1812: 공통 진입점은 레이아웃만 맡는다ts
import "@style/main.scss";
import footer from "@layouts/footer";
import header from "@layouts/header";

header.init();
footer.init();
50b1812: 수련원 메인은 자기 진입점에서 초기화한다ts
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 목록을 한 커밋에서 함께 맞췄습니다.

7abcbd3: 운영 빌드의 압축과 외부 라이브러리js
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의 원형은 그 프로젝트에서 동료가 만든 것이고 저는 패키지와 예외를 이 저장소에 맞게 옮겼습니다.

205a0f7: 관리자 폼 제출은 서버에서 처리하고 목록으로 돌아간다java
@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";
}
205a0f7: 삭제는 표시와 수정자 기록으로 처리한다java
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["유튜브 기본 썸네일"]
01deaed: 두 가지 링크 형식에서 영상 ID를 꺼낸다java
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;
}
c023f0b, 4abf0ba: 썸네일이 없거나 깨지면 유튜브 썸네일로ts
$(".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자리까지만 받아 세 자리, 네 자리, 네 자리 사이에 하이픈을 넣어 정리하고 이름, 연락처, 두 가지 동의가 모두 채워졌을 때 다음 버튼을 열었습니다.

reservation-apply.ts: 신청자 정보의 완료 조건ts
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).

구현 코드: 모바일 캘린더의 범위 선택
calendar.ts: 시작일과 종료일 선택ts
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 클래스와 모듈 변수에 나뉘어 있어 이전 단계로 돌아갈 때 되돌릴 표시가 많았습니다.

결과와 근거

배운 것