이제 프로젝트의 요구사항이 좀 다양해지다 보니 구현 해야 할 기능들이 많아졌다. 하지만, 데이터베이스를 난생 처음 다루면서(그것도 첫 프로젝트에서) 모르는 부분들이 너무 많아 일일히 찾아보면서 여러가지 기능들을 구현하다보니 새롭고 흥미로운 주제를 발견했다. 이전까지 프로젝트를 구현하며 궁금했던건 도대체 데이터베이스와 상호작용은 어떻게 하며, 데이터 모델은 어떻게 구현하지? 였다. 

 

그러니까 내 말은, 예를 들어, users, feedback이라는 두개의 컬렉션이 있을때, 어떤 컬렉션에서 데이터를 찾고, 또 어떤 컬렉션을 선택하여 데이터를 넣을지 등등 아무것도 몰랐던 차에 데이터베이스를 건드릴 일이 생겼다. 그래서 한번 데이터베이스 스키마, CRUD 연산을 간단히 살펴보자.

 

🛠️ 데이터베이스 스키마 정의

사실 이 부분이 백엔드 로직을 짜기 전 더욱 더 중점을 두어야 하는 부분이다. 데이터 스키마를 정의해야 이제 서버측에서 데이터를 원하는대로 다룰 수 있기 때문이다.

 

  1. 요구사항 분석
    우선, 요구사항을 분석 해야한다. 여기서의 요구 사항이란, 어떤 데이터 모델이 어떠한 값들을 담을지, 그리고 데이터 간의 단계를 정확히 파악하고 이해해야 한다. 예를 들어, 이번 프로젝트에서의 모델은 users, feedbacks, course 정도가 있다.
    우선, 유저는 email, 이름, 역할(학생인지 교수님인지) 등등이 있다. 모델은 다음과 같이 정의 할 수 있다.
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  pid: { type: String, required: true, unique: true },
  role: { type: String},
  name: { type: String }, 
  email: { type: String, required: false, unique: true },
  isFirstLogin: { type: Boolean, default: true }, 
  createdAt: { type: Date, default: Date.now },
  group: {type: String}
});

const User = mongoose.model('User', userSchema);

module.exports = User;

 

📌 Mongoose 모델과 스키마를 설정할 때, 해당 모델이 어느 컬렉션에 저장될지 결정된다. 위 코드에서 User 모델을 정의할 때 컬렉션 이름이 결정된다. 이 코드에서 User 모델이 userSchema 스키마를 사용하고 있다. Mongoose는 모델 이름의 복수형을 기본 컬렉션 이름으로 사용한다. 따라서 User 모델의 경우 기본적으로 users라는 컬렉션에 데이터가 저장된다.

 

한번 간단하게 학생이 수업을 등록하고, 등록한 학생을 그 course 모델의 데이터베이스에 추가 할 수 있도록 짜보자. 아래는 CRUD 연산을 이용해 학생이 Course에 등록하는 로직이다.

exports.register = async (req, res) => {
  const { uniqueCode } = req.body;
  const userPid = req.session.user.pid;

  try {
    const course = await Course.findOne({ uniqueCode });
    if (!course) {
      return res.status(404).send('Course not found');
    }

    if (course.students.includes(userPid)) {
      return res.status(400).send('User already registered for this course');
    }

    course.students.push(userPid);
    await course.save();
    res.status(200).json({ message: 'Successfully registered for the course', course });
  } catch (error) {
    console.error('Error registering course:', error);
    res.status(500).json({ message: 'Failed to register course' });
  }
};

 

아래에 course.students.push는 course라는 컬렉션의 student라는 필드에 현재 등록하려는 학생의 id를 넣고자 하는 것이다.

 

이렇게 하고 등록을 해 보면

위와 같이 courses 컬렉션의 students 필드에 kevinc97이라는 학생을 성공적으로 넣어주었다.

 

이제, 이 학생의 정보를 가져와 보여주도록 해 보자.

이번 프로젝트를 진행하며 데이터베이스를 처음 다뤄보았다. 하지만 데이터베이스를 다루기는 커녕 어떻게 손을 댈지 전혀 감이 안 잡히는 상황이였고, 랩실에 있는 phD 학생이 만들어놓은 매뉴얼에 따라 mkdir database 디렉토리를 만들고 그 안에서 mongod -dbpath./를 실행하여 수동으로 mongoDB를 실행 시켜주었다. 

 

정말 이게 다였다. 

 

하지만 프로젝트에서 데이터베이스를 건드릴 일이 한두가지씩 늘어나니 내가 손을 댈 수 없는 상황까지 이르렀고, 결국 서버 로직까지 건드려야 하는 상황이오게 되었다.

 

먼저, 내가 알아내야 할것이 몇가지 있었다.

 

💬 mongod -dbpath./는 무엇인가?

`mongod --dbpath ./`에서 `--dbpath`는 MongoDB 서버가 데이터를 저장할 디렉토리를 지정하는 옵션이다.

`./`는 현재 디렉토리를 의미한다. 따라서 이 명령어는 현재 디렉토리를 데이터 저장소로 사용하여 MongoDB 서버를 시작하는 것을 의미한다.

간단히 요약하자면:

- `mongod`: MongoDB 서버를 실행하는 명령어
- `--dbpath ./`: 현재 디렉토리를 데이터 저장소로 지정

따라서, 이 명령어를 실행하면 MongoDB 서버가 현재 디렉토리에 데이터를 저장하며 실행된다.

 

좋아, 이제 이렇게 하면 db가 실행중이라는거고, 이제 server.js 안에 mongo와 연결하는 코드가 있으므로 

mongoose.connect(process.env.MONGO_URI)
  .then(() => {
    console.log("Connected to db");
  })
  .catch((err) => console.log(err));

 

연결이 될 것이다.

 

그럼 순서는 : 먼저 mongoDB서버를 실행 시켜준 후, server.js에서 mongoose.connect를 통해 mongo서버와 연결을 시켜준다. 만약 mongo가 실행중이 아니라면 위의 mongoose.connect는 실패 할것이다.

 

 

 

