
답변 받았습니다! 는 북경대학교 한국인 유학생들을 위한 강의 정보공유/익명 커뮤니티 웹사이트입니다.
답변, 받았습니다! (https://honeycourses.com) 이 겨울방학을 맞이하여 새로운 업데이트로 찾아왔습니다! 금번 업데이트는 커뮤니티 기능 오픈, 백엔드 스프링 마이그레이션, 강의평가 기능 개선 등 정말 많은 변화들이 있었는데요, 프런트엔드만 놓고봐도 1월 20일 업데이트 이후 60개가 넘는 커밋들이 이루어졌습니다.

잔뜩 쌓여있는 커밋들!
재학생 개발팀인 특성 상 방학에만 개발이 가능한 우리잘했조는, 많은 기능들을 버그없이 개발해내기 위해 개발진 7명 모두- 그리고 특히 팀장인 저, 프론트/백엔드 팀장인 김혜원, 박건호 학우는 밤샘도 마지않으며 개발을 진행했습니다.

방학인데 학기중보다도 뜨거웠던 우리의 새벽..
팀장으로써, 종강 이후 약 한달간 진행한 신 버전 개발을 몇가지 주요 부분으로 나눠 회고해볼까 합니다.
답변, 받았습니다는 최초에 Express.js를 사용한 백엔드로 개발되었습니다. 이를 AWS Amplify 서비스와 연계해서, AWS API Gateway에 올려 Lambda 서비스로 서버리스 API를 구축했었습니다. (해당 백엔드 배포 과정은 Amplify가 자동으로 진행해주었습니다) 당시 구조는 아래와 같습니다:

Amplify 서비스가 Cognito 유저 인증 풀과의 연계, 그리고 Lambda로의 백엔드 배포 관리까지 자동으로 해주었기 때문에 상당히 편리한 구조였으나, 프로젝트의 규모가 커질수록 백엔드 코드가 어수선해지는 문제가 발생하기도 하고, 결정적으로 백엔드 팀장이 백엔드 방향 취업을 결정하여 이번 방학의 첫 목표는 Spring Boot로의 백엔드 마이그레이션이 되었습니다.

백엔드 마이그레이션은 24개의 티켓으로 나누어, 팀장인 제가 강의평가 관련 엔드포인트 기능 구현, 박건호 백엔드 팀장이 커뮤니티 관련 엔드포인트 기능 구현을 맡았습니다.
백엔드 개발 담당인 저와 박건호 학우는 학기 중에도 네트워크 수업 과제로 프런트/백엔드 구현 과제를 완료한 적이 있습니다.

하지만 당시 저희 둘다 스프링으로 제대로 된 프로젝트를 해보는건 처음이었고, Node.js로는 Swagger를 사용한 문서화를 진행해본 적이 있지만 스프링으로는 상당히 번잡하다는 걸 확인하고 노션에 손으로 직접 (...) 문서화를 진행하는 방식으로 진행했었습니다. 문서화의 목적 자체는 이루었지만, 이번에는 제대로 Spring REST Doc을 사용하여 문서화를 진행해보기로 했습니다.
Spring REST Docs는 Swagger와 다르게, 테스트 코드에 문서화를 진행하여 백엔드 코드의 변경을 바로 반영할 수 있는 장점이 있어 Swagger 대신 선택할 이점은 확실했습니다. 다만 설정, 그리고 API 엔드포인트로의 배포는 다소 번거로운 점도 많았고, 시행착오도 많았습니다.
저희는 주로 해당 링크를 참고하며 초기 작업을 진행했습니다. 찾는 자료들마다 build.gradle과 asciidoc.adoc형태가 약간씩 달라 애를 먹었지만, 아래와 같이 구현해 냈습니다.
...
ext {
set('snippetsDir', file("build/generated-snippets")) // snippetsDir : 테스트 실행시 생성되는 응답을 저장할 디렉토리 지정
}
configurations {
asciidoctorExt
}
test {
useJUnitPlatform()
outputs.dir snippetsDir
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
task copyDocument(type: Copy) { // 생성된 html 파일을 옮긴다
dependsOn asciidoctor // Gradle의 asciidoctor Task 이후 수행
from file("${asciidoctor.outputDir}")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument // build 이후 html 파일 복사
}
위의 build.gradle은 테스트 실행시 adoc 스니펫들이 build/generated-snippets에 저장되도록 하고, asciidoc이 생성한 html이 빌드 시 static/docs에 옮겨져, 배포 후 API 엔드포인트에서 접근 가능하게 하고 있습니다.
= 답변 받았습니다! API 명세
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
:sectnums:
:docinfo: shared-head
== 강의 관리
=== 모든 강의 조회 (GET /courses)
==== 성공
operation::courses/find/success[snippets='http-request,http-response']
...
@Test
@DisplayName("모든 강의 조회 요청을 받으면 강의를 반환한다.")
void getCourses() {
Mockito.when(courseService.findAll()).thenReturn(responses);
restDocs
.contentType(MediaType.APPLICATION_JSON_VALUE)
.header("Authorization", "Bearer aws-cognito-access-token")
.when().get("/courses")
.then().log().all()
.apply(document("courses/find/success"))
.statusCode(HttpStatus.OK.value());
}
위는 저희의 index.adoc와 컨트롤러 테스트 코드 예제입니다. 해당 파일로 asciidoc이 html로 문서화를 진행하고, build.gradle에 쓴대로 static/docs에 살포시 이쁘게 포장된 API 명세 html을 넣어주는 방식입니다. 처음 적용하는게 힘들었지, 이후 테스트 코드 작성때마다 패턴이 생겨 큰 어려움은 없었습니다.

해당 링크에 방문해보면, 완성된 API 명세를 확인할 수 있습니다. SecurityConfig.java의 securityFilterChain 메서드에 .requestMatchers("/docs/index.html").permitAll()만 추가해주는것만 잊지 않아주면, 배포된 API의 /docs/index.html에서도 API 명세를 볼 수 있게 됩니다!
개인적으로, 프로젝트 시작 전에 CI/CD를 먼저 확실히 구축해 두는것을 좋아하는 편입니다. Amplify가 Express 백엔드 배포를 자동화해주었기에 필요 없었지만, 이번에 마이그레이션을 진행하며 구축하고 싶었던 워크플로우 기능들은 아래와 같습니다:
구축된 해당 워크플로우들을 간략하게 설명해보겠습니다.
해당 링크를 주로 참고하여 진행하였습니다.
워크플로우는 아래와 같습니다:
name: PR Test
on:
pull_request:
branches: [ main, dev ]
jobs:
test:
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
steps:
- uses: actions/checkout@v2
- name: Set up JDK 17
uses: actions/setup-java@v1
with:
java-version: '17'
- run: touch ./src/main/resources/application.properties
- run: echo "${{ secrets.APPLICATION_PROPERTIES }}" > ./src/main/resources/application.properties
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Test with Gradle
run: ./gradlew --info test
# 테스트 후 Result를 보기위해 Publish Unit Test Results step 추가
- name: Publish Unit Test Results
uses: EnricoMi/publish-unit-test-result-action@v1
if: ${{ always() }} # 테스트가 실패하여도 Report를 보기 위해 `always`로 설정
with:
files: build/test-results/**/*.xml

해당 워크플로우는 main, dev에 PR이 open될 시, Github Actions를 사용하여 테스트를 진행하고, PR comment로 테스트 결과를 위 사진처럼 알려줍니다.
해당 링크를 주로 참고해서 진행했습니다. 워크플로우는 아래와 같습니다:
# 동작 조건 설정 : main 브랜치에 push가 발생할 경우 동작한다.
on:
push:
branches: [ "main" ]
permissions:
contents: read
jobs:
# Spring Boot 애플리케이션을 빌드하여 도커허브에 푸시하는 과정
build-docker-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# 1. Java 17 세팅
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- run: touch ./src/main/resources/application.properties
- run: echo "${{ secrets.APPLICATION_PROPERTIES }}" > ./src/main/resources/application.properties
# 2. Spring Boot 애플리케이션 빌드
- name: Build with Gradle
uses: gradle/gradle-build-action@v2.4.2
with:
arguments: build
# 3. Docker 이미지 빌드
- name: docker image build
run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/honeycourses-backend-spring-prod .
# 4. DockerHub 로그인
- name: docker login
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
# 5. Docker Hub 이미지 푸시
- name: docker Hub push
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/honeycourses-backend-spring-prod
# 위 이미지를 pull해서 ec2에서 실행
run-docker-image-on-ec2:
# build-docker-image (위)과정이 완료되어야 실행됩니다.
needs: build-docker-image
runs-on: self-hosted
steps:
# 1. 최신 이미지를 풀받습니다
- name: docker pull
run: sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/honeycourses-backend-spring-prod
# 2. 기존의 컨테이너를 중지시킵니다
- name: docker stop container
run: sudo docker stop $(sudo docker ps -q) 2>/dev/null || true
# 3. 최신 이미지를 컨테이너화하여 실행시킵니다
- name: docker run new container
run: sudo docker run --name github-actions-demo --rm -d -p 80:8080 ${{ secrets.DOCKERHUB_USERNAME }}/honeycourses-backend-spring-prod
# 4. 미사용 이미지를 정리합니다
- name: delete old docker image
run: sudo docker system prune -f
크게 아래의 단계로 나누어 볼 수 있습니다:
Amplify가 자동으로 배포해주는 API Gateway는 API 보안이 매우 편리했습니다. 결국 모두 자사 내의 서비스다 보니, Cognito 유저풀을 사용한 API 보안 관련 코드는 작성할 필요가 전혀 없었습니다. Amplify-UI를 통해 프런트엔드에서 로그인을 하면 알아서 로컬 스토리지에 토큰 등 유저 인증 정보를 저장했고, API 요청시 알아서 해당 토큰을 빼내어 인증 자격을 전달했으며, API Gateway측에서 알아서 해당 토큰으로 사용자 인증+신원 확인까지 해줬으니 말이죠.
하지만 이번에 마이그레이션을 진행하면서, 아래의 숙제를 해결해야 했습니다:
저희가 택한 해결택은 Spring Security를 사용하는 것이었습니다. (해당 PR) 인증 과정은 아래와 같습니다:
import { Auth } from "aws-amplify";
export async function getAuthHeaders() {
try {
const userSession = await Auth.currentSession();
const jwtToken = userSession.getAccessToken().getJwtToken();
return {
Authorization: `Bearer ${jwtToken}`,
};
} catch (error) {
console.error("Error fetching authentication headers:", error);
throw error;
}
}
...
const headers = await getAuthHeaders();
const response = await axios.get(`${apiUrl}${endpoint}`, { headers });
{aws.cognito.uri}는 Cognito 유저 풀의 /.well-known/jwks.json 로 끝나는 엔드포인트로, 해당 토큰의 유효성을 검증할 수 있는 엔드포인트입니다.@Component
public class TokenProvider {
@Value("${aws.cognito.uri}")
private String uri;
public JwtDecoder accessTokenDecoder() {
return NimbusJwtDecoder.withJwkSetUri(uri).build();
}
}
SecurityConfig의 해당 메서드에서 토큰의 유효성에 따라 엔드포인트의 방문을 허용합니다. .permitAll() 지정된 엔드포인트들만 인증 없이 방문 가능한데, 저희의 경우 브라우저의 OPTIONS preflight 리퀘스트, 그리고 동작 확인을 위한 루트 엔드포인트와 API 명세가 있는 /docs 엔드포인트의 방문을 풀어뒀습니다.@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.cors().and()
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry
.requestMatchers(HttpMethod.OPTIONS).permitAll()
.requestMatchers("/", "/docs/**", "/docs/index.html").permitAll() // 권한이 필요없는 엔드 포인트, 루트와 API 명세 엔드포인트 열어둠
.anyRequest().authenticated())
.oauth2ResourceServer(httpSecurityOAuth2ResourceServerConfigurer ->
httpSecurityOAuth2ResourceServerConfigurer.jwt(jwtConfigurer ->
jwtConfigurer.decoder(tokenProvider.accessTokenDecoder())))
.httpBasic(Customizer.withDefaults())
.build();
}

