실습 로그 / MiCAL · 학사·학과·장학금 일정 통합 캘린더

MiCAL 2단계 · 백엔드 배포와 데이터 적재

(진행 중) 1단계에서 만든 서버 위에 Django 백엔드(회원가입·로그인, 일정 API)와 PostgreSQL을 실제로 띄우고, 팀원 크롤러가 모은 학사·장학금·공모전·비교과 데이터를 DB에 넣는다. 팀원 중 서버가 필요한 사람에게만 계정을 준다.

진행 과정은 실습 내용을 AI와 함께 정리했고, 배운 점은 그걸 보고 제가 이해한 대로 직접 썼습니다. 배운 점 바로가기 ↓

DjangoPostgreSQLDocker ComposeNginx트러블슈팅진행 중

목표

1단계에서 만든 서버 위에 Django 백엔드(회원가입·로그인, 일정 API)와 PostgreSQL을 실제로 띄우고, 팀원 크롤러가 모은 학사·장학금·공모전·비교과 데이터를 DB에 넣는다. 팀원 중 서버가 필요한 사람에게만 계정을 준다. (11/2 진행 회의 1 준비 사항)

A. 백엔드 뼈대와 서버 배포

  • Django 5.2 + Django REST Framework로 백엔드 뼈대를 만듦 (backend/, PR #2)
    • config/ 설정·주소 연결, accounts/ 회원, events/ 일정
    • DRF: Django로 JSON API를 쉽게 만들게 해주는 추가 도구
  • 일정 테이블 Event는 docs/data-format.md와 1:1로 맞춤. 팀원이 추가한 description(내용)도 포함
    • owner가 비어 있으면 모두가 보는 공식 일정, 차 있으면 그 사람만 보는 개인 일정
    • 중복 방지 규칙 2개: 공식 일정은 source + external_id, 개인·런어스 일정은 owner + source + external_id
    • 부분 유니크 제약: 조건에 맞는 행끼리만 중복을 막는 DB 규칙. 같은 과제가 사람마다 따로 있어도 충돌하지 않음
  • API
    • POST /api/auth/signup/ · login/ · logout/, GET /api/auth/me/
    • GET /api/events/?start=&end=&source=&department= 공식 일정 + 내 개인 일정
    • POST / PATCH / DELETE /api/events/ 내 개인 일정만. 남의 일정이나 공식 일정을 지우려면 403
    • GET /api/health/ 서버 살아 있는지 확인용
  • 로그인 정보 관리
    • 비밀번호는 원문이 아니라 해시(PBKDF2)로만 저장. 8자 이상, 흔한 비번·숫자만인 비번 거절
    • 해시: 원래 값으로 되돌릴 수 없게 바꾼 값. DB가 유출돼도 비번 원문은 안 나옴
    • 토큰 인증: 로그인하면 긴 문자열(토큰)을 받고, 이후 요청마다 헤더에 붙여서 본인임을 증명
    • DB 비번·DJANGO_SECRET_KEY는 서버 .env에만. 깃에는 이름만 적은 .env.example
    • 런어스는 아이디·비번을 받지 않음 (iCal URL 방식, 차주 논의)
  • 테스트 8개 작성 → 통과 (해시 저장, 약한 비번 거절, 남의 개인 일정 안 보임, 공식 일정 삭제 막힘, import 중복 없음 등)
  • docker-compose.yml에 web(Django) 컨테이너 추가
    • 컨테이너가 켜질 때마다 migrate → collectstatic → gunicorn 순서로 실행 (entrypoint.sh)
    • gunicorn: 파이썬 웹 앱을 실제 서비스로 돌려주는 서버 프로그램
    • migrate: 코드에 적은 테이블 구조를 실제 DB에 반영하는 명령
    • db에 healthcheck를 넣고 web은 db가 준비된 뒤에 켜지게 함
    • healthcheck: 컨테이너가 진짜 일할 수 있는 상태인지 주기적으로 확인하는 검사
  • nginx 설정(infra/nginx/default.conf): /api/, /admin/, /django-static/은 Django로, 나머지는 프론트 정적 파일
    • 리버스 프록시: 앞에서 요청을 받아 주소에 따라 뒤쪽 서버로 나눠 보내는 역할
    • 화면과 API가 같은 주소(calendar.ihaewon.com)라 CORS 설정이 필요 없음
    • CORS: 다른 주소의 화면이 내 API를 부르는 걸 브라우저가 막는 보안 규칙
  • VM에서 배포: .env 작성 → docker compose up -d --build → 컨테이너 3개(db healthy, web, frontend) 확인
campus-calendar-db-1         postgres:16           Up (healthy)   5432/tcp
campus-calendar-frontend-1   nginx:alpine          Up             127.0.0.1:8080->80/tcp
campus-calendar-web-1        campus-calendar-web   Up             8000/tcp
  • curl -i localhost:8080/api/health/ → 200 OK, {"status": "ok"}
    • 띄운 직후 첫 curl은 Connection reset (트러블슈팅 3번 참고)
HTTP/1.1 200 OK
Server: nginx/1.31.8
Content-Type: application/json

{"status": "ok"}
  • createsuperuser로 관리자 계정 생성 → https://calendar.ihaewon.com/admin/ 로그인 확인
    • 관리자 아이디는 admin을 피함. 공격하는 쪽이 가장 먼저 넣어보는 이름이라
  • 관리자 페이지에 인증 및 권한(그룹·사용자), 인증 토큰, 일정(Events) 메뉴가 보임

B. 팀원 서버 계정

  • 서버 할당 기준: 서버에서 직접 무엇을 돌려야 하는 사람만 계정을 줌
    • 크롤링 담당 팀원: 크롤러 실행과 데이터 적재 때문에 계정 발급
    • 나머지 팀원(PM·UI·UX): 서버 접속 없이 GitHub으로만 작업
  • calendar VM에 팀원 계정 teammate 생성
    • adduser --disabled-password: 비밀번호 로그인 없이 SSH 키로만 들어오게 함
    • docker 그룹에 추가 → docker compose exec로 데이터 넣기를 직접 할 수 있음
  • 팀원 공개키는 커리어 내비(careernavi) VM에 등록돼 있던 것을 그대로 가져와 authorized_keys에 넣음
    • 공개키는 공개해도 되는 열쇠라 같은 사람이면 서버 여러 대에 같은 키를 등록해도 됨
    • ssh 서버 "sudo ..."처럼 한 줄로 sudo를 보내면 비번 입력창이 없어 실패 → ssh -t로 터미널을 붙여서 해결
    • -t: 원격 명령에 입력창(터미널)을 붙여주는 옵션
  • id teammate으로 docker 그룹 포함 확인
uid=1001(teammate) gid=1001(teammate) groups=1001(teammate),100(users),983(docker)
  • Tailscale에서 calendar 기기를 팀원에게 공유 (팀원은 이 VM에만 접근)
  • 맥북 키도 calendar VM에 등록돼 있음을 확인 (비번 없이 접속)

C. 크롤링 데이터 적재

  • PR #1(팀원 크롤러) → PR #2(백엔드) 순서로 main에 합침
    • .gitignore 한 줄 충돌 → 팀원 쪽 주석으로 맞추고 테스트 다시 돌려 통과 확인
    • GitHub 무료 요금제는 Private 저장소에 main 브랜치 보호를 못 걸어서 "main 직접 push 금지"는 팀 약속으로 지킴
  • VM을 main으로 맞추고 다시 빌드
  • 크롤러는 VM에 파이썬 가상환경(~/crawler-venv)을 따로 만들어 실행
    • 가상환경(venv): 프로젝트별로 패키지를 따로 설치하는 공간. 서버 전체 파이썬을 건드리지 않음
  • python -m crawler.run --since 2026-09-01 (2학기 것만)
    • 경영학부 공지 9, With YONSEI 비교과 267, 학교 학사일정 168, 장학금(학생복지처) 31, 공모전 5
    • 소프트웨어학부 공지만 SSL 오류로 실패 (트러블슈팅 2번 참고)
    • 결과 파일 저장에서 권한 오류 (트러블슈팅 1번 참고)
  • docker compose exec web python manage.py import_events /crawler/output로 DB에 적재
    • source + external_id가 같으면 덮어쓰기(upsert)라 여러 번 돌려도 중복 없음
    • 런어스는 사람별 데이터라 이 명령에서는 건너뜀
  • 폴더 권한 고친 뒤 다시 실행 → 새로 740건 적재 (건너뜀 0)
    • 학교 학사일정 168, 경영학부 6, 장학금 28, 공모전 24, 비교과 514
    • 수집 건수와 일정 건수가 다른 이유: 날짜를 못 찾은 공지는 빠지고(경영 9→6, 장학금 31→28), 비교과·공모전은 글 하나에 [신청] 일정과 운영 일정 2개가 나옴(비교과 257→514)
  • 소프트웨어학부는 중간 인증서 등록 후 curl로 200 OK 확인. 크롤러 재실행 → 소프트웨어학부 4건 수집, 일정 2건 (트러블슈팅 2번)
  • 재실행 import 결과: 새로 2 / 갱신 740 / 건너뜀 0 → 이미 있던 740건은 새로 안 생기고 덮어써짐. upsert가 설계대로 동작하는 것 확인
  • 최종 DB 일정 742건 (학사 168, 소웨 2, 경영 6, 장학금 28, 공모전 24, 비교과 514)
합계: 새로 2 / 갱신 740 / 건너뜀 0   (소웨 추가 후 재실행)

D. 비밀키 교체와 API 연결 데모

  • Django 비밀키 새로 발급
    • 이유: 작업 로그에 키가 그대로 찍혔음
    • 새 키를 화면에 출력하지 않고 .env에 바로 넣음
    • 비밀키(SECRET_KEY): Django가 로그인 세션·서명에 쓰는 값. 새면 다른 사람이 서명을 위조할 수 있음
sed -i "s|^DJANGO_SECRET_KEY=.*|DJANGO_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')|" .env
docker compose up -d --force-recreate web
grep -c '^DJANGO_SECRET_KEY=.\+' .env    # 1
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/api/health/   # 200
  • --force-recreate: 설정이 바뀌었으니 컨테이너를 새로 만들어서 새 키를 읽게 함
  • API 연결 데모 페이지 추가 (frontend/api-demo.html, PR #3)
    • 주소: calendar.ihaewon.com/api-demo.html
    • 디자인 시안(calendar-a~d)은 프론트 담당 파일이라 건드리지 않고, 별도 페이지로 연결 방법만 보여줌
    • 화면: 종류별 건수 카드(누르면 켜고 끔), 월간 달력(시작일·마감일 구분), 날짜별 상세, 요청·응답 예시
    • 같은 도메인에서 /api/events/를 부르기 때문에 CORS 설정이 필요 없음
  • 반영: VM에서 git pull만 하면 됨 (nginx가 frontend 폴더를 그대로 읽음)

트러블슈팅

1. 크롤러 결과 저장 실패 (Permission denied)

  • 증상 : 크롤링은 끝났는데 저장할 때 PermissionError: [Errno 13] Permission denied: '.../crawler/output/academic_school.json'. DB에는 0건
  • 확인한 것 : crawler/output 폴더가 root 소유
  • 원인 : docker-compose.yml에서 이 폴더를 web 컨테이너에 연결(bind mount)해두었는데, 폴더가 아직 없는 상태에서 docker compose up을 하자 Docker가 root 권한으로 폴더를 만듦. haewonlee 계정은 거기에 파일을 못 씀
    • bind mount: 서버의 폴더를 컨테이너 안에 그대로 보이게 연결하는 것
  • 해결 : sudo chown -R haewonlee:haewonlee crawler/output 후 크롤러 다시 실행 → 저장 성공, DB에 740건 적재
  • 재발 방지 : bind mount할 폴더는 docker compose up 전에 내 계정으로 미리 만든다 (mkdir -p crawler/output)

2. 소프트웨어학부 공지만 SSL 오류

  • 증상 : [소프트웨어학부 공지] ... 실패, SSLCertVerificationError: certificate verify failed: unable to get local issuer certificate (sw.yonsei.ac.kr)
  • 확인한 것 : 같은 서버에서 다른 출처 5개(경영학부, With YONSEI, 학사일정, 학생복지처, 대학 공지)는 성공. 팀원 컴퓨터(윈도우)에서는 소웨도 수집됐음
  • 원인 : sw.yonsei.ac.kr 서버가 HTTPS 인증서를 보낼 때 중간 인증서를 빠뜨림. 윈도우·맥은 빠진 인증서를 알아서 받아오지만 리눅스 서버는 안 받아와서 신뢰 체인이 끊김
    • 인증서 체인: 사이트 인증서 → 중간 인증서 → 최상위 인증서 순서로 이어지는 신뢰 고리. 가운데가 빠지면 확인 실패
  • 해결 : 사이트 인증서에 적힌 주소(AIA)에서 중간 인증서를 받아 서버 신뢰 목록에 등록 (/usr/local/share/ca-certificates/ → update-ca-certificates)
    • 빠진 중간 인증서: Sectigo Public Server Authentication CA DV R36 (http://crt.sectigo.com/SectigoPublicServerAuthenticationCADVR36.crt)
    • DER 형식으로 받아서 PEM으로 변환 후 등록 → 1 added
    • curl -sI https://sw.yonsei.ac.kr/notice → HTTP/1.1 200 OK 확인
    • DER / PEM: 인증서를 저장하는 두 형식. DER은 바이너리, PEM은 글자(BEGIN CERTIFICATE)로 된 것. 리눅스 신뢰 목록은 PEM을 씀
    • AIA: 인증서 안에 "내 상위 인증서는 여기서 받으세요"라고 적힌 주소
    • 인증서 확인을 끄는 방법(verify=False)은 쓰지 않음. 가짜 사이트에 연결돼도 모르는 보안 구멍이라
  • 재발 방지 : 내 컴퓨터에서 되던 크롤러가 서버에서 안 되면 인증서 체인부터 확인한다 (openssl s_client -connect 도메인:443)

3. 컨테이너를 띄운 직후 curl이 Connection reset

  • 증상 : docker compose up -d 바로 뒤 curl localhost:8080/... → Recv failure: Connection reset by peer
  • 확인한 것 : docker compose logs web에서 migrate·collectstatic이 끝난 뒤에 gunicorn이 켜짐. 몇 초 뒤 다시 치니 200 OK
  • 원인 : 컨테이너는 "Started"인데 안의 프로그램은 아직 준비 중이었음
  • 해결 : 기다렸다가 다시 확인
  • 재발 방지 : 배포 직후 확인은 로그에서 Listening at을 본 뒤에 한다. 응답 내용까지 보려면 curl -i

남은 작업

  • 소프트웨어학부 공지 SSL 해결 (트러블슈팅 2번)
  • 크롤러 다시 돌려 소프트웨어학부 공지 추가 (최종 742건)
  • .env 비밀키 새로 발급 (작업 로그에 노출됨, D 참고)
  • 크롤러 주기 실행 (cron) 방식 팀원과 결정

배운 점

직접 작성

A. 백엔드 뼈대와 서버 배포

  • 목적
    • 화면이 일정을 달라고 하면 DB에서 꺼내 주는 서버 (Django)와 DB (PostgreSQL)를 띄운다
  • 설명
    • 컨테이너 3개로 나눔 (docker-compose.yml)

      db:         # PostgreSQL. 일정·회원 정보 저장. 외부 포트 없음
      web:        # Django. 요청 받아서 DB 조회 후 JSON으로 응답
      frontend:   # nginx. 화면 파일 주고, /api/ 요청은 web으로 넘김
      
      restart: unless-stopped          # 서버 재부팅돼도 자동으로 다시 켜짐
      depends_on:
        db:
          condition: service_healthy   # DB가 준비된 뒤에 Django 켜기
      
      • 각자 역할이 달라서 따로 띄움
      • 컨테이너끼리는 이름으로 부름 (web:8000, db)
      • docker compose : 컨테이너 여러 개를 파일 하나로 묶어서 한 번에 키고 끄는 도구
      • healthcheck : 컨테이너 가 진짜 준비됐는지 주기적으로 확인
    • nginx가 주소 보고 길을 나눔 (infra/nginx/default.conf)

      location ~ ^/(api|admin|django-static)/ {
          proxy_pass http://web:8000;   # Django로
      }
      location / {
          try_files $uri $uri/ =404;    # 화면 파일
      }
      
      • /api/로 시작하면 장고로 보내고 나머지는 화면 파일을 줌
      • 주소 하나 (calendar.ihaewon.com)에 화면과 API가 같이 있음
      • proxy_pass : 받은 요청을 다른 서버로 넘겨주는 nginx 설정
      • API : 화면이 서버에 데이터를 달라고 하는 창구 (주소 + 규칙)
    • 일정 테이블 구조 (events/models.py)

      class Event(models.Model):
          owner = ForeignKey(User, null=True)  # 비면 공식 일정
          source = CharField()       # 어디서 온 일정
          external_id = CharField()  # 원래 사이트 글 번호
          title = CharField()
          start_at = DateTimeField()
          end_at = DateTimeField(null=True)    # 마감 없어도 됨
          raw = JSONField()          # 크롤링 원본
      
      UniqueConstraint(
          fields=["source", "external_id"],   # 둘 다 같으면 같은 일정
          condition=Q(owner__isnull=True),    # 공식 일정에만
      )