📌 문제 1 :

이번에 서버 파일을 수정하다보니 GET요청을 처리하지 못하는 상황이 발생했다.

 

로그인 버튼을 누르면 /Dashboard로 GET요청을 보내도록 되어 있는데 왜 안되는지 차근차근 살펴보자...후....

 

먼저, 아래는 클라이언트측의 get요청 진입점이다.

    const handleLogin = (event) => {
        event.preventDefault();
        toast.info('Navigating to VT CAS login page...');
        const casLoginUrl = 'https://login.vt.edu/profile/cas/login?service=https://crescendo.cs.vt.edu:8080/Dashboard';
        window.location.href = casLoginUrl;
    };

로그인 버튼을 누르면 /Dashboard로 라우팅 된다.

 

다음은 서버측 라우팅 설정을 확인 해보자.

app.use('/auth', require('./routes/authRoutes'));

 

이 라인은 '/auth' 경로로 시작하는 모든 요청을 authRoutes 파일로 라우팅 한다.

 

어 근데... 지금 다시보니 클라이언트에서 요청을 /Dashboard 라고만 보내고 있었다. 방금 말한것 처럼 경로를 잘못 설정해 발생한 일이다. 이렇게 고치니 제대로 요청이 들어왔다.

 

📌 문제 2:

코스를 생성하려고 하면 코스를 생성 할 수 없다고 뜸... 이것도 99% 경로 문제라고 확신한다. 일단 보자....

 

먼저, 서버 파일을 확인해 라우팅 설정을 확인 해준다.

app.use('/courses', require('./routes/courseRoutes'));

 

이 라인은 '/courses' 경로로 시작하는 모든 요청을 courseRoutes 파일로 라우팅 한다.

 

이제, courseRoutes를 확인 해보자.

const express = require('express');
const router = express.Router();
const courseController = require('../controllers/courseController');

router.post('/create', courseController.createCourse);
router.post('/register', courseController.registerCourse);
router.get('/', courseController.getCourses);
router.get('/:courseId', courseController.getCourse);
router.put('/:courseId', courseController.updateCourse);
router.delete('/:courseId', courseController.deleteCourse);

module.exports = router;

 

라우터 설정


-express.Router()를 사용하여 라우터 객체를 생성한다.
-courseController에서 각 컨트롤러 함수를 가져와서 사용한다.


라우터 엔드포인트


router.post('/create', courseController.createCourse): 강좌 생성 엔드포인트. POST /create 요청을 받아 새로운 강좌를 생성한다.
router.post('/register', courseController.registerCourse): 강좌 등록 엔드포인트. POST /register 요청을 받아 사용자를 강좌에 등록한다.
router.get('/', courseController.getCourses): 강좌 목록 조회 엔드포인트. GET / 요청을 받아 모든 강좌를 반환한다.
router.get('/:courseId', courseController.getCourse): 특정 강좌 조회 엔드포인트. GET /:courseId 요청을 받아 특정 ID의 강좌를 반환한다.
router.put('/:courseId', courseController.updateCourse): 강좌 업데이트 엔드포인트. PUT /:courseId 요청을 받아 특정 ID의 강좌 정보를 업데이트한다.
router.delete('/:courseId', courseController.deleteCourse): 강좌 삭제 엔드포인트. DELETE /:courseId 요청을 받아 특정 ID의 강좌를 삭제한다.

 

모두 제대로 되어 있는것 같으니 요청을 보내는 클라이언트쪽 코드를 보자.

    const handleCreateCourse = async () => {
        try {
            const response = await axios.post('https://crescendo.cs.vt.edu:8080/createCourse', {
                name: courseName,
                term: term,
                crn: crn
            }, {
                withCredentials: true,
                headers: {
                    'Content-Type': 'application/json'
                }
            });

            if (response.status === 201) {
                toast.success('Course created successfully!');
                setCourses([...courses, response.data]);
                setShowModal(false);
                setCourseName('');
                setTerm('');
                setCrn('');
                setSelectedCourseId(null); 
            }
        } catch (error) {
            toast.error('Failed to create course');
            console.error('Error creating course:', error);
        }
    };

 

역시나! 요청 경로가 잘못되어 저런 오류가 발생했다.

https://crescendo.cs.vt.edu:8080/courses/create 의 경로로 요청을 보내야 제대로 된 요청을 받고 처리 할 수 있다.

 

📌 문제 3: 코스 삭제 불가

생성된 Course를 삭제하려고 하니 자꾸 404 에러가 발생. 이건 확인해보니 경로 문제는 아니였다.

 

문제는 다음과 같았다 : 컨트롤러에서는 req.params.id를 사용하고 있지만, 라우트에서는 :courseId로 정의되어 있다. 이를 일치시켜야 한다.

 

이게 무슨 말이냐면...

 

Express.js에서 라우트를 정의할 때, URL 파라미터를 사용할 수 있다. 이 파라미터는 콜론(:)을 사용하여 정의하며, 이를 통해 동적으로 값을 받을 수 있다.


이전 코드에서는 다음과 같이 라우트가 정의되어 있었다:


router.delete('/:courseId', courseController.deleteCourse);


여기서 :courseId는 URL 파라미터다. 예를 들어, /courses/123이라는 요청이 오면, 123이 courseId 파라미터의 값이다.
그러나 컨트롤러 함수에서는 이 파라미터를 다음과 같이 접근하고 있다:


const courseId = req.params.id;


여기서 문제가 발생한다. 라우트에서는 :courseId로 정의했지만, 컨트롤러에서는 id로 접근하고 있어 불일치가 발생한다.
이 문제를 해결하기 위해서는 두 가지 방법이 있다:

라우트 정의를 컨트롤러와 일치시키기:
router.delete('/:id', courseController.deleteCourse);

컨트롤러에서 파라미터 접근 방식을 라우트와 일치시키기:
const courseId = req.params.courseId;


