프로젝트 팀원이 새롭게 합류함에 따라 2024 공개SW 개발자 대회를 목표로 MVP 모델을 개발하고
추후 MVP 모델에 대한 성과를 바탕으로 하여 장기적인 사업화 계획을 수립할 예정이다.
「프로젝트 특장점 및 주요 기능」
유사 서비스 대비 차별성
유사 서비스들의 음성인식, 자막 기능들을 통합 제공
사용자는 대학교 강의 시간표 형태로 강의 기록과 생성된 문제를 관리할 수 있음
대화나 회의 상황을 타겟으로 하는 서비스와 달리 대학교 강의 환경에 최적화된 프로젝트
Speech-to-Text 기능
웹 환경과 마이크만 있다면 오프라인, 온라인 환경의 강의 원본 텍스트를
Whisper STT 라이브러리로 변환된 실시간 자막 및 스크립트로 제공받을 수 있음
생성형 AI 문제 생성 기능
대학 강의들은 일반화된 문제집이나, 공부 방법이 존재하지 않음
생성형 AI를 활용하여 변환된 스크립트로부터 문제를 생성하여 제공받을 수 있음
시간표 형태로 이후의 강의, 문제 데이터가 관리될 것이기 때문에 위와 같이 /schedule
에 대한 API를 우선적으로 구현하는 것을 목표로 하였다.
또한 위와 같이 이전의 기획 내용과는 다르게 Schedule과 Schedele Element에 관한 DB 명세서를 정의하였고, STT 변환 강의 내용과 문제들은 MongoDB에 저장되는 것으로 설계하였다.
추가적으로 1:1
, 1:N
관계를 명시하여 데이터들의 관계를 더 명확히 하였다.
@Getter
@RequiredArgsConstructor
public enum RoleType {
USER("USER","일반 사용자"),
ADMIN("ADMIN","관리자");
private final String key;
private final String title;
}
가장 먼저 Authorization에 대한 처리를 용이하게 하기 위해 기존의 RoleType Enum을 수정하였다.
또한 JWT Filter를 테스트하기 위하여 기존에 구현해두었던 로그인을 수행한다.
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOrigins(Arrays.asList("*"));
config.setAllowedHeaders(Arrays.asList("*"));
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
먼저 기존 Socket.io, WebSocket을 연결할때 정의한 CorsConfig를 별도로 정의하였다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig{
@Autowired
private CorsConfig corsConfig;
@Autowired
private JwtFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.addFilter(corsConfig.corsFilter())
.addFilterAfter(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.securityMatcher("/api/v1/main")
.securityMatcher("/api/v1/schedule")
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/v1/auth/**").permitAll()
.anyRequest().hasRole(RoleType.USER.getKey())
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
이후 SecurityConfig
을 위와 같이 적용해주었다.
.addFilter(corsConfig.corsFilter())
를 통해 CORS Filter를 적용하고
.addFilterAfter(jwtFilter...
를 통해 JWT Token의 유효 여부를 판단할 Filter를 적용한다.
또한 .authorizeHttpRequests
를 통해 권한에 따라 접근할 수 있는 라우트를 제한하였다.
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<JwtFilter> JwtFilterRegistration() {
FilterRegistrationBean<JwtFilter> bean = new FilterRegistrationBean<>(new JwtFilter());
// Filter를 적용할 Route 설정
bean.addUrlPatterns("/api/v1/main");
bean.addUrlPatterns("/api/v1/schedule");
bean.setOrder(0);
return bean;
}
}
이후 위와 같이 FilterConfig
를 정의하여 Filter를 적용할 Route를 설정하였다.
Postman에 로그인을 통해 발급받은 토큰을 Auth에 Bearer Token에 넣어주어 테스트할 준비를 마쳤다.
@Slf4j
@Component
public class JwtFilter implements Filter {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestURI = httpRequest.getRequestURI();
// 필터 로직을 수행하지 않고 다음 필터로 이동
if (!requestURI.startsWith("/api/v1/main") && !requestURI.startsWith("/api/v1/schedule")) {
chain.doFilter(request, response);
return;
}
// 인증이 필요한 Route의 경우 필터 로직 수행
// Bearer xxx 형태로 Token이 들어온 경우만을 가정하고 Authorization Header를 Split
String token = httpRequest.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.split(" ")[1];
if (jwtTokenProvider.validateAccessToken(token)) {
chain.doFilter(request, response);
return;
}
}
// 토큰이 유효하지 않은 경우 CommonResponse로 응답
ObjectMapper objectMapper = new ObjectMapper();
CommonResponse commonResponse = new CommonResponse(false, HttpStatus.UNAUTHORIZED, "Invalid Access Token", null);
String jsonResponse = objectMapper.writeValueAsString(commonResponse);
httpResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpResponse.setContentType("application/json");
httpResponse.setCharacterEncoding("UTF-8");
httpResponse.getWriter().write(jsonResponse);
}
}
JwtFilter
를 위와 같이 정의하여 만약 현재 requestURI
가 Authentication이 필요하지 않은 URI라면 필터 로직을 수행하지 않고 다음 필터로 이동하도록 하였다.
만약 인증이 필요한 Route의 경우 필터 로직을 수행하는데, 이때 Authorization
헤더에 담긴 Token을 가져온 후 Slicing을 통해 Method와 토큰을 분리하고 JwtTokenProvider
를 통해 해당 Access Token의 Validate 여부를 판단한다.
만약 토큰이 유효하지 않은 경우에는 CommonResponse
를 생성하고 httpResponse
에 Write하여 요청에 대해 응답하고 이후의 chain
을 실행하지 않는다.
정상적인 토큰의 경우 위와 같이 아직 라우트가 구현되지 않았기 때문에 정상적으로 404 오류를 띄워주는 것을 볼 수 있으며
잘못된 토큰의 경우 Invalid Access Token 응답이 오는 것을 확인할 수 있다.
이후 위와 같은 API 명세에 따라 API를 구현하였다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@Table(name = "schedule")
public class ScheduleEntity extends BaseEntitiy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "schedule", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ScheduleElementEntity> scheduleElements = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private UserEntity user;
private String name;
public void addScheduleElement(ScheduleElementEntity scheduleElement) {
scheduleElements.add(scheduleElement);
scheduleElement.setSchedule(this);
}
public void removeScheduleElement(ScheduleElementEntity scheduleElement) {
scheduleElements.remove(scheduleElement);
scheduleElement.setSchedule(null);
}
public ScheduleDTO toDTO() {
List<ScheduleElementDTO> scheduleElementDTOs = scheduleElements.stream()
.map(ScheduleElementEntity::toDTO)
.collect(Collectors.toList());
return ScheduleDTO.builder()
.id(id)
.scheduleElements(scheduleElementDTOs)
.userId(user.getId())
.build();
}
}
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@Table(name = "schedule_element")
public class ScheduleElementEntity extends BaseEntitiy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
// @JoinColumn 다대일 관계에서 외래 키 매핑
@JoinColumn(name = "schedule_id")
private ScheduleEntity schedule;
private String name;
private String location;
private String dayOfWeek;
@Temporal(TemporalType.TIMESTAMP)
private Date startTime;
@Temporal(TemporalType.TIMESTAMP)
private Date endTime;
public ScheduleElementDTO toDTO() {
return ScheduleElementDTO.builder()
.id(id)
.scheduleId(schedule.getId())
.name(name)
.location(location)
.dayOfWeek(dayOfWeek)
.startTime(startTime)
.endTime(endTime)
.build();
}
}
먼저 ScheduleEntity
와 ScheduleElementEntity
를 생성하였다.
이때 1:N
관계에서 외래 키를 매핑시키기 위해 @ManyToOne
, @JoinColumn
Annotaion을 사용하였다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ScheduleDTO {
private Long id;
private List<ScheduleElementDTO> scheduleElements;
private String name;
private String userId;
public ScheduleEntity toEntity(UserEntity user) {
List<ScheduleElementEntity> scheduleElementEntities = scheduleElements.stream()
.map(ScheduleElementDTO::toEntity)
.collect(Collectors.toList());
return ScheduleEntity.builder()
.id(id)
.scheduleElements(scheduleElementEntities)
.name(name)
.user(user)
.build();
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ScheduleElementDTO {
private Long id;
private Long scheduleId;
private String name;
private String location;
private String dayOfWeek;
private Date startTime;
private Date endTime;
public ScheduleElementEntity toEntity() {
return ScheduleElementEntity.builder()
.id(id)
.name(name)
.location(location)
.dayOfWeek(dayOfWeek)
.startTime(startTime)
.endTime(endTime)
.build();
}
}
ScheduleDTO
, ScheduleElementDTO
의 경우 ScheduleDTO
에서 여러 scheduleElements
를 담은 리스트를 통해 Entitiy의 1:N
관계로부터 실제로 서버에서 데이터를 사용할 수 있도록 구조화하였다.
public interface ScheduleRepository extends JpaRepository<ScheduleEntity, Long> {
Optional<ScheduleEntity> findById(Long id);
Optional<ScheduleEntity> findByUserIdAndName(String userId, String name);
boolean existsByUserIdAndName(String userId, String name);
List<ScheduleEntity> findByUserId(String userId);
boolean deleteByUserIdAndName(String userId, String name);
}
public interface ScheduleElementRepository extends JpaRepository<ScheduleElementEntity, Long> {
List<ScheduleElementEntity> findByScheduleId(Long scheduleId);
}
이후 위와 같이 ScheduleRepository
, ScheduleElementRepository
를 생성하였다.
public interface ScheduleDAO {
CommonResponse addSchedule(ScheduleEntity scheduleEntity);
CommonResponse deleteSchedule(ScheduleEntity scheduleEntity);
}
@Slf4j
@Service
public class ScheduleDAOImpl implements ScheduleDAO {
ScheduleRepository scheduleRepository;
ScheduleElementRepository scheduleElementRepository;
UserRepository userRepository;
private String userId;
private String scheduleName;
@Autowired
public ScheduleDAOImpl(ScheduleRepository scheduleRepository, ScheduleElementRepository scheduleElementRepository, UserRepository userRepository) {
this.scheduleRepository = scheduleRepository;
this.scheduleElementRepository = scheduleElementRepository;
this.userRepository = userRepository;
}
@Override
// 메서드 내부의 데이터베이스 작업이 하나의 트랜잭션으로 처리될 수 있도록 한다
// Spring Boot를 사용하는 경우, 별도의 설정 없이 @EnableTransactionManagement가 자동으로 활성화
@Transactional
public CommonResponse addSchedule(ScheduleEntity scheduleEntity) {
userId = scheduleEntity.getUser().getId();
scheduleName = scheduleEntity.getName();
// 동일한 사용자에 대해 같은 이름의 Schedule을 생성하는 것은 불가함
if(scheduleRepository.existsByUserIdAndName(userId, scheduleName)){
log.info("[ScheduleDAO]-[addSchedule] ({}) User's ({}) ScheduleName Already Exists", userId, scheduleName);
return new CommonResponse(false, HttpStatus.CONFLICT,"ScheduleName Already Exists");
}
log.info("[ScheduleDAO]-[addSchedule] Create new ScheduleEntity ({})-({})", userId, scheduleName);
// scheduleRepository에 Schedule 저장
ScheduleEntity savedSchedule = scheduleRepository.save(scheduleEntity);
// 해당 User의 schedule 필드에 새로운 스케줄의 ID 추가
UserEntity user = userRepository.findFirstById(userId);
if (user != null) {
String schedule = user.getSchedule();
if (schedule == null) {
schedule = String.valueOf(savedSchedule.getId());
} else {
schedule += "," + savedSchedule.getId();
}
user.setSchedule(schedule);
userRepository.save(user);
}
return new CommonResponse(true, HttpStatus.CREATED,"Schedule Created");
}
@Override
@Transactional
public CommonResponse deleteSchedule(ScheduleEntity scheduleEntity) {
userId = scheduleEntity.getUser().getId();
scheduleName = scheduleEntity.getName();
ScheduleEntity deleteSchedule = scheduleRepository.findByUserIdAndName(userId, scheduleName);
// ScheduleEntity가 존재하지 않을 경우
if(!scheduleRepository.existsByUserIdAndName(userId, scheduleName)){
log.info("[ScheduleDAO]-[addSchedule] ({}) User's ScheduleEntity ({}) doesnt exists", userId, scheduleName);
return new CommonResponse(false, HttpStatus.INTERNAL_SERVER_ERROR,"ScheduleName Doesn't Exists");
}
log.info("[ScheduleDAO]-[deleteSchedule] Delete ScheduleEntity ({})-({})", userId, scheduleName);
scheduleRepository.deleteByUserIdAndName(userId, scheduleName);
// 해당 User의 schedule 필드에서 삭제된 스케줄의 ID 제거
UserEntity user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found with id: " + userId));
String schedule = user.getSchedule();
if (schedule != null) {
List<String> scheduleIdList = Arrays.asList(schedule.split(","));
String scheduleIdToRemove = String.valueOf(deleteSchedule.getId());
// 삭제할 스케줄 ID를 제외한 새로운 리스트 생성
List<String> newScheduleIdList = scheduleIdList.stream()
.filter(id -> !id.equals(scheduleIdToRemove))
.collect(Collectors.toList());
// 새로운 리스트를 문자열로 변환하여 user의 schedule 필드에 저장
String newSchedule = String.join(",", newScheduleIdList);
user.setSchedule(newSchedule);
userRepository.save(user);
}
return new CommonResponse(true, HttpStatus.OK,"Schedule Deleted");
}
}
ScheduleDAOImpl
의 메소드들은 user
, schedule
테이블에 대해 각각 명령을 수행하므로 @Transactional
Annotation을 통해 메서드 내부의 데이터베이스 작업이 하나의 트랜잭션으로 처리될 수 있도록 하였다.
이때 Spring Boot를 사용하는 경우 별도의 설정 없이 @EnableTransactionManagement
가 자동으로 활성화되어 Annotation을 사용할 수 있다.
addSchedule
에서는 동일한 사용자에 대해 같은 이름의 Schedule을 생성하는 것을 막아 중복으로 시간표가 생성되는 것을 방지하였고, User의 Schedule 필드에 새롭게 생성된 Schedule의 ID 추가하여 user
테이블에 접근하여 Schedule에 대한 정보를 얻을 수 있게 하였다.
deleteSchedule
의 경우 addSchedule
와 유사하게 동작하지만 user
테이블의 Schedule 필드에서 삭제된 Schedule의 ID를 제거하는 과정에서, user
테이블에서는 ,
로 Schedule들을 구분하기 때문에 기존의 user
테이블의 Schedule 필드를 가져와 새로운 리스트를 생성하고 이를 다시 문자열로 변환하여 저장하는 방식으로 구현하였다.
public interface ScheduleService {
CommonResponse addSchedule(ScheduleDTO user);
CommonResponse deleteSchedule(ScheduleDTO user);
}
@Slf4j
@Service
public class ScheduleServiceImpl implements ScheduleService {
@Autowired
private ScheduleDAO scheduleDAO;
@Autowired
UserRepository userRepository;
@Override
public CommonResponse addSchedule(ScheduleDTO scheduleDTO) {
UserEntity user = userRepository.findFirstById(scheduleDTO.getUserId());
return scheduleDAO.addSchedule(scheduleDTO.toEntity(user));
}
@Override
public CommonResponse deleteSchedule(ScheduleDTO scheduleDTO) {
UserEntity user = userRepository.findFirstById(scheduleDTO.getUserId());
return scheduleDAO.deleteSchedule(scheduleDTO.toEntity(user));
}
}
이후 위와 같이 ScheduleService
를 생성해주었다.
public class JwtTokenProvider {
...
// 유저 정보를 통해 Access Token, Refresh Token 생성하는 매소드
public TokenDTO generateToken(UserDTO userDTO) {
long now = (new Date()).getTime();
// Access Token 생성
// subject는 User의 ID
// Access Token의 유효기간은 1시간
Date accessTokenExpiresIn = new Date(now + 3600000);
String accessToken = Jwts.builder()
.setSubject(userDTO.getUserId())
.claim("role", userDTO.getUserRole())
.setExpiration(accessTokenExpiresIn)
.signWith(accessKey, SignatureAlgorithm.HS256)
.compact();
JwtTokenProvider
의 Subject는 위와 같이 userDTO.getUserId()
이다.
...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
...
// 인증이 필요한 Route의 경우 필터 로직 수행
// Bearer Token이 들어온 경우만을 가정하고 Authorization Header를 Split
String token = httpRequest.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.split(" ")[1];
if (jwtTokenProvider.validateAccessToken(token)) {
// 인증 정보를 SecurityContext에 저장
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(jwtTokenProvider.getTokenInfo(token), null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
return;
}
}
따라서 클라이언트가 Header에 Bearer Token을 포함해서 보내는 것 만으로도 JWT Token의 Claim
과 Subject
를 Controller
에서 추출해 사용할 수 있도록 필터링을 하는 과정에서 해당 Token이 Valid할 경우 인증 정보를 SecurityContext
에 저장한다.
@Slf4j
@RestController
@RequestMapping("/api/v1/schedule")
public class ScheduleController {
@Autowired
private ScheduleService scheduleService;
private CommonResponse response;
@PostMapping(value="/addSchedule")
public ResponseEntity<CommonResponse> addSchedule(@Valid @RequestBody ScheduleDTO scheduleDTO){
log.info("[ScheduleController]-[addSchedule] API Call");
if(scheduleDTO.getName().isEmpty() ){
log.info("[ScheduleController]-[addSchedule] Failed : Empty Name");
response = new CommonResponse(false, HttpStatus.BAD_REQUEST,"Empty Name");
return ResponseEntity.status(response.getStatus()).body(response);
}
// SecurityContext에서 Authentication으로 UserID를 받아온다
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String userId = (String) authentication.getPrincipal();
// 이후 scheduleDTO의 UserID를 설정한다
scheduleDTO.setUserId(userId);
response = scheduleService.addSchedule(scheduleDTO);
return ResponseEntity.status(response.getStatus()).body(response);
}
@DeleteMapping(value="/deleteSchedule")
public ResponseEntity<CommonResponse> deleteSchedule(@Valid @RequestBody ScheduleDTO scheduleDTO){
log.info("[ScheduleController]-[deleteSchedule] API Call");
if(scheduleDTO.getName().isEmpty()){
log.info("[ScheduleController]-[deleteSchedule] Failed : Empty Name");
response = new CommonResponse(false, HttpStatus.BAD_REQUEST,"Empty Name");
return ResponseEntity.status(response.getStatus()).body(response);
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String userId = (String) authentication.getPrincipal();
scheduleDTO.setUserId(userId);
response = scheduleService.deleteSchedule(scheduleDTO);
return ResponseEntity.status(response.getStatus()).body(response);
}
}
이후 위와 같이 ScheduleController
를 구성하는데, SecurityContext
에서 Authentication
으로 UserID를 받아와 Service
에 전달하기 전 scheduleDTO
의 setUserId
를 통해 해당 정보를 저장한다.
이후 요청을 보냈을 때 ScheduleDTO
와 ScheduleEntity
의 비어있는 ScheduleElement
필드들 때문에 오류가 발생하였다.
public class ScheduleEntity extends BaseEntitiy {
@OneToMany(mappedBy = "schedule", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ScheduleElementEntity> scheduleElements = new ArrayList<>();
...
public ScheduleDTO toDTO() {
List<ScheduleElementDTO> scheduleElementDTO = new ArrayList<>();
if(scheduleElements != null) {
for (ScheduleElementEntity scheduleElement : scheduleElements) {
scheduleElementDTO.add(scheduleElement.toDTO());
}
}
return ScheduleDTO.builder()
.id(id)
.scheduleElements(scheduleElementDTO)
.userId(user.getId())
.build();
}
public class ScheduleDTO {
private List<ScheduleElementDTO> scheduleElements;
...
public ScheduleEntity toEntity(UserEntity user) {
List<ScheduleElementEntity> scheduleElementEntities = new ArrayList<>();
if(scheduleElements != null) {
for (ScheduleElementDTO scheduleElementDTO : scheduleElements) {
scheduleElementEntities.add(scheduleElementDTO.toEntity());
}
}
return ScheduleEntity.builder()
.id(id)
.scheduleElements(scheduleElementEntities)
.name(name)
.user(user)
.build();
}
}
이러한 오류를 ScheduleEntity
와 ScheduleDTO
의 변환 메소드에서의 예외 처리를 통해 해결하였다.
위와 같이 /addSchedule
라우트로 요청을 보내면 정상적으로 Schedule이 생성되는 것을 확인할 수 있다.
또한 user
테이블에도 각 schedule들의 ID가 정상적으로 반영된 것을 확인할 수 있으며
동일한 User가 이미 생성한 Name으로 요청을 보내면 거부되는 것을 확인할 수 있다.
/deleteSchedule
의 경우에도 위와 같이 정상적으로 동작하는 것을 볼 수 있으며
user
테이블에서도 Schedule의 ID가 삭제된 것을 확인할 수 있다.
또한 동일한 User가 이미 삭제한 Name으로 요청을 보내면 거부되는 것을 확인할 수 있다.
public class ScheduleDAOImpl implements ScheduleDAO {
...
@Override
@Transactional
public CommonResponse addElement(ScheduleEntity scheduleEntity, ScheduleElementEntity scheduleElementEntity) {
userId = scheduleEntity.getUser().getId();
scheduleName = scheduleEntity.getName();
log.info("[ScheduleDAO]-[addElement] Save ElementEntity ({})-({})", userId, scheduleElementEntity.getName());
ScheduleElementEntity savedElement = scheduleElementRepository.save(scheduleElementEntity);
scheduleEntity = scheduleRepository.findByUserIdAndName(userId, scheduleName);
scheduleEntity.addScheduleElement(savedElement);
scheduleRepository.save(scheduleEntity);
return new CommonResponse(true, HttpStatus.OK,"ScheduleElement Added", savedElement.toDTO());
}
@Override
@Transactional
public CommonResponse deleteElement(ScheduleEntity scheduleEntity, ScheduleElementEntity scheduleElementEntity) {
userId = scheduleEntity.getUser().getId();
scheduleName = scheduleEntity.getName();
log.info("[ScheduleDAO]-[addElement] Save ElementEntity ({})-({})", userId, scheduleElementEntity.getName());
ScheduleElementEntity deleteEntity = scheduleElementRepository.findFirstById(scheduleElementEntity.getId());
scheduleEntity = scheduleRepository.findByUserIdAndName(userId, scheduleName);
scheduleEntity.removeScheduleElement(deleteEntity);
scheduleRepository.save(scheduleEntity);
return new CommonResponse(true, HttpStatus.OK,"ScheduleElement Deleted");
}
}
addElement
와 deleteElement
메소드를 위와 같이 ScheduleDAO에 추가한다.
public class ScheduleServiceImpl implements ScheduleService {
...
@Override
public CommonResponse addElement(ScheduleDTO scheduleDTO, ScheduleElementDTO scheduleElementDTO) {
UserEntity user = userRepository.findFirstById(scheduleDTO.getUserId());
return scheduleDAO.addElement(scheduleDTO.toEntity(user), scheduleElementDTO.toEntity());
}
@Override
public CommonResponse deleteElement(ScheduleDTO scheduleDTO, ScheduleElementDTO scheduleElementDTO) {
UserEntity user = userRepository.findFirstById(scheduleDTO.getUserId());
return scheduleDAO.deleteElement(scheduleDTO.toEntity(user), scheduleElementDTO.toEntity());
}
}
이후 ScheduleService에서도 addElement
와 deleteElement
를 추가하는데, 이때 ScheduleDTO
를 Entitiy로 만들어주기 위해 UserRepository
를 통해 User를 받아온다.
public class ScheduleController {
...
@PostMapping(value="/addElement")
public ResponseEntity<CommonResponse> addElement(@Valid @RequestBody Map<String, Object> requestBody){
// ObjectMapper를 사용해 Map에서 키에 대한 값을 각각의 객체로 변환
ObjectMapper objectMapper = new ObjectMapper();
ScheduleDTO scheduleDTO = objectMapper.convertValue(requestBody.get("scheduleDTO"), ScheduleDTO.class);
ScheduleElementDTO scheduleElementDTO = objectMapper.convertValue(requestBody.get("scheduleElementDTO"), ScheduleElementDTO.class);
log.info("[ScheduleController]-[addElement] API Call");
if(scheduleDTO.getName() == null || scheduleDTO.getName().isEmpty()){
log.info("[ScheduleController]-[addElement] Failed : Empty Schedule Name");
response = new CommonResponse(false, HttpStatus.BAD_REQUEST,"Empty Schedule Name");
return ResponseEntity.status(response.getStatus()).body(response);
}
String userId = getUserIdFromContext();
scheduleDTO.setUserId(userId);
response = scheduleService.addElement(scheduleDTO, scheduleElementDTO);
return ResponseEntity.status(response.getStatus()).body(response);
}
@PostMapping(value="/deleteElement")
public ResponseEntity<CommonResponse> deleteElement(@Valid @RequestBody Map<String, Object> requestBody){
ObjectMapper objectMapper = new ObjectMapper();
ScheduleDTO scheduleDTO = objectMapper.convertValue(requestBody.get("scheduleDTO"), ScheduleDTO.class);
ScheduleElementDTO scheduleElementDTO = objectMapper.convertValue(requestBody.get("scheduleElementDTO"), ScheduleElementDTO.class);
log.info("[ScheduleController]-[deleteElement] API Call");
if(scheduleDTO.getName() == null || scheduleDTO.getName().isEmpty()){
log.info("[ScheduleController]-[deleteElement] Failed : Empty Schedule Name");
response = new CommonResponse(false, HttpStatus.BAD_REQUEST,"Empty Schedule Name");
return ResponseEntity.status(response.getStatus()).body(response);
}
String userId = getUserIdFromContext();
scheduleDTO.setUserId(userId);
response = scheduleService.deleteElement(scheduleDTO, scheduleElementDTO);
return ResponseEntity.status(response.getStatus()).body(response);
}
private String getUserIdFromContext(){
// SecurityContext에서 Authentication으로 UserID를 받아온다
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (String) authentication.getPrincipal();
}
}
ScheduleController에서는 POST 요청의 Body로 두 가지의 DTO를 받을 것이기 때문에 @RequestBody
로 Map을 받아오고 이후 ObjectMapper
를 사용해 Map에서 키에 대한 값을 각각의 객체로 변환하여 사용한다.
public enum DaysType {
MON("MON"),
TUE("TUE"),
WED("WED"),
THU("THU"),
FRI("FRI"),
SAT("SAT"),
SUN("SUN");
private final String key;
DaysType(String key) {
this.key = key;
}
}
또한 추후 개발을 용이하게 하기 위해 DaysType
Enum을 위와 같이 정의한다.
/addElement
요청을 보내면 서버가 정상적으로 응답을 돌려주는 것을 볼 수 있고
위와 같이 JOIN을 통해 ScheduleElement가 Schedule에 정상적으로 참조되어있는 것을 볼 수 있다.
public class ScheduleEntity extends BaseEntitiy {
...
@OneToMany(mappedBy = "schedule", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ScheduleElementEntity> scheduleElements = new ArrayList<>();
이때 ScheduleEntity
에서는 scheduleElements
들을 @OneToMany
관계로 참조하는데
mappedBy
속성은 양방향 관계에서 반대쪽 엔티티의 어떤 필드와 매핑되는지를 지정한다.
ScheduleElementEntity 클래스에서 ScheduleEntity를 참조하는 필드의 이름이 schedule임을 나타내고 외래 키의 관리를 반대쪽 엔티티에 위임하게 된다.
cascade
속성은 엔티티 간의 영속성 전이(Cascade) 동작을 지정한다.
영속성 전이란, 한 엔티티에서 수행한 영속성 작업(저장, 수정, 삭제 등)을 관련된 엔티티에도 전파하는 것을 의미하며 CascadeType.ALL은 모든 영속성 전이 동작을 수행하도록 설정한다.
즉, 부모 엔티티의 영속성 작업이 자식 엔티티에도 전파되게 된다.
orphanRemova
l 속성은 고아 객체 제거 기능을 활성화하는 데 사용된다.
고아 객체란, 부모 엔티티와의 관계가 끊어진 자식 엔티티를 말하는데 orphanRemoval = true로 설정하면, 부모 엔티티에서 자식 엔티티와의 관계를 끊을 때(ex. 컬렉션에서 제거) 해당 자식 엔티티가 데이터베이스에서 자동으로 삭제된다.
이 기능을 사용하면 자식 엔티티의 생명주기를 부모 엔티티에 따라 관리할 수 있는 장점이 있다.
이때 Schedule
, scheduleElement
는 위와 같은 속성을 가지고 관계를 맺는다.
public class ScheduleDAOImpl implements ScheduleDAO {
...
@Override
public ScheduleEntity deleteElement(ScheduleEntity scheduleEntity) {
userId = scheduleEntity.getUser().getId();
scheduleName = scheduleEntity.getName();
return scheduleRepository.findByUserIdAndName(userId, scheduleName);
}
}
public class ScheduleServiceImpl implements ScheduleService {
...
@Override
public ScheduleDTO getSchedule(ScheduleDTO scheduleDTO) {
UserEntity user = userRepository.findFirstById(scheduleDTO.getUserId());
return scheduleDAO.getSchedule(scheduleDTO.toEntity(user)).toDTO();
}
@RequestMapping("/api/v1/schedule")
public class ScheduleController {
...
@GetMapping("/getSchedule")
public ResponseEntity<CommonResponse> getSchedule(@RequestParam("name") String name) {
log.info("[ScheduleController]-[getSchedule] API Call");
// name 파라미터가 null이거나 빈 문자열인 경우 예외 처리
if (name == null || name.trim().isEmpty()) {
log.warn("[ScheduleController]-[getSchedule] Schedule name is null or empty");
return ResponseEntity.badRequest().body(new CommonResponse(false, HttpStatus.BAD_REQUEST, "Schedule name is required"));
}
try {
ScheduleDTO scheduleDTO = new ScheduleDTO();
String userId = getUserIdFromContext();
scheduleDTO.setName(name);
scheduleDTO.setUserId(userId);
ScheduleDTO resultScheduleDTO = scheduleService.getSchedule(scheduleDTO);
if (resultScheduleDTO == null) {
log.info("[ScheduleController]-[getSchedule] Schedule not found with name: {}", name);
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(new CommonResponse(true, HttpStatus.OK, "Schedule retrieved successfully", resultScheduleDTO));
} catch (Exception e) {
log.error("[ScheduleController]-[getSchedule] An error occurred while retrieving schedule", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new CommonResponse(false, HttpStatus.INTERNAL_SERVER_ERROR, "An error occurred while retrieving schedule"));
}
}
...
}
또한 요청한 name에 해당하는 User의 Shchedule을 가져올 수 있는 API를 위와 같이 구현하였고
정상적으로 요청이 수행되는 것을 볼 수 있다.
이후 FE 파트와의 협업을 더욱 원활하게 하기 위해 Postman Mock Server를 구축하였다.
{
"status": "OK",
"msg": "Schedule retrieved successfully",
"object": {
"id": 7,
"scheduleElements": [
{
"id": 1,
"scheduleId": 7,
"name": "경제학원론",
"location": "경영관",
"dayOfWeek": "MON",
"startTime": "2023-06-12T10:00:00.000+00:00",
"endTime": "2023-06-12T12:00:00.000+00:00"
},
{
"id": 2,
"scheduleId": 7,
"name": "경제학원론",
"location": "경영관",
"dayOfWeek": "WED",
"startTime": "2023-06-12T10:00:00.000+00:00",
"endTime": "2023-06-12T12:00:00.000+00:00"
},
{
"id": 5,
"scheduleId": 7,
"name": "미시경제학",
"location": "경영관 301호",
"dayOfWeek": "TUE",
"startTime": "2023-06-13T09:00:00.000+00:00",
"endTime": "2023-06-13T10:30:00.000+00:00"
},
{
"id": 6,
"scheduleId": 7,
"name": "거시경제학",
"location": "사회과학관 201호",
"dayOfWeek": "WED",
"startTime": "2023-06-14T13:00:00.000+00:00",
"endTime": "2023-06-14T14:30:00.000+00:00"
},
{
"id": 7,
"scheduleId": 7,
"name": "경제통계학",
"location": "경상관 402호",
"dayOfWeek": "THU",
"startTime": "2023-06-15T15:00:00.000+00:00",
"endTime": "2023-06-15T16:30:00.000+00:00"
},
{
"id": 8,
"scheduleId": 7,
"name": "경제수학",
"location": "수학관 101호",
"dayOfWeek": "WED",
"startTime": "2023-06-12T13:00:00.000+00:00",
"endTime": "2023-06-12T14:30:00.000+00:00"
},
{
"id": 9,
"scheduleId": 7,
"name": "경제학세미나",
"location": "경영관 세미나실",
"dayOfWeek": "FRI",
"startTime": "2023-06-16T13:00:00.000+00:00",
"endTime": "2023-06-16T16:00:00.000+00:00"
}
],
"name": "건국대학교 3-1학기",
"userId": null
},
"success": true
}
먼저 추후 개발될 FE의 시간표 UI에서 실제 시간표에 가깝게 더미 데이터를 구성하기 위하여 위와 같은 /getSchedule?name=
라우트에 대한 exmaple json response를 구성하였다.
이후 Postman의 Create Mock Server
에서 Select an axisting Collection
을 통해 기존의 Collection으로 Mock Server를 만들어주고
위와 같이 각 HTTP Req에 대한 example들을 생성해주었다.
Postman과 브라우저에서 Mock Server가 정상적으로 작동하는 것을 볼 수 있었으며
팀의 프론트엔드 채널에 이를 전달하였다.