
리팩토링 해서 업그레이드 하려고 했는데 튜터님의 피드백을 받고 그 기반으로 다시 복습할 겸 새로 만드는 걸로......
1. 엔티티 수정하고 나서 습관처럼 save를 한 번 더 호출한 거 빼기..... JPA는 트랜잭션 안에서 조회된 엔티티 영속상태면 값 바뀐 거 알아서 update 날려 주는데 그걸 save로 한 번 더 눌림... 왜 눌림 거기서 저장 버튼 한 번 더 눌린 거다
@Transactional 이라는 친구
- JPA를 통해 조회를 하게 되면 JPA가 '영속성 컨텍스트'에서 관리를 시작함
- JPA 이 조회된 친구의 원래 모습을 알고 있고
- Transactional이 포함한 범위가 끝났을 때, 더티 체킹 : 원래 모습 vs 마지막 모습... 바뀐 게 있다? UPDATE 쿼리 실행. 없다? 그냥 둠
5. 중간에 응답 dto 나눠 놓은 게...... 뭔가 헷갈리고 짜증 나서 응답 dto 통합했다. 튜터님한테 물어보니까 통합해도 된다. 사실 실무에서는 그렇게 많이 쓰이고 필요할 때 나눠도 된다고 하셔서 바로 통합
같은 팀원 분인 태훈님이 알려 주셔서 포스트맨 써 보기 예시로는 지원님이 협찬해 주셨습니다
이건 그냥 테스트 캡처






전역 예외처리
https://hkoonsdiary.tistory.com/135
[Spring] Spring Boot 예외 처리 설명 & 예제 (Kotlin)
Exception Annotation @ControllerAdvice : Global 예외 처리 및 특정 pakage / Controller 예외 처리 @ExceptionHandler : 특정 Controller의 예외 처리 GlobalControllerAdvice //@RestControllerAdvice(basePackageClasses = [ExceptionApiController::c
hkoonsdiary.tistory.com
https://mangkyu.tistory.com/204
[Spring] 스프링의 다양한 예외 처리 방법(ExceptionHandler, ControllerAdvice 등) 완벽하게 이해하기 - (1/2)
예외 처리는 애플리케이션을 만드는데 매우 중요한 부분을 차지한다. Spring 프레임워크는 매우 다양한 에러 처리 방법을 제공하는데, 어떠한 방법들이 있고 가장 좋은 방법(Best Practice)은 무엇인
mangkyu.tistory.com


B컷전문 | 개발과 인생의 이야기
B컷전문 | 개발과 인생의 이야기
bcuts.tistory.com
Errors (Spring Framework 7.0.3 API)
Stores and exposes information about data-binding and validation errors for a specific object. Field names are typically properties of the target object (for example, "name" when binding to a customer object). Implementations may also support nested fields
docs.spring.io
첫 번째 에러만 내려 주기
package com.springschedule.common.exception;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
// 400 입력값 검증 실패
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(
IllegalArgumentException e,
HttpServletRequest request
) {
return error(HttpStatus.BAD_REQUEST, e.getMessage(), request);
}
// 404 일정 없음 등
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
IllegalStateException e,
HttpServletRequest request
) {
return error(HttpStatus.NOT_FOUND, e.getMessage(), request);
}
// 400 Bean Validation 실패 예외 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException e,
HttpServletRequest request
) {
String message = e.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(fieldError -> fieldError.getDefaultMessage())
.orElse("요청 값이 유효하지 않음");
return error(HttpStatus.BAD_REQUEST, message, request);
}
// 공통 응답 중복으로 메서드로 뺌
private ResponseEntity<ErrorResponse> error(
HttpStatus status,
String message,
HttpServletRequest request
) {
ErrorResponse body = new ErrorResponse(
status.value(),
message,
request.getRequestURI()
);
return ResponseEntity.status(status).body(body);
}
}
🚩 유저 CRUD