두 방법 중 하나를 선택하여 파라미터 이름을 일치시키면, 서버가 올바르게 course ID를 받아 삭제 작업을 수행할 수 있다.
이 불일치로 인해 courseId가 undefined가 되어 course를 찾지 못하거나 삭제하지 못하는 문제가 발생했던 것이다. 

이번에 프로젝트를 진행하던 도중 문제가 생겼다.

 

텍스트만 있는 버튼보단, 옆에 아이콘도 같이 넣어주면 더 좋아보일까 싶어 버튼 옆에 아이콘을 추가 하려고 했다. 그래서 이번엔 MUI를 사용해 버튼을 넣으려고 했다. 하지만, 아래와 같이 버튼 아이콘을 넣으면 자꾸 이러한 에러가 떴다. 콘솔문을 보면 useContext에서 null값을 읽어서 발생하는 오류 같은데, useContext는 내가 사용한 기억도 없고, 프로젝트 전체를 뒤져봐도 useContext는 찾을 수 없었다.

 


Cannot read properties of null (reading 'useContext') TypeError: Cannot read properties of null (reading 'useContext') at Object.useContext (https://crescendo.cs.vt.edu/static/js/bundle.js:76455:25) at useTheme (https://crescendo.cs.vt.edu/static/js/bundle.js:71137:59) at useTheme (https://crescendo.cs.vt.edu/static/js/bundle.js:71045:77) at useThemeProps (https://crescendo.cs.vt.edu/static/js/bundle.js:71101:68) at useThemeProps (https://crescendo.cs.vt.edu/static/js/bundle.js:67972:79) at SvgIcon (https://crescendo.cs.vt.edu/static/js/bundle.js:66741:82) at renderWithHooks (https://crescendo.cs.vt.edu/static/js/bundle.js:39186:22) at updateForwardRef (https://crescendo.cs.vt.edu/static/js/bundle.js:42435:24) at beginWork (https://crescendo.cs.vt.edu/static/js/bundle.js:44496:20) at HTMLUnknownElement.callCallback (https://crescendo.cs.vt.edu/static/js/bundle.js:29442:18)

import AutoStoriesOutlinedIcon from '@mui/icons-material/AutoStoriesOutlined';

                    <div style={{ marginTop: '2rem', textAlign: 'center' }}>
                        <Button onClick={handleClickCourses} className="custom-button mb-4">
                        <AutoStoriesOutlinedIcon />
                            Courses</Button>
                        <Button onClick={handleClickManageStudents} className="custom-button mb-4">Manage Students</Button>
                        <Button variant="light" className="custom-button mb-4">Settings</Button>
                    </div>

 

도대체 저 버튼 아이콘이 뭐길래 런타임 에러가 뜨는지... 구글에 검색을 해보았다. 검색을 해보니 여러가지 경우가 있었지만, 내가 발견한 케이스는 다음과 같다.

 

"MaterialUI(MUI)를 사용하려면 ThemeProvider 안에 하위 디렉토리를 감싸야 한다"

 

이 말이 뭔가 싶어 공식 문서를 뒤져보았다.


`ThemeProvider`의 역할
`ThemeProvider`는 Material-UI에서 제공하는 테마를 React 컴포넌트 트리에 전달하는 역할을 한다. 이 테마는 색상, 타이포그래피, 간격 등 여러 스타일 속성을 포함하며, MUI 컴포넌트들이 이 테마를 참고하여 스타일을 적용한다.

왜 `ThemeProvider`가 필요한가?
Material-UI 컴포넌트들은 기본적으로 테마를 필요로 한다. 테마가 없으면 컴포넌트들이 어떻게 스타일링 되어야 할지 알지 못하기 때문에 오류가 발생할 수 있다. 특히, MUI 아이콘 컴포넌트들은 테마와 긴밀하게 연관되어 있으며, 테마가 없으면 올바르게 동작하지 않는다.

예시: `AutoStoriesOutlinedIcon`
`AutoStoriesOutlinedIcon`과 같은 MUI 아이콘 컴포넌트는 테마에 접근하여 색상이나 크기 등의 스타일 속성을 가져온다. `ThemeProvider`를 사용하지 않으면 이러한 스타일 속성을 찾을 수 없게 되고, 결과적으로 컴포넌트가 제대로 렌더링되지 않는다.

테마 적용 전후의 차이

테마 적용 전 (`ThemeProvider` 없이):

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<LoginPage />} />
        <Route path="/Dashboard" element={<Dashboard />} />
        <Route path="/FeedbackPage" element={<FeedbackPage />} />
        <Route path="/GiveFeedback" element={<GiveFeedback />} />
        <Route path="/FacultyDashboard" element={<FacultyDashboard />} />
        <Route path="/ManageStudents" element={<ManageStudents />} />
        <Route path="/course/:courseId" element={<CourseDetails />} />
        <Route path="/Courses" element={<Courses />} />
      </Routes>
    </BrowserRouter>
  );
}


이 상태에서는 MUI 컴포넌트들이 테마를 참조할 수 없기 때문에, `AutoStoriesOutlinedIcon`과 같은 컴포넌트에서 오류가 발생할 수 있다.

테마 적용 후 (`ThemeProvider` 추가):

import { ThemeProvider, createTheme } from '@mui/material/styles';

// 테마 설정
const theme = createTheme({
  palette: {
    primary: {
      main: '#1976d2',
    },
    secondary: {
      main: '#dc004e',
    },
  },
  typography: {
    fontFamily: 'Roboto, Arial, sans-serif',
  },
});

function App() {
  return (
    <ThemeProvider theme={theme}>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<LoginPage />} />
          <Route path="/Dashboard" element={<Dashboard />} />
          <Route path="/FeedbackPage" element={<FeedbackPage />} />
          <Route path="/GiveFeedback" element={<GiveFeedback />} />
          <Route path="/FacultyDashboard" element={<FacultyDashboard />} />
          <Route path="/ManageStudents" element={<ManageStudents />} />
          <Route path="/course/:courseId" element={<CourseDetails />} />
          <Route path="/Courses" element={<Courses />} />
        </Routes>
      </BrowserRouter>
    </ThemeProvider>
  );
}


