로그인, 로그아웃, 세션 유지까지 구현이 끝났으니 이제 본격적으로 시간표 페이지를 구성한다. 일전에 테스트를 위한 subjectInfoPage까지 구성했는데, 이 페이지는 개설과목정보를 한눈에 보는 페이지로 남겨두었다.
css를 살짝 수정해. 중앙페이지 중앙에 배치하도록 하였고, 추후 필요에 따라 검색 기능 등을 추가할 수 있을 것 같다.
다음으로 시간표 페이지인데, 아래 이미지처럼 구상하였다.
검색창을 통해 과목명을 검색하면 검색결과가 아래 테이블에 나타난다. 담기 버튼을 클릭하면 그 아래 테이블이 추가되며, 이 테이블들은 과목마다 별도로 생성된다. 그리고 체크박스로 선택하면 오른쪽 공간의 시간표에 배치되는 구조이다. 이렇게 미리 관심있는 과목을 담아두고, 과목마다 즉각적으로 시간표에 반영하여 시간표를 짜는데 사용자 편의성을 향상시킨 시스템을 구상하였다. 그리고 이 모든 정보는 당연히 데이터베이스에 저장되어, 계정마다 저장/로드가 이루어지도록 할 것이다.
현재까지 구현한 기능 중 가장 복잡한 기능으로, 이 포스트에서는 과목을 검색하고 담기, 시간표에 추가 및 삭제 구현 과정까지만 기록한다.
1. 백엔드(Backend)
이를 위해 우선 테이블을 생성한다.
id는 자동증가되는 주키로 설정. oauth_member_id는 oauth_member 테이블의 id로 계정을 구분하기 위한 외래키, subject_id는 과목의 주키인 subject_id로 과목 정보를 저장하기 위한 외래키이다.
본래 계정을 구분하기 위한 외래키는 oauth_server_id로 설정하였는데, OauthId 클래스가 임베디드 클래스로 매핑에 오류가 발생했다.임베디드 클래스는 모든 필드가 외래키로 매핑된다.
임베디드 클래스 (Embeddable Class)
여러 필드를 하나의 객체로 묶어 다른 엔티티에서 재사용할 수 있게 하는 클래스. 외래키로 매핑될 때는 각 필드가 개별적으로 매핑되므로 복잡한 매핑 구조가 될 수 있다.
oauth_server_id는 oauth_member 테이블의 컬럼이지만 임베디드 클래스의 컬럼이기 때문에 oauth_server 컬럼을 외래키 매핑으로 추가해줘야 했다. 문제는 이 OauthId는 데이터베이스에 실제 존재하는 테이블이 아니기 때문에 데이터 소스를 할당할 수 없어 인식할 수 없었다.
이를 해결하기 위해 외래키를 oauth_member 테이블의 id로 설정하여 임베디드 클래스의 사용으로 인해 발생한 복잡함과 문제를 해결하였다. 본래 schedule 테이블도 id로 주키를 설정했기 때문에 괜히 헷갈리지 않기 위한 선택이었으나, 구분만 할 수 있으면 되니 문제가 발생할 가능성이 현저히 적은 id를 외래키로 채택하기로 하였다.
selected는 이 과목을 시간표에 추가할 것인지 체크박스의 상태를 나타낸다. (MySQL은 boolean값을 지원하지 않아 자동으로 TINYINT로 설정)
위와 같은 내용을 바탕으로 Schedule Entity를 생성한다.
Schedule.java
package zerogod.ecetaskhelper.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import zerogod.ecetaskhelper.domain.OauthMember;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "schedule")
public class Schedule {
public Schedule(OauthMember oauthMember, Subject subject, Boolean selected) {
this.oauthMember = oauthMember;
this.subject = subject;
this.selected = selected;
}
// 엔티티의 기본 키를 나타내며, 값은 자동으로 생성됨
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// OauthMember 엔티티와의 다대일 관계 설정
// "oauth_member_id"라는 외래 키로 매핑되며, "id" 컬럼을 참조
// 지연 로딩(LAZY) 전략을 사용하여 필요할 때 데이터를 로드
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "oauth_member_id", referencedColumnName = "id", nullable = false)
private OauthMember oauthMember;
// Subject 엔티티와의 다대일 관계 설정
// "subject_id"라는 외래 키로 매핑되며, "subject_id" 컬럼을 참조
// 지연 로딩(LAZY) 전략을 사용
// 직렬화 과정에서 hibernateLazyInitializer와 handler 속성을 무시하여 직렬화 에러 방지
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "subject_id", referencedColumnName = "subject_id", nullable = false)
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
private Subject subject;
@Column(name = "selected", nullable = false)
private Boolean selected;
public boolean isSelected() {
return selected;
}
}
@ManyToOne 어노테이션은 두 엔티티 간의 관계가 다대일 (Many-to-One) 관계임을 나타낸다. 즉, 여러 개의 엔티티가 하나의 엔티티와 관계를 맺을 때 사용된다. 여기서는 하나의 schedule 엔티티가 여러개의 subject 엔티티, oauth_member 엔티티와 관계를 맺고 있다.
@JoinColumn 어노테이션은 데이터베이스에서 외래키를 매핑할 때 사용된다. 외래키는 관계를 맺고 있는 두 테이블 간의 연결을 담당한다.
즉, 외래키가 존재하는 테이블에 @ManyToOne 어노테이션으로 외래키가 있음을, @JoinColumn 어노테이션은 그 외래키를 나타낸다고 보면 된다.
@ManyToOne 옆에 FetchType.LAZY라고 써있는데, 이는 Hibernate 지연 로딩을 의미한다.
Hibernate 지연 로딩 (Lazy Loading)
엔티티가 처음 조회될 때 모든 관련 데이터를 한 번에 가져오는 대신, 실제로 해당 데이터가 필요할 때 가져오는 방식. 이 과정에서 프록시 객체가 사용되는데, 이는 나중에 데이터를 로드하기 위한 대리 객체이다.
그런데 이로 인해 사용자의 schedule 엔티티를 불러오는 과정에서 문제가 발생했다. Hibernate는 엔티티를 지연 로딩(lazy loading)할 때 프록시 객체를 사용한다. 이 프록시 객체들은 Jackson(스프링에서 사용하는 JSON 라이브러리)에서 직렬화(객체를 JSON으로 변환)되는데, Jackson이 이 프록시 객체를 이해하지 못하고 직렬화하는 과정에서 에러가 발생한다고 한다.
이를 해결하기 위해 @JsonIgnoreProperties 어노테이션을 사용하여 Jackson이 프록시 객체의 특정 필드를 무시하도록 설정했다. 이렇게 하면 프록시 객체의 hibernateLazyInitializer나 handler와 같은 필드가 JSON으로 변환되지 않아 에러가 발생하지 않는다.
@JsonIgnoreProperties
특정 필드를 직렬화 과정에서 무시하도록 설정하는 어노테이션.
그리고 기존에 service, repository, controller를 만들어두었다. 다음과 같이 검색 기능을 추가한다,
SubjectRepository.java
package zerogod.ecetaskhelper.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import zerogod.ecetaskhelper.model.Subject;
import java.util.List;
@Repository // 이 인터페이스가 Spring의 저장소(Repository)로 사용됨을 나타냄
public interface SubjectRepository extends JpaRepository<Subject, String> {
// 주어진 문자열을 포함하는 과목명을 가진 Subject들을 찾는 쿼리 메서드
List<Subject> findBySubjectNameContaining(String name);
}
SubjectService.java
// 주어진 문자열을 포함하는 과목명을 가진 Subject들을 조회하는 메서드
public List<Subject> findByNameContaining(String name) {
return subjectRepository.findBySubjectNameContaining(name);
}
SubjectController.java
// 과목명을 기준으로 과목들을 검색하는 엔드포인트
// 요청 파라미터로 전달된 name을 포함하는 과목들을 반환
@GetMapping("/search")
public List<Subject> searchSubjectsByName(@RequestParam String name) {
return subjectService.findByNameContaining(name);
}
이렇게 추가하면 검색어가 과목명에 포함된 과목 엔티티를 모두 반환해준다. 이 findByNameContaining 메서드의 동작 원리가 어떻게 되는가 하면, 꽤나 신기하다.
현재 필자는 Spring Data JPA를 종속성에 추가하여 사용하고 있다. Spring Data JPA는 메서드 이름을 분석하여 쿼리를 자동으로 생성한다. 메서드 이름이 findByNameContaining인 경우, Spring Data JPA는 Name이라는 필드에서 Containing이라는 조건을 사용하여 데이터를 검색하도록 쿼리를 생성한다.
1. findBy는 쿼리 메서드의 시작 부분으로, JPA가 이 메서드를 통해 데이터를 조회할 것임을 나타낸다.
2. Name은 엔티티의 필드 이름을 나타내며, 여기서 Name 필드에서 검색을 수행한다.
3. Containing은 조건자를 나타내며, SQL에서 부분 문자열 매칭을 의미하는 LIKE '%keyword%' 조건과 유사한 역할을 한다.
즉, findByNameContaining(String name) 메서드를 호출하면, Spring Data JPA는 자동으로 다음과 같은 SQL 쿼리를 생성하여 실행한다
SELECT * FROM subjects WHERE name LIKE %:name%;
여기서 :name은 메서드의 매개변수로 전달된 값으로, 양쪽에 % 와일드카드가 추가되어 부분 일치 검색이 이루어진다.
상당히 신기하고 유용한 기능이다.
아무튼 백엔드에서 과목 검색 기능 자체는 구현이 끝났다. 다음으로 Schedule 테이블의 service, repository, controller를 추가한다.
ScheduleRepository.java
package zerogod.ecetaskhelper.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import zerogod.ecetaskhelper.model.Schedule;
import zerogod.ecetaskhelper.domain.OauthMember;
import zerogod.ecetaskhelper.model.Subject;
import java.util.List;
@Repository // 이 인터페이스가 Spring Data JPA repository임을 나타냄
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
// 특정 OauthMember에 해당하는 모든 Schedule 목록을 조회
List<Schedule> findByOauthMember(OauthMember oauthMember);
// 특정 OauthMember가 이미 특정 Subject를 담고 있는지 여부를 확인
boolean existsByOauthMemberAndSubject(OauthMember oauthMember, Subject subject);
// 특정 Schedule ID가 주어진 OauthMember의 소유인지 여부를 확인
boolean existsByIdAndOauthMember(Long id, OauthMember oauthMember);
}
ScheduleService.java
package zerogod.ecetaskhelper.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zerogod.ecetaskhelper.domain.OauthMember;
import zerogod.ecetaskhelper.model.Schedule;
import zerogod.ecetaskhelper.model.Subject;
import zerogod.ecetaskhelper.repository.ScheduleRepository;
import java.util.List;
@Service
@RequiredArgsConstructor // 필드에 대한 생성자를 자동으로 생성
public class ScheduleService {
// ScheduleRepository를 주입받아 사용
private final ScheduleRepository scheduleRepository;
// Schedule 엔티티를 데이터베이스에 저장
public void save(Schedule schedule) {
scheduleRepository.save(schedule);
}
// 특정 Schedule ID와 OauthMember에 해당하는 Schedule을 삭제
public boolean deleteByIdAndOauthMember(Long scheduleId, OauthMember member) {
boolean exists = scheduleRepository.existsByIdAndOauthMember(scheduleId, member);
if (exists) {
scheduleRepository.deleteById(scheduleId);
return true;
} else {
return false;
}
}
// 특정 OauthMember에 해당하는 모든 Schedule 목록을 조회
public List<Schedule> findByOauthMember(OauthMember oauthMember) {
return scheduleRepository.findByOauthMember(oauthMember);
}
// 특정 OauthMember가 특정 Subject를 담고 있는지 여부를 확인
public boolean existsByOauthMemberAndSubject(OauthMember oauthMember, Subject subject) {
return scheduleRepository.existsByOauthMemberAndSubject(oauthMember, subject);
}
// ID를 통해 특정 Schedule을 조회
public Schedule findById(Long id) {
return scheduleRepository.findById(id).orElse(null);
}
}
ScheduleController.java
package zerogod.ecetaskhelper.controller;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zerogod.ecetaskhelper.domain.OauthMember;
import zerogod.ecetaskhelper.domain.OauthMemberRepository;
import zerogod.ecetaskhelper.model.Schedule;
import zerogod.ecetaskhelper.model.Subject;
import zerogod.ecetaskhelper.service.ScheduleService;
import zerogod.ecetaskhelper.service.SubjectService;
import java.util.List;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/schedule") // 이 컨트롤러의 기본 URL 매핑 설정
public class ScheduleController {
private final ScheduleService scheduleService; // Schedule 관련 서비스 로직을 담당하는 ScheduleService 주입
private final SubjectService subjectService; // Subject 관련 서비스 로직을 담당하는 SubjectService 주입
private final OauthMemberRepository oauthMemberRepository; // OauthMember 관련 데이터 접근을 담당하는 OauthMemberRepository 주입
@PostMapping("/add") // 새로운 과목을 schedule에 추가하는 엔드포인트
public ResponseEntity<String> addSubjectToSchedule(@RequestBody Map<String, String> requestData, HttpSession session) {
// 클라이언트로부터 받은 요청 데이터에서 subjectId 추출
String subjectId = requestData.get("subjectId");
// 세션에서 로그인된 사용자 ID를 가져옴
Long userId = (Long) session.getAttribute("userId");
// 사용자가 로그인되어 있지 않은 경우 401 Unauthorized 상태 반환
if (userId == null) {
return ResponseEntity.status(401).body("로그인이 필요합니다.");
}
// 로그인된 사용자 정보 가져오기
OauthMember member = oauthMemberRepository.findById(userId).orElse(null);
if (member == null) {
return ResponseEntity.status(404).body("사용자 정보를 찾을 수 없습니다.");
}
// subjectId를 통해 과목 정보 가져오기
Subject subject = subjectService.findById(subjectId);
if (subject == null) {
return ResponseEntity.status(404).body("과목 정보를 찾을 수 없습니다.");
}
// 이미 추가된 과목인지 확인
boolean isSubjectAlreadyAdded = scheduleService.existsByOauthMemberAndSubject(member, subject);
if (isSubjectAlreadyAdded) {
return ResponseEntity.status(400).body("이미 담은 과목입니다.");
}
// 새로운 Schedule 객체 생성 후 저장
Schedule schedule = new Schedule(member, subject, false);
scheduleService.save(schedule);
// 성공적으로 추가되었음을 클라이언트에게 알림
return ResponseEntity.ok("과목이 schedule에 추가되었습니다.");
}
@DeleteMapping("/delete/{scheduleId}") // 시간표에서 과목을 삭제하는 엔드포인트
public ResponseEntity<String> deleteSubjectFromSchedule(@PathVariable Long scheduleId, HttpSession session) {
// 세션에서 로그인된 사용자 ID를 가져옴
Long userId = (Long) session.getAttribute("userId");
// 사용자가 로그인되어 있지 않은 경우 401 Unauthorized 상태 반환
if (userId == null) {
return ResponseEntity.status(401).body("로그인이 필요합니다.");
}
// 로그인된 사용자 정보 가져오기
OauthMember member = oauthMemberRepository.findById(userId).orElse(null);
if (member == null) {
return ResponseEntity.status(404).body("사용자 정보를 찾을 수 없습니다.");
}
// 주어진 ID와 사용자 정보에 해당하는 과목 삭제
boolean isDeleted = scheduleService.deleteByIdAndOauthMember(scheduleId, member);
if (isDeleted) {
return ResponseEntity.ok("과목이 shcedule에서 삭제되었습니다.");
} else {
return ResponseEntity.status(404).body("삭제할 과목을 찾을 수 없습니다.");
}
}
@GetMapping("/user-schedule") // 사용자의 시간표를 조회하는 엔드포인트
public ResponseEntity<List<Schedule>> getUserSchedule(HttpSession session) {
// 세션에서 로그인된 사용자 ID를 가져옴
Long userId = (Long) session.getAttribute("userId");
// 사용자가 로그인되어 있지 않은 경우 401 Unauthorized 상태 반환
if (userId == null) {
return ResponseEntity.status(401).build();
}
// 로그인된 사용자 정보 가져오기
OauthMember member = oauthMemberRepository.findById(userId).orElse(null);
if (member == null) {
return ResponseEntity.status(404).body(null);
}
// 사용자의 시간표를 조회하고 반환
List<Schedule> schedules = scheduleService.findByOauthMember(member);
return ResponseEntity.ok(schedules);
}
@PutMapping("/update-selected") // 시간표의 과목 선택 상태를 업데이트하는 엔드포인트
public ResponseEntity<String> updateScheduleSelected(@RequestBody Map<String, Object> requestData, HttpSession session) {
// 세션에서 로그인된 사용자 ID를 가져옴
Long userId = (Long) session.getAttribute("userId");
// 사용자가 로그인되어 있지 않은 경우 401 Unauthorized 상태 반환
if (userId == null) {
return ResponseEntity.status(401).body("로그인이 필요합니다.");
}
// 디버깅을 위해 요청 데이터를 출력
System.out.println("Request Data: " + requestData);
// 요청 데이터에서 scheduleId와 selected 상태 추출
String scheduleIdString = (String) requestData.get("scheduleId");
Boolean selected = (Boolean) requestData.get("selected");
// 필수 데이터가 없는 경우 400 Bad Request 상태 반환
if (scheduleIdString == null || selected == null) {
return ResponseEntity.status(400).body("잘못된 요청입니다.");
}
// scheduleId를 Long 타입으로 변환
Long scheduleId = Long.parseLong(scheduleIdString);
// 해당 schedule을 ID로 조회하고, 사용자와 일치하는지 확인
Schedule schedule = scheduleService.findById(scheduleId);
if (schedule == null || !schedule.getOauthMember().getId().equals(userId)) {
return ResponseEntity.status(404).body("해당 schedule을 찾을 수 없습니다.");
}
// schedule의 선택 상태를 업데이트하고 저장
schedule.setSelected(selected);
scheduleService.save(schedule);
return ResponseEntity.ok("schedule 상태가 업데이트되었습니다.");
}
}
그리고 각각의 기능들이 어떻게 동작하고 있는가를 정리하였다.
과목 검색
SubjectController.java
// 과목명을 기준으로 과목들을 검색하는 엔드포인트
// 요청 파라미터로 전달된 name을 포함하는 과목들을 반환
@GetMapping("/search")
public List<Subject> searchSubjectsByName(@RequestParam String name) {
return subjectService.findByNameContaining(name);
}
1. 클라이언트 요청: 사용자가 검색을 요청하면, 클라이언트는 /api/subjects/search?name={검색어} 형태로 GET 요청을 서버에 보낸다..
2. 요청 처리: 서버의 SubjectController에서 이 요청을 받아, searchSubjectsByName 메서드를 호출한다. 이 메서드는 URL의 name 파라미터를 @RequestParam으로 받아온다.
3. 서비스 호출: 컨트롤러는 이 파라미터를 사용하여 SubjectService의 findByNameContaining 메서드를 호출한다. 이 메서드는 SubjectRepository를 사용하여 데이터베이스에서 과목명을 포함하는 레코드를 검색한다.
4. 결과 반환: 검색 결과는 list로 클라이언트에게 반환된다.
과목 담기
@PostMapping("/add") // 새로운 과목을 schedule에 추가하는 엔드포인트
public ResponseEntity<String> addSubjectToSchedule(@RequestBody Map<String, String> requestData, HttpSession session) {
1. 클라이언트 요청: 사용자가 특정 과목을 담기 버튼을 클릭하면, 클라이언트는 /api/schedule/add 경로로 POST 요청을 보낸다. 요청 본문에는 subjectId가 포함된다.
// 클라이언트로부터 받은 요청 데이터에서 subjectId 추출
String subjectId = requestData.get("subjectId");
// 세션에서 로그인된 사용자 ID를 가져옴
Long userId = (Long) session.getAttribute("userId");
// 사용자가 로그인되어 있지 않은 경우 401 Unauthorized 상태 반환
if (userId == null) {
return ResponseEntity.status(401).body("로그인이 필요합니다.");
}
2. 세션 확인: 서버는 이 요청을 받은 후, 세션을 통해 사용자가 로그인한 상태인지 확인합니다. 로그인하지 않은 경우 401 에러를 반환한다.
// 로그인된 사용자 정보 가져오기
OauthMember member = oauthMemberRepository.findById(userId).orElse(null);
if (member == null) {
return ResponseEntity.status(404).body("사용자 정보를 찾을 수 없습니다.");
}
3. 사용자 정보 확인: 사용자가 로그인한 상태라면, 세션에서 userId를 가져와 해당 사용자의 정보를 OauthMemberRepository를 통해 조회한다. 만약 사용자가 존재하지 않으면 404 에러를 반환한다.
// subjectId를 통해 과목 정보 가져오기
Subject subject = subjectService.findById(subjectId);
if (subject == null) {
return ResponseEntity.status(404).body("과목 정보를 찾을 수 없습니다.");
}
// 이미 추가된 과목인지 확인
boolean isSubjectAlreadyAdded = scheduleService.existsByOauthMemberAndSubject(member, subject);
if (isSubjectAlreadyAdded) {
return ResponseEntity.status(400).body("이미 담은 과목입니다.");
}
4. 과목 정보 확인: 전달받은 subjectId로 과목 정보를 조회하고, 이미 사용자가 해당 과목을 담은 상태인지 확인한다. 과목이 존재하지 않으면 "과목 정보를 찾을 수 없습니다"라는 메시지와 함꼐 404에러를 반환한다. 담은 상태라면 "이미 담은 과목입니다"라는 메시지와 함께 400 에러를 반환한다.
// 새로운 Schedule 객체 생성 후 저장
Schedule schedule = new Schedule(member, subject, false);
scheduleService.save(schedule);
5. 과목 담기: 위의 모든 검사를 통과하면, 새로운 Schedule 객체를 생성하고 이를 데이터베이스에 저장한다.
// 성공적으로 추가되었음을 클라이언트에게 알림
return ResponseEntity.ok("과목이 schedule에 추가되었습니다.");
6. 응답 반환: 과목이 정상적으로 추가되었음을 클라이언트에게 전달한다.
과목 추가(선택 상태 업데이트)
@PutMapping("/update-selected") // 시간표의 과목 선택 상태를 업데이트하는 엔드포인트
public ResponseEntity<String> updateScheduleSelected(@RequestBody Map<String, Object> requestData, HttpSession session) {
1. 클라이언트 요청: 사용자가 과목 선택 상태를 변경하면, 클라이언트는 /api/schedule/update-selected 경로로 PUT 요청을 보낸다. 요청 본문에는 scheduleId와 selected 값이 포함된다.
// 세션에서 로그인된 사용자 ID를 가져옴
Long userId = (Long) session.getAttribute("userId");
// 사용자가 로그인되어 있지 않은 경우 401 Unauthorized 상태 반환
if (userId == null) {
return ResponseEntity.status(401).body("로그인이 필요합니다.");
}
2. 세션 확인: 서버는 요청을 받은 후, 세션을 통해 사용자가 로그인한 상태인지 확인한다. 로그인하지 않은 경우 401 에러를 반환한다.
// 요청 데이터에서 scheduleId와 selected 상태 추출
String scheduleIdString = (String) requestData.get("scheduleId");
Boolean selected = (Boolean) requestData.get("selected");
// 필수 데이터가 없는 경우 400 Bad Request 상태 반환
if (scheduleIdString == null || selected == null) {
return ResponseEntity.status(400).body("잘못된 요청입니다.");
}
// scheduleId를 Long 타입으로 변환
Long scheduleId = Long.parseLong(scheduleIdString);
// 해당 schedule을 ID로 조회하고, 사용자와 일치하는지 확인
Schedule schedule = scheduleService.findById(scheduleId);
if (schedule == null || !schedule.getOauthMember().getId().equals(userId)) {
return ResponseEntity.status(404).body("해당 schedule을 찾을 수 없습니다.");
}
3. 데이터 확인: 요청 본문에서 scheduleId와 selected 값을 추출하여, 해당 ID의 Schedule 객체를 조회한다. 이때, 요청한 사용자가 해당 과목을 담았는지도 함께 확인한다.
// schedule의 선택 상태를 업데이트하고 저장
schedule.setSelected(selected);
scheduleService.save(schedule);
4. 상태 업데이트: 과목이 정상적으로 조회되면, 선택 상태를 업데이트하고 이를 데이터베이스에 저장한다.
return ResponseEntity.ok("schedule 상태가 업데이트되었습니다.");
5. 응답 반환: 업데이트가 완료되었음을 클라이언트에게 전달한다.
삭제
@DeleteMapping("/delete/{scheduleId}") // 시간표에서 과목을 삭제하는 엔드포인트
public ResponseEntity<String> deleteSubjectFromSchedule(@PathVariable Long scheduleId, HttpSession session) {
1. 클라이언트 요청: 사용자가 삭제 버튼을 클릭하면, 클라이언트는 /api/schedule/delete/{scheduleId} 경로로 삭제 요청을 보낸다.
// 세션에서 로그인된 사용자 ID를 가져옴
Long userId = (Long) session.getAttribute("userId");
// 사용자가 로그인되어 있지 않은 경우 401 Unauthorized 상태 반환
if (userId == null) {
return ResponseEntity.status(401).body("로그인이 필요합니다.");
}
2. 세션 확인: 서버는 이 요청을 받은 후, 세션을 통해 사용자가 로그인한 상태인지 확인한다. 로그인하지 않은 경우 401 에러를 반환한다.
// 로그인된 사용자 정보 가져오기
OauthMember member = oauthMemberRepository.findById(userId).orElse(null);
if (member == null) {
return ResponseEntity.status(404).body("사용자 정보를 찾을 수 없습니다.");
}
3. 사용자 정보 확인: 로그인된 상태라면, 세션에서 userId를 가져와 해당 사용자의 정보를 조회한다.
// 주어진 ID와 사용자 정보에 해당하는 과목 삭제
boolean isDeleted = scheduleService.deleteByIdAndOauthMember(scheduleId, member);
if (isDeleted) {
return ResponseEntity.ok("과목이 shcedule에서 삭제되었습니다.");
} else {
return ResponseEntity.status(404).body("삭제할 과목을 찾을 수 없습니다.");
}
4. 삭제: 전달받은 scheduleId와 로그인된 사용자의 정보를 이용해 해당 과목을 시간표에서 삭제한다. 과목이 삭제되지 않았으면 404 에러를 반환하고, 삭제에 성공하면 성공 메시지를 반환한다.
2. 프론트엔드(Frontend)
프론트엔드에서는 앞서 구상한 UI를 담당할 것이다. 사실 프론트엔드 부분은 설명할 거리가 별로 없어서 최종 코드를 바탕으로 살펴보려 한다.
우선, 이 페이지에서 여러가지 기능을 사용하기 때문에 구현할 함수도 상당히 많았고, 테이블 형태다 보니 클래스도 많고 이것저것 많았다. 그래서 API를 담당하는 함수들과 핸들러 함수들을 별도 파일에 저장하여 import 해 사용하기로 하였다.
schedulAPI.js는백엔드로 요청을 보내는 함수들을 모아두었다. 그리고 scheduleHandlers.js는 ScheudlePage.js에서 사요할 핸들러 함수들을 미리 구현한 것으로 schedulAPI.js의 요청 함수들을 사용한다.
각 기능에 따라정리해보겠다.
과목 검색
schedulAPI.js
/**
* 검색어를 기반으로 과목을 검색하는 함수
* @param {string} searchTerm - 검색할 과목명
* @returns {Promise<Array>} - 검색된 과목들의 배열을 반환
*/
export const searchSubjects = async (searchTerm) => {
if (searchTerm) {
try {
// 서버에 GET 요청을 보내 과목을 검색
const response = await axios.get(`/api/subjects/search?name=${searchTerm}`);
return response.data; // 검색 결과 반환
} catch (error) {
// 에러 발생 시 콘솔에 출력하고 에러를 다시 던짐
console.error("Error searching subjects:", error);
throw error;
}
}
};
scheduleHandlers.js
/**
* 과목 검색을 처리하는 함수
* @param {string} searchTerm - 검색할 과목명
* @param {function} setSubjects - 검색된 과목 리스트를 업데이트하는 상태 설정 함수
*/
export const handleSearch = async (searchTerm, setSubjects) => {
try {
// 검색어를 이용해 과목을 검색하고 상태를 업데이트
const data = await searchSubjects(searchTerm);
setSubjects(data);
} catch (error) {
// 에러 발생 시 콘솔에 출력
console.error(error);
}
};
/**
* 검색어 입력 시 상태를 업데이트하는 함수
* @param {object} event - 입력 이벤트 객체
* @param {function} setSearchTerm - 검색어 상태를 업데이트하는 함수
*/
export const handleInputChange = (event, setSearchTerm) => {
// 입력된 값을 검색어 상태로 설정
setSearchTerm(event.target.value);
};
새삼 깨달은 건데, 자바를 배운건 내 인생에서 신의 한수 같다. 문법도 쉽고 여러 언어들과 비슷하며, 객체지향을 공부하는데 있어서 정말 쉽다. 예외처리도 자바, C++이 거의 유사하다는 건 알고 있었는데 자바스크립트도 동일하다.
과목 담기
schedulAPI.js
/**
* 사용자의 시간표에 새로운 과목을 담는 함수
* @param {string} subjectId - 추가할 과목의 ID
* @returns {Promise<string>} - 서버로부터의 응답 메시지 반환
*/
export const addSubjectToSchedule = async (subjectId) => {
try {
// 서버에 POST 요청을 보내 과목을 시간표에 추가
const response = await axios.post('/api/schedule/add', { subjectId });
return response.data; // 서버의 응답 메시지 반환
} catch (error) {
// 에러 발생 시 콘솔에 출력하고 에러를 다시 던짐
console.error("Error adding subject to schedule:", error);
throw error;
}
};
scheduleHandlers.js
/**
* 사용자가 과목을 선택할 때 호출되는 함수
* @param {object} subject - 선택된 과목 객체
* @param {array} selectedSchedules - 사용자가 이미 선택한 과목 리스트
* @param {function} setSelectedSchedules - 선택된 과목 리스트를 업데이트하는 상태 설정 함수
*/
export const handleSelectSubject = async (subject, selectedSchedules, setSelectedSchedules) => {
// 이미 선택된 과목인지 확인
const alreadySelected = selectedSchedules.some(schedule => schedule.subject.subjectId === subject.subjectId);
if (alreadySelected) {
alert("이미 담은 과목입니다");
return;
}
try {
// 과목을 시간표에 추가하고 상태를 업데이트
const message = await addSubjectToSchedule(subject.subjectId);
alert(message);
const data = await getUserSchedule();
setSelectedSchedules(data);
} catch (error) {
// 에러 발생 시 콘솔에 출력하고 오류 메시지 표시
console.error("Error adding subject to schedule:", error);
alert("과목을 추가하는 중 오류가 발생했습니다.");
}
};
과목 추가(선택 상태 업데이트)
schedulAPI.js
/**
* 특정 과목의 선택 상태를 업데이트하는 함수
* @param {number} scheduleId - 업데이트할 시간표 항목의 ID
* @param {boolean} selected - 업데이트할 선택 상태
*/
export const updateSelectedStatus = async (scheduleId, selected) => {
try {
// 서버에 PUT 요청을 보내 과목의 선택 상태를 업데이트
await axios.put('/api/schedule/update-selected', { scheduleId: scheduleId.toString(), selected });
} catch (error) {
// 에러 발생 시 콘솔에 출력하고 에러를 다시 던짐
console.error("Error updating subject selection:", error);
throw error;
}
};
scheduleHandlers.js
/**
* 체크박스 변경 시 호출되는 함수
* 그룹 내에서 하나의 과목만 선택할 수 있도록 제한함
* @param {number} scheduleId - 변경할 시간표 항목의 ID
* @param {boolean} selected - 체크박스 선택 여부
* @param {function} setSelectedSchedules - 선택된 과목 리스트를 업데이트하는 상태 설정 함수
* @param {object} groupedSubjects - 과목들을 그룹으로 묶은 객체
* @param {string} groupName - 현재 그룹의 이름
*/
export const handleCheckboxChange = async (scheduleId, selected, setSelectedSchedules, groupedSubjects, groupName) => {
// 그룹 내에서 이미 체크된 항목이 있는지 확인
if (groupedSubjects[groupName]) {
const alreadyChecked = groupedSubjects[groupName].some(schedule => schedule.selected);
if (alreadyChecked && selected) {
alert("이미 추가한 과목입니다");
return;
}
} else {
console.error("groupName or groupedSubjects is undefined or null");
return;
}
try {
// 체크박스 상태를 업데이트하고 상태를 변경
await updateSelectedStatus(scheduleId, selected);
setSelectedSchedules(prevSchedules =>
prevSchedules.map(schedule =>
schedule.id === scheduleId ? { ...schedule, selected } : schedule
)
);
} catch (error) {
// 에러 발생 시 콘솔에 출력하고 오류 메시지 표시
console.error("Error updating subject selection:", error);
alert("선택 상태를 업데이트하는 중 오류가 발생했습니다.");
}
};
과목 삭제
scheduleAPI.js
/**
* 사용자의 시간표에서 특정 과목을 삭제하는 함수
* @param {number} scheduleId - 삭제할 시간표 항목의 ID
*/
export const deleteSubjectFromSchedule = async (scheduleId) => {
try {
// 서버에 DELETE 요청을 보내 시간표에서 과목을 삭제
await axios.delete(`/api/schedule/delete/${scheduleId}`);
} catch (error) {
// 에러 발생 시 콘솔에 출력하고 에러를 다시 던짐
console.error("Error deleting subject from schedule:", error);
throw error;
}
};
scheduleHandlers.js
/**
* 사용자가 과목을 삭제할 때 호출되는 함수
* @param {number} scheduleId - 삭제할 시간표 항목의 ID
* @param {function} setSelectedSchedules - 선택된 과목 리스트를 업데이트하는 상태 설정 함수
*/
export const handleDeleteSubject = async (scheduleId, setSelectedSchedules) => {
try {
// 과목을 시간표에서 삭제하고 상태를 업데이트
await deleteSubjectFromSchedule(scheduleId);
setSelectedSchedules(prevSchedules => {
const updatedSchedules = prevSchedules.filter(schedule => schedule.id !== scheduleId);
alert("삭제 완료");
return updatedSchedules;
});
} catch (error) {
// 에러 발생 시 콘솔에 출력하고 오류 메시지 표시
console.error("Error deleting subject from schedule:", error);
alert("과목을 삭제하는 중 오류가 발생했습니다.");
}
};
그 외
scheduleAPI.js
/**
* 현재 사용자의 시간표를 불러오는 함수
* @returns {Promise<Array>} - 사용자가 담은 과목들의 배열을 반환
*/
export const getUserSchedule = async () => {
try {
// 서버에 GET 요청을 보내 사용자의 시간표 데이터를 가져옴
const response = await axios.get('/api/schedule/user-schedule');
return response.data; // 시간표 데이터 반환
} catch (error) {
// 에러 발생 시 콘솔에 출력하고 에러를 다시 던짐
console.error("Error fetching user schedule:", error);
throw error;
}
};
그리고 이 모든 것을 동작할 SchedulePage를 구성한다. 진짜 너무 힘든 시간이었음.
SchedulePage
SchedulePage.js
// src/main/frontend/src/pages/SchedulePage.js
import React, { useEffect, useState } from 'react';
import './SchedulePage.css'; // CSS 파일을 import
import { useAuth } from '../context/AuthContext';
import {
handleSearch,
handleInputChange,
handleSelectSubject,
handleCheckboxChange,
handleDeleteSubject
} from '../utils/scheduleHandlers'; // 핸들러 import
import { getUserSchedule } from '../utils/scheduleAPI';
function SchedulePage() {
const { userInfo } = useAuth();
const [subjects, setSubjects] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedSchedules, setSelectedSchedules] = useState([]);
useEffect(() => { // 로그인된 계정의 담은 과목 불러오기
if (userInfo) {
getUserSchedule()
.then(data => setSelectedSchedules(data))
.catch(error => console.log(error));
}
}, [userInfo]);
// 과목명 기준으로 선택된 과목들을 그룹화
const groupedSubjects = selectedSchedules.reduce((groups, schedule) => {
const { subjectName } = schedule.subject;
if (!groups[subjectName]) {
groups[subjectName] = [];
}
groups[subjectName].push(schedule);
return groups;
}, {});
return (
<div className="schedule-page">
<h1>시간표 구성</h1>
<div className="page-layout">
<div className="left-section">
<input
type="text"
placeholder="과목명을 검색하세요"
value={searchTerm}
onChange={(event) => handleInputChange(event, setSearchTerm)}
className="search-input"
/>
<button onClick={() => handleSearch(searchTerm, setSubjects)} className="search-button">검색</button> {/* 검색 버튼 추가 */}
<div className="subjects-table-container">
<table className="subjects-table">
<thead>
<tr>
<th>수강번호</th>
<th>과목명</th>
<th>교수명</th>
<th>강의시간</th>
<th>강의실</th>
<th>담기</th>
</tr>
</thead>
<tbody>
{subjects.map(subject => (
<tr key={subject.subjectId}>
<td>{subject.subjectId}</td>
<td>{subject.subjectName}</td>
<td>{subject.professor}</td>
<td>{subject.time}</td>
<td>{subject.location}</td>
<td>
<button onClick={() => handleSelectSubject(subject, selectedSchedules, setSelectedSchedules)}>담기</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{Object.keys(groupedSubjects).length > 0 && (
<div className="selected-subjects-container">
{Object.entries(groupedSubjects).map(([subjectName, schedules]) => (
<div key={subjectName} className="subject-group">
<table className="selected-subjects-table">
<thead>
<tr>
<th colSpan="5">{subjectName}</th>
</tr>
<tr>
<th>수강번호</th>
<th>교수명</th>
<th>강의시간</th>
<th>강의실</th>
<th>추가/삭제</th>
</tr>
</thead>
<tbody>
{schedules.map(schedule => (
<tr key={schedule.id}>
<td>{schedule.subject.subjectId}</td>
<td>{schedule.subject.professor}</td>
<td>{schedule.subject.time}</td>
<td>{schedule.subject.location}</td>
<td>
<input
type="checkbox"
checked={schedule.selected}
onChange={(e) => handleCheckboxChange(schedule.id, e.target.checked, setSelectedSchedules, groupedSubjects, subjectName)}
/>
<button
onClick={() => handleDeleteSubject(schedule.id, setSelectedSchedules)}>삭제
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
)}
</div>
<div className="right-section">
{/* 여기에 시간표 또는 다른 추가 콘텐츠를 배치할 수 있습니다. */}
</div>
</div>
</div>
);
}
export default SchedulePage;
Schedulepage.css
/* src/main/frontend/src/pages/SchedulePage.css */
.schedule-page {
width: 90%; /* 페이지의 90%를 차지하도록 설정 */
max-width: 1500px; /* 최대 너비 설정 */
margin: 0 auto;
padding: 20px;
}
.page-layout {
display: flex;
width: 100%; /* 전체 페이지를 차지하도록 설정 */
}
.left-section {
width: 60%; /* 왼쪽 영역을 전체 페이지의 60%로 설정 */
padding-right: 20px; /* 오른쪽과 여백 추가 */
}
.right-section {
width: 40%; /* 오른쪽 영역을 전체 페이지의 40%로 설정 */
background-color: #f7f7f7; /* 오른쪽 영역 배경색 설정 */
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
height: 400px; /* 고정된 높이 설정 */
display: flex;
justify-content: center;
align-items: center;
}
.search-input {
width: 40%;
padding: 5px;
font-size: 12px; /* 텍스트 크기를 줄임 */
}
.subjects-table-container {
max-height: 200px; /* 테이블의 최대 높이 설정 (4개 행에 맞게 조정) */
overflow-y: auto; /* 높이를 초과하면 스크롤 활성화 */
margin-bottom: 20px; /* 여백 추가 */
}
.subjects-table {
width: 100%;
border-collapse: collapse;
table-layout: auto; /* 열 너비를 텍스트 내용에 따라 유동적으로 변경 */
}
.selected-subjects-container {
margin-bottom: 20px;
}
.subject-group {
margin-bottom: 20px; /* 각 과목 그룹 사이에 여백 추가 */
}
.selected-subjects-table {
width: 100%;
border-collapse: collapse;
}
.subjects-table th,
.subjects-table td,
.selected-subjects-table th,
.selected-subjects-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: center;
font-size: 12px; /* 글자 크기를 줄임 */
white-space: nowrap; /* 텍스트 줄바꿈 방지 */
}
.subjects-table th,
.selected-subjects-table th {
background-color: #f4f4f4;
position: sticky; /* 헤더를 고정 */
top: 0; /* 상단에 고정 */
z-index: 1; /* 다른 내용 위에 고정되도록 설정 */
}
.subjects-table tbody tr:nth-child(odd),
.selected-subjects-table tbody tr:nth-child(odd) {
background-color: #f9f9f9;
}
.subjects-table tbody tr:hover,
.selected-subjects-table tbody tr:hover {
background-color: #f1f1f1;
}
여기서 끝내서는 안된다. 모든 동작은 로그인이 필요하니, 애초에 로그인해야만 사용할 수 있는 서비스로 설정해야 한다. 이 부분은 다음 포스트에서 PrivateRoute와 NavBar, 반응형 UI 수정 등 일부 변화를 정리하도록 하겠다.
참고자료 및 출처
HTML, CSS - 헤더컬럼 고정형 table 구성하기 :: 개발 흔적 남기기 (tistory.com)