# PRO-SEJONGPEIA
> 세종대 개발에 필요한 모든 것을 한 번에. 학번 인증부터 학과·강의·학사일정·학식까지, Java · Python · Node.js 라이브러리.
Source: https://cassiiopeia.github.io/pro-sejongpeia/
---
# 소개
Source: https://cassiiopeia.github.io/pro-sejongpeia/start/introduction/
Section: 시작하기
pro-sejongpeia는 **세종대학교 통합 라이브러리**입니다. 학번과 비밀번호로 재학생인지 확인하는 **포털 로그인**과, 로그인 없이 학교가 공개로 내려주는 **학교 데이터**(학과 코드, 개설강좌, 학사일정, 기숙사 식단, 학식 등)를 Java, Python, Node.js 어느 쪽에서든 같은 기능, 같은 결과 필드, 같은 에러 코드로 씁니다.
포털 로그인, SSO 리다이렉트, 세션 쿠키, 오래된 TLS 방식, HTML·JSON 파싱은 라이브러리가 처리합니다.
> **한눈에**
> - **학생 인증**은 학번과 비밀번호가 필요하고, **학교 데이터**는 필요 없습니다.
> - Java, Python, Node.js가 같은 기능, 같은 결과 필드, 같은 에러 코드를 씁니다.
> - 학교 웹 화면이 쓰는 비공식 주소를 따라가므로, 학교가 바꾸면 멈출 수 있습니다.
## 동작 한눈에 보기
호출 한 번이 라이브러리를 거쳐 학교 서버로 가고, 응답은 같은 모양의 결과로 돌아옵니다.
> 다이어그램: 내 앱이 라이브러리를 한 번 호출하면 라이브러리가 캐시, TLS와 User-Agent 처리, 실패 격리를 맡아 학교 서버에 요청하고, 같은 모양의 결과로 돌려준다
## 누가 쓰나요
- 동아리, 학생회, 학내 커뮤니티의 **세종대 학생 인증**
- 학과·학년 조건이 있는 학내 이벤트 참여 확인
- 학과·강의 검색 페이지, 학사일정 위젯
- 기숙사 식단과 학식을 알려 주는 봇
## 무엇을 쓸 수 있나요
이름, 학과, 학년, 학적 상태, 고전독서 인증 현황, 연락처를 가져옵니다. 호출 한 번에 포털 로그인 한 번입니다. [인증 레퍼런스](https://cassiiopeia.github.io/pro-sejongpeia/auth/methods/)
학과·학기 코드, 개설강좌, 교육과정, 학사일정(학부·대학원), 기숙사, 학식, 캠퍼스 장소, 비교과 프로그램. [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)
결과 필드와 에러 코드는 세 언어에서 같고, 이름 표기만 언어 관례를 따릅니다. 공통 값은 `spec/contract.json` 한 곳에 있습니다.
공개 데이터는 한 곳이 실패해도 나머지에 영향이 없습니다. 실패하면 마지막 캐시 값이나 빈 목록을 돌려줍니다. [실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/)
## 데이터 출처
| 출처 | 도메인 | 제공 정보 | 로그인 |
|---|---|---|---|
| DHC (대양휴머니티칼리지) | classic.sejong.ac.kr | 학생 정보 + 고전독서 인증 현황 | 필요 |
| SIS (학사정보시스템) | sjpt.sejong.ac.kr | 학생 정보 + 이메일, 전화번호, 영문 이름, 학교 코드 | 필요 |
| SIS 공개 조회 | sjpt.sejong.ac.kr | 소속 분류, 학기, 학과, 개설강좌 | 불필요 |
| 학교 홈페이지 | www.sejong.ac.kr | 학사일정, 학식, 캠퍼스 장소, 교육과정 | 불필요 |
| 대학원 사이트 | `<사이트>.sejong.ac.kr` | 대학원 학사일정 | 불필요 |
| 기숙사 | happydorm.sejong.ac.kr | 기숙사 식단, 공지 | 불필요 |
| 두드림 | do.sejong.ac.kr | 비교과 프로그램 일정 | 불필요 |
## 알아 둘 점
- **세종대학교의 공식 제품이 아닙니다.** 세종대 학생이 만든 개인 오픈소스이며 학교와 제휴 관계가 없습니다.
- 학교가 문서로 공개한 API가 아니라 **학교 웹 화면이 쓰는 비공식 주소**를 따라갑니다. 학교가 바꾸면 동작이 멈출 수 있고, 매일 점검해서 깨지면 이슈로 고칩니다. 수정 시점에 대한 약속은 없습니다.
- **하위 호환을 보장하지 않습니다.** 같은 `1.x` 안에서도 깨지는 변경이 생길 수 있으니 버전을 정확히 고정하세요. [API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/)
- 학교 값은 **학교가 준 문자열 그대로**입니다. 코드표나 이름 매핑을 라이브러리가 들고 있지 않습니다.
## 요구 사항
| Java / Spring Boot | Python | Node.js |
|---|---|---|
| Java 17 이상, Spring Boot 3.x. 일반 Java도 가능 | Python 3.9 이상, 표준 라이브러리 외 의존성 없음 | Node 18 이상, 의존성 없음, TypeScript 타입 포함, ESM·CommonJS. 서버 전용 |
Java의 HTTP는 OkHttp, HTML 파싱은 jsoup을 씁니다.
## 어떤 문서부터 읽을까요
필요한 것을 고르면 맞는 가이드로 이어집니다. 상자를 누르면 그 문서로 갑니다.
> 다이어그램: 필요한 것에 따라 읽을 문서를 고른다. 학생 인증, 강의 검색, 학사일정, 기숙사와 학식, 그 밖의 학교 데이터
## 다음으로
- [빠른 시작](https://cassiiopeia.github.io/pro-sejongpeia/start/quickstart/): 설치하고 첫 호출까지 몇 분이면 됩니다.
- [학생 인증 붙이기](https://cassiiopeia.github.io/pro-sejongpeia/guides/student-verification/): 가입 폼에서 받은 학번과 비밀번호로 재학 여부를 확인합니다.
- [학교 데이터 레퍼런스](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/): 메서드, 요청, 결과 필드, 캐시와 실패 동작.
- [브라우저에서 바로 체험](https://cassiiopeia.github.io/pro-sejongpeia/#demo): 본인 포털 계정으로 실제 결과를 확인합니다.
---
# 빠른 시작
Source: https://cassiiopeia.github.io/pro-sejongpeia/start/quickstart/
Section: 시작하기
> **한눈에**
> 세 단계입니다. 설치, 학생 인증 호출(서버에서만), 로그인 없는 학교 데이터 호출. Spring 없이 쓰는 일반 Java는 맨 아래에 있습니다.
1. **설치합니다.** 버전은 정확히 고정하세요. 전체 목록은 [GitHub Releases](https://github.com/Cassiiopeia/pro-sejongpeia/releases)에 있습니다 ([API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/)).
**Java**
[](https://central.sonatype.com/artifact/kr.suhsaechan/sejongpeia)
Maven Central에서 배포하므로 저장소를 따로 추가하지 않습니다. Java 17 이상, Spring Boot 3.x.
```groovy
dependencies {
implementation "kr.suhsaechan:sejongpeia:"
}
```
```xml
kr.suhsaechan
sejongpeia
```
``에는 최신 버전을 넣으세요. [Maven Central 아티팩트 페이지](https://central.sonatype.com/artifact/kr.suhsaechan/sejongpeia)에서 확인하고, 전체 버전 목록은 [저장소 목록](https://repo1.maven.org/maven2/kr/suhsaechan/sejongpeia/)에 있습니다.
**Python**
Python 3.9 이상, 표준 라이브러리 외 의존성이 없습니다.
```bash
pip install pro-sejongpeia==
```
**Node.js**
Node 18 이상, 의존성 0개, TypeScript 타입 포함, ESM과 CommonJS 모두 지원합니다.
```bash
npm install pro-sejongpeia@
```
2. **학생 인증을 호출합니다.** 학번과 비밀번호로 통합 인증을 한 번 호출합니다.
**Java**
Spring Boot 자동 설정이 `SuhSejongAuthEngine` 빈을 등록합니다.
```java
@RestController
@RequiredArgsConstructor
public class AuthController {
private final SuhSejongAuthEngine sejongAuthEngine;
@PostMapping("/auth/sejong")
public SejongAuthResult authenticate(@RequestParam String studentId,
@RequestParam String password) {
return sejongAuthEngine.authenticate(studentId, password);
}
}
```
**Python**
```python
from sejongpeia import authenticate, SejongAuthError, ErrorCode
try:
result = authenticate("21011234", "비밀번호")
print(result.name, result.major, result.grade)
except SejongAuthError as error:
if error.code == ErrorCode.AUTHENTICATION_FAILED:
print("학번 또는 비밀번호가 틀렸습니다")
elif error.code == ErrorCode.ACCOUNT_LOCKED:
print("포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요")
```
**Node.js**
```js
import { authenticate, SejongAuthError, ErrorCode } from 'pro-sejongpeia';
try {
const result = await authenticate('21011234', '비밀번호');
console.log(result.name, result.major, result.grade);
} catch (error) {
if (error instanceof SejongAuthError && error.code === ErrorCode.AUTHENTICATION_FAILED) {
console.log('학번 또는 비밀번호가 틀렸습니다');
} else if (error instanceof SejongAuthError && error.code === ErrorCode.ACCOUNT_LOCKED) {
console.log('포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요');
}
}
```
분기는 에러 메시지가 아니라 **코드**로 합니다. 코드 목록은 [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)입니다.
3. **로그인 없이 쓰는 학교 데이터를 호출합니다.** 학과 목록을 받고, 첫 학과의 개설강좌를 조회합니다 (학부). `"20"`(학부)과 학기 값은 학교 값입니다.
**Java**
```java
SejongPublicData publicData = new SejongPublicData(); // Spring에서는 빈으로 주입
List departments = publicData.departments("20", "2026", "20");
List lectures = publicData.lectures("20", "2026", "20", departments.get(0).getCode(), "", "");
```
**Python**
```python
from sejongpeia import SejongPublicData
public_data = SejongPublicData()
departments = public_data.departments("20", "2026", "20") # 학부, 학기 값은 학교 값
lectures = public_data.lectures("20", "2026", "20", department_code=departments[0].code)
# 강의의 과목 번호로 그 학과 교육과정의 학년·학기·교과목 개요 (없으면 빈 목록)
courses = public_data.curriculum_courses(lectures[0].department_code, lectures[0].course_number)
```
**Node.js**
```js
import { SejongPublicData } from 'pro-sejongpeia';
const publicData = new SejongPublicData();
const departments = await publicData.departments({ categoryCode: '20', year: '2026', semesterCode: '20' });
const lectures = await publicData.lectures({ categoryCode: '20', year: '2026', semesterCode: '20', departmentCode: departments[0].code });
```
`SejongPublicData`는 **프로세스당 하나만** 만드세요. 메모리 캐시(기본 24시간)와 같은 요청 합치기가 인스턴스 안에 있습니다.
## 일반 Java (Spring 없이)
Spring Boot는 선택 의존성이라 일반 Java 프로젝트는 받지 않습니다. 포털이 허용하는 오래된 TLS 암호를 JVM이 막고 있어서, `main()` **맨 앞**에서 다른 HTTPS 호출보다 먼저 `SejongTlsSupport.ensureLegacyCipherEnabled()`를 불러야 합니다.
```java
import kr.suhsaechan.sejongpeia.auth.client.SejongPortalClient;
import kr.suhsaechan.sejongpeia.auth.client.SejongSisClient;
import kr.suhsaechan.sejongpeia.auth.model.SejongAuthResult;
import kr.suhsaechan.sejongpeia.auth.parser.SejongClassicReadingParser;
import kr.suhsaechan.sejongpeia.auth.parser.SejongSisParser;
import kr.suhsaechan.sejongpeia.auth.parser.SejongStudentInfoParser;
import kr.suhsaechan.sejongpeia.auth.service.SuhSejongAuthEngine;
import kr.suhsaechan.sejongpeia.common.config.SejongAuthProperties;
import kr.suhsaechan.sejongpeia.common.util.SejongTlsSupport;
SejongTlsSupport.ensureLegacyCipherEnabled(); // main() 맨 앞, 다른 HTTPS 호출보다 먼저
SejongAuthProperties properties = new SejongAuthProperties();
SuhSejongAuthEngine engine = new SuhSejongAuthEngine(new SejongPortalClient(properties), new SejongStudentInfoParser(),
new SejongClassicReadingParser(), new SejongSisClient(properties), new SejongSisParser());
SejongAuthResult result = engine.authenticate(studentId, password);
```
늦게 부르면 인증이 `CONNECTION_FAILED`와 원인 안내 메시지로 실패합니다. 이 설정은 JVM 전체에 적용됩니다. 자세한 이유는 [레거시 TLS](https://cassiiopeia.github.io/pro-sejongpeia/understand/legacy-tls/)를 보세요. Spring Boot는 시작할 때 자동으로 처리하고, Python과 Node는 자기 연결의 TLS 설정만 바꿉니다.
> **서버에서만 호출하세요**
> 브라우저나 앱에서 직접 부르면 비밀번호가 제3자 코드에 노출됩니다. Node 패키지도 서버 전용입니다. 틀린 비밀번호를 반복해서 보내면 포털이 계정을 잠그므로, 누구나 학번을 입력할 수 있는 폼이라면 학번별 실패 횟수를 제한하세요. [로그인 판정과 계정 잠금 방지](https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/)
> **빈 목록의 의미**
> 공개 데이터는 학교 서버가 실패하면 예외 없이 마지막 캐시 값이나 빈 목록을 돌려줍니다. 빈 목록은 「데이터 없음」일 수도 「학교 쪽 문제」일 수도 있으니, 화면에는 「불러오지 못했습니다」와 같은 안내를 함께 두세요.
## 다음으로
- [학생 인증 붙이기](https://cassiiopeia.github.io/pro-sejongpeia/guides/student-verification/): 동아리 가입 폼 예시와 주의할 점.
- [학과·강의 검색](https://cassiiopeia.github.io/pro-sejongpeia/guides/department-lecture-search/): 학과와 개설강좌를 JSON으로 내려주는 작은 서버.
- [인증 설정](https://cassiiopeia.github.io/pro-sejongpeia/auth/configuration/): 제한 시간, 재시도, 인증서 검증.
---
# 학생 인증 붙이기
Source: https://cassiiopeia.github.io/pro-sejongpeia/guides/student-verification/
Section: 가이드
가입 폼에서 받은 학번과 비밀번호로 재학 여부를 확인하고, 학번·이름·학과만 가져옵니다. 비밀번호는 저장하거나 로그에 남기지 않습니다.
> **예시 코드는 참고용입니다**
> 메서드 이름, 인자, 결과 필드, 에러 코드는 [RESULT_SCHEMA](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/RESULT_SCHEMA.md)를 기준으로 합니다. 실제 포털 계정이 있다면 레포의 `scripts/verify-live.sh`로 세 언어 결과를 비교할 수 있습니다 ([이슈와 PR](https://cassiiopeia.github.io/pro-sejongpeia/contribute/issues-and-prs/)).
## 인증 흐름
비밀번호가 틀리면 첫 실패에서 멈춥니다. 재시도가 없어서 라이브러리가 계정을 잠그지 않습니다.
> 다이어그램: 포털에 로그인한 뒤 응답 안의 판정값을 읽는다. 비밀번호가 틀리거나 계정이 잠겼으면 첫 실패에서 멈추고 에러 코드를 돌려주며, 성공이면 같은 세션으로 DHC와 SIS를 조회해 결과를 만든다
## 요청 파라미터
필드 이름은 Python `snake_case` 기준이고, Node.js와 Java는 `camelCase`입니다. 포털 로그인이 필요합니다.
| 메서드 | 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|---|
| `authenticate` | `student_id` | 문자열 | 예 | | 학번 |
| | `password` | 문자열 | 예 | | 포털 비밀번호. 저장하거나 로그에 남기지 않음 |
| | `include_raw` | boolean | 아니오 | `false` | 포털 원문(`rawHtml`, `rawJson`)도 받음 |
| | `include_extra` | boolean | 아니오 | `false` | 학교가 주는 나머지 SIS 필드를 `extra`로 받음 |
| `authenticate_dhc` | `student_id`, `password`, `include_raw` | | | | 학번·이름·학과·학년·학적 상태만 필요할 때 (연락처 조회 없이 더 가벼움) |
## 응답 데이터 구조
`authenticate`가 돌려주는 결과(위쪽)와 예시 함수가 가입 폼에 돌려주는 값(아래쪽)입니다. 필드 이름은 Python `snake_case`이고(`student_id`), Node.js와 Java는 `camelCase`(`studentId`, `getStudentId()`)입니다. 값은 형태를 보여 주기 위한 가상의 예시입니다.
```json
{
"success": true,
"student_id": "20000000",
"name": "홍길동",
"major": "컴퓨터공학과",
"grade": "3",
"status": "재학",
"email": "user@example.com",
"phone_number": "010-0000-0000",
"english_name": "Gildong Hong",
"user_category": "학부생"
}
```
```json
{ "verified": true, "student_id": "20000000", "name": "홍길동", "major": "컴퓨터공학과" }
```
| 필드 | 의미 |
|---|---|
| `student_id` / `name` / `major` | 학번 / 이름 / 학과(전공). 예시 함수가 돌려주는 값 |
| `status` | 학적 상태 (재학, 휴학, 졸업 등). 포털 원문이라 표기가 바뀔 수 있음 |
| `grade` | 학년 (문자열) |
| `email` / `phone_number` / `english_name` / `user_category` | 연락처(SIS) 조회 결과. SIS 조회가 실패하면 `null`. 그 밖의 SIS 학교 코드 필드는 생략함 |
| `verified` | 예시 함수가 `status`를 `재학`과 비교해 만든 값. 라이브러리 필드가 아님 |
전체 필드는 [결과 필드](https://cassiiopeia.github.io/pro-sejongpeia/auth/result-fields/)를 보세요.
**실패 시**: 비밀번호 오류, 계정 잠김 등은 `SejongAuthError`(에러 코드 포함)로 던져집니다. [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)
## 알아둘 점
- **틀린 비밀번호를 반복해서 보내지 마세요.** 포털이 계정을 잠급니다. 라이브러리는 첫 실패에서 멈추지만, 누구나 학번을 입력할 수 있는 폼이라면 **학번별 실패 횟수를 제한**하세요. [로그인 판정과 계정 잠금 방지](https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/)
- `status`, `major`는 포털의 원문 문자열입니다. 위 `재학` 비교는 예시이며, 표기가 바뀔 수 있으니 한 곳에 상수로 두세요.
- 한 번의 `authenticate`는 연락처(SIS) 조회까지 포함합니다. 학번·이름·학과만 필요하면 DHC만 부르는 `authenticate_dhc` / `authenticateDhc` / `authenticateWithDHC`가 더 가볍습니다. 결과 필드는 [인증 메서드](https://cassiiopeia.github.io/pro-sejongpeia/auth/methods/)와 [결과 필드](https://cassiiopeia.github.io/pro-sejongpeia/auth/result-fields/)를 보세요.
- 서버에서만 호출하세요. 브라우저나 앱에서 직접 부르면 비밀번호가 제3자 코드에 노출됩니다.
- 분기는 메시지가 아니라 에러 **코드**로 합니다. [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)
## 예시 코드
**Java**
```java
import kr.suhsaechan.sejongpeia.auth.model.SejongAuthResult;
import kr.suhsaechan.sejongpeia.auth.service.SuhSejongAuthEngine;
import kr.suhsaechan.sejongpeia.common.exception.SejongAuthErrorCode;
import kr.suhsaechan.sejongpeia.common.exception.SejongAuthException;
import org.springframework.stereotype.Service;
@Service
class StudentVerificationService {
private static final String ACTIVE_STATUS = "재학"; // 학교 원문 그대로 비교
private final SuhSejongAuthEngine sejongAuthEngine;
StudentVerificationService(SuhSejongAuthEngine sejongAuthEngine) {
this.sejongAuthEngine = sejongAuthEngine;
}
/** 학번, 이름, 학과만 돌려주고 비밀번호는 보관하지 않음 */
String verifySejongStudent(String studentId, String password) {
try {
SejongAuthResult result = sejongAuthEngine.authenticate(studentId, password);
boolean isActiveStudent = ACTIVE_STATUS.equals(result.getStatus());
return isActiveStudent ? result.getName() + " (" + result.getMajor() + ")" : "재학생이 아닙니다";
} catch (SejongAuthException exception) {
SejongAuthErrorCode errorCode = exception.getErrorCode();
if (errorCode == SejongAuthErrorCode.AUTHENTICATION_FAILED) {
return "학번 또는 비밀번호가 틀렸습니다";
}
if (errorCode == SejongAuthErrorCode.ACCOUNT_LOCKED) {
return "포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요";
}
return "지금은 확인할 수 없습니다. 잠시 뒤 다시 시도하세요";
}
}
}
```
**Python**
```python
from sejongpeia import authenticate, SejongAuthError, ErrorCode
ACTIVE_STATUS = "재학" # 학교 원문 문자열 그대로 비교 (포털 표기가 바뀌면 여기만 수정)
def verify_sejong_student(student_id: str, password: str) -> dict:
"""동아리 가입 시 학생 인증: 학번, 이름, 학과만 돌려주고 비밀번호는 보관하지 않음"""
try:
result = authenticate(student_id, password)
except SejongAuthError as error:
if error.code == ErrorCode.AUTHENTICATION_FAILED:
return {"verified": False, "reason": "학번 또는 비밀번호가 틀렸습니다"}
if error.code == ErrorCode.ACCOUNT_LOCKED:
return {"verified": False, "reason": "포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요"}
if error.code == ErrorCode.INVALID_INPUT:
return {"verified": False, "reason": "학번과 비밀번호를 입력하세요"}
return {"verified": False, "reason": "지금은 확인할 수 없습니다. 잠시 뒤 다시 시도하세요"} # 연결, 파싱 등
return {
"verified": result.status == ACTIVE_STATUS,
"student_id": result.student_id,
"name": result.name,
"major": result.major,
}
```
**Node.js**
```js
import { authenticate, SejongAuthError, ErrorCode } from 'pro-sejongpeia';
const ACTIVE_STATUS = '재학'; // 학교 원문 문자열 그대로 비교 (포털 표기가 바뀌면 여기만 수정)
/** 동아리 가입 시 학생 인증: 학번, 이름, 학과만 돌려주고 비밀번호는 보관하지 않음 */
export async function verifySejongStudent(studentId, password) {
try {
const result = await authenticate(studentId, password);
return { verified: result.status === ACTIVE_STATUS, studentId: result.studentId, name: result.name, major: result.major };
} catch (error) {
if (!(error instanceof SejongAuthError)) throw error;
if (error.code === ErrorCode.AUTHENTICATION_FAILED) return { verified: false, reason: '학번 또는 비밀번호가 틀렸습니다' };
if (error.code === ErrorCode.ACCOUNT_LOCKED) return { verified: false, reason: '포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요' };
if (error.code === ErrorCode.INVALID_INPUT) return { verified: false, reason: '학번과 비밀번호를 입력하세요' };
return { verified: false, reason: '지금은 확인할 수 없습니다. 잠시 뒤 다시 시도하세요' }; // 연결, 파싱 등
}
}
```
---
# 학과·강의 검색
Source: https://cassiiopeia.github.io/pro-sejongpeia/guides/department-lecture-search/
Section: 가이드
학과 목록과 개설강좌를 JSON으로 내려주는 작은 서버입니다. 프런트엔드는 이 두 주소만 부르면 됩니다.
## 요청 파라미터
예시 서버의 HTTP 요청(모두 `GET`)과, 그 안에서 부르는 라이브러리 메서드입니다. 필드 이름은 Python `snake_case` 기준이고, Node.js는 `camelCase` 객체 인자, Java는 위치 인자입니다. 로그인은 필요 없습니다.
**예시 서버**
| 주소 | 파라미터 | 필수 | 설명 |
|---|---|---|---|
| `/departments` | | | 파라미터 없음 |
| `/lectures` | `department` | 아니오 | 학과 코드 |
| | `name` | 아니오 | 과목명 (부분 일치). 둘 중 하나는 필수인 것은 이 예시 서버가 정한 규칙 |
**라이브러리 메서드**
| 메서드 | 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|---|
| `departments` | `category_code` | 문자열 | 예 | | 소속 분류 (`20` 학부) |
| | `year` | 문자열 | 예 | | 연도 |
| | `semester_code` | 문자열 | 예 | | 학기 코드 (`10` 1학기, `20` 2학기, `11`·`21` 계절학기) |
| `lectures` | `category_code`, `year`, `semester_code` | 문자열 | 예 | | 위와 같음 |
| | `department_code` | 문자열 | 아니오 | 빈 문자열 | 학과 코드 |
| | `course_name` | 문자열 | 아니오 | 빈 문자열 | 과목명 |
| | `instructor_name` | 문자열 | 아니오 | 빈 문자열 | 교수명. 세 조건을 모두 비우면 그 학기 전체 |
예시 코드는 학부(`"20"`)이고 연도와 학기(`"20"` = 2학기)는 코드 상단의 상수입니다. 코드 값은 모두 학교 값이며 목록은 `organization_categories()`, `terms(...)`로 받습니다 ([학과·학기 코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/)).
## 응답 데이터 구조
예시 서버가 내려주는 JSON입니다. 위쪽이 `GET /departments`, 아래쪽이 `GET /lectures?...`의 응답이며, 값은 형태를 보여 주기 위한 예시입니다. 키 이름은 Python 예시 기준이고 Node.js 예시는 `course_number`가 `courseNumber`입니다. Java 컨트롤러는 라이브러리 모델(`Department`, `Lecture`)을 그대로 돌려주므로 필드가 더 많고 이름은 `camelCase`입니다.
```json
[
{ "code": "3517", "college": "인공지능융합대학", "name": "AI로봇학과" },
{ "code": "3331", "college": "연계전공", "name": "스마트투어리즘매니지먼트소프트웨어" }
]
```
```json
[
{
"course": "알고리즘및실습",
"department": "컴퓨터공학과",
"schedule": "화13:00-15:00",
"credits": "3.0",
"course_number": "009954"
}
]
```
| 필드 | 의미 |
|---|---|
| `code` | 학과 코드. `/lectures?department=`에 그대로 넘김 |
| `college` / `name` | 단과대 / 학과(또는 전공) 이름 |
| `course` / `department` | 과목명 / 개설 학과 이름 |
| `schedule` | 요일과 시간 (학교 원문, 예: `화13:00-15:00`) |
| `credits` | 학점 (문자열) |
| `course_number` | 교과목 번호. 교육과정 조회에 이어서 쓸 수 있음 |
라이브러리가 돌려주는 전체 필드(`extra` 포함)는 [개설강좌](https://cassiiopeia.github.io/pro-sejongpeia/data/lectures/#%EC%9D%91%EB%8B%B5-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B5%AC%EC%A1%B0)와 [학과·학기 코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/#%EC%9D%91%EB%8B%B5-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B5%AC%EC%A1%B0)를 보세요.
**실패 시**: 학교 서버가 실패하면 예외 없이 마지막 캐시 또는 빈 목록입니다. 예시 서버는 이를 빈 배열(`[]`)로 내려줍니다.
## 알아둘 점
- `lectures`의 학과 코드, 과목명, 교수명은 모두 선택입니다. **모두 비우면 그 학기 전체**를 받는데 응답이 수 MB로 크므로([개설강좌](https://cassiiopeia.github.io/pro-sejongpeia/data/lectures/)), 예시 서버는 조건을 필수로 했습니다. 전체가 필요하면 학과 단위로 나눠 조회하세요.
- 강의 결과에는 교수 이름(`instructor`)도 들어 있습니다. 예시는 필요한 필드만 골라서 내려주고, 외부에 공개할 때는 어떤 필드를 낼지 직접 정하세요.
- 과목명 검색은 부분 일치입니다. 결과가 0건인 이름도 있으므로 빈 결과 안내가 필요합니다.
- 강의의 `course_number`와 `department_code`로 학과 교육과정의 학년·학기·교과목 개요(`curriculum_courses`)를 이어서 조회할 수 있습니다. [교육과정](https://cassiiopeia.github.io/pro-sejongpeia/data/curriculum/)
## 예시 코드
**Java**
```java
import java.util.List;
import kr.suhsaechan.sejongpeia.publicdata.SejongPublicData;
import kr.suhsaechan.sejongpeia.publicdata.model.Department;
import kr.suhsaechan.sejongpeia.publicdata.model.Lecture;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
class LectureSearchController {
private static final String UNDERGRADUATE_CATEGORY = "20";
private static final String YEAR = "2026";
private static final String SEMESTER = "20";
private final SejongPublicData publicData;
LectureSearchController(SejongPublicData publicData) {
this.publicData = publicData;
}
@GetMapping("/departments")
List departments() {
return publicData.departments(UNDERGRADUATE_CATEGORY, YEAR, SEMESTER);
}
@GetMapping("/lectures")
List lectures(@RequestParam(defaultValue = "") String department,
@RequestParam(defaultValue = "") String name) {
if (department.isBlank() && name.isBlank()) { // 조건이 없으면 그 학기 전체(수 MB)라서 이 서버는 조건을 필수로 함
return List.of();
}
return publicData.lectures(UNDERGRADUATE_CATEGORY, YEAR, SEMESTER, department, name, "");
}
}
```
**Python**
표준 라이브러리 서버입니다.
```python
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
from sejongpeia import SejongPublicData
public_data = SejongPublicData() # 프로세스당 하나만 만들어 캐시를 공유
UNDERGRADUATE_CATEGORY = "20" # 학교의 소속 분류 코드 (organization_categories()로 확인)
YEAR, SEMESTER = "2026", "20" # 학기 목록은 terms(UNDERGRADUATE_CATEGORY)
def search_lectures(department_code: str, course_name: str) -> list:
lectures = public_data.lectures(UNDERGRADUATE_CATEGORY, YEAR, SEMESTER,
department_code=department_code, course_name=course_name)
return [
{"course": lecture.name, "department": lecture.department_name, "schedule": lecture.schedule,
"credits": lecture.credits, "course_number": lecture.course_number}
for lecture in lectures
]
class LectureSearchHandler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlparse(self.path)
if url.path == "/departments":
departments = public_data.departments(UNDERGRADUATE_CATEGORY, YEAR, SEMESTER)
body = [{"code": department.code, "college": department.college_name,
"name": department.major_name or department.unit_name}
for department in departments]
elif url.path == "/lectures":
query = parse_qs(url.query)
department_code = query.get("department", [""])[0].strip()
course_name = query.get("name", [""])[0].strip()
if not department_code and not course_name: # 조건이 없으면 그 학기 전체(수 MB)라서 이 서버는 조건을 필수로 함
self.send_error(400, "department or name is required")
return
body = search_lectures(department_code, course_name)
else:
self.send_error(404)
return
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(payload)
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8000), LectureSearchHandler).serve_forever()
```
**Node.js**
```js
import { createServer } from 'node:http';
import { SejongPublicData } from 'pro-sejongpeia';
const publicData = new SejongPublicData(); // 프로세스당 하나만 만들어 캐시를 공유
const UNDERGRADUATE_CATEGORY = '20'; // 학교의 소속 분류 코드 (organizationCategories()로 확인)
const YEAR = '2026';
const SEMESTER = '20'; // 학기 목록은 terms(UNDERGRADUATE_CATEGORY)
async function searchLectures(departmentCode, courseName) {
const lectures = await publicData.lectures({
categoryCode: UNDERGRADUATE_CATEGORY, year: YEAR, semesterCode: SEMESTER, departmentCode, courseName,
});
return lectures.map((lecture) => ({
course: lecture.name, department: lecture.departmentName, schedule: lecture.schedule,
credits: lecture.credits, courseNumber: lecture.courseNumber,
}));
}
createServer(async (request, response) => {
const url = new URL(request.url, 'http://localhost');
let body;
if (url.pathname === '/departments') {
const departments = await publicData.departments({ categoryCode: UNDERGRADUATE_CATEGORY, year: YEAR, semesterCode: SEMESTER });
body = departments.map((department) => ({ code: department.code, college: department.collegeName, name: department.majorName || department.unitName }));
} else if (url.pathname === '/lectures') {
const departmentCode = (url.searchParams.get('department') ?? '').trim();
const courseName = (url.searchParams.get('name') ?? '').trim();
if (!departmentCode && !courseName) { // 조건이 없으면 그 학기 전체(수 MB)라서 이 서버는 조건을 필수로 함
response.writeHead(400).end('department or name is required');
return;
}
body = await searchLectures(departmentCode, courseName);
} else {
response.writeHead(404).end();
return;
}
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify(body));
}).listen(8000, '127.0.0.1');
```
---
# 학사일정 위젯
Source: https://cassiiopeia.github.io/pro-sejongpeia/guides/academic-calendar/
Section: 가이드
오늘부터 30일 안의 학부 학사일정을 시작일 순으로 보여 줍니다. 로그인이 필요 없습니다.
## 요청 파라미터
필드 이름은 Python `snake_case` 기준이고, Node.js는 `camelCase` 객체 인자(`{ start, end }`), Java는 위치 인자입니다. 로그인은 필요 없습니다.
| 메서드 | 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|---|
| `calendar` | `start` | 문자열 | 예 | | 시작일 (`yyyy-MM-dd`) |
| | `end` | 문자열 | 예 | | 종료일 (`yyyy-MM-dd`) |
| | `category_code` | 문자열 | 아니오 | `0001` | 분류 코드 (`0001` 학부, `0002`~`0007` 대학원) |
| `graduate_calendar` | `site_id` | 문자열 | 예 | | 대학원 사이트 이름 (예: `graduate`). 호출하는 쪽이 넘기므로 새 대학원도 릴리스 없이 조회 |
| | `year` / `month` | 정수 | 예 | | 조회할 연도 / 월 |
## 응답 데이터 구조
`calendar(start, end)`가 돌려주는 목록의 한 건입니다. 필드 이름은 Python `snake_case`이고, Node.js는 `camelCase`(`start_date` → `startDate`), Java는 getter(`getStartDate()`)입니다. 값은 형태를 보여 주기 위한 예시입니다.
```json
[
{
"year": "2026",
"month": "3",
"category_code": "0001",
"category_name": "학부",
"title": "2학기 중간고사 성적 입력",
"title_en": "Grades input for Fall semester Midterm Examination",
"start_date": "2026-03-02",
"end_date": "2026-03-08",
"extra": {}
}
]
```
| 필드 | 의미 |
|---|---|
| `title` / `title_en` | 제목 / 영문 제목 (학교 원문) |
| `start_date` / `end_date` | 시작일 / 종료일 (학부는 `yyyy-MM-dd`, 하루짜리는 같은 값) |
| `year` / `month` | 일정이 속한 연도 / 월 |
| `category_code` / `category_name` | 분류 코드 / 이름 (`0001` 학부) |
| `extra` | 그 밖의 학교 필드 (학부 일정은 비어 있음) |
전체 필드는 [학사일정](https://cassiiopeia.github.io/pro-sejongpeia/data/calendar/#%EC%9D%91%EB%8B%B5-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B5%AC%EC%A1%B0)을 보세요.
**실패 시**: 학교 서버가 실패하면 예외 없이 마지막 캐시 또는 빈 목록입니다. 일정이 없는 기간과 구분되지 않습니다.
## 알아둘 점
- 날짜 형식은 학부 `yyyy-MM-dd`, 대학원 사이트 `MM.dd`로 다릅니다. [학사일정](https://cassiiopeia.github.io/pro-sejongpeia/data/calendar/)
- 위젯이 열릴 때마다 학교에 요청하지 않습니다. 같은 인자는 24시간 캐시됩니다. [실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/)
- 제목은 학교 원문입니다. 영문 제목은 `title_en` / `titleEn`입니다.
## 예시 코드
**Java**
```java
import java.time.LocalDate;
import java.util.List;
import kr.suhsaechan.sejongpeia.publicdata.SejongPublicData;
import kr.suhsaechan.sejongpeia.publicdata.model.CalendarEvent;
class AcademicCalendarWidget {
static List upcomingAcademicEvents(SejongPublicData publicData, int daysAhead) {
LocalDate today = LocalDate.now();
return publicData.calendar(today.toString(), today.plusDays(daysAhead).toString()).stream()
.sorted(java.util.Comparator.comparing(CalendarEvent::getStartDate))
.toList();
}
}
```
**Python**
```python
from datetime import date, timedelta
from sejongpeia import SejongPublicData
public_data = SejongPublicData()
def upcoming_academic_events(days_ahead: int = 30) -> list:
"""오늘부터 days_ahead일 안에 걸쳐 있는 학부 학사일정 (시작일 순)"""
today = date.today()
events = public_data.calendar(today.isoformat(), (today + timedelta(days=days_ahead)).isoformat())
# 날짜는 학교가 주는 yyyy-MM-dd 문자열이라 문자열 정렬이 날짜 순서와 같음
return sorted(events, key=lambda event: event.start_date)
if __name__ == "__main__":
for event in upcoming_academic_events():
period = event.start_date if event.start_date == event.end_date else f"{event.start_date} ~ {event.end_date}"
print(f"{period} {event.title}")
```
**Node.js**
```js
import { SejongPublicData } from 'pro-sejongpeia';
const publicData = new SejongPublicData();
/** 오늘부터 daysAhead일 안에 걸쳐 있는 학부 학사일정 (시작일 순) */
export async function upcomingAcademicEvents(daysAhead = 30) {
const toDateText = (date) => date.toLocaleDateString('sv-SE'); // yyyy-MM-dd (로컬 날짜)
const today = new Date();
const end = new Date(today.getTime() + daysAhead * 24 * 60 * 60 * 1000);
const events = await publicData.calendar({ start: toDateText(today), end: toDateText(end) });
// 날짜는 학교가 주는 yyyy-MM-dd 문자열이라 문자열 정렬이 날짜 순서와 같음
return events.sort((first, second) => first.startDate.localeCompare(second.startDate));
}
for (const event of await upcomingAcademicEvents()) {
const period = event.startDate === event.endDate ? event.startDate : `${event.startDate} ~ ${event.endDate}`;
console.log(`${period} ${event.title}`);
}
```
---
# 기숙사·학식 봇
Source: https://cassiiopeia.github.io/pro-sejongpeia/guides/dormitory-cafeteria-bot/
Section: 가이드
오늘의 기숙사 식단, 학식, 기숙사 공지를 메시지 문자열로 만듭니다. 슬랙, 디스코드, 카카오 등 보내는 쪽에 붙이면 됩니다. 로그인이 필요 없습니다.
## 요청 파라미터
필드 이름은 Python `snake_case` 기준이고, Node.js는 `camelCase` 객체 인자(`{ rows: 3 }`), Java는 위치 인자입니다. 로그인은 필요 없습니다.
| 메서드 | 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|---|
| `dormitory_menu` | `location` | 문자열 | 아니오 | `SJ` | 기숙사 위치 코드 |
| `cafeteria_menu` | `place_id` | 문자열 | 예 | | 학교의 식당 번호 (호출하는 쪽이 넘김) |
| `cafeteria_places` | `max_place_id` | 정수 | 아니오 | `10` | 식당 번호 `1`~이 값을 훑어 목록을 만듦. 식당이 늘면 올림 |
| `dormitory_notices` | `rows` | 정수 | 아니오 | `10` | 한 번에 받을 공지 수 |
| | `page` | 정수 | 아니오 | `1` | 공지 쪽 번호 |
| | `keyword` | 문자열 | 아니오 | 빈 문자열 | 제목 검색어 |
| | `location` | 문자열 | 아니오 | `SJ` | 기숙사 위치 코드 |
## 응답 데이터 구조
예시 코드가 메시지로 만드는 원본 데이터입니다. 필드 이름은 Python `snake_case`이고, Node.js는 `camelCase`(`meal_name` → `mealName`), Java는 getter(`getMealName()`)입니다. 값은 형태를 보여 주기 위한 예시입니다.
**`dormitory_menu()`**: 하루 한 건, 최대 7일치 목록
```json
[
{
"date": "2026-03-02",
"breakfast": "쌀밥 된장국 계란말이",
"lunch": "쌀밥 육개장 부추전 배추김치",
"dinner": "쌀밥 북어국 안동찜닭 배추김치",
"alternative": "식권 1장으로 간편식 교환"
}
]
```
| 필드 | 의미 |
|---|---|
| `date` | 날짜 (`yyyy-MM-dd`) |
| `breakfast` / `lunch` / `dinner` | 조식 / 중식 / 석식. 학교 원문이라 반찬이 한 문자열에 이어져 있음 |
| `alternative` | 대체 메뉴 (없으면 빈 문자열) |
**`cafeteria_menu(place_id)`**: 메뉴 한 줄이 한 건. 식당에 따라 날짜가 있는 주간 메뉴이거나 날짜가 빈 상시 메뉴
```json
[
{
"place_id": "1",
"place_name": "계절밥상",
"building_name": "군자관",
"location": "6층",
"date": "2026-03-02",
"day": "월",
"meal_type_id": "1",
"meal_name": "중식",
"menu_name": "된장국 · 제육볶음 · 배추김치",
"price": "7000"
},
{
"place_id": "3",
"place_name": "진관키친",
"building_name": "진관홀",
"location": "B1층",
"date": "",
"day": "",
"meal_type_id": "",
"meal_name": "",
"menu_name": "라면",
"price": "3300"
}
]
```
| 필드 | 의미 |
|---|---|
| `place_id` / `place_name` | 식당 번호 / 이름 |
| `building_name` / `location` | 건물 / 층 |
| `date` / `day` | 날짜 / 요일. 상시 메뉴 식당은 빈 문자열 |
| `meal_name` | 식사 구분 (중식, 석식 등). 상시 메뉴는 빈 문자열 |
| `menu_name` | 메뉴 (여러 개는 ` · `로 이어짐) |
| `price` | 가격 (원, 문자열) |
**`dormitory_notices(rows=3)`**: 공지 목록. 고정 공지(`pinned`)와 일반 공지(`items`)가 나뉘어 있음
```json
{
"total": 120,
"pinned": [],
"items": [
{
"id": "37570",
"title": "[기숙사] 정기소독 실시 안내",
"date": "2026-03-02",
"views": 426,
"file_name": "안내.png"
}
]
}
```
| 필드 | 의미 |
|---|---|
| `total` | 전체 공지 수 (정수) |
| `pinned` / `items` | 고정 공지 / 일반 공지 목록. 항목 모양은 같음 |
| `id` / `title` / `date` | 공지 번호 / 제목 / 등록일 |
| `views` / `file_name` | 조회수 (정수) / 첨부 파일 이름 (없으면 빈 문자열) |
전체 필드는 [기숙사](https://cassiiopeia.github.io/pro-sejongpeia/data/dormitory/#%EC%9D%91%EB%8B%B5-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B5%AC%EC%A1%B0)와 [학식](https://cassiiopeia.github.io/pro-sejongpeia/data/cafeteria/#%EC%9D%91%EB%8B%B5-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B5%AC%EC%A1%B0)을 보세요.
**실패 시**: 학교 서버가 실패하면 예외 없이 마지막 캐시 또는 빈 목록(`dormitory_notices`는 `total`이 `0`인 빈 결과)을 돌려줍니다. 빈 목록은 「데이터 없음」과 구분되지 않으니 화면에 안내를 두세요.
## 알아둘 점
- 식당 번호는 학교 값입니다. 번호는 바뀔 수 있고, 어느 식당인지는 결과의 `place_name`으로 알 수 있습니다. 빈 목록은 번호가 틀렸을 때와 그날 메뉴가 없을 때 모두 나옵니다. 식당 목록은 `cafeteria_places()`로도 받을 수 있습니다. [학식](https://cassiiopeia.github.io/pro-sejongpeia/data/cafeteria/)
- 식당에 따라 일주일치가 날짜와 함께 오거나, 날짜 없는 상시 메뉴가 옵니다. 예시는 둘 다 처리합니다.
- 기숙사 식단은 7일치이며 `dormitory_menu(location="SJ")`입니다. [기숙사](https://cassiiopeia.github.io/pro-sejongpeia/data/dormitory/)
- 학교 서버에 부담을 주지 않도록 봇은 하루 몇 번만 갱신해도 충분합니다 (라이브러리 캐시가 이미 24시간).
## 예시 코드
**Java**
```java
import java.time.LocalDate;
import java.util.List;
import kr.suhsaechan.sejongpeia.publicdata.SejongPublicData;
import kr.suhsaechan.sejongpeia.publicdata.model.CafeteriaMenuItem;
import kr.suhsaechan.sejongpeia.publicdata.model.DormitoryMenuDay;
class CampusInfoBot {
static String dormitoryMenuMessage(SejongPublicData publicData) {
String today = LocalDate.now().toString();
List menuDays = publicData.dormitoryMenu();
DormitoryMenuDay menuDay = menuDays.stream().filter(day -> today.equals(day.getDate())).findFirst()
.orElse(menuDays.isEmpty() ? null : menuDays.get(0));
if (menuDay == null) {
return "기숙사 식단을 불러오지 못했습니다";
}
return "[기숙사 " + menuDay.getDate() + "]\n조식: " + menuDay.getBreakfast()
+ "\n중식: " + menuDay.getLunch() + "\n석식: " + menuDay.getDinner();
}
static String cafeteriaMessage(SejongPublicData publicData, String placeId) {
String today = LocalDate.now().toString();
List items = publicData.cafeteriaMenu(placeId);
if (items.isEmpty()) {
return placeId + "번 식당: 메뉴 없음";
}
List todaysItems = items.stream()
.filter(item -> item.getDate().isEmpty() || item.getDate().equals(today)).toList();
List shownItems = todaysItems.isEmpty() ? items : todaysItems;
StringBuilder message = new StringBuilder("[" + shownItems.get(0).getPlaceName() + "]");
shownItems.stream().limit(5).forEach(item ->
message.append("\n").append(item.getMealName()).append(" ").append(item.getMenuName()).append(" ").append(item.getPrice()).append("원"));
return message.toString();
}
}
```
**Python**
```python
from datetime import date
from sejongpeia import SejongPublicData
public_data = SejongPublicData()
CAFETERIA_PLACE_IDS = ["1", "3", "4", "5", "6"] # 학교의 식당 번호, 바뀔 수 있음
def dormitory_menu_message() -> str:
today = date.today().isoformat()
menu_days = public_data.dormitory_menu()
# 오늘 날짜가 없으면(주말 갱신 전 등) 학교가 준 첫날을 보여 줌
menu_day = next((day for day in menu_days if day.date == today), menu_days[0] if menu_days else None)
if menu_day is None:
return "기숙사 식단을 불러오지 못했습니다"
return f"[기숙사 {menu_day.date}]\n조식: {menu_day.breakfast}\n중식: {menu_day.lunch}\n석식: {menu_day.dinner}"
def cafeteria_message(place_id: str) -> str:
items = public_data.cafeteria_menu(place_id)
if not items: # 식당 번호가 없거나 그날 메뉴가 없으면 빈 목록 (둘을 구분하지 못함)
return f"{place_id}번 식당: 메뉴 없음"
today = date.today().isoformat()
# 날짜가 있는 식당은 일주일치가 한 번에 오므로 오늘 것만, 날짜가 없는 식당(상시 메뉴)은 그대로
todays_items = [item for item in items if item.date in ("", today)] or items
first = todays_items[0]
lines = [f"{item.meal_name} {item.menu_name} {item.price}원".strip() for item in todays_items[:5]]
return f"[{first.place_name} {first.building_name} {first.location}]\n" + "\n".join(lines)
def latest_dormitory_notice_message() -> str:
notices = public_data.dormitory_notices(rows=3)
return "\n".join(f"{notice.date} {notice.title}" for notice in notices.items) or "공지를 불러오지 못했습니다"
if __name__ == "__main__":
print(dormitory_menu_message())
for place_id in CAFETERIA_PLACE_IDS:
print(cafeteria_message(place_id))
print(latest_dormitory_notice_message())
```
**Node.js**
```js
import { SejongPublicData } from 'pro-sejongpeia';
const publicData = new SejongPublicData();
const CAFETERIA_PLACE_IDS = ['1', '3', '4', '5', '6']; // 학교의 식당 번호, 바뀔 수 있음
const todayText = () => new Date().toLocaleDateString('sv-SE'); // yyyy-MM-dd (로컬 날짜)
export async function dormitoryMenuMessage() {
const menuDays = await publicData.dormitoryMenu();
// 오늘 날짜가 없으면(주말 갱신 전 등) 학교가 준 첫날을 보여 줌
const menuDay = menuDays.find((day) => day.date === todayText()) ?? menuDays[0];
if (!menuDay) return '기숙사 식단을 불러오지 못했습니다';
return `[기숙사 ${menuDay.date}]\n조식: ${menuDay.breakfast}\n중식: ${menuDay.lunch}\n석식: ${menuDay.dinner}`;
}
export async function cafeteriaMessage(placeId) {
const items = await publicData.cafeteriaMenu(placeId);
if (items.length === 0) return `${placeId}번 식당: 메뉴 없음`; // 식당 번호가 없거나 그날 메뉴가 없으면 빈 목록 (둘을 구분하지 못함)
// 날짜가 있는 식당은 일주일치가 한 번에 오므로 오늘 것만, 날짜가 없는 식당(상시 메뉴)은 그대로
const todaysItems = items.filter((item) => item.date === '' || item.date === todayText());
const shownItems = todaysItems.length > 0 ? todaysItems : items;
const lines = shownItems.slice(0, 5).map((item) => `${item.mealName} ${item.menuName} ${item.price}원`.trim());
const first = shownItems[0];
return `[${first.placeName} ${first.buildingName} ${first.location}]\n${lines.join('\n')}`;
}
export async function latestDormitoryNoticeMessage() {
const notices = await publicData.dormitoryNotices({ rows: 3 });
return notices.items.map((notice) => `${notice.date} ${notice.title}`).join('\n') || '공지를 불러오지 못했습니다';
}
console.log(await dormitoryMenuMessage());
for (const placeId of CAFETERIA_PLACE_IDS) console.log(await cafeteriaMessage(placeId));
console.log(await latestDormitoryNoticeMessage());
```
---
# 코딩 에이전트와 함께 쓰기
Source: https://cassiiopeia.github.io/pro-sejongpeia/guides/agents/
Section: 가이드
Claude Code, Cursor, Codex, Copilot 같은 코딩 에이전트가 이 라이브러리를 정확하게 쓰도록, 사이트가 빌드 때마다 에이전트가 읽기 좋은 파일을 만들어 줍니다. 이 저장소에 기여하는 에이전트는 저장소 루트의 [`AGENTS.md`](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/AGENTS.md)를 읽게 하세요.
## 에이전트에게 줄 파일
| 파일 | 내용 | 이럴 때 |
|---|---|---|
| [`/llms.txt`](https://cassiiopeia.github.io/pro-sejongpeia/llms.txt) | 문서 목차와 한 줄 설명 (한국어 목록 뒤에 영어 목록) | 에이전트가 필요한 문서를 스스로 골라 읽게 할 때 |
| [`/api.json`](https://cassiiopeia.github.io/pro-sejongpeia/api.json) | 공개 메서드 전체: 세 언어 이름, 인자와 기본값, 반환 모델 필드, 실패 동작, 캐시, 로그인 필요 여부 | 코드를 바로 짜게 할 때 (가장 정확하고 짧음) |
| [`/llms-full.txt`](https://cassiiopeia.github.io/pro-sejongpeia/llms-full.txt) | 한국어 문서 전체를 한 파일로 (영어는 [`/llms-full.en.txt`](https://cassiiopeia.github.io/pro-sejongpeia/llms-full.en.txt)) | 에이전트가 링크를 따라가지 못하는 환경에 통째로 붙여 줄 때 |
| [`AGENTS.md`](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/AGENTS.md) | 저장소 구조, 의존 규칙, 테스트 명령, 새 정보 출처 추가 절차 | 이 저장소를 고치는 기여 작업 |
세 파일은 문서 사이트를 빌드할 때 만들어지므로 사이트 문서와 항상 같은 내용입니다. 원본은 하나입니다: `api.json`은 저장소의 `spec/api-index.json`, 두 `llms` 파일은 이 사이트의 문서입니다. `spec/api-index.json`이 세 언어 구현과 어긋나면 CI가 실패하도록 해 두었습니다.
## 프롬프트 예시
에이전트에게 파일 주소를 먼저 알려 주고, 만들 것을 한 문장으로 말하면 됩니다.
### 예시 1. Spring 앱에 재학생 확인 붙이기
```text
https://cassiiopeia.github.io/pro-sejongpeia/api.json 과
https://cassiiopeia.github.io/pro-sejongpeia/guides/student-verification/ 를 읽고,
내 Spring Boot 앱의 동아리 가입 API에 세종대 재학생 확인을 추가해 줘.
- pro-sejongpeia 의존성은 api.json 의 libraryVersion 으로 버전을 고정해서 넣어 줘
- authenticate 결과의 학번과 소속만 확인하고, 비밀번호는 저장하지도 로그에 남기지도 마
- 실패는 SejongAuthException 의 errorCode 로 나눠서 응답해 줘 (틀린 비밀번호는 재시도하지 마)
- 테스트는 실제 포털을 부르지 말고 SuhSejongAuthEngine 을 mock 으로 대체해 줘
```
### 예시 2. Python 봇에 이번 학기 강의 보여 주기
```text
https://cassiiopeia.github.io/pro-sejongpeia/llms.txt 를 보고 필요한 문서를 읽은 다음,
내 디스코드 봇에 "/강의 학과 과목명" 명령을 추가해 줘. pro-sejongpeia 의 SejongPublicData 로
이번 학기 개설 강의를 찾아 보여 주는 거야. (로그인 필요 없음)
- 학과 코드와 학기 코드는 코드로 적지 말고 departments, terms 로 찾아서 써 줘
- 결과가 빈 목록이면 "정보를 불러오지 못했거나 검색 결과가 없어요"라고 답해 줘
- 학과를 비우면 학기 전체(수천 행)가 오니까 학과 코드는 꼭 넘겨 줘
```
## 알아 두면 좋은 점
- **버전을 고정하세요.** 이 라이브러리는 하위 호환을 보장하지 않습니다. 에이전트에게 `api.json`의 `libraryVersion`을 쓰라고 하면 문서와 같은 버전이 설치됩니다. 자세한 내용은 [API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/)입니다.
- **프롬프트에 학번과 비밀번호를 넣지 마세요.** 에이전트의 대화 기록에 남습니다. 테스트 계정이 필요하면 환경변수 이름만 알려 주고 값은 직접 설정하세요. 에이전트가 만든 코드가 비밀번호를 로그나 예외 메시지에 남기지 않는지도 확인하세요.
- **로그인은 서버에서만 합니다.** 브라우저나 앱에서 직접 부르는 코드를 짜 오면 되돌리게 하세요. 이유는 [인증 메서드](https://cassiiopeia.github.io/pro-sejongpeia/auth/methods/)에 있습니다.
- **로그인 없는 데이터는 실제로 한 번 확인하세요.** 에이전트가 지어낸 필드 이름이 흔한 실수입니다. `python3 scripts/probe-public-data.py`는 계정 없이 학교 공개 데이터의 응답 모양을 확인합니다 (저장소를 받았을 때). 로그인이 필요한 부분은 본인 계정으로 한 번만 시도하세요. 비밀번호를 여러 번 틀리면 계정이 잠길 수 있습니다 ([로그인 판정과 계정 잠금 방지](https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/)).
- **값은 학교 원문이고 없으면 빈 문자열입니다.** `null`을 기대하는 코드나 값을 바꿔 주는 코드를 짜 오면 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)과 맞는지 확인하세요.
## `api.json` 읽는 법
```json
{
"libraryVersion": "…",
"methods": [
{
"id": "lectures",
"group": "public_data",
"requiresLogin": false,
"names": { "python": "lectures", "node": "lectures", "java": ["lectures"] },
"usage": { "python": "lectures(category_code, year, …)", "node": "await lectures({ categoryCode, year, … })", "java": ["lectures(String categoryCode, …)"] },
"parameters": [{ "name": "category_code", "required": true, "…": "…" }],
"returns": { "model": "Lecture", "isList": true, "fields": [{ "python": "course_number", "node": "courseNumber", "java": "courseNumber", "type": "string" }] },
"failure": { "mode": "returns-empty-value", "emptyValue": "[]", "invalidInput": null }
}
],
"models": { "Lecture": [{ "python": "course_number", "node": "courseNumber", "java": "courseNumber", "type": "string" }] }
}
```
- `group`이 `public_data`면 로그인이 필요 없고, `auth`면 학번과 비밀번호가 필요합니다.
- `failure.mode`가 `returns-empty-value`이면 학교 서버 문제로 예외를 던지지 않고 마지막 캐시 값이나 빈 값을 줍니다. `invalidInput`에 적힌 경우만 예외입니다.
- 필드 이름은 Python `snake_case`, Node.js `camelCase`입니다. Java 모델은 같은 `camelCase` 이름의 getter(`getCourseNumber()`)입니다.
## 이어서 보기
- [빠른 시작](https://cassiiopeia.github.io/pro-sejongpeia/start/quickstart/): 설치와 첫 호출
- [학교 데이터 레퍼런스](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/): 메서드마다 요청, 필드, 캐시
- [레포 구조](https://cassiiopeia.github.io/pro-sejongpeia/contribute/structure/): 기여하는 에이전트가 먼저 볼 폴더 구성
---
# 공통 규칙
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/overview/
Section: 학교 데이터 레퍼런스
학교 데이터(공개 데이터)는 학번과 비밀번호 없이 학교가 공개로 내려주는 정보입니다. Java, Python, Node.js가 같은 메서드, 같은 결과 필드, 같은 값 규칙을 가집니다. 이 페이지는 모든 메서드가 공유하는 규칙입니다. 메서드별 내용은 왼쪽 메뉴의 각 페이지를 보세요.
> **비공식 엔드포인트**
> 이 데이터는 학교 웹 화면이 내부에서 부르는 문서화되지 않은 주소에서 가져옵니다. 학교가 예고 없이 바꾸거나 없앨 수 있습니다. 바뀌면 [계약 문서(spec)](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)를 먼저 고치고 세 언어를 한 PR에서 고칩니다. 매일 돌아가는 형태 점검은 [매일 도는 실제 포털 점검](https://cassiiopeia.github.io/pro-sejongpeia/understand/daily-live-check/)을 보세요.
## 클라이언트
| 언어 | 진입점 | 이름 표기 |
|---|---|---|
| Java | `kr.suhsaechan.sejongpeia.publicdata.SejongPublicData`, 모델은 `kr.suhsaechan.sejongpeia.publicdata.model.*` | `camelCase` 메서드, getter로 필드 접근 |
| Python | `sejongpeia.SejongPublicData` | `snake_case` 메서드와 필드 |
| Node.js | 패키지에서 내보내는 `SejongPublicData` 클래스 | `camelCase`, 인자는 객체 하나로 받는 메서드가 많음 |
- Spring Boot는 자동 설정이 `SejongPublicData` 빈을 등록합니다. 일반 Java는 `new SejongPublicData()`로 만들고, 먼저 [레거시 TLS](https://cassiiopeia.github.io/pro-sejongpeia/understand/legacy-tls/) 설정이 필요합니다.
- **프로세스당 하나만** 만들어 쓰세요. 메모리 캐시와 같은 요청 합치기가 인스턴스 안에 있습니다.
- 호출 방식: Java와 Python은 동기, Node.js는 `Promise`를 돌려줍니다.
**Java**
```java
// Spring Boot 에서는 빈으로 주입받음
SejongPublicData publicData = new SejongPublicData();
```
**Python**
```python
from sejongpeia import SejongPublicData
public_data = SejongPublicData() # 프로세스당 하나만 만들어 캐시 공유
```
**Node.js**
```javascript
import { SejongPublicData } from 'pro-sejongpeia';
const publicData = new SejongPublicData(); // 프로세스당 하나만 만들어 캐시 공유
```
### 설정
모두 선택 항목입니다.
| 설정 | Java (`sejongpeia.*`) | Python 인자 | Node.js 옵션 | 기본값 |
|---|---|---|---|---|
| 요청 제한 시간 (초) | `timeout-seconds` | `timeout_seconds` | `timeoutSeconds` | 10 |
| 캐시 유효 시간 (초) | `public-data-cache-ttl-seconds` | `cache_ttl_seconds` | `cacheTtlSeconds` | 86400 |
| 실패 기억 시간 (초) | `public-data-failure-cache-seconds` | `failure_cache_seconds` | `failureCacheSeconds` | 60 |
| 캐시 최대 항목 수 | `public-data-cache-max-entries` | `cache_max_entries` | `cacheMaxEntries` | 500 |
| 인증서 검증 | `ssl-verification` | `ssl_verification` | `sslVerification` | true |
Java는 `application.yml`의 `sejongpeia` 접두사 설정(`SejongAuthProperties`)을 쓰고, Spring 없이 쓸 때는 같은 프로퍼티 객체를 만들어 `new SejongPublicData(properties)`로 넘깁니다. 인증과 공유하는 설정은 [설정](https://cassiiopeia.github.io/pro-sejongpeia/auth/configuration/)을 보세요.
## 메서드 목록
| 정보 | Java | Python | Node.js | 페이지 |
|---|---|---|---|---|
| 소속 분류 | `organizationCategories()` | `organization_categories()` | `organizationCategories()` | [코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/) |
| 학기 | `terms(categoryCode)` | `terms(category_code)` | `terms(categoryCode)` | [코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/) |
| 학과 | `departments(categoryCode, year, semesterCode)` | `departments(category_code, year, semester_code)` | `departments({ categoryCode, year, semesterCode })` | [코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/) |
| 이수 구분 | `courseTypes()` | `course_types()` | `courseTypes()` | [코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/) |
| 선택 영역 | `selectiveDomains()` | `selective_domains()` | `selectiveDomains()` | [코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/) |
| 개설강좌 | `lectures(...)` | `lectures(...)` | `lectures({ ... })` | [개설강좌](https://cassiiopeia.github.io/pro-sejongpeia/data/lectures/) |
| 교육과정 과목 | `curriculumCourses(departmentCode, courseNumber)` | `curriculum_courses(department_code, course_number)` | `curriculumCourses({ departmentCode, courseNumber })` | [교육과정](https://cassiiopeia.github.io/pro-sejongpeia/data/curriculum/) |
| 학사일정 | `calendar(...)` | `calendar(...)` | `calendar({ ... })` | [학사일정](https://cassiiopeia.github.io/pro-sejongpeia/data/calendar/) |
| 대학원 학사일정 | `graduateCalendar(siteId, year, month)` | `graduate_calendar(site_id, year, month)` | `graduateCalendar({ siteId, year, month })` | [학사일정](https://cassiiopeia.github.io/pro-sejongpeia/data/calendar/) |
| 기숙사 식단 | `dormitoryMenu(...)` | `dormitory_menu(...)` | `dormitoryMenu(...)` | [기숙사](https://cassiiopeia.github.io/pro-sejongpeia/data/dormitory/) |
| 기숙사 공지 | `dormitoryNotices(...)` | `dormitory_notices(...)` | `dormitoryNotices({ ... })` | [기숙사](https://cassiiopeia.github.io/pro-sejongpeia/data/dormitory/) |
| 학식 메뉴 | `cafeteriaMenu(placeId)` | `cafeteria_menu(place_id)` | `cafeteriaMenu(placeId)` | [학식](https://cassiiopeia.github.io/pro-sejongpeia/data/cafeteria/) |
| 학식 식당 목록 | `cafeteriaPlaces()` | `cafeteria_places()` | `cafeteriaPlaces()` | [학식](https://cassiiopeia.github.io/pro-sejongpeia/data/cafeteria/) |
| 캠퍼스 장소 | `campusPlaces(...)` | `campus_places(...)` | `campusPlaces(...)` | [캠퍼스 장소](https://cassiiopeia.github.io/pro-sejongpeia/data/campus-places/) |
| 비교과 일정 | `extracurricularEvents(...)` | `extracurricular_events(...)` | `extracurricularEvents({ ... })` | [비교과 프로그램](https://cassiiopeia.github.io/pro-sejongpeia/data/extracurricular/) |
## 결과 값 규칙
세 언어가 같습니다. 필드 대응의 기준은 [`scripts/generate-public-data-fixtures.py`](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/scripts/generate-public-data-fixtures.py)이고, 테스트는 같은 기대 결과 파일([`expected_public_data.json`](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/fixtures/public_data/expected_public_data.json))과 비교합니다.
- **학교 값 그대로**: 코드표나 이름 매핑을 라이브러리가 들고 있지 않습니다. 학과, 학기, 식당 번호 같은 값은 학교가 바꾸면 달라집니다. 값 목록으로 분기하는 코드를 만들지 마세요.
- **없는 값은 빈 문자열**: 값이 없거나 `null`이면 빈 문자열입니다. 숫자 필드도 문자열로 옵니다. 예외는 `views`, `total`(정수)과 `latitude`, `longitude`(실수)이고, 이 필드는 값이 없으면 `0`과 `0.0`입니다.
- **행은 버리지 않음**: 이상한 행 하나가 목록 전체를 실패시키지 않습니다. 목록 전체를 담는 컨테이너(`data`, `items`, `events`, `root`, `dl_*`)가 없거나 배열이 아닐 때만 실패입니다.
- **스칼라는 텍스트로**: 문자열은 그대로, 숫자는 평범한 십진 표기, 불리언은 `true`와 `false`로 바꿉니다.
- **`extra`**: 매핑에 쓰지 않은 학교 필드를 `문자열: 문자열` 맵으로 담습니다. 비어 있거나 `null`인 값, 아래 제외 목록은 뺍니다. 객체나 배열 값은 공백 없는 JSON 문자열이 됩니다. 학교가 필드를 더하면 새 릴리스 없이도 `extra`에 나타날 수 있습니다. 기숙사 모델과 학식 메뉴 항목(`CafeteriaMenuItem`)에는 `extra`가 없습니다.
- **`extra` 제외 목록**: `CD_ALIAS1`~`CD_ALIAS3`(과 `_ENG`), `regname`, `regid`, `STACK_TRACE`. 직원 이름은 `extra`에 들어가지 않습니다.
- **Python 이름 표기**: 결과 필드는 Python이 `snake_case`, Node.js와 Java가 `camelCase`입니다. 이 문서의 필드 표는 Python 이름을 쓰고, Node.js와 Java는 `camelCase`로 바꿔 읽으면 됩니다 (예: `start_date` > `startDate`).
## 호출 한 번의 흐름
> 다이어그램: 공개 데이터 호출은 24시간 안의 캐시가 있으면 그대로 돌려주고, 없으면 학교에 요청 한 번을 보낸다. 성공하면 저장하고 돌려주며, 실패하면 마지막 캐시 값이나 빈 목록을 돌려준다
## 실패 격리
- 연결 실패, 시간 초과, 2xx가 아닌 상태, JSON이 아닌 본문, 기대한 키 없음, `_SUBMIT_ERROR_` 객체는 모두 **예외 없이** 마지막 캐시 값(만료됐어도)을 돌려줍니다. 캐시가 없으면 빈 목록입니다. 기숙사 공지만 빈 객체(`total` 0, 빈 목록)입니다.
- 학교 서버의 내부 정보(`STACK_TRACE` 등)는 로그와 결과에 내보내지 않습니다.
- **예외를 던지는 경우는 잘못된 입력뿐입니다.** `curriculum_courses`에 빈 학과 코드나 과목 번호를 주거나, `graduate_calendar`에 형태가 틀린 사이트 이름을 주면 `INVALID_INPUT`이고 요청은 보내지 않습니다. 그 밖의 인자는 예외를 던지지 않습니다 ([에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)).
- 인증과 공개 데이터는 서로 의존하지 않습니다. 공개 데이터가 실패해도 인증 결과는 바뀌지 않고, 반대도 같습니다.
- **빈 목록은 두 가지 뜻입니다.** 「데이터가 없음」과 「학교 쪽 문제」를 구분하지 못합니다. 화면에는 「불러오지 못했습니다」 같은 안내를 함께 두세요.
## 캐시
- 메서드와 인자 조합마다 메모리에 캐시합니다. 기본 유효 시간은 86400초(하루)이고, 신선한 캐시가 있으면 요청을 보내지 않습니다.
- 캐시 없이 실패하면 같은 호출은 `failure_cache_seconds`(기본 60초) 동안 요청 없이 빈 값을 돌려줍니다. 만료된 캐시 값이 있으면 실패해도 그 값을 돌려줍니다.
- 캐시는 최대 `cache_max_entries`(기본 500)개이고, 가득 차면 가장 오래된 항목을 지웁니다. 항목마다 있는 잠금도 함께 지워져서 사용자가 입력한 검색어를 인자로 써도 메모리가 계속 늘지 않습니다.
- **같은 인자의 동시 호출은 요청 한 번을 나눠 씁니다.**
- 호출 한 번에 요청은 한 번입니다. 예외는 `cafeteria_places`로, 식당 번호마다 한 건씩 차례로 보냅니다 ([학식](https://cassiiopeia.github.io/pro-sejongpeia/data/cafeteria/)).
- 학교 서버에 부담을 주지 않도록 쓰세요. 봇이나 위젯은 하루 몇 번만 갱신해도 충분합니다.
## 요청 방식
- 모든 요청은 라이브러리의 전송 계층을 씁니다. 포털이 허용하는 오래된 TLS 암호 설정([레거시 TLS](https://cassiiopeia.github.io/pro-sejongpeia/understand/legacy-tls/))과 식별 가능한 User-Agent(`pro-sejongpeia-<언어>/<버전>`, [방화벽과 User-Agent](https://cassiiopeia.github.io/pro-sejongpeia/understand/firewall-user-agent/))를 자동으로 씁니다.
- SIS(`sjpt.sejong.ac.kr`) 요청: 본문은 JSON(`{"dm_search": ...}`), 헤더 `Content-Type: application/json; charset="UTF-8"`, `Accept: application/json`.
- 폼 요청(기숙사, 비교과): `application/x-www-form-urlencoded; charset=UTF-8`, `X-Requested-With: XMLHttpRequest`.
- 기숙사(`happydorm`) 응답은 `text/html` 콘텐츠 타입의 JSON입니다. 본문을 항상 JSON으로 해석합니다.
- 로그인과 쿠키 없이 요청합니다.
## 더 보기
- 계약 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md), [spec/REFERENCE_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/REFERENCE_DATA.md), 학교 데이터 출처 정리는 [spec/REFERENCE_DATA_SURVEY.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/REFERENCE_DATA_SURVEY.md)
- 사용 예시: [학과·강의 검색](https://cassiiopeia.github.io/pro-sejongpeia/guides/department-lecture-search/), [학사일정 위젯](https://cassiiopeia.github.io/pro-sejongpeia/guides/academic-calendar/), [기숙사·학식 봇](https://cassiiopeia.github.io/pro-sejongpeia/guides/dormitory-cafeteria-bot/)
- 실패와 캐시가 왜 이렇게 설계됐는지: [실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/)
- 새 출처를 더하는 방법: [새 정보 출처 추가하기](https://cassiiopeia.github.io/pro-sejongpeia/contribute/add-data-source/)
- 하위 호환 보장이 없으니 버전을 고정하세요: [API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/)
---
# 학과·학기 코드
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/codes/
Section: 학교 데이터 레퍼런스
학교 학사정보시스템(SIS)의 강의시간표 화면이 쓰는 코드표(소속 분류, 학기, 학과, 이수 구분, 선택 영역)를 로그인 없이 받습니다. 코드와 이름은 직접 적어 두지 말고 이 메서드로 받아서 다른 메서드에 넘기세요. 학과 코드는 로그인한 학생의 SIS 결과(`departmentCode`)와 같은 체계입니다. 공통 규칙은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)입니다.
## 요청 파라미터
메서드는 5개입니다: `organization_categories()`, `terms(category_code)`, `departments(category_code, year, semester_code)`, `course_types()`, `selective_domains()`. Node.js와 Java는 `camelCase`(`organizationCategories()`, `courseTypes()` 등)이고, `departments`는 Node.js에서 `departments({ categoryCode, year, semesterCode })`처럼 객체로 받습니다.
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `category_code` | 문자열 | `terms`, `departments`에서 필수 | - | 소속 분류 코드. 하드코딩한 목록이 아니라 `organization_categories()`가 돌려준 `code`를 넘깁니다 |
| `year` | 문자열 | `departments`에서 필수 | - | 연도. `terms(category_code)`가 돌려준 `year`를 넘깁니다 |
| `semester_code` | 문자열 | `departments`에서 필수 | - | 학기 코드 (아래 표) |
나머지 세 메서드(`organization_categories`, `course_types`, `selective_domains`)는 파라미터가 없습니다.
| 학기 코드 | 학기 |
|---|---|
| `10` | 1학기 |
| `11` | 하계 계절학기 |
| `20` | 2학기 |
| `21` | 동계 계절학기 |
**학교 요청**
- `POST https://sjpt.sejong.ac.kr/sch/sch/ext/SueOpenTimeQ/` 아래 엔드포인트: 소속 분류·이수 구분·선택 영역은 `doOnload.do`, 학기는 `doYearsmtlist.do`, 학과는 `doLesndeptgroup.do`. 본문은 `{"dm_search": {...}}` JSON입니다.
- 학과 요청의 `dm_search`는 `ORGN_CLSF_CD`, `YEAR`, `SMT_CD`, `YEAR_SMT`(`연도/학기 코드`, 예: `연도/20`)를 담습니다.
- 소속 분류·이수 구분·선택 영역은 같은 `doOnload.do` 응답에서 나오지만 메서드마다 캐시 항목이 따로입니다.
## 응답 데이터 구조
모두 목록을 돌려줍니다 (`OrganizationCategory`, `Term`, `Department`, `CourseType`, `SelectiveDomain`). 예시 JSON의 필드 이름은 Python `snake_case`이고 Node.js와 Java는 `camelCase`입니다 (예: `name_en` > `nameEn`). 학교 데이터는 바뀌므로 값은 예시입니다.
```jsonc
// terms("20")
[{ "year": "2026", "semester_code": "20", "code": "2026/20", "name": "2026/2학기", "extra": {} }]
// organization_categories()
[{ "code": "20", "name": "학부", "name_en": "Undergraduate", "extra": { "PRT_ORD": "200" } }]
// departments("20", "2026", "20")
[{
"code": "3517", "level": "2",
"college_code": "3500", "college_name": "인공지능융합대학",
"unit_code": "3517", "unit_name": "AI로봇학과",
"major_code": "", "major_name": "",
"name_en": "College of AI Convergence Department of Artificial Intelligence and Robotics",
"extra": { "DEPT_NM": "인공지능융합대학 AI로봇학과【3517 학부】", "ORGN_CLSF_CD": "20" }
}]
// course_types()
[{ "code": "11", "name": "교필", "category_code": "20", "extra": { "CURI_TYPE_FULL_NM": "교양필수" } }]
// selective_domains()
[{ "code": "10", "name": "인성과도덕", "name_en": "Character & Morality", "extra": { "PRT_ORD": "1" } }]
```
값이 없으면 빈 문자열이고, 모든 모델에 `extra`(문자열 맵)가 있습니다.
### 소속 분류 (`OrganizationCategory`)
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `code` | `COM_CD` | 분류 코드 (학부 `20`, 일반대학원 `30` 등). 다른 메서드의 `category_code`로 넘깁니다 |
| `name`, `name_en` | `CD_NM`, `CD_NM_ENG` | 한국어, 영어 이름 |
| `extra` | 그 밖의 필드 | 예: 표시 순서 `PRT_ORD` |
학교 응답에는 직원 이름이 들어 있는 `CD_ALIAS1`~`CD_ALIAS3` 필드가 있습니다. 라이브러리는 읽지도, `extra`에 담지도 않습니다.
### 학기 (`Term`)
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `year` | `YEAR` | 연도 (문자열) |
| `semester_code` | `SMT_CD` | 학기 코드 (위 표) |
| `code` | `YEAR_SMT_CD` | `연도/학기 코드` 형태 |
| `name` | `YEAR_SMT_NM` | 학교 표기 이름 |
### 학과 (`Department`)
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `code` | `DEPT_CD` | 학과 코드. 학생의 SIS 결과 `departmentCode`와 같은 체계 |
| `level` | `DEPT_LEVEL_CD` | `2` 학과 또는 학부, `3` 학부 안의 전공 |
| `college_code`, `college_name` | `DEPT_L_CD`, `DEPT_L_PRINT` | 단과대 코드와 깨끗한 이름 |
| `unit_code`, `unit_name` | `DEPT_M_CD`, `DEPT_M_PRINT` | 학과 또는 학부 코드와 이름 |
| `major_code`, `major_name` | `DEPT_S_CD`, `DEPT_S_PRINT` | 전공 코드와 이름 (`level`이 `3`일 때만) |
| `name_en` | `DEPT_NM_ENG` | 영어 이름 (일부 행은 비어 있음) |
- `level`이 `2`이면 `code`와 `unit_code`가 같고, `3`이면 `code`와 `major_code`가 같으며 상위 학과는 `unit_code`입니다.
- 이름은 `*_PRINT` 계열이 깨끗합니다. `extra`에 들어가는 `DEPT_NM` 같은 `*_NM` 필드에는 `【3517 학부】` 같은 꾸밈이 붙어 있고, 라이브러리는 이를 지우지 않습니다.
- `college_code`가 항상 실제 단과대는 아닙니다. 융합전공이나 옛 묶음 코드가 올 수 있으니 코드는 연결 키로만 쓰세요.
- 위 필드 밖의 학교 컬럼은 모두 `extra`에 담깁니다.
### 이수 구분 (`CourseType`)과 선택 영역 (`SelectiveDomain`)
| 모델 | 필드 | 학교 키 |
|---|---|---|
| `CourseType` | `code`, `name`, `category_code` | `CURI_TYPE_CD`, `CURI_TYPE_CD_NM`, `ORGN_CLSF_CD` |
| `SelectiveDomain` | `code`, `name`, `name_en` | `COM_CD`, `CD_NM`, `CD_NM_ENG` |
개설강좌의 `course_type_code`, `domain_code`와 같은 이름의 코드입니다 ([개설강좌](https://cassiiopeia.github.io/pro-sejongpeia/data/lectures/)).
- 메서드와 인자마다 하루(기본 86400초) 캐시합니다. 학과는 소속 분류, 연도, 학기 조합마다 따로 캐시되고, 호출당 학교 요청은 한 번입니다.
- 연결 실패, 시간 초과, 데이터셋 키 없음, 학교의 `_SUBMIT_ERROR_` 응답(HTTP 200이어도 오류 객체가 올 수 있음)은 예외 없이 마지막 캐시 값 또는 빈 목록입니다.
- 빈 목록은 정상 응답일 수 있습니다. 예를 들어 해당 학기에 학과가 없는 소속 분류가 있습니다.
- 이 다섯 메서드는 입력 오류로 예외를 던지지 않습니다.
## 알려진 한계
- 비공식·무버전 엔드포인트입니다. 학교가 예고 없이 바꾸거나 없앨 수 있습니다.
- 학과 목록은 학기마다 달라집니다. 학과가 추가되고 이름이 바뀌므로 원하는 학기를 항상 같이 넘기세요.
- 이 목록은 그 학기에 강의를 여는 학과(시간표 필터용)입니다. 센터, 처 같은 비학술 조직 등 모든 행정 조직은 아닙니다.
- `code`가 학생 SIS의 학과 코드와 같다는 점은 학부 재학생 계정 기준입니다. 대학원생, 복수전공, 교직원 계정의 코드는 다를 수 있습니다.
- 계약 원본: [spec/REFERENCE_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/REFERENCE_DATA.md)
## 예시 코드
**Java**
```java
List categories = publicData.organizationCategories();
List terms = publicData.terms("20"); // 학부 학기 목록
List departments = publicData.departments("20", "2026", "20"); // 학부, 학기 코드 20 = 2학기
```
**Python**
```python
categories = public_data.organization_categories()
terms = public_data.terms("20") # 학부 학기 목록
departments = public_data.departments("20", "2026", "20") # 학부, 학기 코드 20 = 2학기
```
**Node.js**
```javascript
const categories = await publicData.organizationCategories();
const terms = await publicData.terms('20'); // 학부 학기 목록
const departments = await publicData.departments({ categoryCode: '20', year: '2026', semesterCode: '20' }); // 학부, 학기 코드 20 = 2학기
```
학과 목록으로 강의를 찾는 전체 예시는 [학과·강의 검색](https://cassiiopeia.github.io/pro-sejongpeia/guides/department-lecture-search/)을 보세요.
---
# 개설강좌
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/lectures/
Section: 학교 데이터 레퍼런스
학기별 개설강좌를 학과 코드, 과목명, 교수명으로 검색합니다. 로그인이 필요 없고, 세 조건이 모두 선택이라 **모두 비우면 그 학기 전체**를 받습니다. 학과와 학기 코드는 [학과·학기 코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/)로 확인하세요. 공통 규칙은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)입니다.
> **한눈에**
> - 메서드 `lectures`, 로그인 불필요, 반환은 `Lecture` 목록입니다.
> - 학과·과목명·교수명을 모두 비우면 그 학기 전체를 받습니다. 응답이 크니 가능하면 학과 단위로 조회하세요.
> - 학교 쪽이 실패하면 예외 대신 마지막 캐시 값이나 빈 목록을 돌려줍니다.
## 요청 파라미터
Python은 `snake_case`, Node.js와 Java는 `camelCase`입니다 (예: `department_code` > `departmentCode`). Java는 선택 인자도 빈 문자열(`""`)로 직접 넘깁니다.
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `category_code` | 문자열 | 필수 | - | 소속 분류 코드 (학부 `20`, 일반대학원 `30` 등). 값은 [코드표](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/)에서 받아 넘깁니다 |
| `year` | 문자열 | 필수 | - | 연도 |
| `semester_code` | 문자열 | 필수 | - | 학기 코드 (`10`, `11`, `20`, `21`) |
| `department_code` | 문자열 | 선택 | `""` | 학과 코드. 비우면 학과 제한 없음 |
| `course_name` | 문자열 | 선택 | `""` | 과목명 검색어 (부분 일치). 비우면 제한 없음 |
| `instructor_name` | 문자열 | 선택 | `""` | 교수명 검색어. 비우면 제한 없음 |
- 세 조건은 서로 독립이며 원하는 것만 조합합니다. **셋 모두 비우면 그 학기 전체**입니다.
- 반환은 `Lecture` 목록입니다.
**학교 요청**
- `POST https://sjpt.sejong.ac.kr/sch/sch/ext/SueOpenTimeQ/doList.do`, 본문 `{"dm_search": {...}}`, 응답 데이터셋 `dl_main`.
- 학교는 필터 키가 하나라도 빠지면 `_SUBMIT_ERROR_`로 답하므로, 라이브러리가 사용하지 않는 키도 빈 값으로 모두 채워 보냅니다.
- 공백 한 칸도 검색어로 받으므로 값은 앞뒤 공백을 지운 뒤 보냅니다.
## 응답 데이터 구조
예시 JSON입니다. 필드 이름은 Python `snake_case`이고 Node.js와 Java는 `camelCase`입니다. 학교 데이터는 바뀌므로 값은 예시입니다.
```json
[
{
"course_number": "011839",
"section": "001",
"name": "전공탐색",
"course_type_code": "1E",
"course_type_name": "공필",
"credits": "1.0",
"hours": "1.0 / 0 / 1",
"total_hours": "1",
"schedule": "목19:00-20:00",
"room": "센B104",
"instructor": "홍길동",
"grade_level": "1",
"department_code": "3517",
"department_name": "AI로봇학과",
"year": "2026",
"semester_code": "20",
"extra": { "PLAN_REG_YN": "Y", "YEAR_SMT_NM": "2026/2학기" }
}
]
```
모든 필드는 문자열이고 없으면 빈 문자열입니다. `extra`는 위 표에 쓰지 않은 학교 필드를 담은 문자열 맵입니다.
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `course_number` | `CURI_NO` | 과목 번호 |
| `section` | `CLASS` | 분반 |
| `name` | `CURI_NM` | 과목명 |
| `course_type_code`, `course_type_name` | `CURI_TYPE_CD`, `CURI_TYPE_CD_NM` | 이수 구분 코드와 이름 |
| `domain_code`, `domain_name` | `SLT_DOMAIN_CD`, `SLT_DOMAIN_CD_NM` | 선택 영역 코드와 이름 |
| `credits` | `CDT` | 학점 (예: `3.0`) |
| `hours` | `TM_NUM` | 시간 (학교 표기 그대로, 예: `3.0 / 3 / 0`) |
| `total_hours` | `TOT_TM_NUM` | 총 시간 |
| `schedule` | `LESN_TIME` | 강의 시간 (예: `화13:00-15:00`) |
| `room` | `LESN_ROOM` | 강의실 |
| `instructor` | `LESN_EMP` | 교수 이름 |
| `grade_level` | `STUDENT_YEAR` | 학년 |
| `language_code`, `language_name` | `CURI_LANG_CD`, `CURI_LANG_NM` | 강의 언어 |
| `department_code`, `department_name` | `DEPT_CD`, `SCH_DEPT_ALIAS` | 개설 학과 코드와 이름 |
| `managing_department` | `MANAGE_DEPT_NM` | 관리 학과 |
| `remark` | `REMARK` | 비고 |
| `year`, `semester_code` | `YEAR`, `SMT_CD` | 연도, 학기 코드 |
| `extra` | 그 밖의 필드 | 학교 원문 필드 |
> **교수 이름이 들어 있습니다**
> `instructor`는 학교 시간표에 공개된 교수 이름입니다. 결과를 외부에 그대로 공개하는 서비스라면 내보낼 필드를 직접 고르세요. [학과·강의 검색](https://cassiiopeia.github.io/pro-sejongpeia/guides/department-lecture-search/) 예시도 필요한 필드만 내려줍니다.
- 조건 조합마다 하루(기본 86400초) 캐시하고, 호출당 학교 요청은 한 번입니다.
- 학교 쪽 실패(연결, 시간 초과, 비정상 응답, `_SUBMIT_ERROR_`)는 마지막 캐시 값 또는 빈 목록입니다. 캐시가 없으면 `failure_cache_seconds`(기본 60초) 동안 요청 없이 빈 목록을 돌려줍니다.
- `lectures`는 필터 값 때문에 예외를 던지지 않습니다.
- 빈 목록은 「조건에 맞는 강의가 없음」과 「학교 쪽 문제」를 구분하지 못합니다.
## 알려진 한계
- 비공식 엔드포인트입니다. 필터 키 요구 사항 같은 동작은 학교가 바꾸면 달라집니다.
- 언어 필터는 `LANG_CHK="KOR"`로 고정해서 보냅니다.
- 조건을 모두 비운 응답은 수 MB로 크고 캐시 항목 하나가 `cache_ttl_seconds` 동안 메모리에 남습니다. 전체가 필요하지 않다면 학과 단위로 조회하세요.
- 강의 계획서(수업계획서)는 제공하지 않습니다. 양식에 교수 이름과 연락처 줄이 있어 기본 제공 대상에서 뺐습니다. 교과목 개요는 [교육과정](https://cassiiopeia.github.io/pro-sejongpeia/data/curriculum/)의 `overview`로 받을 수 있습니다.
- 계약 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)
## 예시 코드
**Java**
```java
// 학과 하나의 강의 (학기 코드 20 = 2학기, 과목명·교수명은 비움)
List departments = publicData.departments("20", "2026", "20");
List lectures = publicData.lectures("20", "2026", "20", departments.get(0).getCode(), "", "");
// 과목명으로만 검색
List byName = publicData.lectures("20", "2026", "20", "", "알고리즘", "");
```
**Python**
```python
departments = public_data.departments("20", "2026", "20")
lectures = public_data.lectures("20", "2026", "20", department_code=departments[0].code)
# 과목명으로만 검색
by_name = public_data.lectures("20", "2026", "20", course_name="알고리즘")
```
**Node.js**
```javascript
const departments = await publicData.departments({ categoryCode: '20', year: '2026', semesterCode: '20' });
const lectures = await publicData.lectures({ categoryCode: '20', year: '2026', semesterCode: '20', departmentCode: departments[0].code });
// 과목명으로만 검색
const byName = await publicData.lectures({ categoryCode: '20', year: '2026', semesterCode: '20', courseName: '알고리즘' });
```
강의의 `course_number`와 `department_code`로 그 학과 교육과정의 학년·학기·교과목 개요를 이어서 조회할 수 있습니다 ([교육과정](https://cassiiopeia.github.io/pro-sejongpeia/data/curriculum/)).
---
# 학사일정 (학부·대학원)
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/calendar/
Section: 학교 데이터 레퍼런스
기간을 주면 학사일정을 제목과 시작일, 종료일로 돌려줍니다. 로그인이 필요 없습니다. 학부와 대학원 일정은 학교의 출처가 달라서 메서드가 둘입니다. `calendar`는 홈페이지의 학사일정(학부 `0001`, 대학원 `0002`~`0007`), `graduate_calendar`는 대학원 사이트별 일정이고, 둘은 같은 결과 모델(`CalendarEvent`)을 쓰지만 **날짜 형식이 다릅니다.** 오늘부터 30일 안의 일정을 보여 주는 위젯은 [학사일정 위젯](https://cassiiopeia.github.io/pro-sejongpeia/guides/academic-calendar/)을 보세요. 공통 규칙은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)입니다.
## 요청 파라미터
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `start` (`calendar`) | 문자열 `yyyy-MM-dd` | 필수 | 없음 | 조회 시작일 |
| `end` (`calendar`) | 문자열 `yyyy-MM-dd` | 필수 | 없음 | 조회 종료일. 여러 달을 한 번에 받을 수 있음 |
| `category_code` (`calendar`) | 문자열 | 선택 | `0001` | 분류 선택: `0001` 학부, `0002`~`0007` 대학원. 분류 이름은 결과의 `category_name`으로 확인 |
| `site_id` (`graduate_calendar`) | 문자열 | 필수 | 없음 | 대학원의 사이트 이름 (예: `graduate`, `gsi`, `mba`). 호출하는 쪽이 넘기므로 새 대학원이 생겨도 릴리스가 필요 없음. 호스트 이름이 되므로 `[a-z0-9][a-z0-9-]{0,30}`에 맞아야 하고, 아니면 요청 없이 `INVALID_INPUT` |
| `year` (`graduate_calendar`) | 정수 | 필수 | 없음 | 연도 (Node.js는 숫자 또는 문자열) |
| `month` (`graduate_calendar`) | 정수 | 필수 | 없음 | 월 (Node.js는 숫자 또는 문자열) |
- 이름 표기: Python은 `snake_case`, Node.js와 Java는 `camelCase`입니다 (`categoryCode`, `siteId`). Node.js는 객체 하나로 받습니다: `calendar({ start, end, categoryCode? })`, `graduateCalendar({ siteId, year, month })`. Java는 `calendar(startDate, endDate)`, `calendar(startDate, endDate, categoryCode)`, `graduateCalendar(siteId, year, month)`이고 두 인자 버전이 `0001`을 씁니다.
- `calendar` 학교 요청: `GET https://www.sejong.ac.kr/kor/academics/academic-calendar.do?mode=getCalendarData&start=&end=&collDiv=<코드>`, 응답 배열 `data`.
- `graduate_calendar` 학교 요청: `GET https://.sejong.ac.kr/_custom/sejong/_app/calendar-graduate/proc/getCalendarData.jsp?year=&month=&siteId=`.
- 응답의 `items`는 JSON 문자열이라 라이브러리가 한 번 더 해석합니다 (배열로 와도 받음). `success`가 true가 아니거나 `items`가 없으면 학교 쪽 실패입니다. 일정이 없는 달은 정상 응답입니다.
## 응답 데이터 구조
`calendar` 예시입니다 (필드 이름은 Python 기준, Node.js와 Java는 `camelCase`). 학교 데이터는 바뀌므로 값은 예시입니다.
```json
[
{
"year": "2026", "month": "3", "category_code": "0001", "category_name": "학부",
"title": "개강", "title_en": "Start of classes",
"start_date": "2026-03-02", "end_date": "2026-03-02", "extra": {}
}
]
```
`graduate_calendar` 예시입니다. 날짜는 `MM.dd`이고 `extra`에 요일이 들어갑니다.
```json
[
{
"year": "2026", "month": "3", "category_code": "0002", "category_name": "일반대학원",
"title": "논문 제출 신청", "title_en": "Thesis submission",
"start_date": "03.04", "end_date": "03.10", "extra": { "frDtKo": "수", "toDtKo": "화" }
}
]
```
모든 필드는 문자열이고 없으면 빈 문자열입니다.
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `year`, `month` | `scmYear`, `scmMonth` | 일정이 속한 연도, 월 |
| `category_code`, `category_name` | `collDiv`, `collDivNm` | 분류 코드와 이름 (예: `0001`) |
| `title` | `scmSubject` | 제목 (학교 원문) |
| `title_en` | `scmSubjectEng` | 영문 제목 |
| `start_date`, `end_date` | `frDt`, `toDt` | 시작일, 종료일 |
| `extra` | 그 밖의 필드 | 예: `graduate_calendar`의 `frDtKo`, `toDtKo`(각 날짜의 요일) |
### 날짜 형식 차이
| 메서드 | `start_date` / `end_date` 형식 | 비고 |
|---|---|---|
| `calendar` | `yyyy-MM-dd` | 문자열 정렬이 날짜 순서와 같음 |
| `graduate_calendar` | `MM.dd` | 연도는 행의 `year`를 쓰고, 연말을 넘는 기간은 펼치지 않음 |
두 형식을 한 목록으로 합칠 때는 직접 변환하세요. 라이브러리는 바꿔 주지 않습니다.
### 실패 동작
- 메서드, 분류 코드 또는 사이트, 기간 조합마다 하루(기본 86400초) 캐시합니다. 호출당 요청은 한 번입니다.
- 학교 실패(연결, 시간 초과, 비정상 응답, 기대한 키 없음)는 마지막 캐시 값 또는 빈 목록이고, 캐시가 없으면 60초(기본) 동안 요청 없이 빈 목록입니다.
- 예외는 `graduate_calendar`의 사이트 이름 형태 오류(`INVALID_INPUT`) 하나뿐입니다. `calendar`는 예외를 던지지 않습니다.
- 빈 목록은 「그 기간에 일정이 없음」과 「학교 쪽 문제」를 구분하지 못합니다.
## 알려진 한계
- 제목은 학교 원문입니다. 학교가 표기를 바꾸면 달라지므로 제목으로 분기하지 마세요.
- `graduate_calendar`는 `MM.dd`만 주기 때문에 연말을 넘는 일정의 종료 연도를 알 수 없습니다.
- `calendar`의 `0002`~`0007` 코드가 각각 어느 대학원인지는 결과의 `category_name`으로 확인하세요.
- 사이트마다 같은 계열의 화면을 쓰지만 사이트별 응답은 달라질 수 있으니 사용할 `site_id`로 직접 호출해 보세요.
- 로그인이 필요한 학사 업무 기간은 이 메서드가 아닙니다 ([학사 업무 기간](https://cassiiopeia.github.io/pro-sejongpeia/auth/business-periods/)).
- 계약 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)
## 예시 코드
**Java**
```java
// 학부 학사일정 (yyyy-MM-dd)
List events = publicData.calendar("2026-10-30", "2026-11-30");
// 대학원 사이트 일정 (사이트 이름, 연, 월)
List graduateEvents = publicData.graduateCalendar("graduate", 2026, 10);
```
**Python**
```python
events = public_data.calendar("2026-10-30", "2026-11-30") # 학부 (category_code 기본값 0001)
# 대학원 사이트 일정 (사이트 이름, 연, 월)
graduate_events = public_data.graduate_calendar("graduate", 2026, 10)
```
**Node.js**
```javascript
const events = await publicData.calendar({ start: '2026-10-30', end: '2026-11-30' }); // 학부
// 대학원 사이트 일정 (사이트 이름, 연, 월)
const graduateEvents = await publicData.graduateCalendar({ siteId: 'graduate', year: 2026, month: 10 });
```
---
# 기숙사
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/dormitory/
Section: 학교 데이터 레퍼런스
기숙사(`happydorm.sejong.ac.kr`)가 공개로 주는 일주일 식단과 공지 목록을 받습니다. 로그인이 필요 없습니다. 식단을 메시지로 만드는 예시는 [기숙사·학식 봇](https://cassiiopeia.github.io/pro-sejongpeia/guides/dormitory-cafeteria-bot/), 공통 규칙은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)입니다.
## 요청 파라미터
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `location` (`dormitory_menu`, `dormitory_notices`) | 문자열 | 선택 | `SJ` | 기숙사 위치 구분 값 |
| `page` (`dormitory_notices`) | 정수 | 선택 | `1` | 공지 쪽 번호 |
| `rows` (`dormitory_notices`) | 정수 | 선택 | `10` | 한 쪽에 받을 공지 건수 |
| `keyword` (`dormitory_notices`) | 문자열 | 선택 | `""` | 제목 검색어. 비우면 전체 |
- 이름 표기: Python은 `snake_case`, Node.js와 Java는 `camelCase`입니다. Node.js는 `dormitoryMenu(location = 'SJ')`, `dormitoryNotices({ page, rows, keyword, location })`로 받습니다. Java는 선택 인자를 생략한 `dormitoryMenu()`, `dormitoryNotices()`와 전부 받는 `dormitoryMenu(String location)`, `dormitoryNotices(int page, int rows, String keyword, String location)`가 있고, 검색어 없이 부를 때는 `""`를 넘깁니다.
- 학교 요청(식단): `POST https://happydorm.sejong.ac.kr/food/indexFoodList.do`, 폼 `locgbn=`.
- 학교 요청(공지): `POST https://happydorm.sejong.ac.kr/bbs/getBbsList.do`, 폼 `cPage`, `rows`, `bbs_locgbn`, `bbs_id=notice`, `sType=subject`, `sWord`.
- 응답은 JSON인데 콘텐츠 타입이 `text/html`이라 라이브러리는 본문을 항상 JSON으로 읽습니다.
## 응답 데이터 구조
`dormitory_menu`는 `DormitoryMenuDay`의 목록(7일치)입니다 (필드 이름은 Python 기준, Node.js와 Java는 `camelCase`). 학교 데이터는 바뀌므로 값은 예시입니다.
```json
[
{
"date": "2026-03-02",
"breakfast": "밥 국 반찬",
"lunch": "밥 국 주반찬 김치",
"dinner": "밥 국 주반찬 김치",
"alternative": "간편식 교환"
}
]
```
`dormitory_notices`는 `DormitoryNotices` 객체 하나입니다.
```json
{
"total": 120,
"pinned": [
{ "id": "1001", "title": "고정 공지 제목", "date": "2026-03-01", "views": 300, "file_name": "안내.png" }
],
"items": [
{ "id": "1000", "title": "일반 공지 제목", "date": "2026-02-28", "views": 80, "file_name": "" }
]
}
```
기숙사 모델에는 `extra`가 없습니다. 값이 없으면 빈 문자열이고 `views`와 `total`은 `0`입니다.
### 식단 (`DormitoryMenuDay`)
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `date` | `fo_date1`~`7` | 날짜 |
| `breakfast` | `fo_menu_mor1`~`7` | 조식 |
| `lunch` | `fo_menu_lun1`~`7` | 중식 |
| `dinner` | `fo_menu_eve1`~`7` | 석식 |
| `alternative` | `fo_sub_menu1`~`7` | 대체 메뉴 |
메뉴 문자열은 학교 원문이라 한 칸에 반찬 여러 개가 이어져 있을 수 있습니다. 라이브러리는 나누지 않습니다.
### 공지 (`DormitoryNotices`와 `DormitoryNotice`)
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `total` | `totalCount[0].cnt` | 전체 건수 (정수) |
| `pinned` | `topList` | 고정 공지 목록 |
| `items` | `list` | 일반 공지 목록 |
공지 한 건:
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `id` | `seq` | 공지 번호 |
| `title` | `subject` | 제목 |
| `date` | `regdate` | 등록일 |
| `views` | `visitcnt` | 조회수 (정수) |
| `file_name` | `file_name1` | 첨부 파일 이름 (없으면 빈 문자열) |
작성자 필드(`regname`, `regid`)는 읽지 않고 결과에도 없습니다.
### 실패 동작
- 메서드와 인자(위치, 쪽, 건수, 검색어)마다 하루(기본 86400초) 캐시합니다. 호출당 요청은 한 번입니다.
- 학교 실패는 마지막 캐시 값, 없으면 식단은 빈 목록, 공지는 `total` 0과 빈 목록인 객체입니다. 입력 오류로 예외를 던지지 않습니다.
- 응답에서 `root` 배열이 없거나 비어 있으면 식단과 공지 모두 실패입니다.
- 공지만의 예외: `topList`, `list`, `totalCount`가 없으면 장애가 아니라 「결과 없음」으로 보고 빈 목록과 `total` 0을 돌려줍니다. 검색어에 맞는 공지가 없는 경우를 장애로 취급하지 않기 위해서입니다.
## 알려진 한계
- `location`은 기본값 `SJ`만 쓰는 것을 전제로 합니다. 다른 값은 직접 호출해 보고 쓰세요.
- 학교가 식단을 올리기 전 주말 등에는 오늘 날짜가 목록에 없을 수 있습니다. 호출하는 쪽에서 처리하세요 ([기숙사·학식 봇](https://cassiiopeia.github.io/pro-sejongpeia/guides/dormitory-cafeteria-bot/)은 첫날을 대신 보여 줍니다).
- 빈 목록은 「공지·식단이 없음」과 「학교 쪽 문제」를 구분하지 못합니다.
- 공지 본문은 제공하지 않습니다. 제목, 날짜, 조회수, 첨부 파일 이름만 받습니다.
- 계약 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)
## 예시 코드
**Java**
```java
List menuDays = publicData.dormitoryMenu(); // 7일치
DormitoryNotices notices = publicData.dormitoryNotices(1, 3, "", "SJ"); // 1쪽, 3건
```
**Python**
```python
menu_days = public_data.dormitory_menu() # 7일치
notices = public_data.dormitory_notices(rows=3) # 최신 3건
for notice in notices.items:
print(notice.date, notice.title)
```
**Node.js**
```javascript
const menuDays = await publicData.dormitoryMenu(); // 7일치
const notices = await publicData.dormitoryNotices({ rows: 3 }); // 최신 3건
```
---
# 학식
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/cafeteria/
Section: 학교 데이터 레퍼런스
학교 식당 번호로 그 식당의 메뉴와 가격을 받고(`cafeteria_menu`), 식당 번호, 이름, 건물, 위치 목록을 받습니다(`cafeteria_places`). 로그인이 필요 없습니다. 학교에 식당 목록 API가 없어서 목록은 라이브러리가 번호 1부터 `max_place_id`까지의 메뉴 응답을 차례로 조회해 모은 것입니다. 공통 규칙은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)입니다.
## 요청 파라미터
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `place_id` (`cafeteria_menu`) | 문자열 | 필수 | 없음 | 학교의 식당 번호. 라이브러리에 번호표가 없으므로 `cafeteria_places()`로 확인 |
| `max_place_id` (`cafeteria_places`) | 정수 | 선택 | `10` | 식당 목록을 만들 때 조회할 번호 범위 `1`~`max_place_id`. 식당이 늘어나면 올림 (최대 `50`, 벗어나면 요청 없이 `INVALID_INPUT`) |
- 이름 표기: Python은 `snake_case`, Node.js와 Java는 `camelCase`입니다 (`maxPlaceId`). Node.js는 `cafeteriaMenu(placeId)`, `cafeteriaPlaces({ maxPlaceId })`이고, Java는 `cafeteriaMenu(String placeId)`, `cafeteriaPlaces()`, `cafeteriaPlaces(int maxPlaceId)`입니다.
- 학교 요청: `GET https://www.sejong.ac.kr/kor/unilife/cafeteria-info.do?mode=getMenuList&placeId=<번호>`, 응답 배열 `items`.
- `cafeteria_places`는 같은 주소를 번호 1부터 `max_place_id`까지 한 건씩 차례로 호출합니다 (병렬 요청 없음). 호출 한 번에 요청이 여러 번 나가는 유일한 메서드입니다.
- 번호마다 응답의 첫 행에서 식당 한 곳을 만들고, 메뉴 행이 없는 번호는 목록에 더하지 않습니다. 같은 `place_id`는 한 번만 나오며 결과는 번호 순입니다.
## 응답 데이터 구조
식당 메뉴 `cafeteria_menu` 예시입니다 (필드 이름은 Python 기준, Node.js와 Java는 `camelCase`). 학교 데이터는 바뀌므로 값은 예시입니다.
```json
[
{
"place_id": "1", "place_name": "식당 이름", "building_name": "건물 이름", "location": "6층",
"date": "2026-03-02", "day": "월", "meal_type_id": "1", "meal_name": "중식",
"menu_name": "국 · 주반찬 · 김치", "price": "7000"
},
{
"place_id": "3", "place_name": "다른 식당", "building_name": "건물 이름", "location": "B1층",
"date": "", "day": "", "meal_type_id": "", "meal_name": "",
"menu_name": "라면", "price": "3300"
}
]
```
식당 목록 `cafeteria_places` 예시입니다.
```json
[
{ "place_id": "1", "place_name": "식당 이름", "building_name": "건물 이름", "location": "6층", "extra": {} }
]
```
모든 필드는 문자열이고 없으면 빈 문자열입니다. 식당에 따라 날짜가 있는 주간 메뉴이거나 날짜 없는 상시 메뉴입니다. 상시 메뉴 식당의 행은 `date`, `day`, `meal_type_id`, `meal_name`이 빈 문자열입니다.
### 메뉴 (`CafeteriaMenuItem`, `extra` 없음)
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `place_id` | `placeId` | 식당 번호 |
| `place_name` | `placeName` | 식당 이름 |
| `building_name` | `buildingName` | 건물 |
| `location` | `location` | 위치 (예: `6층`) |
| `date` | `menuDate` | 메뉴 날짜 (상시 메뉴 식당은 빈 문자열) |
| `day` | `day` | 요일 (상시 메뉴 식당은 빈 문자열) |
| `meal_type_id` | `mealTypeId` | 식사 구분 번호 (`1` 중식, `2` 석식) |
| `meal_name` | `mealName` | 식사 이름 (학교 원문) |
| `menu_name` | `menuName` | 메뉴 이름 |
| `price` | `price` | 가격 (문자열, 예: `5500`) |
라이브러리는 식사 구분 번호를 번역하지 않으니 학교가 주는 `meal_name`을 쓰세요.
### 식당 (`CafeteriaPlace`)
| 필드 | 설명 |
|---|---|
| `place_id`, `place_name` | 식당 번호, 이름 |
| `building_name`, `location` | 건물, 위치 |
| `extra` | 메뉴 필드(날짜, 요일, 식사, 메뉴 이름, 가격)를 뺀 나머지 학교 필드 |
### 실패 동작
- 번호마다 하루(기본 86400초) 캐시합니다. 학교 실패는 마지막 캐시 값, 없으면 빈 목록이고 캐시가 없는 실패는 60초(기본) 동안 요청 없이 빈 목록입니다.
- `cafeteria_places`는 요청 하나라도 실패하면 목록 전체가 실패입니다. 일부만 하루 동안 캐시되는 일이 없도록 부분 목록은 캐시하지 않습니다.
- 두 메서드 모두 학교 쪽 문제로 예외를 던지지 않습니다. `max_place_id`가 범위를 벗어난 경우만 `INVALID_INPUT`입니다.
- 빈 `cafeteria_menu`는 번호가 틀린 것을 증명하지 않습니다. 없는 번호와 메뉴가 아직 올라오지 않은 날(휴일 포함) 모두 빈 목록입니다.
## 알려진 한계
- 학교에 식당 목록 API가 없어 **`max_place_id`를 넘는 번호의 식당은 찾지 못합니다.** 식당이 늘어나면 `max_place_id`를 올리세요.
- 메뉴가 비어 있는 식당(주간 메뉴 미등록, 휴일)은 다음 갱신(기본 하루)까지 목록에서 빠집니다.
- 메뉴 번호가 없는 식당은 이 목록에 나올 수 없습니다.
- 식당 번호는 바뀔 수 있으니 번호를 코드에 적어 두기보다 `cafeteria_places()`로 확인하세요. 라이브러리는 번호로 이름을 짐작하지 않습니다.
- 계약 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)
## 예시 코드
**Java**
```java
List places = publicData.cafeteriaPlaces(); // 기본 10건 (maxPlaceId 인자로 최대 50), 하루 캐시
List menu = publicData.cafeteriaMenu("1"); // 1번 식당 메뉴
```
**Python**
```python
places = public_data.cafeteria_places() # 기본 10건 (max_place_id 인자로 최대 50), 하루 캐시
menu = public_data.cafeteria_menu("1") # 1번 식당 메뉴
```
**Node.js**
```javascript
const places = await publicData.cafeteriaPlaces(); // 기본 10건 (maxPlaceId 인자로 최대 50), 하루 캐시
const menu = await publicData.cafeteriaMenu('1'); // 1번 식당 메뉴
```
---
# 캠퍼스 장소
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/campus-places/
Section: 학교 데이터 레퍼런스
학교 캠퍼스 지도가 쓰는 장소 목록을 로그인 없이 받습니다. 건물과 편의시설의 이름, 위도와 경도, 안내 정보가 들어 있어서 지도 앱이나 학내 안내 서비스에 바로 쓸 수 있습니다. 공통 규칙은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)입니다.
## 요청 파라미터
Python은 `snake_case`(`campus_places`), Node.js와 Java는 `camelCase`(`campusPlaces`)입니다. Java는 `campusPlaces()`와 `campusPlaces(String placeType)` 두 형태가 있고, Node.js는 `campusPlaces(placeType = '')`입니다.
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `place_type` | 문자열 | 선택 | `""` | 학교의 장소 유형 값. 고정 목록이 아니라 결과의 `place_type` 값을 그대로 넘겨 거를 수 있고, 비우면 유형 제한 없이 받습니다 |
- 반환은 `CampusPlace` 목록입니다.
**학교 요청**
- `GET https://www.sejong.ac.kr/kor/intro/campus-map.do?mode=getPlaceUserList&placeType=<유형>`, 응답 배열 `items`.
## 응답 데이터 구조
예시 JSON입니다. 필드 이름은 Python `snake_case`이고 Node.js와 Java는 `camelCase`입니다. 학교 데이터는 바뀌므로 값은 예시입니다.
```json
[
{
"id": "3",
"name": "산들푸드",
"name_en": "",
"place_type": "WELFARE",
"category": "식당",
"latitude": 37.5495931628,
"longitude": 127.0751638599,
"info": "학생회관 B1층",
"phone": "02-000-0000",
"description": "",
"extra": { "orderNo": "1", "useYn": "Y" }
}
]
```
값이 없으면 빈 문자열이고, 위도와 경도는 숫자(없거나 읽을 수 없으면 `0.0`)입니다.
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `id` | `id` | 장소 번호. 식당 번호와는 다른 체계 ([학식](https://cassiiopeia.github.io/pro-sejongpeia/data/cafeteria/)) |
| `name`, `name_en` | `placeName`, `placeNameEn` | 한국어, 영어 이름 |
| `place_type` | `placeType` | 장소 유형 (예: `BUILDING`, `WELFARE`) |
| `category` | `welfareCategory` | 편의시설 분류 |
| `latitude`, `longitude` | `latitude`, `longitude` | 위도, 경도 (실수) |
| `info` | `placeInfo` | 안내 정보 |
| `phone` | `telNum` | 전화번호 |
| `description` | `description` | 설명 |
| `extra` | 그 밖의 필드 | 학교 원문 필드 |
- 유형 값마다 하루(기본 86400초) 캐시하고, 호출당 학교 요청은 한 번입니다.
- 학교 쪽 실패(연결, 시간 초과, 비정상 응답, `items` 없음)는 마지막 캐시 값 또는 빈 목록입니다. 캐시가 없으면 60초(기본) 동안 요청 없이 빈 목록입니다.
- 이 메서드는 예외를 던지지 않습니다.
- 빈 목록은 「장소가 없음」과 「학교 쪽 문제」를 구분하지 못합니다.
## 알려진 한계
- 장소 `id`는 식당 메뉴의 식당 번호와 다른 체계이고, 둘을 잇는 값이 없습니다.
- 위도·경도가 없으면 `0.0`이므로 지도에 찍기 전에 확인하세요.
- `phone`, `info`는 학교가 입력한 값 그대로입니다. 외부에 그대로 내보내기 전에 값을 확인하세요.
- 계약 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)
## 예시 코드
**Java**
```java
List places = publicData.campusPlaces();
for (CampusPlace place : places) {
System.out.println(place.getName() + " " + place.getLatitude() + "," + place.getLongitude());
}
```
**Python**
```python
places = public_data.campus_places()
for place in places:
print(place.name, place.latitude, place.longitude)
```
**Node.js**
```javascript
const places = await publicData.campusPlaces();
for (const place of places) console.log(place.name, place.latitude, place.longitude);
```
---
# 비교과 프로그램
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/extracurricular/
Section: 학교 데이터 레퍼런스
학교의 비교과 프로그램 포털(`do.sejong.ac.kr`)이 공개로 주는 일정을 기간으로 조회합니다. 로그인이 필요 없습니다. 분류, 시작·종료 시각, 장소, 설명, 상세 주소가 들어 있습니다. 공통 규칙은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)입니다.
## 요청 파라미터
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `start` | 문자열 `yyyy-MM-dd` | 필수 | 없음 | 조회 시작일 |
| `end` | 문자열 `yyyy-MM-dd` | 필수 | 없음 | 조회 종료일 |
| `category` | 문자열 | 선택 | `""` | 학교의 분류 값. 비우면 전체. 분류 이름은 결과의 `category_title`로 확인 |
- 이름 표기: Python은 `snake_case`, Node.js와 Java는 `camelCase`입니다. Node.js는 `extracurricularEvents({ start, end, category = '' })`, Java는 `extracurricularEvents(startDate, endDate)`와 `extracurricularEvents(startDate, endDate, category)`입니다 (선택 인자를 비우려면 `""`).
- 학교 요청: `POST https://do.sejong.ac.kr/ko/process/schedule/getEvents`, 폼 `cid=eco`, `category`, `start`, `end`, `start_year`, `end_year` (연도 필드는 각 날짜의 앞 네 글자), 응답 배열 `events`.
- 폼 요청 헤더는 `application/x-www-form-urlencoded; charset=UTF-8`와 `X-Requested-With: XMLHttpRequest`입니다.
## 응답 데이터 구조
`ExtracurricularEvent`의 목록입니다 (필드 이름은 Python 기준, Node.js와 Java는 `camelCase`). 학교 데이터는 바뀌므로 값은 예시입니다.
```json
[
{
"id": "event-id",
"title": "프로그램 제목",
"start": "2026-03-10T14:00:00+09:00",
"end": "2026-03-10T16:00:00+09:00",
"category_title": "분류 이름",
"url": "https://do.sejong.ac.kr/ko/program/all/view/0000",
"description": "프로그램 설명",
"location": "장소"
}
]
```
모두 문자열이고 없으면 빈 문자열입니다. 이 모델에는 `extra`가 없습니다.
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `id` | `id` | 프로그램 번호 |
| `title` | `title` | 제목 |
| `start`, `end` | `start`, `end` | 시작, 종료 (날짜·시각 문자열, 학교 원문) |
| `category_title` | `category_title` | 분류 이름 |
| `url` | `data.url` | 상세 주소 |
| `description` | `data.description` | 설명 |
| `location` | `data.location` | 장소 |
`url`, `description`, `location`은 학교 응답의 `data` 객체 안에서 읽습니다.
### 실패 동작
- 기간과 분류 조합마다 하루(기본 86400초) 캐시합니다. 호출당 요청은 한 번입니다.
- 학교 실패(연결, 시간 초과, 비정상 응답, `events` 없음)는 마지막 캐시 값 또는 빈 목록이고, 캐시가 없으면 60초(기본) 동안 요청 없이 빈 목록입니다.
- 이 메서드는 예외를 던지지 않습니다.
- 빈 목록은 「그 기간에 프로그램이 없음」과 「학교 쪽 문제」를 구분하지 못합니다.
## 알려진 한계
- `category`에 쓸 수 있는 값의 목록은 제공하지 않습니다. 결과의 `category_title`로 분류 이름을 확인하세요.
- `start`, `end`는 학교 원문 형식이므로 파싱 전에 실제 값을 확인하세요.
- 한 번에 받을 수 있는 기간의 상한이 명시되어 있지 않으니 기간을 길게 잡기 전에 직접 호출해 보세요.
- 계약 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)
## 예시 코드
**Java**
```java
List events = publicData.extracurricularEvents("2026-10-01", "2026-10-31");
```
**Python**
```python
events = public_data.extracurricular_events("2026-10-01", "2026-10-31")
for event in events:
print(event.start, event.title, event.location)
```
**Node.js**
```javascript
const events = await publicData.extracurricularEvents({ start: '2026-10-01', end: '2026-10-31' });
```
---
# 교육과정
Source: https://cassiiopeia.github.io/pro-sejongpeia/data/curriculum/
Section: 학교 데이터 레퍼런스
개설강좌 목록에서 얻은 학과 코드와 과목 번호로, 그 학과 교육과정에서 이 과목이 권장되는 학년·학기, 이수 구분, 학점, 시수와 **교과목 개요**를 받습니다. 로그인이 필요 없고, 교육과정 전체 목록 없이 과목 하나씩 조회합니다. 공통 규칙은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)입니다.
## 요청 파라미터
Python은 `snake_case`(`curriculum_courses`), Node.js와 Java는 `camelCase`(`curriculumCourses`)입니다. Node.js는 `curriculumCourses({ departmentCode, courseNumber })`처럼 객체로 받습니다.
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `department_code` | 문자열 | 필수 | - | 학위 학과의 SIS 코드(`DEPT_CD`). [학과 목록](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/)이나 강의 결과의 `department_code`에서 받아 넘깁니다 |
| `course_number` | 문자열 | 필수 | - | 과목 번호. 강의 결과의 `course_number`(`CURI_NO`)를 넘깁니다 |
- 반환은 `CurriculumCourse` 목록입니다 (한 과목에 0건 또는 1건).
- **학과 코드나 과목 번호가 비어 있으면 `INVALID_INPUT`**을 던지고 요청은 보내지 않습니다 ([에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)).
**학교 요청**
- `GET https://www.sejong.ac.kr/curriculum/getCurriculumOneKor.do?curiNo=<과목 번호>&deptNo=<학과 코드>`, 응답 배열 `items`.
- `Accept: application/json` 헤더가 없으면 학교가 404를 돌려주므로 세 언어 모두 이 헤더를 보냅니다.
- 교육과정 응답의 `deptCd`는 SIS의 `DEPT_CD`와 같은 코드 체계입니다.
## 응답 데이터 구조
예시 JSON입니다. 필드 이름은 Python `snake_case`이고 Node.js와 Java는 `camelCase`입니다. 학교 데이터는 바뀌므로 값은 예시입니다.
```json
[
{
"department_code": "3517",
"department_name": "AI로봇학과",
"category_code": "20",
"year": "2026",
"course_number": "011839",
"name": "전공탐색",
"course_type_code": "1E",
"course_type_name": "공통교양필수",
"credits": "1",
"lecture_hours": "0",
"practice_hours": "1",
"total_hours": "1",
"grade_level": "1",
"semester_code": "2",
"semester_name": "2학기",
"overview": "신입생이 전공 관련 주제를 탐구하고 소논문으로 정리하는 과목 ...",
"extra": { "curiYear": "2026", "studentYear": "1" }
}
]
```
모두 문자열이고 없으면 빈 문자열입니다.
| 필드 | 학교 키 | 설명 |
|---|---|---|
| `department_code`, `department_name` | `deptCd`, `deptNm` | 학과 코드와 이름 |
| `category_code` | `orgnClsfCd` | 소속 분류 코드 |
| `year` | `year` | 교육과정 연도 |
| `course_number`, `name` | `curiNo`, `curiNm` | 과목 번호, 과목명 |
| `course_type_code`, `course_type_name` | `curiTypeCd`, `curiTypeNm` | 이수 구분 코드와 이름 |
| `credits` | `cdt` | 학점 |
| `lecture_hours`, `practice_hours`, `total_hours` | `lecTmNum`, `exeTmNum`, `curiTm` | 강의, 실습, 총 시수 |
| `grade_level` | `stuYearCd` | 권장 학년 |
| `semester_code`, `semester_name` | `smtDanCd`, `smtDanNm` | 권장 학기 코드와 이름 |
| `overview` | **`remark`** | 교과목 개요 본문 |
| `extra` | 그 밖의 필드 | 예: `curiYear`, `studentYear`, `sameSlcCnt` |
- 학과 코드와 과목 번호 조합마다 하루(기본 86400초) 캐시하고, 호출당 학교 요청은 한 번입니다.
- 학교 쪽 실패(연결, 시간 초과, 비정상 응답, `items` 없음)는 마지막 캐시 값 또는 빈 목록입니다. 캐시가 없으면 60초(기본) 동안 요청 없이 빈 목록입니다.
- 빈 `items`는 정상 응답입니다. 그 학과의 교육과정에 없는 과목이면 빈 목록이고 오류가 아닙니다.
- 예외는 빈 학과 코드·과목 번호(`INVALID_INPUT`) 하나뿐입니다.
## 알려진 한계
- 학위 학과의 교육과정에 있는 과목만 돌아옵니다. 교양대학이나 대학원 학과 코드로는 빈 목록이 올 수 있습니다.
- 개요는 현재 교육과정 연도의 교육과정 글이고, 분반·교수별 글이 아닙니다.
- 교육과정 전체 목록은 제공하지 않습니다. 학교의 목록 요청은 학과 코드를 무시하고 화면 메뉴 번호로 학과를 정하는데, 그 번호와 학과 코드의 대응표가 없습니다. 한 학과 전체가 필요하면 강의 목록의 과목마다 이 메서드를 부를 수 있지만 과목당 요청이 한 번입니다.
- 교수의 수업계획서는 제공하지 않습니다. 양식에 교수 이름과 연락처 줄이 있습니다.
- 계약 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)
## 예시 코드
**Java**
```java
// 강의 결과의 학과 코드와 과목 번호로 교육과정 정보 조회 (없으면 빈 목록)
Lecture lecture = lectures.get(0);
List courses = publicData.curriculumCourses(lecture.getDepartmentCode(), lecture.getCourseNumber());
```
**Python**
```python
# 강의 결과의 학과 코드와 과목 번호로 교육과정 정보 조회 (없으면 빈 목록)
courses = public_data.curriculum_courses(lectures[0].department_code, lectures[0].course_number)
if courses:
print(courses[0].grade_level, courses[0].semester_name, courses[0].overview)
```
**Node.js**
```javascript
// 강의 결과의 학과 코드와 과목 번호로 교육과정 정보 조회 (없으면 빈 목록)
const courses = await publicData.curriculumCourses({
departmentCode: lectures[0].departmentCode,
courseNumber: lectures[0].courseNumber,
});
```
개설강좌를 먼저 찾는 방법은 [개설강좌](https://cassiiopeia.github.io/pro-sejongpeia/data/lectures/)를 보세요.
---
# 인증 메서드
Source: https://cassiiopeia.github.io/pro-sejongpeia/auth/methods/
Section: 인증 레퍼런스
학번과 비밀번호로 포털에 로그인하는 작업의 목록입니다. 세 언어가 같은 기능과 같은 결과를 가지고, 이름 표기만 다릅니다.
## 요청 파라미터
이름은 Python 기준이고 Java와 Node.js는 `camelCase`입니다 (`includeRaw`, `includeExtra`). Node.js는 옵션을 마지막 인자의 객체로 받습니다 (`{ includeRaw: true }`).
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `student_id` | string | 예 | | 학번. 비어 있으면 네트워크 호출 전에 `INVALID_INPUT` |
| `password` | string | 예 | | 포털 비밀번호. 비어 있으면 `INVALID_INPUT` |
| `year` | 4자리 숫자 또는 문자열 | 업무 기간 조회만 | | 조회 연도 |
| `month` | 1~12 또는 빈 값 | 아니오 | 빈 값 (연간 전체) | 조회 월. 업무 기간 조회만 |
| `include_raw` | boolean | 아니오 | `false` | `true`면 학교 원문(`raw_html`, `raw_json`)을 결과에 담음. 확장: [원문·extra 옵션](https://cassiiopeia.github.io/pro-sejongpeia/auth/raw-and-extra/) |
| `include_extra` | boolean | 아니오 | `false` | `true`면 이름 붙지 않은 나머지 SIS 필드를 `extra`에 담음. 확장: [원문·extra 옵션](https://cassiiopeia.github.io/pro-sejongpeia/auth/raw-and-extra/) |
## 메서드 대응표
| 작업 | Java | Python | Node.js |
|---|---|---|---|
| 통합: 학생 정보 + 고전독서 + 연락처 | `authenticate(id, pw)` | `authenticate(id, pw)` | `authenticate(id, pw)` |
| DHC: 학생 정보 + 고전독서 | `authenticateWithDHC(id, pw)` | `authenticate_dhc(id, pw)` | `authenticateDhc(id, pw)` |
| SIS: 학생 정보 + 연락처 | `authenticateWithSIS(id, pw)` | `authenticate_sis(id, pw)` | `authenticateSis(id, pw)` |
| SIS 학사 업무 기간 (로그인 필요) | `fetchSisBusinessPeriods(id, pw, year, month)` | `fetch_sis_business_periods(id, pw, year, month="")` | `fetchSisBusinessPeriods(id, pw, { year, month })` |
| 원문 포함 (디버깅) | `authenticateWithRaw`, `authenticateWithDHCRaw`, `authenticateWithSISRaw` | `include_raw=True` | `{ includeRaw: true }` |
| 나머지 SIS 필드 포함 | `authenticate(id, pw, false, true)` | `include_extra=True` | `{ includeExtra: true }` |
- Java는 Spring Boot 자동 설정이 등록하는 `SuhSejongAuthEngine` 빈의 메서드입니다. Spring 없이 쓰는 방법은 [설정](https://cassiiopeia.github.io/pro-sejongpeia/auth/configuration/)을 보세요.
- Python은 모듈 함수(`from sejongpeia import authenticate`)가 기본 설정의 클라이언트를 쓰고, 설정을 바꾸려면 `SejongAuthClient`를 직접 만듭니다.
- Node.js는 모든 메서드가 `Promise`를 돌려줍니다. Python과 Java는 동기 호출입니다.
## 응답 데이터 구조
통합 인증 결과의 일부입니다. 예시는 Python의 `snake_case` 이름이고 Java와 Node.js는 `camelCase`(`studentId`, `phoneNumber`)입니다. 전체 필드는 [결과 필드](https://cassiiopeia.github.io/pro-sejongpeia/auth/result-fields/)에 있습니다.
```json
{
"success": true,
"student_id": "20000000",
"name": "홍길동",
"major": "컴퓨터공학과",
"email": "user@example.com",
"phone_number": "010-0000-0000"
}
```
| 메서드 | 반드시 채워지는 것 | 실패하면 |
|---|---|---|
| 통합 | DHC가 주는 학생 정보와 고전독서 | DHC 단계가 실패하면 호출 전체가 실패합니다 |
| 통합 (연락처 등) | SIS가 주는 이메일, 전화번호, 영문 이름, 학교 코드 | SIS 단계가 실패해도 호출은 성공하고 그 필드만 `null`입니다 |
| 통합 (상위 소속) | 포털 메인 화면의 `parentUnitName` | 포털 메인 단계가 실패해도 호출은 성공하고 `null`입니다 |
| DHC | 학생 정보, 고전독서 | 실패하면 예외 |
| SIS | 학생 정보, 연락처, 학교 코드 (`grade`, `status`는 항상 빈 문자열) | 실패하면 예외 |
| 학사 업무 기간 | 업무별 허용 기간 목록 | 실패하면 재시도 없이 예외 |
- 빈 학번이나 빈 비밀번호는 `INVALID_INPUT`, 틀린 비밀번호는 `AUTHENTICATION_FAILED`입니다. 전체 코드는 [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)에 있습니다.
- 통합 인증은 포털에 **한 번만** 로그인하고 같은 세션으로 DHC, SIS, 포털 메인을 조회합니다. 학생 정보와 연락처를 따로 부르면 로그인이 두 번 일어나므로 통합 호출 하나를 쓰세요.
- 틀린 비밀번호는 재시도하지 않고 첫 실패에서 멈춥니다. 이유는 [로그인 판정과 계정 잠금 방지](https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/)에 있습니다.
- 소요 시간의 대부분은 포털 자체의 응답 시간이며 학교 서버 상태에 따라 달라집니다.
>
> 서버에서만 호출하세요. 브라우저나 앱에서 직접 부르면 비밀번호가 제3자 코드에 노출됩니다. 비밀번호는 저장하거나 로그에 남기지 마세요.
## 예시 코드
**Java**
```java
SejongAuthResult result = sejongAuthEngine.authenticate(studentId, password);
System.out.println(result.getName() + " " + result.getMajor());
```
**Python**
```python
from sejongpeia import authenticate
result = authenticate(student_id, password)
print(result.name, result.major)
```
**Node.js**
```javascript
import { authenticate } from 'pro-sejongpeia';
const result = await authenticate(studentId, password);
console.log(result.name, result.major);
```
## 이어서 보기
- [학생 인증 붙이기](https://cassiiopeia.github.io/pro-sejongpeia/guides/student-verification/): 동아리 가입 예시
- [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/): 실패 사유와 분기 방법
- [SSO 흐름](https://cassiiopeia.github.io/pro-sejongpeia/understand/sso-flow/): 통합 호출 안에서 일어나는 일
---
# 결과 필드
Source: https://cassiiopeia.github.io/pro-sejongpeia/auth/result-fields/
Section: 인증 레퍼런스
통합, DHC, SIS 인증이 돌려주는 결과의 필드와 타입을 정리합니다. 요청 쪽은 [인증 메서드](https://cassiiopeia.github.io/pro-sejongpeia/auth/methods/)에 있고, 원본 스키마는 [spec/RESULT_SCHEMA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/RESULT_SCHEMA.md)입니다.
## 응답 데이터 구조
통합 인증 결과의 예시입니다. 예시는 Python의 `snake_case` 이름이고, Java와 Node.js는 `camelCase`(`studentId`, `phoneNumber`)입니다.
```json
{
"success": true,
"student_id": "20000000",
"name": "홍길동",
"major": "컴퓨터공학과",
"grade": "3",
"status": "재학",
"classic_reading": {
"completed_semesters": "4 학기",
"certified_semesters": "4 학기",
"certification_status": "인증",
"certifications": [{ "area": "서양의 역사와 사상", "required_count": "1", "certified_count": "1" }],
"exam_records": [],
"subject_substitutions": [],
"contest_records": [],
"curriculum_records": []
},
"email": "user@example.com",
"phone_number": "010-0000-0000",
"english_name": "Gildong Hong",
"user_category": "학부생",
"parent_unit_name": "",
"authenticated_at": "yyyy-MM-ddTHH:mm:ss",
"department_code": "0000",
"extra": null,
"raw_html": null,
"raw_json": null
}
```
### 통합 결과 필드
| 필드 | 타입 | 출처 | 규칙 |
|---|---|---|---|
| `success` | boolean | | 결과가 돌아왔다면 항상 true |
| `studentId` | string | DHC | 학번 |
| `name` | string | DHC | 이름 |
| `major` | string | DHC | 학과(전공) |
| `grade` | string | DHC | 학년 |
| `status` | string | DHC | 학적 상태 (재학, 휴학, 졸업 등 포털 원문) |
| `classicReading` | object | DHC | 아래 「고전독서」 |
| `email` | string 또는 null | SIS | SIS 조회가 실패하면 null |
| `phoneNumber` | string 또는 null | SIS | 같음. `010-0000-0000` 형식 |
| `englishName` | string 또는 null | SIS | 같음 |
| `userCategory` | string 또는 null | SIS | 사용자 구분, 예: `학부생` |
| `parentUnitName` | string 또는 null | 포털 메인 | 포털 메인 호출이 실패하면 null, 쓸 만한 값이 없으면 빈 문자열 |
| `authenticatedAt` | 날짜·시간 | | 인증 시각 (각 언어의 지역 시간 타입) |
| SIS 학교 코드 11개 | string 또는 null | SIS | 아래 「SIS 학교 코드」. SIS 조회가 실패하면 null |
| `extra` | 맵 또는 null | SIS | extra 옵션을 켰을 때만. 아니면 null |
| `rawHtml` | string 또는 null | DHC | 원문 옵션을 켰을 때만 |
| `rawJson` | string 또는 null | SIS | 원문 옵션을 켰을 때만. SIS가 실패했으면 null |
### 실패했을 때
- DHC 단계가 실패하면 결과 없이 예외입니다 (`AUTHENTICATION_FAILED`, `DATA_FETCH_FAILED`, `SESSION_ERROR` 등. [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)).
- SIS 단계가 실패하면 호출은 성공하고 SIS 출처 필드(연락처, 영문 이름, 사용자 구분, 학교 코드)만 `null`입니다.
- 포털 메인 단계가 실패하면 호출은 성공하고 `parentUnitName`만 `null`입니다.
> **parentUnitName으로 판정하지 마세요**
> 학과 위의 소속 이름(단과대학 또는 학부)을 포털 화면 글자에서 뽑은 보조 정보입니다. 학생 유형에 따라 빈 문자열일 수 있고, 학년도마다 표기가 바뀌는 포털 원문이므로 값 목록으로 분기하거나 인증 판정에 쓰지 마세요.
## DHC 결과
`success`, `studentId`, `name`, `major`, `grade`, `status`, `classicReading`, `authenticatedAt`, `rawHtml`(원문 옵션일 때만)
연락처 필드는 없습니다.
## SIS 결과
`success`, `studentId`, `name`, `major`, `grade`(항상 빈 문자열), `status`(항상 빈 문자열), `email`, `phoneNumber`, `englishName`, `userCategory`, SIS 학교 코드, `extra`(extra 옵션일 때만), `authenticatedAt`, `rawJson`(원문 옵션일 때만)
고전독서 필드는 없습니다.
## 고전독서 (`classicReading`)
DHC의 「사용자 정보」 표에서 가져오는 포털 원문 문자열 3개와 하위 목록 5개입니다.
| 필드 | 타입 | 규칙 |
|---|---|---|
| `completedSemesters` | string | `이수 학기` 원문 (예: `8 학기`). 없으면 빈 문자열 |
| `certifiedSemesters` | string | `인증완료 학기` 원문. 없으면 빈 문자열 |
| `certificationStatus` | string | `인증여부` 원문. 자유 문장이라 대체 사유가 들어올 수 있음. 없으면 빈 문자열 |
| 목록 | 항목 필드 |
|---|---|
| `certifications` (영역별 인증 현황) | `area`, `requiredCount`, `certifiedCount` |
| `examRecords` (인증 시험) | `semester`, `area`, `bookTitle`, `examDate`, `score`, `passStatus` |
| `subjectSubstitutions` (과목 대체 인증) | `semester`, `subjectName`, `area`, `bookTitle`, `completion` |
| `contestRecords` (대회 인증) | `semester`, `contestName`, `area`, `bookTitle` |
| `curriculumRecords` (교과연계 인증) | `semester`, `subjectName`, `area`, `bookTitle`, `completion` |
Python 이름은 `snake_case`입니다 (`completed_semesters`, `required_count`, `book_title` 등). 해당 표에 행이 없으면 빈 목록입니다.
영역별 인증 현황을 읽는 Java 예시입니다.
```java
SejongDhcAuthResult result = sejongAuthEngine.authenticateWithDHC(studentId, password);
for (ClassicCertification certification : result.getClassicReading().getCertifications()) {
System.out.println(certification.getArea() + " "
+ certification.getCertifiedCount() + "/" + certification.getRequiredCount());
}
```
## SIS 학교 코드
학교가 준 문자열 그대로입니다. 코드표나 이름 매핑은 라이브러리에 없고, 값이 없으면 빈 문자열입니다. SIS 데이터셋을 `dm_UserInfoSch`, `dm_UserInfoGam`, `dm_UserInfo` 순서로 찾아 처음 나온 비어 있지 않은 값을 씁니다. 기본으로 SIS 결과와 통합 결과에 들어갑니다.
| 필드 (Python) | SIS 키 | 비고 |
|---|---|---|
| `departmentCode` (`department_code`) | `DEPT_CD`, 없으면 `DEPT_NO` | 두 값은 같은 학과 코드이며 `DEPT_CD`를 먼저 씁니다 |
| `organizationClassificationCode` | `ORGN_CLSF_CD` | |
| `enrollmentStatusCode` | `STATUS_DIV_CD` | 학교 원문 코드이며 뜻 매핑은 없음 |
| `userClassificationCode` | `USER_CLSF_CD` | |
| `userClassificationLargeCode` | `USER_CLSF_L_CD` | |
| `userClassificationMiddleCode` | `USER_CLSF_M_CD` | |
| `roleCode` / `roleName` | `RPSN_ROLE_CD` / `RPSN_ROLE_CD_NM` | |
| `menuSystemId` / `menuSystemName` | `MENU_SYS_ID` / `MENU_SYS_NM` | |
| `locale` | `INTG_LOCALE` | |
`departmentCode`는 [학과·학기 코드](https://cassiiopeia.github.io/pro-sejongpeia/data/codes/)의 학과 코드와 같은 체계입니다.
## null과 빈 문자열
- 인증 결과의 필드는 「학교 값이 없음」이면 빈 문자열입니다.
- SIS와 포털 메인에서 오는 필드는 **그 조회 자체가 실패했을 때** null입니다. 통합 호출은 이때도 성공합니다.
- 원문(`rawHtml`, `rawJson`)과 `extra`는 옵션을 켜지 않으면 null입니다.
- 이 값들은 모두 학교 원문이라, 학교가 표기를 바꾸면 달라질 수 있습니다. [API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/)을 보세요.
---
# 에러 코드
Source: https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/
Section: 인증 레퍼런스
모든 언어가 `code`를 가진 에러 하나를 던집니다. 메시지는 영어이고 바뀔 수 있으므로, 분기는 항상 **코드**로 하세요.
| 언어 | 에러 타입 | 코드를 읽는 곳 |
|---|---|---|
| Java | `SejongAuthException` | `getErrorCode()` (`SejongAuthErrorCode`) |
| Python | `SejongAuthError` | `error.code` (`ErrorCode`) |
| Node.js | `SejongAuthError` | `error.code` (`ErrorCode`) |
## 코드 표
| 코드 | 의미 | 주로 이럴 때 |
|---|---|---|
| `INVALID_INPUT` | 학번이나 비밀번호가 비었음 | 네트워크 호출 전에 거절. 학사 업무 기간의 연도·월 형식 오류도 포함 |
| `AUTHENTICATION_FAILED` | 학번 또는 비밀번호가 일치하지 않음 | 포털 판정 `erridpwd`/`Error`. 보조로 DHC 401, SIS의 로그아웃 오류 |
| `ACCOUNT_LOCKED` | 비밀번호를 여러 번 틀려 포털이 계정을 잠금 | 포털 판정 `pwdNeedChg` |
| `ACCOUNT_UNAVAILABLE` | 포털 계정으로 로그인할 수 없음 | 사용 기간 종료(`invalidDt`) 또는 관리자 차단(`invalid`) |
| `CONNECTION_FAILED` | 포털에 연결할 수 없음 | 네트워크 오류, 또는 로그인 재시도를 모두 소진 |
| `CONNECTION_TIMEOUT` | 포털 응답 시간 초과 | 마지막 로그인 시도가 시간 초과 |
| `SESSION_ERROR` | 포털 세션 처리 오류 | SSO 단계가 2xx가 아닌 응답, 알 수 없는 로그인 판정값 |
| `DATA_FETCH_FAILED` | 학생 정보 조회 실패 | 데이터 페이지가 200이 아니거나 비어 있음 |
| `PARSE_ERROR` | 응답 구조가 예상과 다름 | 포털이 화면이나 JSON 구조를 바꿨을 때 |
| `SSL_CONFIGURATION_ERROR` | TLS 설정 실패 | 클라이언트를 만드는 단계의 TLS 구성 오류 |
Java에는 더 이상 던지지 않는 `INVALID_CREDENTIALS`가 폐기 표시(`@Deprecated`)로 남아 있습니다. 기존 코드가 컴파일되도록 둔 것이고 Python과 Node.js에는 없습니다.
## 계정 잠금
세종대 포털은 비밀번호를 여러 번 틀리면 계정을 잠급니다.
- 잠금은 [포털 비밀번호 찾기](https://portal.sejong.ac.kr/jsp/inquiry/pwinquiry.jsp)에서 비밀번호를 재설정해야 풀립니다. 기다린다고 풀리지 않고, 라이브러리가 대신 풀 수도 없습니다.
- 라이브러리는 거부된 로그인을 재시도하지 않고 첫 실패에서 추가 요청 없이 멈춥니다.
- 누구나 학번을 입력할 수 있는 서비스라면 **학번별 실패 횟수를 제한**하세요. 그렇지 않으면 다른 사람이 남의 학번으로 일부러 실패를 반복해 그 학생의 포털 계정을 잠글 수 있습니다.
판정 방식은 [로그인 판정과 계정 잠금 방지](https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/)에 있습니다.
## 에러 분기 예시
**Java**
```java
try {
SejongAuthResult result = sejongAuthEngine.authenticate(studentId, password);
} catch (SejongAuthException exception) {
switch (exception.getErrorCode()) {
case AUTHENTICATION_FAILED -> { /* 학번·비밀번호 불일치: 학번별 실패 횟수 올리기 */ }
case ACCOUNT_LOCKED -> { /* 포털에서 비밀번호 재설정 안내 */ }
case ACCOUNT_UNAVAILABLE -> { /* 사용 기간 종료 또는 차단 */ }
case CONNECTION_FAILED, CONNECTION_TIMEOUT -> { /* 포털 장애: 잠시 뒤 재시도 안내 */ }
default -> { /* 파싱·세션 오류: 로그에 코드만 남기기 */ }
}
}
```
**Python**
```python
from sejongpeia import authenticate, SejongAuthError, ErrorCode
try:
result = authenticate(student_id, password)
except SejongAuthError as error:
if error.code == ErrorCode.AUTHENTICATION_FAILED:
... # 학번·비밀번호 불일치: 학번별 실패 횟수 올리기
elif error.code == ErrorCode.ACCOUNT_LOCKED:
... # 포털에서 비밀번호 재설정 안내
elif error.code in (ErrorCode.CONNECTION_FAILED, ErrorCode.CONNECTION_TIMEOUT):
... # 포털 장애: 잠시 뒤 재시도 안내
else:
... # 파싱·세션 오류: 로그에 코드만 남기기
```
**Node.js**
```javascript
import { authenticate, SejongAuthError, ErrorCode } from 'pro-sejongpeia';
try {
const result = await authenticate(studentId, password);
} catch (error) {
if (!(error instanceof SejongAuthError)) throw error;
if (error.code === ErrorCode.AUTHENTICATION_FAILED) {
// 학번·비밀번호 불일치: 학번별 실패 횟수 올리기
} else if (error.code === ErrorCode.ACCOUNT_LOCKED) {
// 포털에서 비밀번호 재설정 안내
} else if (error.code === ErrorCode.CONNECTION_FAILED || error.code === ErrorCode.CONNECTION_TIMEOUT) {
// 포털 장애: 잠시 뒤 재시도 안내
} else {
// 파싱·세션 오류: 로그에 코드만 남기기
}
}
```
>
> 공개 데이터(학과, 강좌, 학사일정 등)는 학교 서버가 실패해도 이 에러를 던지지 않고 마지막 캐시 값이나 빈 목록을 돌려줍니다. 예외는 잘못된 입력뿐입니다. 자세한 내용은 [실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/)를 보세요.
---
# 설정
Source: https://cassiiopeia.github.io/pro-sejongpeia/auth/configuration/
Section: 인증 레퍼런스
모두 선택 항목입니다. 기본값으로 충분하면 아무것도 설정하지 않아도 됩니다.
## 설정 항목
| 설정 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| 요청 제한 시간 | 숫자 (초) | 10 | 연결과 읽기 제한 시간. Java `sejongpeia.timeout-seconds`, Python `timeout_seconds`, Node.js `timeoutSeconds` |
| 포털 로그인 재시도 횟수 | 숫자 | 3 | Java `sejongpeia.max-retry`, Python `max_retry`, Node.js `maxRetry` |
| 인증서 검증 | boolean | `true` | Java `sejongpeia.ssl-verification`, Python `ssl_verification`, Node.js `sslVerification` |
## 재시도의 범위
- 재시도는 **포털 로그인 요청**에만 적용됩니다. 2xx가 아닌 응답과 시간 초과 때 다시 시도합니다.
- 마지막 시도가 시간 초과면 `CONNECTION_TIMEOUT`, 횟수를 모두 쓰면 `CONNECTION_FAILED`입니다.
- 포털이 틀린 비밀번호나 잠금으로 거부한 로그인은 재시도하지 않습니다. 계정을 지키기 위해서입니다.
## 인증서 검증
기본은 켜짐입니다. 끄는 옵션은 있지만 디버깅 용도로만 생각하세요. 끄면 로그인 정보가 가는 연결을 검증하지 않습니다.
## Java: Spring 없이 쓰기
Spring Boot는 선택 의존성이라 일반 Java 프로젝트는 받지 않습니다. 직접 객체를 엮어 쓰세요.
```java
import kr.suhsaechan.sejongpeia.auth.client.SejongPortalClient;
import kr.suhsaechan.sejongpeia.auth.client.SejongSisClient;
import kr.suhsaechan.sejongpeia.auth.model.SejongAuthResult;
import kr.suhsaechan.sejongpeia.auth.parser.SejongClassicReadingParser;
import kr.suhsaechan.sejongpeia.auth.parser.SejongSisParser;
import kr.suhsaechan.sejongpeia.auth.parser.SejongStudentInfoParser;
import kr.suhsaechan.sejongpeia.auth.service.SuhSejongAuthEngine;
import kr.suhsaechan.sejongpeia.common.config.SejongAuthProperties;
import kr.suhsaechan.sejongpeia.common.util.SejongTlsSupport;
SejongTlsSupport.ensureLegacyCipherEnabled(); // main() 맨 앞, 다른 HTTPS 호출보다 먼저
SejongAuthProperties properties = new SejongAuthProperties();
SuhSejongAuthEngine engine = new SuhSejongAuthEngine(new SejongPortalClient(properties), new SejongStudentInfoParser(),
new SejongClassicReadingParser(), new SejongSisClient(properties), new SejongSisParser());
SejongAuthResult result = engine.authenticate(studentId, password);
```
> **Java TLS 주의**
> 포털은 최신 JDK가 막아 둔 암호(`TLS_RSA_WITH_AES_256_CBC_SHA`)만 제공합니다. 라이브러리는 `jdk.tls.disabledAlgorithms`에서 `TLS_RSA_*`를 빼는데, 이 설정은 **JVM 전체**에 적용되고 JVM의 **첫 TLS 연결 전에만** 효과가 있습니다.
>
> - Spring Boot는 시작할 때 자동으로 처리합니다.
> - Spring 없이 쓰면 `main()` 맨 앞에서 `SejongTlsSupport.ensureLegacyCipherEnabled()`를 부르세요. 늦게 부르면 `CONNECTION_FAILED`와 그 원인을 알리는 메시지로 실패합니다.
> - JVM 전체 변경을 허용할 수 없다면 인증을 별도 프로세스로 분리하세요.
> - Python과 Node.js는 자기 연결의 TLS 설정만 바꿉니다.
>
> 배경은 [레거시 TLS](https://cassiiopeia.github.io/pro-sejongpeia/understand/legacy-tls/)에 있습니다.
## 공개 데이터 클라이언트
학교 공개 데이터(`SejongPublicData`)의 캐시 시간, 실패 기억 시간, 캐시 최대 개수 같은 설정은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)에 있습니다.
## 예시 코드
**Java**
```yaml
sejongpeia:
timeout-seconds: 10 # 요청 제한 시간 (기본 10)
max-retry: 3 # 포털 로그인 재시도 횟수 (기본 3)
ssl-verification: true # 포털 인증서 검증 (기본 true)
```
**Python**
```python
from sejongpeia import SejongAuthClient
# 기본값과 다른 설정이 필요할 때만 직접 만들기
client = SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)
result = client.authenticate(student_id, password)
```
**Node.js**
```javascript
import { SejongAuthClient } from 'pro-sejongpeia';
// 기본값과 다른 설정이 필요할 때만 직접 만들기
const client = new SejongAuthClient({ timeoutSeconds: 10, maxRetry: 3, sslVerification: true });
const result = await client.authenticate(studentId, password);
```
---
# 원문·extra 옵션
Source: https://cassiiopeia.github.io/pro-sejongpeia/auth/raw-and-extra/
Section: 인증 레퍼런스
기본 결과는 이름이 붙은 필드뿐입니다. 두 옵션은 필요할 때만 켜며, 켜도 다른 필드는 바뀌지 않습니다.
## 요청 파라미터
이름은 Python 기준이고 Java와 Node.js는 `camelCase`입니다. Node.js는 마지막 인자의 객체로 넘깁니다 (`{ includeRaw: true }`).
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `include_raw` | boolean | 아니오 | `false` | 학교 응답 원문을 결과에 담음 (`raw_html`, `raw_json`). Java는 `authenticateWithRaw`, `authenticateWithDHCRaw`, `authenticateWithSISRaw` |
| `include_extra` | boolean | 아니오 | `false` | 이름 붙지 않은 나머지 SIS 필드를 `extra` 맵에 담음. 통합·SIS 인증만. Java는 `authenticate(id, pw, false, true)`의 마지막 인자 |
## 응답 데이터 구조
옵션을 켜면 아래 필드가 채워집니다. 예시는 Python의 `snake_case` 이름이고 Java와 Node.js는 `camelCase`(`rawHtml`, `rawJson`)입니다.
```json
{
"student_id": "20000000",
"name": "홍길동",
"extra": { "dm_UserInfoSch.FIELD_NAME": "값" },
"raw_html": "...",
"raw_json": "{...}"
}
```
| 필드 | 타입 | 채워지는 조건 |
|---|---|---|
| `raw_html` | string 또는 null | 원문 옵션을 켰을 때 DHC 페이지. DHC·통합 결과 |
| `raw_json` | string 또는 null | 원문 옵션을 켰을 때 SIS `initUserInfo` 응답. SIS·통합 결과. SIS가 실패했으면 null |
| `extra` | 맵 또는 null | extra 옵션을 켰을 때. 아니면 null. DHC 결과에는 없음 |
### 원문 옵션
학교가 보낸 응답을 그대로 결과에 담습니다. 디버깅과 파서 점검용입니다.
- 통합 결과는 `raw_html`과 `raw_json`이 모두, DHC 결과는 `raw_html`만, SIS 결과는 `raw_json`만 채워집니다.
- **추가 요청은 없습니다.** 이미 받은 응답을 보관할 뿐입니다.
> **원문을 외부로 내보내지 마세요**
> SIS 원문에는 세션 식별자로 보이는 값, 접속 IP, 접속 시각이 들어 있습니다. 외부 클라이언트에 그대로 내려주지 마세요.
### extra 옵션
이름 붙인 필드 말고 학교가 주는 나머지 SIS 필드를 `{"데이터셋.필드": "값"}` 맵으로 받습니다. 학교가 필드를 더해도 새 릴리스 없이 접근할 수 있게 하는 통로입니다.
- 대상 데이터셋은 `dm_UserInfoSch`, `dm_UserInfoGam`, `dm_UserInfo`의 스칼라 필드입니다.
### 항상 제외되는 키
`spec/contract.json`의 `extraExcludedKeyPattern`에 걸리는 키(대소문자 무시)는 extra에 들어가지 않습니다.
```text
(PWD|PASS|JUMIN|RRN|RESI|SSN|BANK|ACNT|ACCT|ACCOUNT|ADDR|ZIP|POST|ALIAS|SESS|TOKEN|SECRET|BIRTH|BRTH|VALID|PHONE|TEL|MOBILE|MAIL|LOGIN|RUNNING|(^|_)IP($|_)|_YN$)
```
세션 값, 접속 IP, 비밀번호와 계정 상태 플래그, 주민번호, 계좌, 주소, 직원 이름 계열(`ALIAS`), 전화, 메일, 생년월일 등이 여기에 해당합니다. 다만 **이름 패턴으로 거르는 최선의 필터**입니다. extra를 외부 클라이언트에 그대로 내려주지 말고, 필요한 키만 골라 쓰세요.
## 호환성 보장 범위 밖
원문의 키와 구조, extra의 키와 값은 학교가 바꾸면 달라집니다. 앱의 핵심 로직을 여기에 걸지 마세요. 안정적인 접근은 [결과 필드](https://cassiiopeia.github.io/pro-sejongpeia/auth/result-fields/)의 이름 붙은 필드로 하세요. 정책은 [API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/)에 있습니다.
## 예시 코드
### 원문 옵션
**Java**
```java
SejongAuthResult result = sejongAuthEngine.authenticateWithRaw(studentId, password);
String html = result.getRawHtml(); // DHC 원문
String json = result.getRawJson(); // SIS 원문 (SIS 실패 시 null)
sejongAuthEngine.authenticateWithDHCRaw(studentId, password); // DHC 결과 + rawHtml
sejongAuthEngine.authenticateWithSISRaw(studentId, password); // SIS 결과 + rawJson
```
**Python**
```python
result = authenticate(student_id, password, include_raw=True)
html, json_text = result.raw_html, result.raw_json
authenticate_dhc(student_id, password, include_raw=True) # DHC 결과 + raw_html
authenticate_sis(student_id, password, include_raw=True) # SIS 결과 + raw_json
```
**Node.js**
```javascript
const result = await authenticate(studentId, password, { includeRaw: true });
const { rawHtml, rawJson } = result;
await authenticateDhc(studentId, password, { includeRaw: true }); // DHC 결과 + rawHtml
await authenticateSis(studentId, password, { includeRaw: true }); // SIS 결과 + rawJson
```
### extra 옵션
**Java**
```java
// 인자: 학번, 비밀번호, 원문 포함 여부, extra 포함 여부
SejongAuthResult result = sejongAuthEngine.authenticate(studentId, password, false, true);
Map extra = result.getExtra();
```
**Python**
```python
result = authenticate(student_id, password, include_extra=True)
extra = result.extra # {"데이터셋.필드": "값"}
```
**Node.js**
```javascript
const result = await authenticate(studentId, password, { includeExtra: true });
const { extra } = result; // { '데이터셋.필드': '값' }
```
---
# 학사 업무 기간
Source: https://cassiiopeia.github.io/pro-sejongpeia/auth/business-periods/
Section: 인증 레퍼런스
수강신청, 성적 조회처럼 **업무가 어느 기간에 허용되는지**를 본인 학사정보시스템(SIS)의 「학사일정조회」 화면과 같은 방식으로 가져옵니다. 로그인이 필요합니다.
이 자료는 학교 공개 학사일정([학사일정](https://cassiiopeia.github.io/pro-sejongpeia/data/calendar/))과 **다른 자료**입니다. 공개 학사일정은 학교 행사 일정이고, 이것은 로그인한 본인의 SIS가 업무별로 주는 허용 기간입니다.
## 요청 파라미터
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| `student_id` | string | 예 | | 학번. 비어 있으면 `INVALID_INPUT` |
| `password` | string | 예 | | 포털 비밀번호. 비어 있으면 `INVALID_INPUT` |
| `year` | 4자리 숫자 또는 문자열 | 예 | | 조회 연도. 형식이 틀리면 요청 없이 `INVALID_INPUT` |
| `month` | 1~12 또는 빈 값 | 아니오 | 빈 값 (그 해 전체) | 조회 월. 형식이 틀리면 요청 없이 `INVALID_INPUT` |
언어별 메서드와 연도·월 표기입니다.
| 언어 | 메서드 | 연도 / 월 |
|---|---|---|
| Java | `fetchSisBusinessPeriods(id, pw, year, month)` | 문자열 (월은 `""`이면 연간 전체) |
| Python | `fetch_sis_business_periods(id, pw, year, month="")` | 숫자 또는 문자열 |
| Node.js | `fetchSisBusinessPeriods(id, pw, { year, month })` | 숫자 또는 문자열, `month` 생략 가능 |
## 응답 데이터 구조
항목 하나가 목록의 한 행입니다. 예시는 Python의 `snake_case` 이름이고 Java와 Node.js는 `camelCase`입니다.
```json
[
{
"business_name": "업무 이름",
"schedule_name": "일정 이름",
"period": "yyyy-MM-dd ∼ yyyy-MM-dd",
"start_date": "yyyyMMdd",
"end_date": "yyyyMMdd",
"extra": {}
}
]
```
값은 학교 원문이고 없으면 빈 문자열입니다. 행 하나가 이상해도 목록 전체가 실패하지 않고, 빈 목록도 정상 응답입니다.
| 필드 (Python) | 학교 키 | 비고 |
|---|---|---|
| `period` | `PERIOD` | 텍스트. `yyyy-MM-dd ∼ yyyy-MM-dd` 형식 |
| `startDate` (`start_date`), `endDate` (`end_date`) | `START_DT`, `TO_DT` | `yyyyMMdd` 원문. 기간이 열려 있으면 끝이 `29991231` |
| `scheduleName` | `SCHEDULE` | 일정 이름 |
| `courseGroupName` | `CORS_SCHE_GRP_CD_NM` | |
| `target` | `TARGET_AUTH` | 대상 |
| `businessName` | `SYS_NM` | 업무 이름 |
| `yearSemester` | `YEAR_SMT` | |
| `timeText` | `TMS` | |
| `categoryCode` | `ORGN_CLSF_CD` | |
| `sortOrder` | `PRT_ORD` | 문자열 |
| `extra` | 나머지 스칼라 키 | SIS extra와 같은 제외 패턴 ([원문·extra 옵션](https://cassiiopeia.github.io/pro-sejongpeia/auth/raw-and-extra/)) |
### 실패했을 때
어느 단계든 실패하면 재시도 없이 예외입니다.
| 상황 | 코드 |
|---|---|
| 연도·월 형식 오류, 빈 입력 | `INVALID_INPUT` |
| 응답이 200이 아니거나 비어 있음, 소속·사용자 구분·메뉴 코드가 비어 있음 | `DATA_FETCH_FAILED` |
| 401 | `AUTHENTICATION_FAILED` |
| 학교가 로그아웃 오류를 돌려줌 | `SESSION_ERROR` |
| 그 밖의 학교 오류 | `DATA_FETCH_FAILED` |
| 기간 정보나 목록 구조가 예상과 다름 | `PARSE_ERROR` |
전체 코드는 [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)에 있습니다.
### 안에서 일어나는 일
학교는 형태가 틀린 요청에 세션을 끊습니다. 그래서 라이브러리는 브라우저와 같은 순서로만 요청합니다.
1. 포털 로그인(SIS용 반환 주소)과 SSO, SIS 사용자 정보 조회
2. 메뉴 목록 요청: 서버 세션에 메뉴 권한을 올림
3. 화면 열기 요청
4. 기간 목록 요청: 본인의 소속 분류, 사용자 구분 코드와 연도, 월을 담음
모든 요청이 한 세션, 로그인 한 번입니다. **어느 단계든 실패하면 재시도 없이 중단합니다.** 요청 순서와 값의 상세는 [spec/PORTAL_FLOW.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PORTAL_FLOW.md)의 2d에 있습니다.
> **세션 값은 남지 않습니다**
> SIS의 세션 값(통합 사용자 번호, 실행 값, 로그인 시각)은 결과, 로그, 에러 메시지에 들어가지 않습니다. 학교의 오류 문구도 에러 메시지에 옮기지 않고 단계 이름만 남깁니다.
학교가 화면을 바꾸면 값이나 구조가 달라질 수 있습니다.
## 예시 코드
**Java**
```java
// 한 달 (월을 "" 로 두면 그 해 전체)
List periods = sejongAuthEngine.fetchSisBusinessPeriods(studentId, password, "2026", "10");
for (SisBusinessPeriod period : periods) {
System.out.println(period.businessName() + " " + period.scheduleName() + " " + period.period());
}
```
**Python**
```python
from sejongpeia import fetch_sis_business_periods
# 한 달 (month 를 비우면 그 해 전체)
periods = fetch_sis_business_periods(student_id, password, 2026, 10)
for period in periods:
print(period.business_name, period.schedule_name, period.period)
```
**Node.js**
```javascript
import { fetchSisBusinessPeriods } from 'pro-sejongpeia';
// 한 달 (month 를 생략하면 그 해 전체)
const periods = await fetchSisBusinessPeriods(studentId, password, { year: 2026, month: 10 });
for (const period of periods) console.log(period.businessName, period.scheduleName, period.period);
```
---
# 레거시 TLS
Source: https://cassiiopeia.github.io/pro-sejongpeia/understand/legacy-tls/
Section: 이해하기
## 왜 어려운가
세종 포털은 TLS 1.2에서 암호 `TLS_RSA_WITH_AES_256_CBC_SHA`(OpenSSL 이름 `AES256-SHA`) **하나만** 제안합니다. 이 암호는 오래된 방식이라 최근 JDK와 OpenSSL의 기본 설정이 꺼 둡니다. 그래서 아무 설정 없이 HTTPS 클라이언트를 만들면 연결 단계(handshake)에서 실패하고, 로그인 폼까지 가지도 못합니다.
## 라이브러리가 하는 일
언어마다 푸는 방식이 다릅니다.
| 언어 | 방식 | 범위 |
|---|---|---|
| Python | `ssl` 컨텍스트의 암호 목록을 `DEFAULT:AES256-SHA:@SECLEVEL=1`로 설정 | 이 라이브러리의 연결만 |
| Node.js | `https.Agent({ ciphers: 'DEFAULT:AES256-SHA:@SECLEVEL=1' })` | 이 라이브러리의 연결만 |
| Java | `jdk.tls.disabledAlgorithms`에서 `TLS_RSA_*` 제거 | **JVM 전체** |
- Python과 Node.js는 OpenSSL 3.x에서 이 암호를 쓰려면 목록에 직접 추가하고 보안 레벨을 `1`로 내려야 합니다.
- `@SECLEVEL=1`을 유지하는 이유: 레벨 0으로 낮추면 약한 인증서 키까지 받아들이기 때문입니다.
- 인증서 검증은 기본으로 켜져 있습니다 ([설정](https://cassiiopeia.github.io/pro-sejongpeia/auth/configuration/)).
## Java에서 특히 조심할 점
JSSE는 이 보안 속성을 JVM이 **처음 TLS를 쓸 때 한 번만** 읽습니다. 그 뒤에 바꿔도 효과가 없습니다.
- Spring Boot: 시작할 때 `SejongTlsEnvironmentPostProcessor`가 자동으로 처리합니다.
- 일반 Java: `main()` 맨 앞에서 `SejongTlsSupport.ensureLegacyCipherEnabled()`를 부르세요. 다른 HTTPS 호출이 먼저 일어나면 늦습니다.
- 너무 늦게 부르면 `CONNECTION_FAILED`로 실패하고, 메시지에 원인과 해결 방법이 같이 나옵니다.
- 이 변경은 JVM 전체에 적용되어 같은 프로세스의 다른 코드도 이 암호를 쓸 수 있게 됩니다. 허용할 수 없다면 인증을 별도 프로세스로 분리하세요.
```java
public static void main(String[] args) {
SejongTlsSupport.ensureLegacyCipherEnabled(); // 다른 HTTPS 호출보다 먼저
// ... 이후 앱 시작
}
```
> **직접 구현한다면**
> 언어의 기본 TLS 설정만 믿으면 포털 연결이 안 됩니다. 연결 오류가 나면 먼저 서버가 제안하는 암호와 내 런타임이 허용하는 암호가 겹치는지 확인하세요. 낮추는 범위(연결 하나인지 프로세스 전체인지)는 보안 영향이 달라서 의식적으로 선택해야 합니다.
## 이어서 보기
- 원본 표: [spec/PORTAL_FLOW.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PORTAL_FLOW.md)의 Transport requirements
- 다음 장벽: [방화벽과 User-Agent](https://cassiiopeia.github.io/pro-sejongpeia/understand/firewall-user-agent/)
---
# 방화벽과 User-Agent
Source: https://cassiiopeia.github.io/pro-sejongpeia/understand/firewall-user-agent/
Section: 이해하기
## 왜 어려운가
로그인이 되고 TLS도 통과했는데 요청이 이상하게 실패하는 경우가 있습니다. 원인은 요청 헤더의 `User-Agent`입니다. 포털 흐름에서 확인한 동작은 두 가지입니다.
| 상황 | 결과 |
|---|---|
| `User-Agent`를 보내지 않음 | `classic.sejong.ac.kr`이 SSO 이후 **404**를 돌려줍니다 |
| 라이브러리 기본값 (예: `Python-urllib`) | `sjpt.sejong.ac.kr`의 방화벽이 막고 「Alert!!!」 HTML 페이지를 돌려줍니다 |
둘 다 코드가 올바른데도 응답만 이상하므로, 모르면 파싱이나 로그인 문제로 오해하기 쉽습니다.
## 라이브러리가 하는 일
비어 있지 않고 기본값도 아닌, 식별 가능한 `User-Agent`를 모든 요청에 보냅니다. 형식은 계약에 이렇게 정해져 있습니다.
```text
pro-sejongpeia-<언어>/<버전>
```
- Python: `pro-sejongpeia-python/<버전>`
- Node.js: `pro-sejongpeia-node/<버전>`
- Java 공개 데이터 호출: `pro-sejongpeia-java`
- 인증 요청의 User-Agent 형식은 위 계약을 따릅니다.
## 직접 구현한다면
- 모든 HTTP 클라이언트에 비어 있지 않은 고유한 `User-Agent`를 설정하세요.
- 브라우저를 흉내 내는 대신 라이브러리 이름과 버전이 보이게 두면, 문제가 생겼을 때 서로 원인을 찾기 쉽습니다.
- 404나 「Alert!!!」 페이지가 오면 파서보다 먼저 헤더를 의심하세요.
## 이어서 보기
- [레거시 TLS](https://cassiiopeia.github.io/pro-sejongpeia/understand/legacy-tls/)
- [SSO 흐름](https://cassiiopeia.github.io/pro-sejongpeia/understand/sso-flow/)
- 원본: [spec/PORTAL_FLOW.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PORTAL_FLOW.md)
---
# SSO 흐름
Source: https://cassiiopeia.github.io/pro-sejongpeia/understand/sso-flow/
Section: 이해하기
## 왜 어려운가
세종대의 서비스는 도메인이 나뉘어 있습니다 (`portal`, `classic`, `sjpt`). 학번과 비밀번호는 포털에만 보내고, 나머지는 포털이 심어 준 SSO 쿠키로 들어갑니다. 브라우저는 이를 자동으로 하지만 서버 코드에서는 직접 해야 합니다.
- SSO 쿠키(`ssotoken`)는 `.sejong.ac.kr` 전체에 설정되므로, `Domain=.sejong.ac.kr`을 지키는 쿠키 저장소가 필요합니다.
- SSO 단계는 `303` 같은 리다이렉트로 응답합니다. 301, 302, 303, 307을 자동으로 따라가야 합니다.
- 호출마다 쿠키 저장소를 새로 만들어야 사용자 사이에 세션이 섞이지 않습니다.
## 통합 인증의 요청 순서
로그인은 한 번이고, 같은 쿠키 저장소로 세 곳을 조회합니다.
1. **포털 로그인.** `portal.sejong.ac.kr`의 로그인 주소에 학번과 비밀번호를 폼으로 보냅니다. 돌아온 200 응답 안의 판정값을 읽어 성공이면 계속합니다 ([로그인 판정](https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/)).
2. **DHC (`classic.sejong.ac.kr`).** SSO 반환 주소를 열어 세션을 만들고, 고전독서 현황 페이지를 받아 HTML 표를 파싱합니다. 학생 정보와 고전독서가 여기서 나옵니다. 401은 `AUTHENTICATION_FAILED`, 그 밖의 비정상 응답은 `DATA_FETCH_FAILED`, SSO 단계 오류는 `SESSION_ERROR`입니다.
3. **SIS (`sjpt.sejong.ac.kr`).** SSO 주소를 열고 사용자 정보 API를 호출해 JSON을 파싱합니다. 이메일, 전화번호, 영문 이름, 학교 코드가 여기서 나옵니다. 이 단계는 실패해도 통합 호출을 실패시키지 않습니다.
4. **포털 메인 (`portal.sejong.ac.kr`).** 메인 화면에서 학과의 상위 소속 이름을 뽑습니다. 한 번만 시도하고 실패하면 `parentUnitName`이 null입니다.
| 단계 | 호스트 | 형식 | 실패하면 |
|---|---|---|---|
| 1 로그인 | `portal.sejong.ac.kr` | 폼 POST, 200 안의 판정값 | 호출 전체 실패 |
| 2 DHC | `classic.sejong.ac.kr` | HTML | 호출 전체 실패 |
| 3 SIS | `sjpt.sejong.ac.kr` | JSON | 연락처 필드만 null |
| 4 포털 메인 | `portal.sejong.ac.kr` | HTML | `parentUnitName`만 null |
DHC 로그인 뒤에도 SIS SSO가 동작하므로 로그인 한 번으로 DHC와 SIS를 모두 조회할 수 있습니다.
## DHC나 SIS만 부를 때
- DHC 인증은 로그인 뒤 2단계만, SIS 인증은 SIS용 반환 주소로 로그인한 뒤 3단계만 실행합니다.
- 학사 업무 기간 조회는 SIS용 반환 주소로 로그인하고, SIS 사용자 정보 뒤에 메뉴 목록과 화면 열기, 목록 요청이 이어집니다 ([학사 업무 기간](https://cassiiopeia.github.io/pro-sejongpeia/auth/business-periods/)).
> **직접 구현한다면**
> 쿠키 저장소를 호출 단위로 분리하고, 리다이렉트를 따라가고, 단계마다 응답 코드를 구분해 에러로 옮기세요. 상세한 요청과 헤더는 [spec/PORTAL_FLOW.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PORTAL_FLOW.md)에 있고, 화면별 설명은 [DHC 로그인 플로우](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/docs/DHC_LOGIN_FLOW.md)와 [SIS 로그인 플로우](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/docs/SIS_LOGIN_FLOW.md)에 있습니다.
## 이어서 보기
- [로그인 판정과 계정 잠금 방지](https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/)
- [실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/)
---
# 로그인 판정과 계정 잠금 방지
Source: https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/
Section: 이해하기
## 왜 어려운가
포털은 로그인에 실패해도 **HTTP 200**을 돌려줍니다. 상태 코드로는 성공과 실패를 구분할 수 없습니다. 결과는 응답으로 온 작은 HTML 페이지의 스크립트 안에 있습니다.
```text
var result = '<값>'
```
게다가 비밀번호가 틀릴 때마다 포털의 실패 횟수가 올라가고, 일정 횟수를 넘으면 계정이 잠깁니다. 실패한 로그인을 습관처럼 재시도하면 사용자의 계정을 잠그게 됩니다.
## 판정 흐름 한눈에
> 다이어그램: 포털에 로그인한 뒤 응답 안의 판정값을 읽는다. 비밀번호가 틀리거나 계정이 잠겼으면 첫 실패에서 멈추고 에러 코드를 돌려주며, 성공이면 같은 세션으로 DHC와 SIS를 조회해 결과를 만든다
## 판정값과 에러 코드
| `result` 값 | 포털 메시지의 뜻 | 라이브러리 |
|---|---|---|
| `OK` | 성공 (반환 주소로 이동) | 다음 단계 진행 |
| `erridpwd`, `Error` | 아이디나 비밀번호가 일치하지 않음 | `AUTHENTICATION_FAILED`, 추가 요청 없이 중단 |
| `pwdNeedChg` | 비밀번호를 여러 번 틀려 계정이 잠김 | `ACCOUNT_LOCKED` |
| `invalidDt`, `invalid` | 사용 기간 종료 또는 관리자 차단 | `ACCOUNT_UNAVAILABLE` |
| 그 밖의 값 | 로그인 처리 중 오류 | `SESSION_ERROR` |
| `var result`가 없음 | 포털 화면 형식이 바뀐 것으로 봄 | 계속 진행하고, 뒤 단계의 401이 판정 |
이 대응은 `spec/contract.json`이 단일 원본이고, 세 언어의 테스트가 같은 값을 쓰는지 확인합니다.
## 라이브러리의 규칙
- 포털이 거부한 로그인(틀린 비밀번호, 잠금, 사용 불가)은 **재시도하지 않습니다.** 첫 실패에서 멈춥니다.
- 재시도는 2xx가 아닌 응답이나 시간 초과 같은 **전송 문제**에만 합니다 ([설정](https://cassiiopeia.github.io/pro-sejongpeia/auth/configuration/)).
- 통합 인증은 로그인을 한 번만 하므로 실패 횟수도 호출당 최대 한 번만 올라갑니다.
- 입력이 비어 있으면 요청을 보내지 않고 `INVALID_INPUT`으로 끝냅니다.
## 잠금 횟수에 대해 확인하지 못한 것
포털 소스의 주석은 5회 실패에서 잠긴다고 적고 있고, 포털 안내도 비슷하게 약 5회입니다. 그러나 **정확한 횟수와, 로그인에 성공하면 횟수가 초기화되는지는 확인하지 못했습니다.** 보수적으로 다루세요.
> **서비스에서는 학번별로 제한하세요**
> 학번을 아무나 입력할 수 있는 폼이라면 학번별 실패 횟수를 서버에서 직접 제한하세요. 라이브러리는 한 번의 호출 안에서만 재시도를 막을 수 있고, 반복 요청은 막지 못합니다. 잠긴 계정은 사용자가 [포털에서 비밀번호를 재설정](https://portal.sejong.ac.kr/jsp/inquiry/pwinquiry.jsp)해야 풀립니다.
## 직접 구현한다면
- 상태 코드가 아니라 응답 본문의 판정값을 읽으세요. 샘플은 저장소의 `spec/fixtures/portal_login_ok.html`, `portal_login_erridpwd.html`입니다.
- 알 수 없는 판정값은 성공으로 보지 말고 오류로 처리하세요.
- 거부된 로그인을 자동으로 다시 보내지 마세요.
## 이어서 보기
- [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)
- [SSO 흐름](https://cassiiopeia.github.io/pro-sejongpeia/understand/sso-flow/)
- 원본: [spec/PORTAL_FLOW.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PORTAL_FLOW.md)의 Step 1
---
# 실패 격리와 캐시
Source: https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/
Section: 이해하기
## 왜 필요한가
학교 서버는 느리거나(비교과 일정은 수십 초가 걸리기도 합니다), 일시적으로 응답하지 않거나, 응답 모양을 바꿀 수 있습니다. 이런 일이 한 곳에서 생겼다고 앱 전체가 멈추면 곤란합니다. 또 같은 요청을 사용자 수만큼 학교에 보내면 학교 서버에 부담이 됩니다.
## 호출 한 번의 흐름
> 다이어그램: 공개 데이터 호출은 24시간 안의 캐시가 있으면 그대로 돌려주고, 없으면 학교에 요청 한 번을 보낸다. 성공하면 저장하고 돌려주며, 실패하면 마지막 캐시 값이나 빈 목록을 돌려준다
## 공개 데이터의 실패 격리
공개 데이터(`SejongPublicData`)의 모든 메서드는 같은 규칙을 따릅니다.
- 연결 실패, 시간 초과, 2xx가 아닌 상태, JSON이 아닌 본문, 기대한 키가 없음, 학교의 `_SUBMIT_ERROR_` 응답 중 무엇이든 **예외 없이** 마지막 캐시 값(만료됐어도)이나 빈 값(빈 목록, 기숙사 공지는 빈 객체)을 돌려줍니다.
- 학교 서버 내부 정보(`STACK_TRACE` 등)는 로그에도 결과에도 내보내지 않습니다.
- 던지는 경우는 **잘못된 입력**뿐입니다: 빈 학과 코드나 과목 번호의 `curriculum_courses`, 형식이 틀린 사이트 이름의 `graduate_calendar` (`INVALID_INPUT`, 요청 없음). `lectures`는 필터 때문에 던지지 않습니다.
- 값이 없으면 빈 문자열이고(`views`, `total`은 0, 좌표는 0.0), 행이 하나 이상해도 목록 전체가 실패하지 않습니다. 실패로 보는 것은 컨테이너(`data`, `items`, `events` 등)가 없거나 배열이 아닐 때뿐입니다.
> **빈 목록의 두 가지 뜻**
> 공개 데이터에서 빈 목록은 「정말 데이터가 없음」과 「학교 쪽 문제로 못 가져옴」을 구분하지 못합니다. 화면에는 「불러오지 못했습니다」 같은 안내를 함께 두세요. 필요하면 호출하는 쪽에서 의미를 따로 판단해야 합니다.
## 캐시
| 항목 | 기본값 | 동작 |
|---|---|---|
| 캐시 유지 | 86400초 (24시간) | 메서드와 인자마다 메모리에 저장. 신선한 캐시가 있으면 요청하지 않음 |
| 실패 기억 | 60초 | 실패했고 캐시된 값이 없으면 그 시간 동안 같은 호출은 요청 없이 빈 값을 돌려줌. 만료된 캐시 값이 있으면 실패해도 그 값을 돌려줌 |
| 최대 항목 수 | 500개 | 가득 차면 가장 오래된 항목을 밀어냄 |
- 같은 인자로 동시에 부르면 요청 **하나**를 같이 씁니다.
- 키마다 잠금을 쓰지만 항목과 함께 풀리므로, 사용자가 입력한 검색어를 넘겨도 메모리가 끝없이 늘지 않습니다.
- 한 호출은 요청 한 건이 원칙입니다. 예외는 `cafeteria_places`로, 번호 1~10을 **차례로** 최대 10건 요청하고, 하나라도 실패하면 목록 전체를 실패로 보아 부분 목록이 하루 동안 캐시되지 않게 합니다.
- 개설강좌를 조건 없이 부르면 학부 한 학기가 몇 MB라서 캐시 항목 하나가 큽니다. 가능하면 학과 단위로 나눠 부르세요 ([개설강좌](https://cassiiopeia.github.io/pro-sejongpeia/data/lectures/)).
- `SejongPublicData`는 **프로세스당 하나만** 만드세요. 캐시와 요청 합치기가 인스턴스 안에 있습니다.
각 설정(`cache_ttl_seconds`, `failure_cache_seconds`, `cache_max_entries`)은 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)에 있습니다.
## 인증의 실패 격리
| 단계 | 실패하면 |
|---|---|
| 입력 확인, 포털 로그인 | 호출 전체 실패 |
| DHC (학생 정보, 고전독서) | 호출 전체 실패 |
| SIS (연락처, 학교 코드) | 호출은 성공, 그 필드만 null |
| 포털 메인 (`parentUnitName`) | 호출은 성공, 그 필드만 null |
인증과 공개 데이터는 서로 실패에 영향을 주지 않습니다. 두 코드는 서로를 임포트하지 않도록 의존 규칙 테스트가 지킵니다.
## 직접 구현한다면
- 외부 서버 실패를 앱의 실패로 번지지 않게 하고, 마지막 정상 값을 보관해 두세요.
- 실패 직후 같은 요청을 쏟아내지 말고 짧게 기억해 두세요.
- 동시 요청은 합치세요.
## 이어서 보기
- 원본: [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md)의 Failure isolation and cache
- [매일 도는 실제 포털 점검](https://cassiiopeia.github.io/pro-sejongpeia/understand/daily-live-check/)
---
# 매일 도는 실제 포털 점검
Source: https://cassiiopeia.github.io/pro-sejongpeia/understand/daily-live-check/
Section: 이해하기
포털은 예고 없이 바뀝니다. 사용자의 불만으로 알게 되지 않도록, 저장소의 `PROJECT-SEJONG-LIVE-CHECK` 워크플로우가 매일 두 가지를 점검합니다. 아래는 워크플로우와 스크립트가 **실제로 하는 일**만 적은 것입니다.
## 일정
- 매일 09:00 KST (cron `0 0 * * *`, UTC 기준)에 실행됩니다. 수동 실행도 가능합니다.
- 점검 두 개는 별도 작업이라 서로의 결과에 영향을 주지 않습니다.
## 1. 실제 로그인 점검
`scripts/live-check.sh`가 하는 일은 다음과 같습니다.
1. Java, Python, Node.js가 각각 **맞는 비밀번호**로 통합 인증을 한 번씩 수행하고, 필드별 값을 해시로 요약합니다.
2. 세 요약을 비교합니다. 세 언어 모두 성공해야 하고, 결과에 필드가 있어야 하며, **null인 필드가 없어야** 합니다 (SIS 조회가 조용히 실패한 경우를 잡기 위해서입니다). 그리고 모든 필드의 해시가 세 언어에서 같아야 합니다.
3. 출력은 필드 이름과 `same` / `DIFFERENT` / `NULL` 상태뿐입니다. 개인정보 값은 출력하지 않습니다.
계정 잠금 방지 장치가 있습니다.
- **틀린 비밀번호를 시도하지 않습니다.** 언어당 포털 로그인은 한 번입니다.
- 점검 실패 이슈가 열려 있는 동안에는 정기 실행이 로그인하지 않고 건너뜁니다. 원인을 고친 뒤 수동 실행으로 확인합니다.
- 테스트 계정 정보가 저장소에 설정되어 있지 않으면 이 점검은 건너뜁니다.
실패하면 저장소에 이슈가 하나 자동으로 열립니다 (이미 열려 있으면 새로 만들지 않습니다).
## 2. 로그인 없는 형태 점검
계정이 필요 없어 잠금 위험이 없고, 위 로그인 점검의 이슈 상태와 무관하게 매일 실행됩니다.
- `scripts/probe-reference-data.py`: 학과·학기 코드 API 3곳에 요청 3건(1초 간격)을 보내 데이터셋 키와 필수 필드가 그대로인지 확인합니다.
- `scripts/probe-public-data.py`: 개설강좌, 학과 교육과정, 학사일정, 기숙사 식단·공지, 학식, 캠퍼스맵, 비교과 같은 공개 데이터 소스의 응답 모양을 확인합니다.
- 출력은 건수와 누락된 필드 이름뿐입니다.
- 네트워크 오류나 시간 초과는 `SKIP`으로 출력하고 실패로 보지 않습니다. 라이브러리가 실패를 격리하므로, 잡으려는 것은 **모양이 바뀐 경우**뿐입니다.
- 모양이 바뀐 것이 감지되면 이슈가 하나 열립니다 (같은 제목의 이슈가 열려 있으면 만들지 않습니다).
## 무엇을 보장하지 않나
- 점검 계정은 하나라서, 다른 신분(대학원생 등)에서의 동작은 이 점검으로 보장되지 않습니다.
- 형태 점검은 필드가 있는지를 보며, 값이 맞는지는 판단하지 않습니다.
- 매일 한 번이므로 그 사이의 변경은 다음 점검까지 모릅니다. 수정 시점에 대한 약속(SLA)은 없습니다 ([API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/)).
>
> 점검 계정의 정보는 저장소 Secret으로만 쓰이고 이 문서에는 적지 않습니다. 직접 포털 연동 테스트를 돌려 보려면 [이슈와 PR](https://cassiiopeia.github.io/pro-sejongpeia/contribute/issues-and-prs/)을 보세요.
## 이어서 보기
- [API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/): 학교가 바뀌면 어떻게 되는지
- [실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/)
---
# 새 정보 출처 추가하기
Source: https://cassiiopeia.github.io/pro-sejongpeia/contribute/add-data-source/
Section: 기여하기
학교가 공개로 주는 새 정보(개설강좌, 식단 같은 공개 데이터)를 추가하는 방법입니다. 소스는 서로 독립적이고, 공통 계층(`common`: 에러, HTTP 전송, 버전)만 함께 씁니다. 인증(`auth`)도 공개 데이터와 같은 하나의 소스입니다. 그래서 내 소스만 만들면 다른 소스를 건드릴 필요가 없습니다.
## 절차
1. **이슈로 제안**합니다. 이슈 템플릿 `새 정보 출처 제안`을 쓰세요. 출처 위치, 로그인 필요 여부, 요청 형태(JSON 또는 HTML), 필드, 개인정보 포함 여부를 적어 주세요. JSON이 아니고 HTML만 있는 정보는 기본 원본으로 삼지 않습니다.
2. **계약을 먼저** 씁니다. 주소, 요청 키, 고정 값은 `spec/contract.json`의 `publicData`에 넣고 (언어별 상수는 각자 두며 계약 테스트가 일치를 검사합니다), `spec/PUBLIC_DATA.md`에 메서드, 요청, 필드 대응을 한 줄씩 추가합니다. 확인한 날짜와 확인한 계정 종류도 적습니다. 에이전트용 메서드 목록 `spec/api-index.json`에도 같은 메서드를 추가합니다 (인자, 반환 모델, Java 시그니처). 세 언어 구현과 어긋나면 `python3 scripts/check-api-index.py`가 실패합니다.
3. **합성 fixture와 기대 결과**를 만듭니다. `scripts/generate-public-data-fixtures.py`의 `FIXTURES`와 출처별 `map_*()` 함수에 추가하고(`map_all()`이 합칩니다) 실행합니다. 실제 응답을 복사하지 않고, 교직원 이름 같은 개인 식별 값은 넣지 않습니다.
4. **Java, Python, Node를 한 PR에서** 구현합니다. 출처마다 파일 하나(`<출처>_source.py`, `<출처>Source.ts`, `<출처>Source.java`)에 요청 조립과 변환을 두고 `SejongPublicData`에는 공개 메서드 한 줄만 더합니다. 캐시와 실패 격리는 공유 파일(`fetcher`)을 그대로 씁니다. 값 규칙(없으면 빈 문자열, `extra`)은 계약을 따릅니다.
5. **테스트는 필요한 만큼만** 씁니다. fixture 결과가 기대 결과와 같은지 하나면 충분합니다. 학교 서버를 부르는 테스트는 넣지 않습니다.
6. **형태 점검**에 항목을 추가합니다. `scripts/probe-public-data.py`의 `SOURCES` 표에 한 줄을 더합니다. 계정 없이 도는 점검입니다.
7. [기능 노출 체크리스트](https://cassiiopeia.github.io/pro-sejongpeia/contribute/structure/)의 항목을 확인합니다.
## 코드를 놓을 위치
공개 데이터 소스 기준입니다. 메서드 하나에 파일 세 곳이 대응합니다.
| 언어 | 메서드와 요청 | 결과 모델 | 테스트 |
|---|---|---|---|
| Python | `python/src/sejongpeia/public_data/<출처>_source.py` (요청, 변환), 공개 메서드 한 줄은 `client.py` | `public_data/models.py` | `python/tests/test_public_data.py` |
| Node | `node/src/publicData/<출처>Source.ts` (요청, 변환), 공개 메서드 한 줄은 `client.ts`, 인자 객체는 `queries.ts` | `publicData/models.ts` | `node/test/publicData.test.mjs` |
| Java | `publicdata/<출처>Source.java` (요청, 변환), 공개 메서드는 `SejongPublicData.java` | `publicdata/model/` | `SejongPublicDataTest.java` |
- 메서드 이름은 Python `snake_case`, Node와 Java `camelCase`입니다. 쓰지 않은 학교 필드는 `extra`에 담습니다.
- 로그인이 필요한 소스는 이 절차의 대상이 아닙니다. 소스끼리는 서로 임포트할 수 없어서 로그인 세션을 어떻게 나눌지 설계가 필요하니, 이슈에서 먼저 방향을 맞춰 주세요.
- 공개 메서드나 결과 필드를 더하거나 바꾸면 [API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/) 문서의 「공개 API」 목록과 [통합 예시](https://cassiiopeia.github.io/pro-sejongpeia/guides/student-verification/)가 여전히 맞는지 확인하세요.
## 의존 규칙
> 다이어그램: 진입점이 인증과 공개 데이터를 조립하고, 둘 다 공통 계층만 불러온다. 인증과 공개 데이터는 서로 불러오지 않는다
세 계층으로 나뉩니다. 공통(`common`)은 어떤 소스도 임포트하지 않고, 소스(`auth`, `public_data`)는 공통만 임포트하며 서로 임포트하지 않습니다. 최상위 진입점(`__init__.py`, `index.ts`, Spring `config`)은 조립만 하므로 모든 계층을 임포트할 수 있습니다. 테스트가 이 규칙을 지킵니다.
| 언어 | 규칙 테스트 |
|---|---|
| Python | `python/tests/test_dependency_rules.py` |
| Node | `node/test/dependencyRules.test.mjs` |
| Java | `DependencyRulesTest` |
공개 데이터 안에서도 출처 파일(`*_source`)끼리와 진입점(`client`)은 서로 임포트하지 않고 공유 파일(`fetcher`, `row_reader`, `models`)만 씁니다. 규칙에 걸리면 공유가 필요한 부분을 `common`으로 옮기세요.
**폴더 구조** (세 언어 같은 모양)
| 계층 | Python `python/src/sejongpeia/` | Node `node/src/` | Java `java/src/main/java/kr/suhsaechan/sejongpeia/` |
|---|---|---|---|
| 공통 | `common/` (`errors`, `transport`, `version`) | `common/` (`errors`, `transport`, `version`) | `common/` (`exception`, `util`, `http`, `config`) |
| 인증 | `auth/` (`client`, `parsers`, `portal_main_parser`, `models`) | `auth/` (`client`, `parsers`, `portalMainParser`, `models`) | `auth/` (`client`, `parser`, `model`, `service`) |
| 공개 데이터 | `public_data/` (`client`, `fetcher`, `row_reader`, `models`, `*_source`) | `publicData/` (`client`, `fetcher`, `rowReader`, `queries`, `models`, `*Source`) | `publicdata/` (`SejongPublicData`, `*Source`, `PublicDataFetcher`, `model`) |
| 조립 | `__init__.py` | `index.ts` | `config/` (Spring 자동 설정, TLS 초기화) |
## spec/에 무엇을 두는가
학교의 서비스와 세 구현 사이의 계약은 `spec/`에 있습니다. 학교가 무언가를 바꾸면 `spec/`을 먼저 고치고, 같은 PR에서 세 구현을 고칩니다.
| 종류 | 위치 | 규칙 |
|---|---|---|
| 요청·응답 계약, 필드 의미, 한계 | `spec/<출처>.md` | 출처마다 문서 하나. 확인한 것만 쓰고, 확인하지 못한 것은 못 했다고 씁니다 |
| 구현이 함께 쓰는 값 (주소, 오류 문구) | `spec/contract.json` | 단일 원본. 값이 어긋나면 언어별 contract 테스트가 실패합니다 |
| 학교가 무엇을 어디서 주는지, 막다른 길, 확인하지 못한 곳 | `spec/REFERENCE_DATA_SURVEY.md` | 다시 찾기 전에 먼저 읽고, 새로 살펴본 곳이 있으면 고칩니다 |
| 테스트용 응답 샘플 | `spec/fixtures/` | 합성 데이터만. 실제 응답이나 개인정보, 세션 값, 교직원 이름은 두지 않습니다 |
| 응답 모양 변화 감지 | `scripts/probe-*.py` | 계정 없이 돌고, 출처마다 한 항목 |
## 안전을 지키는 규칙
- 학교 문자열 그대로 돌려줍니다. 라이브러리에 값 목록이나 코드표를 두지 않고, 이름으로 코드를 짐작하지 않습니다.
- 한 출처의 실패가 다른 출처를 막지 않습니다. 특히 인증은 막지 않습니다. 빈 값이나 마지막 캐시 값을 돌려줍니다.
- 내부 서버 정보(스택 트레이스, 세션 식별자, IP 주소, 비밀번호 상태 플래그)를 로그나 결과에 남기지 않습니다.
- 공개 페이지와 엔드포인트에는 부담을 주지 않습니다. 캐시하고, 간격을 두고, 한 번 갱신에 보내는 요청 수를 제한합니다.
더 자세한 내용은 [실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/)와 [공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/)을 보세요.
---
# 레포 구조
Source: https://cassiiopeia.github.io/pro-sejongpeia/contribute/structure/
Section: 기여하기
## 폴더 구성
| 폴더 | 내용 |
|---|---|
| `java/` | Java / Spring Boot 라이브러리 (`kr.suhsaechan:sejongpeia`) |
| `python/` | Python 패키지 (`pro-sejongpeia`, import `sejongpeia`) |
| `node/` | Node.js 패키지 (`pro-sejongpeia`, TypeScript) |
| `spec/` | 공통 계약: 포털 흐름, 결과 스키마, 모든 언어 테스트가 쓰는 개인정보 제거 응답 샘플 |
| `website/` | 이 문서 사이트 (Astro Starlight). 한국어가 기본, 영어는 `/en/` |
| `scripts/` | 실제 포털 점검, 형태 점검, fixture 생성, 문서 점검 스크립트 |
| `docs/` | 통합 예시, API 안정성, 로그인 흐름 문서와 이미지 |
세 언어는 버전 번호 하나를 같이 씁니다. `version.yml`을 두고 릴리스 워크플로우가 갱신합니다. 각 언어 소스 안의 폴더 구조(`common`, `auth`, `public_data`)는 [새 정보 출처 추가하기](https://cassiiopeia.github.io/pro-sejongpeia/contribute/add-data-source/)의 표에 있습니다.
## 계층과 의존 방향
공통 계층은 어떤 소스도 부르지 않고, 인증과 공개 데이터는 서로 부르지 않습니다. 테스트가 이 규칙을 지킵니다 ([새 정보 출처 추가하기](https://cassiiopeia.github.io/pro-sejongpeia/contribute/add-data-source/)).
> 다이어그램: 진입점이 인증과 공개 데이터를 조립하고, 둘 다 공통 계층만 불러온다. 인증과 공개 데이터는 서로 불러오지 않는다
## 개발 환경
```bash
./gradlew build # Java (JDK 17+, Gradle Wrapper 포함)
cd python && python -m pip install pytest && python -m pytest # Python 3.9+
cd node && npm ci && npm test # Node 18+
```
## 실제 포털 연동 테스트
실제 세종대 포털에 로그인하므로 계정을 넣었을 때만 실행됩니다.
- Python, Node: `SEJONG_TEST_STUDENT_ID`, `SEJONG_TEST_PASSWORD` 환경변수를 설정하고 위 명령으로 실행합니다.
- Java: `java/src/test/resources/application-dev.yml`을 만듭니다 (`.gitignore` 대상).
```yaml
sejong:
auth:
test:
student-id: "본인 학번"
password: "본인 비밀번호"
```
`SuhSejongAuthEngineTest`의 `실제인증_*` 테스트는 `@Disabled`입니다. 로컬에서만 빼고 실행한 뒤 커밋 전에 되돌립니다.
- 세 언어를 한 번에 돌리고 결과를 필드별로 비교합니다 (개인정보는 출력하지 않음).
```bash
SEJONG_TEST_STUDENT_ID=... SEJONG_TEST_PASSWORD=... scripts/verify-live.sh
```
- 맞는 비밀번호로만 점검합니다 (틀린 비밀번호 시도 없음): `scripts/live-check.sh`. `PROJECT-SEJONG-LIVE-CHECK` 워크플로우가 매일 실행하고, 실패하면 이슈를 엽니다. 자세한 내용은 [매일 도는 실제 포털 점검](https://cassiiopeia.github.io/pro-sejongpeia/understand/daily-live-check/)에 있습니다.
:::caution
비밀번호를 여러 번 틀리면 포털 계정이 잠길 수 있습니다. 실패하면 반복 실행하지 말고 원인을 먼저 확인하세요.
:::
## 포털이 바뀌었을 때
1. `spec/PORTAL_FLOW.md`와 `spec/contract.json`을 고칩니다. 응답 모양이 바뀌었으면 `spec/fixtures/`도 고치되 개인정보는 제거합니다. 언어별 contract 테스트는 상수가 `spec/contract.json`과 같아질 때까지 실패합니다.
2. Java, Python, Node를 **한 PR에서** 같이 고칩니다.
3. 언어별 테스트와 `scripts/verify-live.sh`를 실행합니다.
## 문서 언어
- 기본 문서는 한국어 `X.md`, 번역은 `X.<언어코드>.md`입니다 (예: `README.en.md`). 새 언어는 같은 규칙으로 파일을 추가하면 됩니다.
- 문서 맨 위 언어 전환 줄에 같은 문서의 모든 언어를 링크합니다.
- 한국어 문서를 고치면 번역도 같은 PR에서 고칩니다. 번역을 못 하면 PR 설명에 적어 주세요.
- `README.md`의 `## 최신 버전 :` 줄은 릴리스 워크플로우가 갱신하므로 직접 고치지 않습니다.
- 확인: `python3 scripts/check-docs.py` (깨진 상대 링크, 언어 전환 링크 누락). PR에서는 `PROJECT-SEJONG-DOCS-CHECK`도 실행됩니다.
- 이 사이트(`website/`)의 모든 페이지는 한국어와 영어 짝이 있어야 합니다. 한쪽을 고치면 다른 쪽도 같은 PR에서 고칩니다.
## 기능 노출 체크리스트
기능은 만들었다고 끝이 아닙니다. 사용하는 사람이 보는 모든 곳에 드러나야 합니다. PR을 올리기 전에 확인하세요. PR 템플릿에도 같은 체크리스트가 있습니다.
- README 한국어·영어: 기능 소개와 사용 예시, 필드가 늘었다면 `결과 필드` 표
- `python/README.md`, `node/README.md`: PyPI, npm 패키지 페이지
- `spec/`: 계약 문서, `spec/contract.json`, 합성 fixture
- 문서 사이트 `website/`: 기능 소개, 필드 표, 해당 레퍼런스 페이지 (한국어·영어)
- 패키지 설명과 키워드: `python/pyproject.toml`, `node/package.json`, `java/build.gradle`
- 릴리스 노트에 들어갈 한 줄 요약 (커밋 메시지)
- 홍보 문구는 실제로 지원하는 기능만 씁니다
다음으로 [이슈와 PR](https://cassiiopeia.github.io/pro-sejongpeia/contribute/issues-and-prs/) 규칙을 보세요.
---
# 이슈와 PR
Source: https://cassiiopeia.github.io/pro-sejongpeia/contribute/issues-and-prs/
Section: 기여하기
버그 제보, 포털 변경 대응, 문서 개선 모두 환영합니다. 이슈와 PR은 한국어, 영어 모두 괜찮습니다.
## 이슈 먼저
- 버그: 에러 코드(`SejongAuthException.getErrorCode()` 등)와 사용한 메서드, 언어와 버전을 적어 주세요.
- 포털이 바뀌어 인증이 깨졌다면 [라이브 데모](https://cassiiopeia.github.io/pro-sejongpeia/#demo)로 먼저 재현되는지 확인해 주세요.
- 큰 변경은 PR 전에 이슈로 방향을 맞춥니다.
- 새 학교 정보 출처는 `새 정보 출처 제안` 템플릿을 씁니다. 절차는 [새 정보 출처 추가하기](https://cassiiopeia.github.io/pro-sejongpeia/contribute/add-data-source/)에 있습니다.
:::danger
학번, 비밀번호, 이름, 연락처, 세션 값 같은 개인정보와 실제 응답 원문은 이슈, PR, 댓글 어디에도 올리지 마세요. 필드 이름과 가상 값만 쓰세요.
:::
## 라벨
| 라벨 | 용도 |
|---|---|
| `good first issue` | 처음 시작하는 사람에게 맞는 이슈 |
| `help wanted` | 도움이 필요한 이슈 |
| `data-source` | 새 학교 정보 출처 제안 |
## 어디에 올리나
| 하고 싶은 일 | 가는 곳 |
|---|---|
| 사용법 질문, 제안, 새 정보 출처 아이디어 | [Discussions](https://github.com/Cassiiopeia/pro-sejongpeia/discussions) |
| 버그 제보 (재현할 수 있는 문제) | [이슈](https://github.com/Cassiiopeia/pro-sejongpeia/issues/new/choose) |
| 보안 취약점 | 공개 이슈 대신 [비공개 신고](https://github.com/Cassiiopeia/pro-sejongpeia/security/advisories/new) |
| 직접 고치거나 추가하고 싶음 | [CONTRIBUTING.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/CONTRIBUTING.md) |
올리기 전에 [FAQ](https://cassiiopeia.github.io/pro-sejongpeia/faq/)와 [에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)를 먼저 확인해 주세요. 이 프로젝트는 개인 오픈소스이고 답변은 자원봉사로 이뤄지므로 시간이 걸릴 수 있습니다.
## 보안 제보
이 라이브러리는 세종대 포털 비밀번호를 다루므로 보안 제보를 중요하게 처리합니다. **공개 이슈로 올리지 마세요.**
1. 레포의 [Security 탭](https://github.com/Cassiiopeia/pro-sejongpeia/security)으로 이동합니다.
2. **Report a vulnerability**를 누릅니다.
3. 문제 내용, 영향 버전, 재현 방법을 적습니다. 실제 학번, 비밀번호, 개인정보는 넣지 않습니다.
7일 안에 첫 답변을 드립니다. 가장 최근 배포 버전만 보안 수정을 받습니다. 대상은 이 라이브러리 코드입니다. 예를 들어 로그, 예외, 결과를 통한 비밀번호나 개인정보 노출, 안전하지 않은 TLS 처리, 호출 간 세션 혼선입니다. 자세한 내용은 [SECURITY.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/SECURITY.md)입니다.
## PR 규칙
- 브랜치는 `main` 기준으로 만듭니다.
- 동작이 바뀌면 테스트를 함께 추가합니다. 파서 변경은 `spec/fixtures/`에 샘플을 두고 모든 언어가 같은 기대값을 쓰게 합니다.
- 포털이 바뀐 경우 세 언어를 한 PR에서 같이 고칩니다. 자세한 순서는 [레포 구조](https://cassiiopeia.github.io/pro-sejongpeia/contribute/structure/)에 있습니다.
- 로그와 예외 메시지에 비밀번호나 개인정보가 남지 않는지 확인합니다.
- 새 기능이면 기능 노출 체크리스트를 확인합니다.
## AI·에이전트로 만든 PR
코딩 에이전트가 쓴 PR도 환영합니다. PR을 올리는 사람이 레포의 [AGENTS.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/AGENTS.md)에 적힌 명령을 직접 실행해 통과를 확인하고, PR 설명에 사용한 에이전트를 적어 주세요. 개인정보와 학교의 실제 응답은 넣지 않고(합성 값만), 검토 기준은 사람이 쓴 PR과 같습니다.
## 행동 강령과 라이선스
참여자는 [행동 강령](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/CODE_OF_CONDUCT.md)을 따릅니다. 기여한 코드는 [MIT 라이선스](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/LICENSE)로 배포됩니다.
---
# API 안정성
Source: https://cassiiopeia.github.io/pro-sejongpeia/api-stability/
Section: 더 보기
이 라이브러리를 가져다 쓰는 개발자를 위해, 무엇이 공개 API이고, 버전 번호가 무엇을 뜻하며, 무엇이 바뀔 수 있는지 솔직하게 적은 문서입니다.
> **하위 호환은 보장하지 않습니다**
> 같은 `1.x` 안에서도 깨지는 변경이 생길 수 있습니다. 버전을 정확히 고정해서 쓰세요.
## 먼저 결론
| 질문 | 답 |
|---|---|
| 하위 호환을 보장하나요? | **아니요.** 이 프로젝트는 하위 호환을 목표로 하지 않습니다 (유지보수자의 결정). 같은 `1.x` 안에서도 깨지는 변경이 생길 수 있습니다. |
| 그러면 버전 번호는 뭘 뜻하나요? | 릴리스 순서입니다. 깨지는 변경이 있는지는 번호가 아니라 **릴리스 노트**로 알려 드립니다. |
| 어떻게 쓰면 안전한가요? | 버전을 **정확히 고정**하고, 올리기 전에 릴리스 노트를 읽고, 아래 「안전하게 쓰는 방법」을 따르세요. |
| 학교가 바뀌면요? | 매일 실제 학교 서버로 점검하고, 깨지면 이슈가 열려 빠르게 고칩니다. 수정 시점에 대한 약속(SLA)은 없습니다. |
| 세 언어는 같은가요? | 기능, 에러 코드, 결과 필드, 값 규칙은 같게 유지하고 테스트가 지킵니다. 이름 표기와 일부 언어 전용 기능은 다릅니다. |
## 공개 API와 내부 구현
「공개 API」는 바꿀 때 릴리스 노트에 적고 문서(README, `spec/`)를 함께 고치는 대상입니다. 「내부」는 예고 없이 바뀝니다.
| 언어 | 공개 API | 내부 (직접 쓰지 마세요) |
|---|---|---|
| Python | `sejongpeia` 패키지의 `__all__`에 있는 이름: `authenticate`, `authenticate_dhc`, `authenticate_sis`, `fetch_sis_business_periods`, `SejongAuthClient`, `SejongPublicData`, 결과·모델 클래스, `SejongAuthError`, `ErrorCode` | `sejongpeia.common.*`, `sejongpeia.auth.*`, `sejongpeia.public_data.*` 안의 모듈과 도우미 (`from sejongpeia import ...`로 가져오는 이름만 공개), 밑줄로 시작하는 이름과 인자 (`_session_factory`, `_clock`) |
| Node | 패키지 진입점(`pro-sejongpeia`)이 내보내는 함수, 클래스, 타입. `package.json`의 `exports`가 이 진입점 하나만 열어 둡니다 | 진입점에서 내보내지 않는 모든 것, 밑줄로 시작하는 옵션 (`_sessionFactory`, `_nowMilliseconds`) |
| Java | `auth.service.SuhSejongAuthEngine`의 공개 메서드, `auth.model.*`, `common.exception.*`, `publicdata.SejongPublicData`와 `publicdata.model.*`, `common.util.SejongTlsSupport`, `common.config.SejongAuthProperties`, 설정 접두사 `sejongpeia.*` | `common.util.CommonUtil`, `publicdata`의 호출기·매퍼·페처 클래스 |
Java의 `auth.client.*`, `auth.parser.*`는 Spring 없이 쓸 때 직접 만들어 엮어야 해서 공개되어 있습니다. 그래도 **생성자 모양은 바뀔 수 있는 반공개**입니다. Spring Boot 자동 설정을 쓰면 영향이 없습니다.
공개 API라도 아래는 **보장 범위 밖**입니다.
- 원본 옵션(`include_raw`, `includeRaw`, `authenticateWithRaw`)이 돌려주는 `rawHtml`, `rawJson`: 학교 원문이라 키와 구조가 학교를 따라 바뀝니다. 자세한 내용은 [원문·extra 옵션](https://cassiiopeia.github.io/pro-sejongpeia/auth/raw-and-extra/)을 보세요.
- `extra`의 키와 값: 학교가 필드를 더하거나 빼면 달라집니다. 이름 붙지 않은 필드에 접근하는 통로일 뿐입니다.
- 에러 메시지 문구: 분기는 항상 에러 **코드**로 하세요. ([에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/))
- 로그 문구, 요청 순서, 내부 캐시 방식. 캐시 기본값은 설정으로 바꿀 수 있고 문서에 적혀 있지만 구현은 바뀔 수 있습니다.
## 버전 규칙
- 현재 `1.x`입니다. 세 언어(Java, Python, Node)가 **버전 번호 하나**를 같이 씁니다.
- 릴리스 워크플로우가 **patch(세 번째 자리)를 자동으로 올립니다.** minor와 major는 유지보수자가 직접 올립니다.
- 이 번호는 [유의적 버전(SemVer)](https://semver.org/lang/ko/)의 약속(patch는 호환, major만 파괴)을 따르지 **않습니다.** 자동으로 올라가는 patch 릴리스에도 동작이 바뀌는 변경이 들어갈 수 있습니다.
- 지금 번호가 `1`이라서 안정 선언을 했다는 뜻도 아닙니다.
- 깨지는 변경은 이렇게 알립니다. 모든 릴리스는 [GitHub Releases](https://github.com/Cassiiopeia/pro-sejongpeia/releases)와 [CHANGELOG.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/CHANGELOG.md)에 한 줄 요약이 남고, 이름이나 시그니처가 바뀌면 그 줄에 적습니다.
이름·시그니처 변경(메서드나 필드 이름, 패키지 경로, 설정 키)은 호환용 별칭 없이 한꺼번에 바뀔 수 있습니다. 그런 변경이 앞으로도 생길 수 있다는 뜻입니다.
### 그래도 하는 일
호환을 약속하지는 않지만, 가능한 한 사용자가 덜 놀라게 합니다.
- 새 기능은 기존 필드를 바꾸지 않고 **더하는 쪽**을 먼저 고릅니다 (결과 필드, 메서드 추가).
- 이름이나 모양을 바꿔야 하면 세 언어와 문서를 **한 PR에서** 함께 바꿉니다.
- 학교가 바꾼 것을 따라가는 변경은 사용자 코드가 깨지지 않는 방향을 우선하되, 학교 데이터 자체가 달라지는 것은 막을 수 없습니다.
## 세 언어가 같게 유지하는 것
아래는 언어가 달라도 같아야 하는 것이고, 어긋나면 테스트가 실패하도록 해 두었습니다.
| 같게 유지하는 것 | 지키는 방법 |
|---|---|
| 요청 주소, 포털 판정 → 에러 코드 대응 등 공유 값 | [spec/contract.json](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/contract.json)이 단일 원본이고, 언어별 contract 테스트가 자기 상수와 비교 |
| 에러 코드 10개 (`AUTHENTICATION_FAILED`, `ACCOUNT_LOCKED` 등)와 의미 | [spec/RESULT_SCHEMA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/RESULT_SCHEMA.md), contract 테스트 |
| 인증 결과 필드와 의미 (표기만 다름) | RESULT_SCHEMA, 개인정보를 제거한 응답 샘플(`spec/fixtures/`)에 대한 기대값 테스트 |
| 공개 데이터 메서드 15종과 결과 필드, 값 규칙 (없는 값은 빈 문자열, 실패 격리, 캐시) | [spec/PUBLIC_DATA.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/spec/PUBLIC_DATA.md), 같은 기대 결과 파일(`expected_public_data.json`)과 세 언어 비교 |
| 소스(인증, 공개 데이터)는 공통 계층만 임포트하고 서로 임포트하지 않음 | 의존 규칙 테스트 (Python, Node, Java 각각) |
| 실제 포털에서의 결과 | 매일 세 언어로 실제 로그인하는 점검, 필드별 해시 비교(`scripts/verify-live.sh`) |
**언어마다 다른 것**은 의도된 차이입니다.
- 이름 표기: Python `snake_case`, Node와 Java `camelCase`. 메서드 `authenticate_dhc` / `authenticateDhc` / `authenticateWithDHC`처럼 Java는 일부 이름이 다릅니다. 대응표는 [인증 메서드](https://cassiiopeia.github.io/pro-sejongpeia/auth/methods/)에 있습니다.
- 호출 방식: Python과 Java는 동기, Node는 `Promise`.
- 언어 전용: Spring Boot 자동 설정과 `sejongpeia.*` 설정 키(Java), `SejongTlsSupport`(Java), 옛 코드 `INVALID_CREDENTIALS`(Java에만 남아 있고 던지지 않음).
- 날짜·시간 타입: 인증 시각은 각 언어의 날짜·시간 타입.
## 폐기(deprecation)
- 없애려는 이름은 가능하면 한동안 남겨 두고, 문서와 릴리스 노트에 적고, Java에서는 `@Deprecated`를 답니다. 예: `SejongAuthErrorCode.INVALID_CREDENTIALS`는 더 이상 던지지 않지만 기존 코드가 컴파일되도록 남아 있습니다.
- **기간은 약속하지 않습니다.** 폐기 표시 없이 바로 사라지거나 바뀔 수 있습니다. 위 「버전 규칙」의 알림(릴리스 노트)이 유일한 공식 신호입니다.
## 학교 데이터는 학교 원문입니다
공개 데이터와 인증 결과의 학교 필드는 **학교가 준 문자열 그대로**입니다. 라이브러리는 코드표나 이름 매핑을 들고 있지 않습니다.
- 학과 코드, 소속 이름, 식당 번호, 일정 제목, 학적 상태 표기 같은 값은 **학교가 바꾸면 달라집니다.** 값 목록으로 분기하는 코드를 만들지 마세요. 예를 들어 `parentUnitName`은 학년도마다 바뀌는 표기라 인증 판정에 쓰지 않습니다.
- 날짜 형식도 학교 소스마다 다릅니다. 학사일정은 `yyyy-MM-dd`, 대학원 학사일정은 `MM.dd`입니다. 각 형식은 [학사일정](https://cassiiopeia.github.io/pro-sejongpeia/data/calendar/) 페이지에 적혀 있습니다.
- 값이 없으면 빈 문자열입니다. 공개 데이터는 `null`이 없고, 인증의 SIS 필드는 조회 실패 시 `null`입니다.
- 쓰지 않은 학교 필드는 `extra`에 담기고, 학교가 필드를 더하면 새 릴리스 없이도 `extra`에 나타날 수 있습니다.
### 학교 쪽이 바뀌면
1. 매일 09:00(KST)에 점검이 돌아갑니다. 맞는 비밀번호로 세 언어가 실제 로그인하고(틀린 비밀번호 시도는 하지 않음), 계정이 필요 없는 공개 데이터 소스의 응답 모양도 확인합니다. ([매일 도는 실제 포털 점검](https://cassiiopeia.github.io/pro-sejongpeia/understand/daily-live-check/))
2. 이상을 감지하면 이슈가 자동으로 열립니다.
3. 계약 문서(`spec/`)와 세 언어 구현을 한 PR에서 고치고 릴리스합니다.
공개 데이터는 한 소스가 실패해도 예외를 던지지 않고 마지막 캐시 값이나 빈 목록을 돌려줍니다. 그래서 **빈 목록은 「데이터 없음」과 「학교 쪽 문제」를 구분하지 못합니다.** 필요하면 호출하는 쪽에서 의미를 따로 판단하세요. ([실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/))
## 안전하게 쓰는 방법
- 의존성 버전을 **정확히 고정**하세요. 예: `pro-sejongpeia==<버전>`, `"pro-sejongpeia": "<버전>"`, `kr.suhsaechan:sejongpeia:<버전>`. `<버전>`은 최신 릴리스 번호입니다. 범위 지정(`^`, `~=`, `+`)은 깨지는 변경을 자동으로 받습니다.
- 올리기 전에 [릴리스 노트](https://github.com/Cassiiopeia/pro-sejongpeia/releases)를 읽고 배포 전 테스트를 돌리세요.
- 라이브러리를 앱 안의 얇은 어댑터 한 곳으로 감싸면 바뀔 때 고칠 곳이 한 곳입니다.
- 에러는 메시지가 아니라 **코드**로 분기하세요.
- `extra`, 원본 응답, 학교 코드 값에 앱의 핵심 로직을 걸지 마세요.
- 사용 예시는 [가이드](https://cassiiopeia.github.io/pro-sejongpeia/guides/student-verification/)에 있습니다.
## 이 정책이 바뀌려면
하위 호환을 약속하는 쪽으로 방침을 바꾸게 되면 이 문서를 먼저 고치고 릴리스 노트에 알립니다. 의견은 [Discussions](https://github.com/Cassiiopeia/pro-sejongpeia/discussions)나 이슈로 남겨 주세요.
---
# FAQ
Source: https://cassiiopeia.github.io/pro-sejongpeia/faq/
Section: 더 보기
:::note[먼저 읽어 보세요]
- 세종대 공식 라이브러리가 아니며, 비밀번호는 저장하지 않습니다.
- 서버에서만 호출하세요. 브라우저나 앱에서 부르면 비밀번호가 노출됩니다.
- 인증이 실패하면 데모로 먼저 확인하고, 계정이 잠기면 포털에서 비밀번호를 재설정해야 합니다.
:::
## 세종대 로그인 API가 있나요?
네. pro-sejongpeia는 세종대학교 포털 로그인(SSO)을 대신 처리하고, 로그인 없이 쓰는 학교 데이터도 제공하는 Java, Python, Node.js 통합 라이브러리입니다. 학번과 비밀번호로 재학 상태, 학과, 학년, 고전독서 인증, 연락처를 조회합니다. [소개](https://cassiiopeia.github.io/pro-sejongpeia/start/introduction/)와 [빠른 시작](https://cassiiopeia.github.io/pro-sejongpeia/start/quickstart/)을 보세요.
## 비밀번호를 저장하나요?
아니요. 라이브러리는 비밀번호를 저장하지 않고 포털 로그인 요청에만 씁니다. 라이브 데모 서버도 결과만 돌려주고 입력값을 저장하지 않습니다. 여러분의 서비스에서도 비밀번호를 로그나 DB에 남기지 않도록 주의하세요.
## 세종대학교 공식 라이브러리인가요?
아닙니다. 세종대 학생이 만든 개인 오픈소스이며 학교와 제휴 관계가 없습니다. 포털 구조가 바뀌면 동작이 멈출 수 있습니다.
## 요구 사항은 무엇인가요?
| 언어 | 요구 사항 |
|---|---|
| Java | Java 17 이상, Spring Boot 3.x (Spring 없이도 쓸 수 있습니다) |
| Python | Python 3.9 이상, 표준 라이브러리 외 의존성 없음 |
| Node.js | Node 18 이상, 의존성 0개, TypeScript 타입 포함, ESM·CommonJS 지원 |
## 세 언어의 결과는 같은가요? 어느 언어를 쓸까요?
기능, 에러 코드, 결과 필드, 값 규칙은 같고 테스트가 지킵니다. 이름 표기(`snake_case` / `camelCase`)와 일부 언어 전용 기능만 다릅니다. 쓰는 스택에 맞는 언어를 고르면 됩니다. 자세한 내용은 [API 안정성](https://cassiiopeia.github.io/pro-sejongpeia/api-stability/)에 있습니다.
## 인증이 갑자기 실패해요
[라이브 데모](https://cassiiopeia.github.io/pro-sejongpeia/#demo)로 먼저 확인해 보세요. 데모도 실패하면 포털 쪽이 바뀌었을 가능성이 높습니다. 메시지가 아닌 에러 **코드**를 확인하고 ([에러 코드](https://cassiiopeia.github.io/pro-sejongpeia/auth/error-codes/)), 이슈에 에러 코드와 함께 남겨 주세요. 학번과 비밀번호는 올리지 마세요. 절차는 [이슈와 PR](https://cassiiopeia.github.io/pro-sejongpeia/contribute/issues-and-prs/)에 있습니다.
## 포털 계정이 잠겼어요
비밀번호를 여러 번 틀리면 포털이 계정을 잠그고, 라이브러리는 `ACCOUNT_LOCKED`를 돌려줍니다. 에서 본인 확인 후 비밀번호를 재설정하세요. 기다린다고 풀리지 않고, 라이브러리가 대신 풀 수도 없습니다. 라이브러리는 거부된 로그인을 재시도하지 않고 첫 실패에서 멈춥니다. 누구나 학번을 입력할 수 있는 서비스라면 학번별 실패 횟수를 제한하세요. ([로그인 판정과 계정 잠금 방지](https://cassiiopeia.github.io/pro-sejongpeia/understand/login-verdict/))
## 빈 목록이 오면 데이터가 없는 건가요, 학교에 문제가 있는 건가요?
둘을 구분하지 못합니다. 공개 데이터는 한 소스가 실패해도 예외를 던지지 않고 마지막 캐시 값이나 빈 목록을 돌려줍니다. 화면에는 「불러오지 못했습니다」 같은 안내를 함께 두는 것이 좋습니다. ([실패 격리와 캐시](https://cassiiopeia.github.io/pro-sejongpeia/understand/failure-isolation-cache/))
## 학교 데이터는 얼마나 자주 갱신되나요?
결과를 메모리에 캐시하며 기본 24시간입니다 (`cache_ttl_seconds` 기본 86400). 신선한 캐시가 있으면 학교에 요청하지 않고, 같은 요청은 한 번만 보냅니다. `SejongPublicData`는 프로세스당 하나만 만들어 쓰세요. ([공통 규칙](https://cassiiopeia.github.io/pro-sejongpeia/data/overview/))
## 브라우저나 앱에서 직접 호출해도 되나요?
아니요. 서버에서만 호출하세요. 브라우저나 앱에서 직접 부르면 비밀번호가 제3자 코드에 노출됩니다. Node.js 패키지도 서버 전용입니다.
## 포털이 바뀐 것 같아요. 어떻게 알리나요?
매일 실제 포털로 점검이 돌고 이상이 있으면 이슈가 자동으로 열립니다. 그래도 먼저 알아챘다면 에러 코드와 사용한 메서드를 적어 [이슈](https://github.com/Cassiiopeia/pro-sejongpeia/issues/new/choose)로 알려 주세요. ([매일 도는 실제 포털 점검](https://cassiiopeia.github.io/pro-sejongpeia/understand/daily-live-check/))
## 보안 문제는 어디에 알리나요?
공개 이슈가 아니라 GitHub의 [비공개 신고](https://github.com/Cassiiopeia/pro-sejongpeia/security/advisories/new)를 이용해 주세요. 실제 학번, 비밀번호, 개인정보는 넣지 마세요. 자세한 내용은 [이슈와 PR](https://cassiiopeia.github.io/pro-sejongpeia/contribute/issues-and-prs/)에 있습니다.
## 라이선스는 무엇인가요?
[MIT](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/LICENSE)입니다. 세종대학교 교표의 권리는 세종대학교에 있습니다.