이 상태에서는 모든 MUI 컴포넌트들이 `ThemeProvider`로부터 테마를 참조할 수 있기 때문에, `AutoStoriesOutlinedIcon`과 같은 컴포넌트들이 올바르게 동작한다.

요약
- `ThemeProvider`는 MUI 컴포넌트들이 참조할 수 있는 테마를 제공하며, 이를 통해 일관된 스타일을 적용한다.
- `ThemeProvider`가 없으면 MUI 컴포넌트들이 필요한 스타일 정보를 얻지 못해 오류가 발생할 수 있다.
- `ThemeProvider`를 추가함으로써 MUI 컴포넌트들이 올바르게 스타일링되고, 정상적으로 동작하게 된다.

이렇게 ThemeProvider를 추가하니 아래와 같이 버튼 옆에 아이콘이 보인다.

또 한가지, 

ThemeProvider 컴포넌트는 Routes의 자식 컴포넌트가 될 수 없다. 모든 <Routes>의 자식은 <Route> 또는 <React.Fragment> 여야 한다. 

 

해야 할 일 : CAS SSO 로그인을 통해 유저를 로그인 시킨 후, 유저의 역할(이번 프로젝트에서는 학생, 교수님 밖에 없다.)에 따라 라우팅 되는 페이지를 달리 설정한다.

 

💡로그인 된 유저가 학생인지, 교수님인지 어떻게 알 수 있을까?

- 맨 처음에는 유저에게 직접 물어볼까 생각을 했다. 유저가 처음 로그인을 하는 상태라면, 학생인지 교수님인지 물어 본 후, 그에따라 라우팅을 하려고 했지만, 만약 악의적인 학생이 교수님으로 설정 후 다른 학생들의 정보를 건드리는건 위험한 일이다. 

 

- 개발을 진행하며 디버깅을 위해 콘솔을 마구 찍어내던것이 생각났다. CAS로그인을 한 후, CAS응답에서 역할을 추출하면 될것 같았다. 그래서 이 방법으로 진행 해 보기로 했다.

 

1. 먼저, backend 디렉토리의 서버.js 파일 안에 유저의 role을 가져오는 함수를 하나 작성 해 준다.

아래가 서버 로그에서 추출한 CAS response이다. 여길 잘 확인해보면 cas:eduPersonAffiliation이라는 항목에 affiliate:student라는 항목이 보인다. 나는 VirginiaTech의 학생이기 때문에 여기 student라고 응답이 뜨고, 교수님의 계정으로 테스트 했을땐 faculty라고 되어 있었다.

function getUserRole(attributes) {
  const primaryAffiliation = attributes['cas:eduPersonPrimaryAffiliation'][0];
  const virginiaTechAffiliation = attributes['cas:virginiaTechAffiliation'];

  if (primaryAffiliation === 'faculty' || (virginiaTechAffiliation && virginiaTechAffiliation.includes('VT-FACULTY'))) {
    return 'professor';
  } else if (primaryAffiliation === 'student' || (virginiaTechAffiliation && virginiaTechAffiliation.includes('VT-STUDENT'))) {
    return 'student';
  }
  return 'unknown';
}

 

이와 같이 getUserRole이라는 함수를 작성 해준다.

 

다음으로, 로그인이 될때 /Dashboard 엔드포인트로 유저를 리디렉션 하므로 /Dashboard에 GET요청을 처리하는 부분에 나머지 처리 로직을 구현 해 준다.

app.get('/Dashboard', async (req, res) => {
		//나머지 부분 생략...
        const user = result['cas:serviceResponse']['cas:authenticationSuccess'][0];
        const pid = user['cas:user'][0];
        const attributes = user['cas:attributes'][0];
        const email = attributes['cas:eduPersonPrincipalName'][0];
        const role = getUserRole(attributes);

        if (role === 'student') {
          console.log("Redirecting to student page....")
          res.redirect('https://crescendo.cs.vt.edu/DashBoard');
        } else if (role === 'professor') {
          console.log("Redirecting to faculty page....")
          res.redirect('https://crescendo.cs.vt.edu/FacultyDashBoard');
        } else {
          res.status(403).send('Access denied');
        }
      });
      //나머지 부분 생략...

});

 

이렇게 하면 교수님은 교수님 전용 페이지로, 학생은 학생 전용 페이지로 이동 할 수 있다.

 

- CAS같은 로그인 프로토콜을 이용한다면, CAS의 응답에서 여러가지 정보를 추출 할 수 있다는것을 기억하자! 이번 프로젝트에서는 CAS응답에서 유저의 역할 뿐만 아니라, 학교 이메일, 이름 등등 여러가지를 가져 올 수 있었다.

 

🔎 TroubleShooting

그런데 자꾸 문제가 발생한다. 이전에는 이런 일이 없었는데 로그인을 하고 일정 시간이 지나면 어디선가 /Dashboard로 요청을 보낸다. 하지만 이 요청은 CAS를 통해 인증된 유저가 아니며, 오류를 발생 시키는데, 이 오류에 대한 처리를 하지 않아 그냥 서버가 종료 되어버린다. 내 서버는 screen을 통해 백그라운드에서 실행중이기 때문에 서버가 끊겨버리면 유저는 아무것도 할 수 없이 페이지가 안 보이는 상황을 마주하게 될 것이다. 다음 포스팅에서는 이 오류에 대해 해결하는 포스팅을 이어가겠다!

 

 

정말 아무것도 없는 백지 상태에서 꽤 멀리 왔다,,,(내가 생각하기에) 정말 0에서 혼자 시작하여 웹을 구축하고, 프론트, 서버, 데이터베이스까지 모두 기초적인 단계는 혼자 완료했다. 이제 SSO CAS 로그인 프로토콜을 이용해 유저 로그인도 구현이 되어있고, 여러가지 보안 문제 등 HTTPS에서 발생하는 문제들도 모두 해결하였다. 

 