그런데 유저 목록을 조회하는 거나 단건 조회... 뭐 이런 것들 관리자가 해야 되는 거잖아... admin 생성해서 하면 되는 건가? 아 일단 시키는 것만 하고 바쁨..... 일단 과제 내고
user/me 이런 식으로 추가해서 세션 사용해서 바꿔야겠다
🚩 회원가입


🚩 로그인(인증)
일단 세션에 넣을 dto를 만든다




🚩 비밀번호 암호화
implementation 'at.favre.lib:bcrypt:0.10.2'
package com.springschedule.config;
import at.favre.lib.crypto.bcrypt.BCrypt;
import org.springframework.stereotype.Component;
@Component
public class PasswordEncoder {
public String encode(String rawPassword) {
return BCrypt.withDefaults()
.hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
}
public boolean matches(String rawPassword, String encodedPassword) {
BCrypt.Result result = BCrypt.verifyer()
.verify(rawPassword.toCharArray(), encodedPassword);
return result.verified;
}
}
강의에 있는 예시코드
package com.springschedule.auth.controller;
import com.springschedule.auth.dto.LoginRequest;
import com.springschedule.config.PasswordEncoder;
import com.springschedule.user.entity.User;
import com.springschedule.user.repository.UserRepository;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
// 로그인
@PostMapping("/login")
public ResponseEntity<Void> login(
@Valid @RequestBody LoginRequest request,
HttpSession session
) {
User user = userRepository.findByEmail(request.getEmail());
// 이메일 없으면 로그인 실패
if (user == null) {
throw new IllegalArgumentException("님 이메일이 없음");
}
// 비밀번호 틀리면 로그인 실패
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new IllegalArgumentException("님 비밀번호 틀림");
}
// 로그인 성공
session.setAttribute("loginUserId", user.getId());
return ResponseEntity.ok().build();
}
// 로그아웃
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpSession session) {
session.invalidate();
return ResponseEntity.noContent().build();
}
@GetMapping("/test")
public ResponseEntity<Void> test(
@SessionAttribute(name = "loginUserId", required = false)
Long loginUserId
) {
if (loginUserId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok().build();
}
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public UserResponse save(CreateUserRequest request) {
String encoded = passwordEncoder.encode(request.getPassword());
User user = new User(
request.getUserName(),
request.getEmail(),
encoded
);
User saved = userRepository.save(user);
return toResponse(saved);
}
UserService에서 회원가입 때 비밀번호를 저장하기 전에 encode를 해 준다

🚩 댓글 CRUD



🚩 일정 페이징 조회
응답 필드에 할일 제목, 할일 내용, 댓글 개수, 일정 작성일, 수정일, 작성 유저명이 들어가야 된다
DB는 보통 `LIMIT` 몇 개 가져올래 / `OFFSET` 몇 개 건너뛸래 이런 식으로 잘라서 가져 오는데
Spring Data JPA의 `Pageable/PageRequest`는 이걸 자동으로 만들어서 DB에 보내 주는 도구다
아 그런데 미리 구현하신 팀원 분이 코드를 보여 주셨는데 댓글 개수가 좀 어렵다고 하셔서 어떻게 해야 될지 고민을 좀 했다...... 어 만능 쿼리메서드 쓰면 돼
-----------------------------------------------------------------------------------------
++ ❗아님 취소
페이징 구현 자체는 솔직히 어렵지 않았다 PageRequest.of(page-1, size, sort) 하고 Page<Schedule>로 받으면 끝이라…하 댓글 개수......
나는 응답 dto 만들 때
private ScheduleResponse toScheduleResponse(Schedule schedule) {
Long commentCount = commentRepository.countBySchedule_Id(schedule.getId());
return new ScheduleResponse(..., commentCount, ...);
}
이런 식으로 했었다. 그런데 여기서 중요한 점이......
페이징은 리스트 10개 20개 30개를 한 번에 꺼내 오는데
1. schedules 테이블에서 이번 페이지 데이터 20개 가져옴
2. 그 20개를 `map(toScheduleResponse)`로 하나씩 dto로 바꿈
3. 그런?데? dto 만들 때 마다 countBySchedule_Id()가 실행됨
즉 페이지 size가 20이면
- 일정 가져오는 쿼리 1번(페이지 + 전체 개수 count)
- 댓글 개수 count 쿼리 20번
총 쿼리 최소 21번 아이씨..
이건 지금 데이터 적을 때는 모르겠는데 데이터 많아지면 말도 안 되는 거다..
-> 그러니까 일정 n개를 가져오면 댓글 count 쿼리가 n번 더 나가는 구조 말도 안 됨 나 욕 들을 듯
팀원님이 힌트를 주심... `group by 써라` 네? jpql.....
"어차피 이번 페이지에 일정이 20개면 그 20개에 대한 댓글 개수도 한 번에 가져와라"
-> sql에 대한 지능 이슈로 생각도 못했다.... 아하!
1. schedules 페이지 조회로 이번 페이지의 scheduleId의 목록을 뽑음
2. 그 id들만 모아서 comments 테이블에 쿼리 날림
3. 결과를 `Map<scheduleId, commentCount>`로 만들어서
4. dto 만들 때는 DB 안 패고 Map에서 꺼내 씀
@Query("""
select c.schedule.id as scheduleId, count(c.id) as commentCount
from Comment c
where c.schedule.id in :scheduleIds
group by c.schedule.id
""")
List<ScheduleCommentCount> countByScheduleIds(@Param("scheduleIds") List<Long> scheduleIds);
하하 네 이게 맞죠
| scheduleId | commentCount |
| 25 | 3 |
| 24 | 0 |
| 23 | 7 |
댓글이 하나도 없는 일정은 아예 결과에 안 나오기도 해서 나중에 Map에서 꺼낼 때는 `getOrDefault(id, 0L)`로 처리..
Map<Long, Long> commentCountMap =
commentRepository.countByScheduleIds(scheduleIds).stream()
.collect(Collectors.toMap(
ScheduleCommentCount::getScheduleId,
ScheduleCommentCount::getCommentCount
));
댓글 개수 결과가 리스트로 오면 dto 만들 때마다 내 일정 id 해당하는 댓글 개수 찾기를 계속 해야 한다. 그런데 리스트에서 찾으면 매번 탐색해야 하니까 느릴 수 있다. 그래서 처음에 Map으로 만들어 두면 dto 생성할 때는 0(1)로 바로 꺼낼 수 있다.....
-----------------------------------------------------------------------
Page<Schedule> findByUser_UserName(String userName, Pageable pageable);
ScheduleRepository에 메서드 추가
List는 DB에서 조건에 맞는 것 전부를 가져오는 거고
페이징은 10개만 줍쇼... 다음 10개 줍쇼.. 하고 조금씩 잘라서 가져오는 것이다. 그래서 JPA에서 제공하는 Pageable와 Page를 써서 DB에 들어간 쿼리를 자동 생성하게 만드는 것.
그래서 데이터 목록 + 전체 개수/총 페이지/다음 페이지 존재 여부 같은 페이징 정보까지 포함한다
저것의 쿼리 메서드 이렇게 쓰면
select *
from schedules s
join users u on s.user_id = u.id
where u.user_name = ?
얘랑 비슷한 결과가 나옴
사실 전체 조회 페이징은 이미 있어서 내가 따로 선언할 필요는 없는데 나는 작성자 필터를 했으니까 그것의 페이징 버전만 새로 만들어 줫다..
// 전체 일정 조회
@GetMapping
public ResponseEntity<Page<ScheduleResponse>> getAll(
@RequestParam(required = false) String userName,
@RequestParam(defaultValue = "1") int page
) {
return ResponseEntity.status(HttpStatus.OK)
.body(scheduleService.findPage(userName, page, 10));
}
아 컨트롤러에서 size 파라미터 안 쓰고 그냥 항상 10로 그대로 내리려고 했는데..... 쓰읍 발제 다시 보니까
` /schedules?page=1&size=10` 이런 식으로 내려야 될 것 같아서
파라미터 추가..... 이러면 Max 값을 설정해 줘야 될 것 같아서
// 전체 일정 조회
@GetMapping
public ResponseEntity<Page<ScheduleResponse>> getAll(
@RequestParam(required = false) String userName,
@RequestParam(defaultValue = "1") @Min(value = 1, message = "1 이상이 정상입니다") int page,
@RequestParam(defaultValue = "10")
@Min(value = 1, message = "일정이 한 개는 나와야죠")
@Max(value = 30, message = "30개 이상은 지원 안 합니다") int size
) {
return ResponseEntity.status(HttpStatus.OK)
.body(scheduleService.findPage(userName, page, size));
}
바꿨다..... 30개 이상은 너무 많다
걍 이건 내가 궁금해서..... 정리용
| 항목 | @Valid | @Validated |
| 제공 주체 | Bean Validation 표준(Jakarta/JSR) | Spring Framework |
| 주 목적 | 객체/파라미터 검증 실행 트리거 | 검증 실행 + 검증 그룹(groups) 지원 |
| 대표 사용 위치 | @RequestBody DTO, @ModelAttribute 객체 등 |
컨트롤러/서비스 클래스 레벨, @RequestBody DTO, 메서드 파라미터 |
| 검증 그룹 지정 | 불가 | 가능 (@Validated(Create.class) 등) |
| 메서드 파라미터 검증(메서드 레벨 검증) | 제한적/상황 의존(단독으로는 그룹/타입 레벨 트리거가 약함) | 타입(클래스) 레벨에 붙이면 메서드 파라미터 제약(@Min, @NotBlank) 검증 트리거로 자주 사용 |
| 주로 쓰는 상황 | “그냥 DTO 검증만 하면 됨” (대부분의 CRUD) | “생성/수정 규칙 분리(그룹)” 또는 “서비스/컨트롤러 메서드 인자까지 검증” |
| 예외(대표) | 요청 바디 DTO 검증 실패 시 MethodArgumentNotValidException (Spring MVC) | 요청 바디 DTO면 동일하게 MethodArgumentNotValidException가 흔함 + 메서드 검증 쪽이면 ConstraintViolationException 또는 Spring 버전에 따라 HandlerMethodValidationException |
| 한 줄 요약 | “표준 검증 실행 버튼” | “스프링용 확장판: 그룹 + 메서드 검증에 강함” |

// 400 QueryParam/pathVariable Bean Validation 실패(@Min/@Max) 처리
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(
ConstraintViolationException e,
HttpServletRequest request
) {
String message = e.getConstraintViolations()
.stream()
.findFirst()
.map(v -> v.getMessage())
.orElse("요청 값이 유효하지 않음");
return error(HttpStatus.BAD_REQUEST, message, request);
}
나는 공통 에러로 내려야 되니까 이것도 처리.....

++ 응답 포맷 스탠다드반 보고 바꿨다......

이거 그냥 되게 쉽게 한 것처럼 보이는데 어제오늘 5시간 넘게 붙들고 있었던 듯.. (++ 5시간 택도 없었다 2시간 추가) 그래도 어떻게 쓰는 건지 알았다는 것에 대해서 의의를..... 다시 쓸 때는 좀 덜 헤매겠지? 제발
🚩 Soft Delete
DB에서 row를 진짜 삭제하지 않고 deleteAt 같은 표시를 남겨서 삭제된 것처럼 보이게 하는 방식...
그러니까 님 삭제된 줄 알았죠? ㅋ 사실 아니었습니다! 님 데이터 우리가 다 가지고 있음 ㅋ 이런 거다
처음엔 DELETE 때리면 끝 아닌가 했는데 현실은 이래요.
- 실수로 삭제한 거 복구 가능 (사람은 실수한다 나 포함)
- 누가 언제 삭제했는지 기록 가능
- 운영하면서 삭제된 데이터도 참고할 일이 은근 있다
그래서 서비스에서는 진짜 삭제보다 논리 삭제를 자주 씀.
찾아보면 Hibernate에 @SQLDelete, @Where 이런 어노테이션으로도 가능함. 근데 나는… 지금… 그걸로 멋부릴 실력은 아직 없다… (멋부리다가 터질까 봐 무서움)그래서 팀원분이 알려주신 가장 안전한 방식으로 갔다.
내가 택한 방식은 이거
- deletedAt 컬럼 추가
- 삭제 요청 오면 deletedAt = now()로 업데이트
- 조회할 때는 deletedAt is null인 것만 가져오기
- 그리고 댓글도… 삭제된 일정이면 님 일정 없음 처리
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public void softDelete() {
this.deletedAt = LocalDateTime.now();
}
Schedule 엔티티에 deletedAt 추가..... 삭제 버튼 누르면 디비에서 행이 사라지는 게 아니라 그냥 시간 찍고 숨김 처리 들어감
Page<Schedule> findAllByDeletedAtIsNull(Pageable pageable);
Page<Schedule> findByUser_UserNameAndDeletedAtIsNull(String userName, Pageable pageable);
Optional<Schedule> findByIdAndDeletedAtIsNull(Long id);
레포지토리에 삭제 안 된 것만 조회하도록 메서드 추가하고 조회할 때 삭제된 애들은 없는 척하기
// 일정 조회하고 없으면 예외
private Schedule getScheduleOrThrow(Long scheduleId) {
return scheduleRepository.findByIdAndDeletedAtIsNull(scheduleId).orElseThrow(
() -> new IllegalStateException("일정이 없는데요..? 축하합니다")
);
}
Page<Schedule> schedulesPage = (userName == null || userName.isBlank())
? scheduleRepository.findAllByDeletedAtIsNull(pageable)
: scheduleRepository.findByUser_UserNameAndDeletedAtIsNull(userName, pageable)
// 일정 삭제(soft delete 적용)
@Transactional
public void delete(Long scheduleId, Long loginUserId) {
Schedule schedule = getScheduleOrThrow(scheduleId);
// 작성자 맞는지 체크
if (!schedule.getUser().getId().equals(loginUserId)) {
throw new IllegalArgumentException("님 권한 없음");
}
// commentRepository.deleteAllBySchedule_Id(scheduleId);
// scheduleRepository.delete(schedule);
schedule.softDelete();
}
서비스 로직도 교체했다


그런데... 댓글도 문제임 이러면 삭제된 일정에 댓글이 달릴 수도 있고 삭제된 일정 댓글도 조회할 수 잇음.. 일정이 삭제됐는데 있네? 댓글 다는 참사 가능~ 사람들은 생각보다 특이한 짓을 많이 해서 각종 커뮤니티를 보면 정말 이상하게 버그 유발하는 사람들을 많이 봤다... 나도 많이 바꿔 봤다 이것저것.. ㅎ 그래서 일단 댓글도 조회 못하게 막아야 된다
private Schedule getScheduleOrThrow(Long scheduleId) {
return scheduleRepository.findByIdAndDeletedAtIsNull(scheduleId).orElseThrow(
() -> new IllegalStateException("일정이 없는데요?")
);
}
@Transactional(readOnly = true)
public List<CommentResponse> findAllBySchedule(Long scheduleId) {
getScheduleOrThrow(scheduleId); // << 얘 추가
List<Comment> comments = commentRepository.findAllBySchedule_IdOrderByCreatedAtAsc(scheduleId);
List<CommentResponse> responses = new ArrayList<>();
for (Comment comment : comments) {
responses.add(toResponse(comment));
}
return responses;
}
그래서 댓글 목록 조회에서 삭제된 일정이면 막기...
댓글까지 soft delete를 할 수도 있는데...................... 회피했다......... 가끔 커뮤니티 같은 것들 보면 삭제된 댓글입니다 이런 것 넣던데 나도 넣을까 하다가 그냥......... 회피했다 다음에 꼭 해 볼게요... 확장을 해야 되잖아요 힘들다
🚩 쿼리 메서드 정리
https://www.notion.so/Spring-Data-JPA-Query-Method-305ad743b3ce80739a42d332ca8aef66?source=copy_link
❗ 트러블 슈팅
https://ggoongdeng.tistory.com/247
20260211 [TIL] 외래키로 매핑되어 있는 부모 테이블 row 삭제
❗문제 상황댓글이 달린 일정을 삭제하려고 DELETE 요청을 보냈더니 삭제가 실패함.응답은 500 에러가 뜸콘솔에 이런 게 찍히는데요.....? FK 관련 오류 아니 삭제하게 해 달라고요원인은...... `comment
ggoongdeng.tistory.com
🚩 느낀 점
이번 과제 끝냈는데... 솔직히 힘 들 엇 다 . 아니 다른 분들은 빠르게 쭉쭉 나가는 것 같은데 나는 코드 한 줄 이해하는 것도 시간이 오래 걸리고 작은 오류 하나로 하루가 그냥 날아가고 그러다 보면 괜히 비교하게 되고.. 강의를 더 듣는 게 맞았을까 그건 또 아닌 것 같고 강의를 나보다 늦게 들으신 분들도 과제는 빨리 끝낸다거나~ 코드는 엄청 빨리 짜신다거나..... 좀 슬펐다 그런데 또 웃긴 게 그런 생각을 하면서도 그래도 키보드는 계속 붙잡고 있었다 해내야죠 뭐.. ~ 페이징이랑 예외처리 종범 튜터님이 알아보라고 했던 soft delete 다른 분들도 구현을 했던데 진짜 방법이 여러가지 있더라.. 다 구현하고 보니 아 저렇게 할걸!! 악!!!!!!!!!!! 하는 경우도 있었고 중간에 고친 부분도 있었고...
코드가 한 번에 딱딱 되는 건 거의 없다. 뭔 이상한 오탈자 하나 이름 하나도 틀리면 안 돌아간다. 뭔가 되면 또 다른 데서 터지고 그걸 찾으면 또 다른 게 이상해지고 뭐 완벽하게 아는 게 애초에 있겠냐..~ 누가 처음부터 다 알고 시작해!!!! 다들 구글링하고!!! AI 패고! 나도 맞고! 또 맞고! 하면서 하는 거지!!!!!!!!!!
곧 팀프로젝트 할 텐데 일단 피해만 안 주고 싶다. 최소한 내가 맡은 건 내가 책임지고 막혀도 숨지 말고 빨리 공유하고 이게 사실 내 기준에서 제일 현실적인 목표다 잘하고 싶고~ 빠르고 싶고~ 똑똑해지고 싶지만~ 그래도 같이 하는 프로젝트에서 안전한 사람이 되는 게 목표다...
멘탈 많이 갈아끼웠다 중간에 그냥 아~ 여기까지만 하고 낼까 싶었는데 다른 분들이 한 것 보니까 자극도 된다.. 어 그래도 끝냄 다음도 버텨라
'IL > WIL' 카테고리의 다른 글
| [WIL] Spring - 일정 관리 앱 만들기 (0) | 2026.02.02 |
|---|---|
| [WIL] - Java 커머스 과제 (0) | 2026.01.16 |
| 20260107 [WIL] 왜 Git을 배우는데 머리가 아플까 (0) | 2026.01.07 |