사진 출처: 링크
본래 2주정도 시간을 두고 진행하려 했던 마이그레이션이었으나, 처음부터 구축해보는 재미에 빠져버린 (...) 두명은 수없는 시행착오를 뚫어내며 1주일만에 마이그레이션을 완료하게 되었습니다. 괴물 신인들
최종적으로, 위의 구조를 가진 CI/CD 워크플로우를 구축하여, https://springapi.honeycourses.com/ 엔드포인트에 성공적으로 스프링 API를 배포하였습니다.

테스트 사이트라 내용이 혼란한 상태.
강의평가 기능은 답변 받았습니다! 의 핵심입니다. 1.1.0버전 릴리즈 전에는 커뮤니티 기능이 아직 닫혀있기도 했고, 결론적으로 저희의 핵심 서비스라고 할 수 있겠습니다. 릴리즈 이후 2024년 1월 31일 기준 현재 190개가 넘는 리뷰가 달려 있을정도로 성행중인 서비스지만, 미흡한 점이 많아 방학동안 집중적으로 개선 작업을 진행했습니다. UI 개선은 주로 김혜원 학우의 탁월한 미적 센스로 이루어졌고 (이뻐졌네? 싶은건 모두 혜원이덕), 아래에는 개선된 기능들의 기술적인 부분들을 얘기해볼까 합니다.