이제, 해야 할 것이 하나 더 생겼다. 위의 자잘자잘한 일들을 끝냄과 동시에 교수님이 곧바로 하신 말은,,, "If it is first time a user logging in, check if that user exists in our database, and if not, ask their full name and save it to the database and don't ask it again."

 

우선,,,해야 할 일들은

  • 유저 모델(스키마) 정의하기

- 유저 모델(스키마)을 정의해 보자. 먼저, /backend 디렉토리에 models라는 디렉토리를 만들어 준 후, user.js 라는 유저 모델을 관리할 클래스를 하나 만들어 준다. 보통 Node.js 프로젝트에서는 코드 구조를 체계적으로 관리하기 위해 models라는 디렉토리를 사용하여 데이터베이스 모델을 정의한다. 따라서 models 디렉토리에 user.js 파일을 정의하는 것이 일반적인 관례이다.

const mongoose = require('mongoose');

// user model or schema 
const userSchema = new mongoose.Schema({
  pid: { type: String, required: true, unique: true },
  name: { type: String }, 
  email: { type: String, required: false, unique: true },
  isFirstLogin: { type: Boolean, default: true }, 
  createdAt: { type: Date, default: Date.now }
});

// create a user model
const User = mongoose.model('User', userSchema);

module.exports = User;

 



const mongoose = require('mongoose');

- 이 줄은 `mongoose` 모듈을 가져온다. Mongoose는 Node.js에서 MongoDB와 상호 작용하기 위한 객체 데이터 모델링(ODM) 라이브러리이다.


const userSchema = new mongoose.Schema({
  username: { type: String, required: true, unique: true },
  email: { type: String, required: true, unique: true },
  studentPid: { type: String },
  createdAt: { type: Date, default: Date.now }
});

- 이 부분은 `userSchema`라는 이름의 새로운 스키마를 정의한다. 
  
  - `username: { type: String, required: true, unique: true }`
    - `type: String`: `username` 필드는 문자열 타입이어야 한다.
    - `required: true`: `username` 필드는 필수 항목이다. 즉, 스키마를 생성할 때 반드시 이 필드에 값이 있어야 한다.
    - `unique: true`: `username` 필드는 유일해야 한다. 즉, 데이터베이스 내에서 중복될 수 없다.

  - `email: { type: String, required: true, unique: true }`
    - `type: String`: `email` 필드는 문자열 타입이어야 한다.
    - `required: true`: `email` 필드는 필수 항목이다.
    - `unique: true`: `email` 필드는 유일해야 한다.

  - `studentPid: { type: String }`
    - `type: String`: `studentPid` 필드는 문자열 타입이어야 한다.
    - 이 필드는 필수 항목이 아니다(`required` 속성이 없기 때문).

  - `createdAt: { type: Date, default: Date.now }`
    - `type: Date`: `createdAt` 필드는 날짜 타입이어야 한다.
    - `default: Date.now`: 이 필드는 문서가 생성될 때 자동으로 현재 날짜와 시간으로 설정된다.


const User = mongoose.model('User', userSchema);

- 이 줄은 `userSchema`를 기반으로 `User`라는 모델을 생성한다. 이 모델은 MongoDB의 `users` 컬렉션과 연결된다(모델 이름의 소문자 복수형으로 컬렉션 이름이 자동 설정된다).

module.exports = User;

- 이 줄은 `User` 모델을 모듈로 내보낸다. 이렇게 하면 다른 파일에서 이 모델을 `require`하여 사용할 수 있다.

이 코드의 전체 목적은 MongoDB 데이터베이스에 저장될 사용자 정보를 정의하고, 이 정보를 처리하기 위한 모델을 생성하는 것이다. `required` 속성은 특정 필드가 반드시 존재해야 함을 의미하고, `unique` 속성은 필드 값이 중복되지 않도록 보장한다.

 

  • 백엔드 서버 파일에서 방금 만든 유저 모델 가져오기
    우선, 백엔드 파일에서 방금 만든 유저 모델을 가져오기 위해 소스코드 상단에 
require('dotenv').config();
const fs = require('fs');
const https = require('https');
const express = require('express');
const session = require('express-session');
const axios = require('axios');
const cors = require('cors');
const mongoose = require('mongoose');
const xml2js = require('xml2js');
const User = require('./models/user');

 

의 맨 아랫줄과 같이 User = require(' ./models/user');을 통해 유저를 가져온다. 그 후, /Dashboard 엔드포인트에 정의된 함수 안에서 Dashboard로 get요청을 받으면 데이터베이스에서 유저를 확인하는 코드를 작성한다.

 

app.get('/Dashboard', async (req, res) => {
  console.log('Request received at /Dashboard');
  console.log('Session data:', req.session);
  console.log('User data:' , req.session.user);
  if (!req.session.user) {
    const { ticket } = req.query;
    if (!ticket) {
      return res.redirect(`https://login.vt.edu/cas/login?service=${encodeURIComponent(CAS_SERVICE_URL)}`);
    }
    try {
      const response = await axios.get(CAS_VALIDATE_URL, {
        params: {
          ticket,
          service: CAS_SERVICE_URL
        }
      });
      const parser = new xml2js.Parser();
      parser.parseString(response.data, async(err, result) => {
        console.log("Result : " , JSON.stringify(result)) 
        if (err) {
          console.error('XML parsing error:', err);
          return res.status(500).send('CAS ticket validation failed');
        }
        const user = result['cas:serviceResponse']['cas:authenticationSuccess'][0];
        const pid = user['cas:user'][0];
        const attributes = user['cas:attributes'][0];
        const email = attributes['cas:eduPersonPrincipalName'][0];
        req.session.user = {pid, email};
        let dbUser = await User.findOne({ email: email });
        console.log("DBUSER:" , dbUser);
        if (!dbUser) {
          // if it's first time a user logging in, save it to db and ask name
          console.log("1ST TIME USER LOGIN");
          dbUser = new User({ pid, email, isFirstLogin : true });
          await dbUser.save();
          req.session.user.isFirstLogin = true;
        }
        else{
          req.session.user.name = dbUser.name;
          req.session.user.isFirstLogin = false;
          console.log("NOT 1ST TIME LOGIN");
        }
        res.redirect('https://crescendo.cs.vt.edu/Dashboard');
      });
    } catch (error) {
      console.error('CAS ticket validation failed123:', error);
      res.status(500).send('ticket validation failed');
    }
  } else {
    res.redirect('https://crescendo.cs.vt.edu/Dashboard');
  }
});

 

