📝 Lv 7. 댓글 CRUD 도전
- [ ✅ ] 생성한 일정에 댓글을 남길 수 있습니다.
- [ ✅ ] 댓글과 일정은 연관관계를 가집니다. → 3주차 연관관계 매핑 참고!
- [ ✅ ] 댓글을 저장, 조회, 수정, 삭제할 수 있습니다.
- [ ✅ ] 댓글은 아래와 같은 필드를 가집니다.
- [ ✅ ] 댓글 내용, 작성일, 수정일, 유저 고유 식별자, 일정 고유 식별자 필드
- [ ✅ ] 작성일, 수정일 필드는 JPA Auditing을 활용하여 적용합니다.
1. Entity
@Getter
@Entity
@Table(name = "comment")
@NoArgsConstructor
public class Comment extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String content;
@ManyToOne
@JoinColumn(name = "user_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
@ManyToOne
@JoinColumn(name = "plan_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Plan plan;
public Comment(CreateRequestDto dto) {
this.content = dto.getContent();
}
// 연관관계 설정
public void setUser(User user) {
this.user = user;
}
public void setPlan(Plan plan) {
this.plan = plan;
}
public void updateContent(String content) {
this.content = content;
}
}
- OnDelete 라는 어노테이션은 Cascade 를 적용한 것이다. JPA 어노테이션이 아닌, Hibernate 전용 어노테이션이다.
- 유저를 삭제할 경우 유저가 작성한 일정과 댓글이 있을 경우 에러가 뜨기 때문에 이를 처리하기 위해 유저 삭제가 될 경우 할일과 댓글 모두 삭제할 수 있게끔 하였다.
- 해당 코드에서는 Plan 을 삭제하거나 유저를 삭제할 경우 해당하는 댓글이 삭제되도록 처리한 것이다.
2. Controller
/* 댓글 생성 */
@PostMapping
public ResponseEntity<CreateResponseDto> createComment(
@Positive(message = "양수만 허용합니다.") @PathVariable Long planId,
@Valid @RequestBody CreateRequestDto dto,
HttpServletRequest request) {
LoginDto loginUser = (LoginDto) request.getSession().getAttribute("loginUser");
return ResponseEntity.status(HttpStatus.CREATED).body(commentService.createComment(planId, loginUser.getId(), dto));
}
/* 일정의 댓글 전체 조회 */
@GetMapping
public ResponseEntity<List<FindResponseDto>> findAllByPlanId(
@Positive(message = "양수만 허용합니다.") @PathVariable Long planId) {
return ResponseEntity.ok(commentService.findAllByPlanId(planId));
}
/* 일정의 댓글 단건 조회 */
@GetMapping("/{commentId}")
public ResponseEntity<FindResponseDto> findById(
@Positive(message = "양수만 허용합니다.") @PathVariable Long planId,
@Positive(message = "양수만 허용합니다.") @PathVariable Long commentId
) {
return ResponseEntity.ok(commentService.findById(planId, commentId));
}
/* 댓글 수정 */
@PatchMapping("/{commentId}")
public ResponseEntity<String> updateComment(
@Positive(message = "양수만 허용합니다.") @PathVariable Long planId,
@Positive(message = "양수만 허용합니다.") @PathVariable Long commentId,
@Valid @RequestBody UpdateRequestDto dto,
HttpServletRequest request
) {
LoginDto loginUser = (LoginDto) request.getSession().getAttribute("loginUser");
commentService.updateComment(planId, loginUser.getId(), commentId, dto);
return ResponseEntity.ok("댓글 수정이 완료되었습니다.");
}
/* 댓글 삭제 */
@DeleteMapping("/{commentId}")
public ResponseEntity<String> deleteComment(
@Positive(message = "양수만 허용합니다.") @PathVariable Long planId,
@Positive(message = "양수만 허용합니다.") @PathVariable Long commentId,
@Valid @RequestBody DeleteRequestDto dto,
HttpServletRequest request
) {
LoginDto loginUser = (LoginDto) request.getSession().getAttribute("loginUser");
commentService.deleteComment(planId, loginUser.getId(), commentId, dto);
return ResponseEntity.ok("댓글 삭제가 완료되었습니다.");
}
- 신경 쓴 부분은 수정과 삭제 시, 로그인 된 유저의 댓글일 경우에만 가능하도록 설정한 것이다.
- 또한, 댓글 삭제 시에는 비밀번호 인증을 한 번 더 받도록 설정했다.
3. Service
/* 댓글 생성 */
@Override
public CreateResponseDto createComment(Long planId, Long userId, CreateRequestDto dto) {
// 데이터 검증 및 조회
Plan findPlan = planRepository.findByIdOrElseThrow(planId);
User findUser = userRepository.findByIdOrElseThrow(userId);
// DTO 를 Entity 로 변환
Comment comment = new Comment(dto);
// 연관관계 주입
comment.setPlan(findPlan);
comment.setUser(findUser);
// 댓글 저장
Comment saved = commentRepository.save(comment);
// Dto 로 변환 후 반환
return CreateResponseDto.from(saved);
}
/* 일정의 댓글 전체 조회 */
@Override
public List<FindResponseDto> findAllByPlanId(Long planId) {
// 데이터 검증
planRepository.validateExistenceById(planId);
// Plan ID 가 일치하는 댓글 조회
List<Comment> commentList = commentRepository.findByPlan_Id(planId);
// DTO 로 변환 후 반환
return commentList.stream()
.map(FindResponseDto::from)
.toList();
}
/* 일정의 댓글 단건 조회 */
@Override
public FindResponseDto findById(Long planId, Long commentId) {
// 데이터 검증
planRepository.validateExistenceById(planId);
// Comment ID 가 일치하는 댓글 조회
Comment comment = commentRepository.findByIdAndPlan_IdOrElseThrow(commentId, planId);
// DTO 로 변환 후 반환
return FindResponseDto.from(comment);
}
/* 댓글 수정 */
@Transactional // 확장성 고려
@Override
public void updateComment(Long planId, Long userId, Long commentId, UpdateRequestDto dto) {
// 데이터 검증
verifyAllExistOrThrow(planId, userId, commentId);
// 로그인된 유저의 댓글 수정 접근 검증 및 조회
Comment comment = commentRepository.findByPlan_IdAndUser_IdAndIdOrElseThrow(planId, userId, commentId);
// 댓글 업데이트
comment.updateContent(dto.getContent());
}
/* 댓글 삭제 */
@Transactional
@Override
public void deleteComment(Long planId, Long userId, Long commentId, DeleteRequestDto dto) {
// 데이터 검증
verifyAllExistOrThrow(planId, userId, commentId);
// 로그인된 유저의 댓글 삭제 접근 검증 및 조회
Comment findComment = commentRepository.findByPlan_IdAndUser_IdAndIdOrElseThrow(planId, userId, commentId);
// 입력한 PWD 검증
if (!passwordEncoder.matches(dto.getPwd(), findComment.getUser().getPwd())) {
throw new InvalidPasswordException("비밀번호가 틀렸습니다.");
}
// 댓글 삭제
commentRepository.delete(findComment);
}
/* 데이터 검증 */
private void verifyAllExistOrThrow(Long planId, Long userId, Long commentId) {
// 일정 데이터 검증
planRepository.validateExistenceById(planId);
commentRepository.validateExistenceByPlan_Id(planId);
// 유저 데이터 검증
userRepository.validateExistenceById(userId);
// 댓글 데이터 검증
commentRepository.validateExistenceById(commentId);
}
- 댓글 생성 시에는 연관관계 설정을 위해 set 으로 유저와 일정을 넣어주었다.
- 중요하게 생각한 부분은 수정과 삭제 부분이다. verifyAllExistOrThrow 를 사용한 걸 알 수 있는데.
- 나는 아래와 같은 생각을 가지고 수정과 삭제 시 사용하는 verifyAllExistOrThrow 를 만들었다.
- 각각에 대한 오류가 따로 나왔으면 좋겠어서 이렇게 설정했다.
4. Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
/* plan id 가 일치하는 모든 댓글 */
List<Comment> findByPlan_Id(Long planId);
/* Id 와 Plan Id 가 일치 */
Optional<Comment> findByIdAndPlan_Id(Long id, Long planId);
default Comment findByIdAndPlan_IdOrElseThrow(Long commentId, Long planId) {
return findByIdAndPlan_Id(commentId, planId).orElseThrow(() -> new DataNotFoundException("해당하는 댓글이 존재하지 않습니다."));
}
/* Plan 이 존재하는지 확인 */
boolean existsByPlan_Id(Long planId);
default void validateExistenceByPlan_Id(Long planId) {
if (!existsByPlan_Id(planId)) {
throw new DataNotFoundException("댓글이 존재하지 않는 일정입니다.");
}
}
/* 댓글이 존재하지 않을 경우 */
default void validateExistenceById(Long id) {
if (!existsById(id)) {
throw new DataNotFoundException("해당 댓글이 존재하지 않습니다.");
}
}
/* Comment ID 와 Plan ID, User ID 가 모두 일치 (Long planId, Long userId, Long commentId) */
Optional<Comment> findByPlan_IdAndUser_IdAndId(Long planId, Long userId, Long commentId);
default Comment findByPlan_IdAndUser_IdAndIdOrElseThrow(Long planId, Long userId, Long commentId) {
return findByPlan_IdAndUser_IdAndId(planId, userId, commentId).orElseThrow(() -> new UnauthorizedAccessException("댓글에 접근할 권한이 없습니다."));
}
}
- 해당 부분에서도 중요하게 생각한 부분이 있는데. 예외 처리 어떤 부분이 맡아야하는가? 였다.
- 나중에 트러블 슈팅에도 쓸 생각인데. 이러한 생각 과정을 걸쳤다.
- 요약하자면.. 서비스에 Repository 의 optional 값을 넘겨주지 말자는 것.. !
레퍼지토리에서 optional 을 처리해서 exception을 주는것
=> optional 객체를 레퍼지토리에서 처리해서 좋음
레퍼지토리에서 optional 객체를 반환하기 하여 서비스 레이어에서 exception을 주는것
=> 서비스 레이어에서 오류를 내보낸다는 점에서 계층 분리에 더 맞음.
" 서비스 관점에서 봤을 때 optional을 리턴받게되면 해당 옵션값을 처리하기 위한 로직이 서비스에필요할거임. 그렇게 되버리면 또 다른 단점=> Entity 라는 도메인 객체를 서비스 객체내에서 비즈니스 코드를 만등러 줘야함. optional 이라는 이넡페이스에 감추어져 있으면 서비스 에서 해당 entity 를 바로 사용하기에는 번거로움이 생긴다. 그런 관점에서 도메인과 서비스 간에 원활한 소통을 위해서는 레퍼지토리에서는 판별해주고 . 객체를 유효한 객체를 가져오는게 낫다. 단건에 대한 객체를 활용할려할테니까. default 메서드를 스는게 좋을것이다. "
🔖 Lv 8. 일정 페이징 조회 도전
- 키워드
- offset / limit : SELECT 쿼리에 적용해서 데이터를 제한 범위에 맞게 조회할 수 있습니다.
- Pageable : Spring Data JPA에서 제공되는 페이징 관련 인터페이스 입니다.
- PageRequest : Spring Data JPA에서 제공되는 페이지 요청 관련 클래스입니다.
- 데이터베이스
- [ ✅ ] 일정을 Spring Data JPA의 Pageable과 Page 인터페이스를 활용하여 페이지네이션을 구현
- [ ✅ ] 페이지 번호와 페이지 크기를 쿼리 파라미터로 전달하여 요청하는 항목을 나타냅니다.
- [ ✅ ] 할일 제목, 할일 내용, 댓글 개수, 일정 작성일, 일정 수정일, 일정 작성 유저명 필드를 조회합니다.
- [ ✅ ] 디폴트 페이지 크기는 10으로 적용합니다.
- [ ✅ ] 일정의 수정일을 기준으로 내림차순 정렬합니다.
1. Controller
/* 페이징 조회 */
@GetMapping("/pages")
public ResponseEntity<PageResponseDto> page(
@RequestParam(required = false, defaultValue = "1")
@Min(value = 1, message = "1 이상의 값을 가져야 합니다.")
int pageNumber,
@RequestParam(required = false, defaultValue = "10")
@Min(value = 1, message = "1 이상의 값을 가져야 합니다.")
int pageSize
) {
PageResponseDto page = planService.page(pageNumber - 1, pageSize);
return ResponseEntity.ok(page);
}
- Plan Controller 에 구현했다.
- 자세히 봐야할 부분은 value 를 -1 해준다는 것? 왜나하면 페이지 값을 0부터 시작하기 때문.
2. Dto - PlanWithUserAndCommentDto
@Getter
@AllArgsConstructor
public class PlanWithUserAndCommentDto {
// 일정 작성 유저명
private final String userName;
// 할일 제목
private final String title;
// 할일 내용
private final String contents;
// 댓글 개수
private final Long commentCount;
// 일정 작성일 과 수정일
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private final LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private final LocalDateTime updatedAt;
public static PlanWithUserAndCommentDto from(String name, Plan plan, Long commentCount) {
return new PlanWithUserAndCommentDto(name, plan.getTitle(), plan.getContents(), commentCount, plan.getCreatedAt(), plan.getUpdatedAt());
}
}
- 말만 Dto 지 이거를 뭐라고 지칭할 지는 모르겠다... 데이터를 담는 개체라서 일단은 DTO 라고 지정했다.
- 여기에는 Paging 될 list 담길 정보들이다.
- 요구사항대로 유저명, 제목, 내용, 댓글 개수, 작성일과 수정일이 포함대도록 했다.
3. Dto - PageResponseDto
@Getter
@AllArgsConstructor
public class PageResponseDto {
private List<PlanWithUserAndCommentDto> plans;
private Map<String, Object> pageInfo;
// ResponseDto 로 변환
public static PageResponseDto from(List<PlanWithUserAndCommentDto> plans, int pageNumber, int pageSize, int totalPages, long totalElements) {
Map<String, Object> pageInfo = new LinkedHashMap<>();
pageInfo.put("pageNumber", pageNumber + 1);
pageInfo.put("pageSize", pageSize);
pageInfo.put("totalPages", totalPages);
pageInfo.put("totalElements", totalElements);
return new PageResponseDto(plans, pageInfo);
}
}
- 위에서 만들었던 Dto 를 담고 있는 리스트와 page 에 관련된 정보를 담고 있는 Map 을 선언했다.
- HashMap<> 으로 만드니 Map 안에 있는 값들이 순서없이 나타나길래 LinkedHashMap 을 이용했다.
4. Service
/* 페이징 조회 */
@Override
public PageResponseDto page(int pageNumber, int pageSize) {
// pageable 객체 :: 정렬방향과 속성 properties 를 지정하기 위해 Sort 객체를 생성
Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "updatedAt"));
// pageable 를 통해 Page<Plan> 조회
Page<Plan> planPage = planRepository.findAll(pageable);
// User 의 이름과 Comment 의 개수 추가
List<PlanWithUserAndCommentDto> pageResponseDto = planPage.getContent().stream().map(plan -> {
Long commentCount = commentRepository.countByPlan_Id(plan.getId());
String name = plan.getUser().getName();
return PlanWithUserAndCommentDto.from(name, plan, commentCount);
}).toList();
// DTO 로 변환 후 반환
return PageResponseDto.from(pageResponseDto, pageNumber, pageSize, planPage.getTotalPages(), planPage.getTotalElements());
}
- pagealbe 객체를 만들어서 updatedAt 기준으로 정렬하게 했다.
- 여기서 중요한 부분은 updatedAt 는 Entity 기준의 필드 명으로 해야한다는 것이다. (이거 때문에 애 먹었다 ^ㅡ^)
- 중간에 pageResponDto 를 만들기 위해서 stream.map() 을 이용해 user 이름과 comment 개수를 추가하는 걸 볼 수 있다.
- 페이징 처리하다가 내 자신한테 화난적이 있었는데. 뭘 조회하던간에 빈 배열이 나오는 것이었다.
- 아 진짜 왜 안되지, 맞는데? 에러도 안 뜨는데? 왜 빈 배열이지 싶었다. 근데 단순히 내가 page Number 를 큰 값을 줘서 데이터 조회가 안되는 것이었다. ( 바보다 ㅎㅎㅎㅎ )
💻 Refactoring - 할일 인증 부분 구현
- 애써 외면 했던 Plan 부분의 인증. 인가 부분을 구현했다.
- 로그인 되어있는 세션을 이용하여 일정을 추가, 수정, 삭제를 할 수 있게 했다.
1. Controller
/* 일정 추가 */
@PostMapping
public ResponseEntity<SaveResponseDto> savePlan(
@Valid @RequestBody SaveRequestDto dto,
HttpServletRequest request) {
LoginDto loginUser = (LoginDto) request.getSession().getAttribute("loginUser");
SaveResponseDto responseDto = planService.savePlan(dto, loginUser.getId());
return ResponseEntity.status(HttpStatus.CREATED).body(responseDto);
}
/* 일정 수정 */
@PatchMapping("/{id}")
public ResponseEntity<String> updatePlan(
@Positive(message = "양수만 허용합니다.") @PathVariable Long id,
@Valid @RequestBody UpdateRequestDto dto,
HttpServletRequest request
) {
LoginDto loginUser = (LoginDto) request.getSession().getAttribute("loginUser");
planService.updatePlan(id, loginUser.getId(), dto);
return ResponseEntity.ok("일정 수정이 완료되었습니다.");
}
/* 일정 삭제 */
@DeleteMapping("/{id}")
public ResponseEntity<String> deletePlan(
@Positive(message = "양수만 허용합니다.") @PathVariable Long id,
@Valid @RequestBody DeleteRequestDto dto,
HttpServletRequest request) {
LoginDto loginUser = (LoginDto) request.getSession().getAttribute("loginUser");
planService.deletePlan(id, loginUser.getId(), dto);
return ResponseEntity.ok("일정 삭제가 완료되었습니다.");
}
- 새롭게 보이는게 있는데 바로 HttpServletRequest 이다.
- 해당 request 를 getSession, getAttribute 를 해버리면 Object 가 반환되기 때문에 다운캐스팅 해주어야하는 걸 잊지 말아야한ㄷ다.
- 세션에 있는 유저의 id 값만 서비스한테 보내주는 모습을 볼 수 있다.
2. Service
/* 일정 추가 */
@Override
public SaveResponseDto savePlan(SaveRequestDto dto, Long loginId) {
// 로그인한 유저 데이터 검증 및 조회
User findUser = userRepository.findByIdOrElseThrow(loginId);
// Dto 를 Entity 변환
Plan plan = new Plan(dto.getTitle(), dto.getContents());
// 연관관계 설정
plan.setUser(findUser);
// 일정 저장
Plan saved = planRepository.save(plan);
// DTO 로 변환 후 반환
return SaveResponseDto.from(saved);
}
/* 일정 수정 */
@Transactional
@Override
public void updatePlan(Long planId, Long userId, UpdateRequestDto dto) {
// DTO 데이터 검증
if (!dto.isValid()) {
throw new InvalidRequestException("입력 값이 유효하지 않습니다. 둘 중 하나의 값은 입력해야 합니다.");
}
// 데이터 검증 및 조회
Plan findPlan = planRepository.findByIdOrElseThrow(planId);
// 로그인된 User ID 와 접근하는 Plan 의 User ID 일치 여부 검증
planRepository.validatePlanAccess(planId, userId);
// 기존 값을 유지하거나 입력된 값으로 변경
String setTitle = (StringUtils.isEmpty(dto.getTitle())) ? findPlan.getTitle() : dto.getTitle();
String setContents = (StringUtils.isEmpty(dto.getContents())) ? findPlan.getContents() : dto.getContents();
// 일정 업데이트
findPlan.updateTitle(setTitle);
findPlan.updateContents(setContents);
}
/* 일정 삭제 */
@Override
public void deletePlan(Long planId, Long userId, DeleteRequestDto dto) {
// 데이터 검증 및 조회
Plan findPlan = planRepository.findByIdOrElseThrow(planId);
// 로그인된 User ID 와 접근하는 Plan 의 User ID 일치 여부 검증
planRepository.validatePlanAccess(planId, userId);
// dto 에서 받아온 pwd 와 DB 의 pwd 일치하는지 확인
if (!passwordEncoder.matches(dto.getPwd(), findPlan.getUser().getPwd())) {
throw new InvalidPasswordException("비밀번호가 틀렸습니다.");
}
// 일정 삭제
planRepository.delete(findPlan);
}
- 추가할 때에는 로그인한 유저의 id 를 통해서 User 를 주입하는 걸 볼 수 있다.
- 수정할 때에도 로그인한 유저의 id 를 통해서 User 를 찾아내, 해당 User 와 Plan 의 User 와 일치하는지 확인 후 변경하는 걸 볼 수 있다. 나는 조금 더 직관적이었으면 하는 바람에 validatePlanAccess 라는 void 메서드를 활용 했다. Repository 에서는 exist 를 사용했다.
- 삭제할 때에는 특별히 dto 를 통해서 pwd를 받아온다. pwd 가 실제 Plan 유저의 비밀번호와 일치하는지 확인했다.
🎃 Refactoring - 로그인 시 이중 로그인 금지
- 로그인 중인데도 로그인이 처리되는 현상이 있어서 해당 부분을 에러로 막기로 했다.
- 로그인이 된지 안 된지 검증 후에 서비스 로직을 처리해야한다.
- 서비스 계층에서는 비즈니스 로직만을 담당해야하기 때문, 컨트롤러는 검증된 유저의 정보를 넘긴다는 관점에서 해당 로그인 검증 여부가 컨트롤러에 있는게 적절하다고 생각함.
/* 로그인 */
@PostMapping("/login")
public ResponseEntity<String> login(
@Valid @RequestBody LoginRequestDto dto,
HttpServletRequest request) {
HttpSession session = request.getSession();
// 로그인 중이라면 Bad_Request
if (session.getAttribute("loginUser") != null) {
throw new InvalidRequestException("이미 로그인된 상태입니다.");
}
LoginDto loginDto = userService.login(dto);
session.setAttribute(Const.LOGIN_USER, loginDto);
return ResponseEntity.ok("로그인했습니다.");
}
- session 값이 비어있지 않다면, 로그인 되어 있는 상태라는 것. 그래서 그거에 대한 부분만 커스텀 에러를 throw 한걸 볼 수 있다.
✅ 오늘의 회고
- 구현은 끝났지만 진짜 힘들ㄷ ㅏㅠ... 오전 10시 이후로 계속 리팩토링에만 매달린 거 같은데 ,, 꼼꼼하게 해서 그런가 ? 엄청 오래걸렸다.
- 더 킹받는 건 아직 문서화 하지 않은 것도 많다는 것이다..
- Readme 도 써야하고, 요구사항도 어떤 부분을 어떻게 생각해서 구현했는지 적어야하고,, 그리고 API 도 작성해야하고,, 고민했던 부분이나 트러블 슈팅한 부분도 정리해서 써야하고.. 앞길이 멀다 ㅠ
- 페이징 처리 한 부분도 포스팅 하면 나중에 도움될 거 같은데 ,, 🤣🤣🤣🤣
- 눈 아파서 오늘은 그만.. !
😮 내일 Postman API 문서 툴 만들기
Postman을 활용한 API 문서 만들기
지난번에 Swagger를 활용한 API Specification에 대한 글을 작성했었다. Swagger를 사용하여 간단하게 API 명세가 가능하다는 내용의 글이었는데, 이러한 Swagger 말고도 다른 여러 방법으로 API 문서를 만들
juintination.tistory.com
[TIL] Postman으로 API문서 만들기
Postman? postman.png Postman은 개발한 API를 테스트하기 위해 사용하는 플랫폼으로 유명하다. 많은 사람들이 이용하는 플랫폼인 만큼 다양한 기능들을 제공하고 있다. 그 중에서도 오늘은 Postman을 통해
velog.io
'백엔드 부트캠프 > TIL' 카테고리의 다른 글
[내일배움캠프Spring-34일차] JPA 의 Paging / Pageable / DTO 매핑 페이징 (0) | 2025.04.04 |
---|---|
[내일배움캠프Spring-33일차] CH 3 일정 관리 앱 Develop 完 (0) | 2025.04.03 |
[내일배움캠프Spring-31일차] 과제 트러블슈팅 (1) | 2025.04.01 |
[내일배움캠프Spring-30일차] CH 3 일정 관리 앱 Develop Lv2~Lv6 (1) | 2025.03.31 |
[내일배움캠프Spring-29일차] CH 3 일정 관리 앱 Develop Lv0~Lv1 (2) | 2025.03.28 |