사실 CRUD의 핵심인 C밖에 없었던 지금까지의 강의평가 기능과는 다르게, 이번 버전에는 백엔드 개편과 함께 본인이 작성한 강의평가의 수정과 삭제가 가능해졌습니다. 수정 인터페이스도 별도의 컴포넌트를 만드는 대신,
const [isEditing, setIsEditing] = useState(false)
{review.mine && (
<div>
{editingReviewId === review.review_id ? (
<div className={styles.editButtons}>
...
의 형식을 사용하여 수정 버튼이 눌리면 해당 state를 true로 전환, 본래 리뷰의 내용을 state로 전달하여 두번째 사진처럼 수정이 가능하도록 구현했습니다.

그동안의 강의평가 기능에선, 강의 목록만 보고는 눌러보기 전까진 몇개의 리뷰가 적혀있을지 알 방법이 없어, 눌러보고는 리뷰가 하나도 없다는 허탈한 사실만 마주하고 뒤로가기 버튼을 누르는 경우가 허다했습니다.

이번엔 해당 피드백을 인지하고, 위처럼 눌러보기 전에도 해당 강의에 몇개의 리뷰가 달려있는지 확인할 수 있도록 기능을 추가하였습니다.
[{
"course_id" : 0,
"course_category" : "Zhuanye",
"course_credit" : 5,
"course_name" : "Gaoshu0",
"isYouguan" : 0,
"kaikeYuanxi" : "Xinke",
"reviewCount" : 0
}]
기술적으론 강의 목록을 받아올때 위의 형식으로 reviewCount 값을 받아올 수 있도록 백엔드에 변화를 준 것인데, 사실 원래의 강의 DB 테이블엔 강의 갯수를 알려주는 칼럼이 없어 review_count 칼럼을 추가하고, 해당 SQL 쿼리로 리뷰 갯수를 업데이트해주었습니다:
UPDATE courses
SET review_count = (SELECT COUNT(*)
FROM reviews
WHERE reviews.course_id = courses.course_id);
강의 리스트를 받아올때마다, 모든 강의들에 대해 개별로 SQL을 돌려 실시간으로 강의 수를 받아오는건 부하가 올게 분명했기에 그나마 제일 합리적인 방법으로 해결했다고 생각합니다. 이후 review_count 칼럼의 변화는 백엔드에서 새로운 리뷰가 추가/삭제 될 시에 이루어지니 문제가 해결됩니다!

테스트를 겸해 커뮤니티에서 지적인 대화를 나누고 있는 개발진들
커뮤니티 기능이 베타 테스트를 거쳐서 이번에 정식 오픈했습니다! 일반 커뮤니티들처럼, 글 작성/수정/삭제, 댓글/대댓글 작성/수정/삭제가 가능한 매우 평범한 기능의 커뮤니티지만, 개발진이 가장 공들이고 시간을 많이 들인 부분이기도 합니다. 쉬워보이지만 어려워요 진짜 ㅠㅠ
커뮤니티 기능에서 기술적으로 회고해볼 부분들을 요약해보겠습니다.

개발진 개인의 의견일 뿐입니다. 근데 민초 드실거면 차라리 치약 드세요.
강의평가 기능을 보면 알겠지만, 수업 목록을 불러올때 페이지로 나누지 않고 하나의 json으로 받아오고 있습니다. 하지만 커뮤니티는 글들이 훨씬 많아질거기 때문에 페이지네이션 도입이 필요했습니다. 해당 작업이 프론트에서만 하고 끝낼거면 좋겠지만, 백엔드에서도 개선이 이루어져야 하는 작업이기에 꽤나 시간을 들여 사진처럼 도입에 성공했습니다.
기존과 다르게, 게시글 목록을 /community?page={pageNo} 엔드포인트에 요청시 백엔드가 아래같은 응답을 보내도록 설정했습니다:
{
"posts": [기존 게시글 응답 객체], // 기본 값으로 10개이며, 해당 페이지의 게시글 개수가 10개 미만이면, 해당하는 개수만큼 들어있어요.
"totalPageCount": 3, // 총 페이지 개수
"totalPostCount": 23 // 총 게시글 개수
}
해당 데이터를 사용해서, 프런트엔드 단에서는 react-js-pagination을 사용하여 페이지네이션을 구현했습니다.
Paging.tsx 파일은 아래와 같습니다:
import React from "react";
import "./Paging.css";
import Pagination from "react-js-pagination";
interface PagingProps {
page: number;
count: number;
setPage: (page: number) => void;
}
const Paging: React.FC<PagingProps> = ({ page, count, setPage }) => {
return (
<Pagination
activePage={page}
itemsCountPerPage={10} // 페이지당 10개의 글
totalItemsCount={count} // 총 글 갯수, API가 반환해줌
pageRangeDisplayed={5}
prevPageText={"‹"}
nextPageText={"›"}
onChange={setPage}
/>
);
};
export default Paging;
해당 컴포넌트는 현재 페이지, 총 게시글 갯수, onChange에 반영할 setPage 함수를 인자로 받습니다. 이 컴포넌트를 활용해서 아래와 같이 게시글 페이지네이션을 구현했습니다:
const [currentPage, setCurrentPage] = useState(1);
const handlePageChange = (pageNumber: number) => {
setCurrentPage(pageNumber);
};
...
const response = await apiGet(`/community${categoryPath}?page=${pageNo - 1}`); // 백엔드측에서 0부터 페이지 카운트 시작
...
<Paging
page={currentPage}
count={totalItemsCount}
setPage={handlePageChange}
/>
생각보다 간단했습니다! useEffect를 사용하여 페이지가 바뀔때마다 API 리퀘스트가 제대로 들어가게만 해주면, 페이지별로 글을 받아올 수 있게 됩니다.
사실 전 버전까지만 해도 강의평가의 좋아요 여부는 브라우저의 로컬 스토리지를 사용해서 이루어졌습니다. 하지만 이는 당연히 다른 기기를 사용하거나, 브라우저 캐시를 지우기만 해도 다시 좋아요를 누를 수 있게 되는 등 문제가 많은 방식이어서, 금번엔 백엔드 차원에서 좋아요 로직을 개편하였습니다.
업데이트 이후 강의평가 response는 아래와 같습니다:
{
"review_id" : 1,
"course_id" : 1,
"review_content" : "Test Review 1",
"review_title" : "Title1",
"instructor_name" : "Jiaoshou",
"taken_semyr" : "22-23",
"grade" : "60",
"like_count" : 0,
"review_time" : "2024-01-29T14:57:13.239973274",
"liked" : false,
"updated" : false,
"mine" : false
}
이젠 유저가 특정 리뷰에 좋아요를 누르면, 백엔드측은 리퀘스트를 받고 DB의 reviewLikes테이블에 아래처럼 데이터를 저장합니다:

이후 강의평가 반환시, 백엔드는 reviewLikeRepository.findByReviewAndUsername(review, authInfo.getUsername());를 통해 리퀘스트를 보낸 유저가 해당 리뷰에 좋아요를 누른 기록이 있는지 확인한 후 liked 필드의 값을 반영합니다.
(해당 PR 링크)
프런트엔드 컴포넌트들중 API와 통신이 필요한 코드들은 기본적으로 현재 유저의 Access Token을 받아서 header에 넣는 아래와 같은 코드들이 반복적으로 등장했습니다:
import axios from "axios"; // API 콜을 하기 위한 라이브러리 import
import { Auth } from "aws-amplify"; // 유저 로그인 정보를 얻어오기 위한 import
const apiUrl = process.env.REACT_APP_API_URL; // API 주소, .env에서 설정한 값
const userSession = await Auth.currentSession();
const jwtToken = userSession.getAccessToken().getJwtToken();
const headers = {
Authorization: `Bearer ${jwtToken}`,
};
// 이상은 토큰을 받는 과정
하지만 이제 프로젝트 규모가 커져서 이런 코드가 필요한 컴포넌트가 한두개가 아니고, 매 컴포넌트를 새로 쓸때마다 이 코드를 다시 쓰니 컴포넌트마다 같은 일을 하는 코드들이 형식도 미묘하게 다르게 반복적으로 쓰이고 있었습니다. 이를 해결하기 위해 APIHandler.tsx를 만들어 아래와 같이 리팩토링을 진행했습니다:
import axios from "axios";
import { Auth } from "aws-amplify";
const apiUrl = process.env.REACT_APP_API_URL;
export async function getAuthHeaders() {
try {
const userSession = await Auth.currentSession();
const jwtToken = userSession.getAccessToken().getJwtToken();
return {
Authorization: `Bearer ${jwtToken}`,
};
} catch (error) {
console.error("Error fetching authentication headers:", error);
throw error;
}
}
export async function apiGet(endpoint: string) {
try {
const headers = await getAuthHeaders();
const response = await axios.get(`${apiUrl}${endpoint}`, {
headers,
});
return response;
} catch (error) {
throw error;
}
}
export async function apiPost(endpoint: string, data: any) {
try {
const headers = await getAuthHeaders();
const response = await axios.post(`${apiUrl}${endpoint}`, data, {
headers,
});
return response;
} catch (error) {
throw error;
}
}
보이듯 우선 중복적으로 import 되고있던 axios와 amplify의 auth를 데려왔고, apiUrl도 데려왔습니다. getAuthHeaders는 현재 사용자의 토큰을 불러오고, apiGet과 apiPost는 이를 사용해 토큰을 header에 넣어 리퀘스트해줍니다.
해당 리팩토링을 통해, 아래와 같은 코드를:
import axios from "axios";
import { Auth } from "aws-amplify";
const apiUrl = process.env.REACT_APP_API_URL;
const userSession = await Auth.currentSession();
const jwtToken = userSession.getAccessToken().getJwtToken();
const headers = {
Authorization: `Bearer ${jwtToken}`,
};
const data = {
review_title,
instructor_name,
taken_semyr,
review_content,
grade,
};
axios.post(`${apiUrl}/courses/${courseId}/reviews`, data, { headers })
.then((response) => {
console.log(response.data);
alert("리뷰 등록에 성공했습니다!");
}
...
아래와 같이 간략하게 쓸 수 있게 되었습니다.
import { apiPost } from "../API/APIHandler";
const data = {
review_title,
instructor_name,
taken_semyr,
review_content,
grade,
};
await apiPost(`/courses/${courseId}/reviews`, data)
.then((response) => {
console.log(response.data);
alert("리뷰 등록에 성공했습니다!");
}
...

https://scores.honeycourses.com 을 통해 성적 조회를 할 수 있게 되었습니다. 사실 이 웹사이트는 제가 만든건 아니고, 해당 링크의 저희과 중국 친구가 원래 만들어둔 오픈소스 프로젝트를 가져와 한국어로 번역 및 스타일링한 작품입니다.
해당 웹서비스는 유저의 북경대학교 학번/비밀번호를 받아 iaaa.pku.edu.cn (북경대학교 포탈 인증 서버)로 보내 토큰을 받고, 이를 통해 성적을 얻어내는 방식으로 작동합니다. Angular를 사용해서 개발되었기에 추후에 시간이 된다면 React로 이식해보면 좋을 것 같습니다.