대략적인 방식은 이러하다

 

- 같은 backend 디렉토리 안에 user.js라는 유저 모델을 정의하는 클래스를 만들어주고, export된 User를 여기서 받아 사용한다.

- let duUser = await User.findOne({email : email})로 데이터베이스에서 email이라는 필드를 가진 유저를 검색 한 후

- dbUser가 없다면(처음 로그인 하는 유저라면), 유저 추가 로직을 실행한다

 

하지만, 단순히 이름을 입력받는데에 새로운 페이지로 리디렉션 하는 것 보다는 모달을 띄워 서버에 따로 요청을 보내는것이 더 직관적이기 때문에 모달을 띄우기로 했다.

 

백엔드 디렉토리의 서버 파일에서 새로운 API 엔드포인트를 준비한다.

app.post('/saveName', async (req, res) => {
  console.log("Save name enpoint reached");
  const { firstName, lastName } = req.body;
  const email = req.session.user.email;

  // update user info in db
  await User.updateOne({ email: email }, { name: `${firstName} ${lastName}` });

  // update name in session
  req.session.user.name = `${firstName} ${lastName}`;
  req.session.user.isFirstLogin = false;

  res.status(200).json({ message: 'Name updated successfully' });
});

 

그후, 클라이언트 디렉토리로 이동하여 모달을 띄울 코드를 작성한다.

<Modal.Body>
                    <Form>
                        <Form.Group controlId="formFirstName">
                            <Form.Label>First Name</Form.Label>
                            <Form.Control type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
                        </Form.Group>
                        <Form.Group controlId="formLastName">
                            <Form.Label>Last Name</Form.Label>
                            <Form.Control type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
                        </Form.Group>
                    </Form>
                </Modal.Body>
                <Modal.Footer>

 

이렇게 하면 성공적으로 처음 접근하는 유저에게 이름을 물어보고, 유저가 이미 이름을 입력한 적이 있다면 더이상 묻지 않는다.

'Undergrad Research' 카테고리의 다른 글

[React, TroubleShooting] ThemeProvider은 무엇일까?  (0) 2024.07.16
유저 정보 parsing  (0) 2024.07.03
세션을 통한 인증된 유저의 정보 가져오기  (0) 2024.06.27
Proxy란?  (1) 2024.06.07
CORS란 무엇인가?  (0) 2024.06.05

이제 내가 만든 웹 애플리케이션의 HTTPS 리디렉션, 로그인, 로그아웃 기능은 모두 구현이 되었다. 이제 드디어 조금 더 깊게 파고들 수 있을것 같다. 다음 할 일은 로그인을 하면, 세션으로부터 유저의 정보(여기서는 이름만 가져오기로 했다)를 가져와 프로필 화면에 유저의 이름을 띄우는 것이다.

 

먼저, 나는 CAS로 로그인을 구현 했다. CAS를 통해 로그인을하고 유저 정보를 가져오는 과정은 대략 다음과 같다.

 

1. CAS 인증을 처리하는 라우트를 설정한다.

2. 사용자 정보 전달 및 저장 : 사용자 정보를 세션이나 쿠키에 저장하고, 클라이언트 측에서 이를 요청하거나 표시한다.

 

쿠키, 세션같은 용어는 개발을 하다보면 꼭 듣게되는 용어들중 하나이다. 세션(Session)과 쿠키(Cookie)는 웹 애플리케이션에서 사용자의 상태를 유지하는 데 사용되는 두 가지 주요 방법이다. 두 방법 모두 상태 유지를 가능하게 하지만, 그 방식과 특성에서 차이가 있다.

더보기
  • 세션(Session):         
    • 특징
      서버 측 저장: 세션 데이터는 서버에 저장되고, 클라이언트에는 세션 ID만 저장된다.
      보안성: 세션 ID만 클라이언트에 저장되므로, 민감한 데이터가 클라이언트 측에 노출되지 않는다.
      유효 기간: 세션은 일반적으로 사용자가 웹 애플리케이션을 닫거나 로그아웃하면 만료된다. 서버 설정에 따라 세션 유효 기간을 조정할 수 있다.
    • 장점
      보안성: 중요한 데이터가 서버에만 저장되므로 보안성이 높다.
      대량 데이터 저장 가능: 서버에 저장되므로, 클라이언트가 처리할 데이터 양의 제한이 없다.
    • 단점
      서버 부하 증가: 많은 사용자 세션 데이터를 저장해야 하므로 서버 부하가 증가할 수 있다.
      스케일링 어려움: 세션 데이터가 서버에 저장되므로 서버 확장 시 세션 데이터 동기화가 필요하다.
  • 쿠키(Cookie):
    • 특징
      클라이언트 측 저장: 쿠키 데이터는 클라이언트의 브라우저에 저장된다.
      크기 제한: 각 쿠키의 크기는 약 4KB로 제한됩니다. 또한, 각 도메인당 저장할 수 있는 쿠키의 개수에도 제한이 있다.
      유효 기간: 쿠키는 설정된 만료 시간까지 유지된다. 만료 시간을 설정하지 않으면 브라우저가 닫힐 때 삭제된다.
    • 장점
      서버 부하 감소: 클라이언트 측에 데이터를 저장하므로 서버 부하가 줄어든다.
      스케일링 용이: 클라이언트에 저장되므로 서버 확장 시 별도의 데이터 동기화가 필요하지 않다.
    • 단점
      보안성 취약: 쿠키는 클라이언트 측에 저장되므로, 민감한 데이터가 노출될 위험이 있다.
      크기 제한: 저장할 수 있는 데이터 크기에 제한이 있다.
      사용자 제어: 사용자가 쿠키를 임의로 수정하거나 삭제할 수 있다.

이런 식으로 쿠키와 세션에는 각각 장단점이 있다. 그렇다면, 쿠키와 세션은 언제 구분되어 사용될까?

세션 사용 사례:
민감한 데이터 처리: 로그인 상태 유지, 사용자 정보 관리 등
서버 측에서 데이터 일관성을 유지해야 할 때
대량의 사용자 데이터를 저장해야 할 때


쿠키 사용 사례:
간단한 사용자 설정 저장: 테마, 언어 설정 등
비 로그인 사용자 상태 유지
서버 부하를 최소화해야 할 때

이처럼 여러가지를 고려해 봤을때, 내가 개발중인 애플리케이션은 세션이 더 적합한것 같다. 그 이유는 CAS를 통해 유저의 민감한 정보를 저장하므로, 서버측에 이를 저장해두는것이 더 안전하다.

 

자 그럼 이제 이걸 코드로 어떻게 구현해낼까?

 

1. 사용자 정보를 저장하고 전달하는 API를 추가한다.(/backend)

- 로그인 성공 후, 세션에 사용자 정보를 저장하고, 클라이언트에서 이 정보를 요청 할 수 있도록 API를 추가한다.

app.get('/api/user', (req, res) => {
  if (!req.session.user) {
    return res.status(401).send('User not authenticated');
  }
  res.json(req.session.user); 
});

- /api/user 엔드포인트를 추가하여 로그인된 사용자 정보를 JSON 형식으로 반환한다.
- 로그인 성공 후 세션에 사용자 정보를 저장한다.

 

2. 백엔드의 /api/user 엔드포인트에서 사용자 정보를 가져와 상태에 저장하고, 이를 프로필에 표시한다.

const Dashboard = () => {
    const [isSidebarOpen, setSidebarOpen] = useState(false);
    const [user, setUser] = useState(null); // 사용자 상태 추가
    const navigate = useNavigate();

    useEffect(() => {
        axios.get('/api/user', { withCredentials: true }) // 사용자 정보 요청
            .then(response => {
                setUser(response.data); // 사용자 상태 업데이트
            })
            .catch(error => {
                console.error('Error fetching user data:', error);
            });
    }, []);

 

이렇게 코드를 추가하고 봤더니 정보를 가져오는중 get요청에서 에러가 발생했다(하....). 문제를 해결하기 위해 해볼 수 있는건

 

1.  서버 로그 확인

2. 엔드포인트 경로 확인

3. 세션 설정 확인

 

정도이다. 다음 포스팅에서 이 오류에 관해 더 자세히 보겠다!

'Undergrad Research' 카테고리의 다른 글

유저 정보 parsing  (0) 2024.07.03
[Backend]유저 모델 관리하기  (0) 2024.06.29
Proxy란?  (1) 2024.06.07
CORS란 무엇인가?  (0) 2024.06.05
[React]원격 서버에서 메뉴바가 보이지 않는 상황  (0) 2024.05.30
  • 이번 프로젝트를 진행하며 어려움을 겪던 도중, 프록시 라는 단어를 자주 접하게 되었다. 사실 수업에서도 들어본 적 없고, 지나가다 한번씩 눈으로만 봤던 단어라 생소했지만, 프록시가 뭔지도 모르고 넘어가기엔 내가 "단순히 코드를 찍어내는 코더인지, 배우고자 하는 개발자인지"를 다시 한번 떠올리며 프록시가 뭔지 한번 보고 넘어가기로 했다.

 - Proxy?

더보기

프록시란 영어로 ‘대리인’을 의미합니다. 프록시 서버(proxy server)는 서버를 통해 다른 네트워크 서비스에 간접적으로 접속할 수 있게 해 주는 컴퓨터 시스템이나 응용프로그램을 말합니다.

프록시 서버는 방문 중인 웹사이트와 기기 사이에서 중개자 역할을 하며, 트래픽은 호스트 서버 연결에 사용되는 원격 시스템을 통해 전달됩니다. 프록시 서버를 이용하면 실제 IP 주소를 숨길 수 있으며 웹사이트에서는 원래 IP 주소가 아닌 프록시 서버의 IP 주소를 인식하게 됩니다.

그러나 프록시는 응용프로그램 수준에서만 동작하기 때문에 프록시 서버를 설정한 앱에서 발생하는 트래픽만 재라우팅할 수 있으며 사용자의 인터넷 트래픽을 암호화할 수는 없습니다.

 

출처 : https://nordvpn.com/ko/blog/proxy-versus-vpn/#:~:text=%ED%94%84%EB%A1%9D%EC%8B%9C%EB%9E%80%20%EC%98%81%EC%96%B4%EB%A1%9C%20'%EB%8C%80%EB%A6%AC%EC%9D%B8,%EC%9D%B4%EB%82%98%20%EC%9D%91%EC%9A%A9%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8%EC%9D%84%20%EB%A7%90%ED%95%A9%EB%8B%88%EB%8B%A4.

출처 : 위키피디아

 

위 그림처럼 프록시 서버는 클라이언트와 서버의 중간에 위치하는 "중개인"의 역할이며, 프록시 서버 중 일부는 프록시 서버에 요청된 내용들을 캐시를 이용하여 저장해 둔다. 이렇게 캐시를 해 두고 난 후에, 캐시 안에 있는 정보를 요구하는 요청에 대해서는 원격 서버에 접속하여 데이터를 가져올 필요가 없게 됨으로써 전송 시간을 절약할 수 있게 됨과 동시에 불필요하게 외부와의 연결을 하지 않아도 된다는 장점을 갖게 된다. 또한 외부와의 트래픽을 줄이게 됨으로써 네트워크 병목 현상을 방지하는 효과도 얻을 수 있게 된다. 이 외에도, 프록시는 보안 강화를 위해 사용된다.

 

프록시 서버가 중간에 위치함으로써, 클라이언트는 프록시를 "서버"라고 인식 할 것이고, 마찬가지로 서버도 프록시를 "클라이언트"로 인식 할 것이다.

 

프록시는 어디에 위치 하느냐에 따라 포워드 프록시, 리버스 프록시로 나뉜다.

 

  • Forward Proxy
    -일반적으로 프록시를 말할때 "Forward Proxy"를 뜻한다. 클라이언트가 서버에 리소스를 요청할때, 요청이 서버에 직접 도달하기 전에 프록시 서버에서 요청을 처리한다. 이 경우 서버에서 받는 IP는 클라이언트의 IP가 아닌 프록시 서버의 IP이기 때문에 서버는 클라이언트가 누군지 알 수 없다. 즉, 서버에게 클라이언트가 누구인지 감춰주는 역할을 한다. -> 기업 사내서버에서 자주 이용된다.
  • 포워드 프록시의 특징 / 역할
    - 캐싱 : 첫 번째 요청 이후부터는 동일한 요청이 들어올 경우, 프록시 서버에 캐싱된 내용을 전달해줌으로써 성능을 향상시킬 수 있다.

    웹 서비스에서 요청이 발생할 때마다 1) 요청 → 2) 요청 전송 → 3) 요청 접수 → 4) 응답 생성 → 5) 응답 전송 → 6) 응답 수신 과 같은 과정을 반복해서 거친다. 요청이 한 번 뿐일 때는 괜찮지만, 중복되는 요청을 매번 처리하기에는 심한 자원낭비가 생기고, 웹 서버의 부하가 증가할 것이다.
    (지금 내가 프로젝트를 진행하며 문제를 겪고 있었다. 백엔드의 코드를 모두 comment하고, 실행중인 노드를 모두 종료시키고, 백엔드 서버가 구동중인 포트번호의 프로세스도 kill -9로 강제 종료를 시켰는데도 불구하고 백엔드쪽 로직이 정상 동작 한다는 것이다. 나는 아무것도 모르고 "어떻게 도대체 왜 백엔드 서버가 실행중도 아닌데 왜 모든 기능이 정상 동작을 하는거지? 라고 생각했는데 아마 이 프록시가 이전의 내용들을 이미 캐싱해 같은 요청이 들어오면 서버에 요청하는 대신 프록시에 캐싱된 데이터들을 꺼내 쓰는것 같다. 프록시 설정을 바꿔보고 다시 알아내도록 하겠다.)

    이를 위해 포워드 프록시는 정적 데이터를 저장해두고 동일한 요청의 경우 웹서버 까지 가지 않고 포워드 프록시에서 처리할 수 있는 캐싱 역할을 수행한다.

    IP 우회 : 위에 언급했듯이 클라이언트 측에서 프록시 서버를 거쳐 웹 서비스를 이용할 경우, 서버 측에서는 요청을 받을 때 클라이언트의 IP가 아닌 프록시 서버의 IP를 전달받게 된다. 즉, 서버 측에 클라이언트의 정보를 숨길 수 있게 되는 것이다.

    제한 : 보안이 중요한 사내망에서 정해진 사이트에만 연결 할 수 있도록 설정하는 등 웹 사용 환경을 제한할 수 있다.

Forward Proxy

 

  • Reverse Proxy

리버스 프록시(Reverse Proxy)는 클라이언트 요청을 받아 내부 서버로 전달하고, 서버의 응답을 클라이언트로 반환하는 중간 서버 역할이다.  클라이언트와 서버 사이에 위치하여 클라이언트의 요청을 받아 실제 서버로 전달한 후, 서버의 응답을 다시 클라이언트에게 반환하는 서버이다. 클라이언트는 리버스 프록시 서버와만 통신하며, 실제 서버의 존재나 위치를 알 필요가 없다.

특징
1. 로드 밸런싱 (Load Balancing):
   - 리버스 프록시는 여러 대의 서버에 클라이언트 요청을 분산시켜 서버의 부하를 균형 있게 유지한다. 이를 통해 성능 향상과 고가용성을 보장한다.
  
2. 보안 강화 (Security Enhancement):
   - 실제 서버의 IP 주소와 구조를 클라이언트로부터 숨겨 보안을 강화한다. 또한, SSL/TLS 암호화를 통해 데이터 전송의 보안을 강화할 수 있다.
  
3. 캐싱 (Caching):
   - 리버스 프록시는 정적 콘텐츠나 빈번히 요청되는 데이터를 캐싱하여 서버 부하를 줄이고 응답 시간을 단축할 수 있다.
  
4. SSL 종단 (SSL Termination):
   - 리버스 프록시는 클라이언트와의 SSL 연결을 종료하고 내부 서버와는 평문 통신을 할 수 있다. 이를 통해 서버의 SSL 처리 부담을 줄일 수 있다.
  
5. 애플리케이션 방화벽 (Application Firewall):
   - 리버스 프록시는 웹 애플리케이션 방화벽 기능을 제공하여 클라이언트의 악의적인 요청을 필터링하고 보안을 강화한다.
  
6. 압축 (Compression):
   - 클라이언트로 전달되는 데이터를 압축하여 네트워크 대역폭을 절약하고 전송 속도를 향상시킬 수 있다.
  
예시
- NGINX: 리버스 프록시, 웹 서버, 로드 밸런서 등 다양한 기능을 제공하는 소프트웨어.
- Apache HTTP Server: 모듈을 통해 리버스 프록시 기능을 제공.
- HAProxy: 높은 성능의 TCP 및 HTTP 로드 밸런서와 프록시 서버.

작동 원리
1. 클라이언트 요청: 클라이언트는 리버스 프록시 서버에 요청을 보낸다.
2. 리버스 프록시 처리: 리버스 프록시는 요청을 처리하고 적절한 백엔드 서버로 전달한다.
3. 서버 응답: 백엔드 서버는 요청을 처리하고 응답을 리버스 프록시 서버로 보낸다.
4. 응답 반환: 리버스 프록시 서버는 받은 응답을 클라이언트에게 반환한다.

+ Recent posts