CH.3 일정 관리 앱 develop 과제

정예진·2026년 4월 22일

Spring

목록 보기
14/20

Entity

BaseEntity

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

@MappedSuperclass

"이 클래스는 테이블로 만들지 말고, 이 클래스를 상속받는 엔티티들한테 필드만 물려줘" 라는 뜻

BaseEntity@Entity로 만들면 base_entity 테이블이 생겨버림. 그건 원하는 게 아니니까 @MappedSuperclass로 "나는 테이블 없이 필드만 공유하는 부모야" 라고 선언

abstract

Java에서 추상 클래스를 만들 때 쓰는 키워드로, 직접 객체를 생성할 수 없게 만들고 상속받아서만 사용 가능하게 하는 것

BaseEntity는 단독으로 쓸 이유가 없고 항상 상속받아서만 쓰는 클래스. 그래서 abstract를 붙여서 "나는 혼자 쓰는 클래스가 아니야!" 라고 명시함.

@Column(updatable = false)
DB 컬럼 설정으로 updatable = false는 "이 컬럼은 처음 INSERT할 때만 값 넣고, 이후에 UPDATE할 때는 건드리지 마" 라는 뜻

createdAt은 생성될 때 한번만 찍히고 이후에 절대 바뀌면 안 됨. 그래서 이걸 붙여서 실수로도 수정이 안 되게 막는 것

User

@Getter
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    private String password;

    public User(String name, String email, String password) {
        this.name = name;
        this.email = email;
        this.password = password;
    }

    public void update(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

Schedule

@Getter
@Entity
@Table(name = "schedules")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Schedule extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String content;
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @OneToMany(mappedBy = "schedule")
    private List<Comment> commentList = new ArrayList<>();

    public Schedule(String title, String content, User user) {
        this.title = title;
        this.content = content;
        this.user = user;
    }

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

@ManyToOne(fetch = FetchType.LAZY, optional = false)

@ManyToOne → 일정 여러 개 : 유저 한 명 관계

fetch = FetchType.LAZY → 일정 조회할 때 User를 즉시 안 가져오고 실제로 user 필드를 사용할 때만 가져옴 (성능상 좋음)

optional = false → user가 null이면 안 된다는 뜻, 일정은 반드시 유저가 있어야 함.

@JoinColumn(name = "user_id", nullable = false)

DB에서 user_id 컬럼으로 연결한다는 뜻

nullable = false → DB 레벨에서도 null 허용 안 함

@OneToMany(mappedBy = "schedule")

@OneToMany → 일정 한 개 : 댓글 여러 개 관계

mappedBy = "schedule" → "연관관계의 주인은 내가 아니라 Comment 엔티티의 schedule 필드야" 라는 뜻

User user 추가

일정 생성 시 반드시 어떤 유저의 일정인지 알아야하니까 user 객체를 받아서 넣음.

Comment

@Getter
@Entity
@Table(name = "comments")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "schedule_id", nullable = false)
    private Schedule schedule;

    public Comment(String content, User user, Schedule schedule) {
        this.content = content;
        this.user = user;
        this.schedule = schedule;
    }
}

@ManyToOne(fetch = FetchType.LAZY, optional = false)

@ManyToOne → 댓글 여러 개 : 유저 한 명 관계

@ManyToOne → 댓글 여러 개 : 일정 한 개 관계

User user 추가

댓글 생성 시 반드시 어떤 유저의 댓글인지 알아야하니까 user 객체를 받아서 넣음.

Schedule schedule 추가

댓글 생성 시 반드시 어떤 일정의 댓글인지 알아야하니까 user 객체를 받아서 넣음.

Repository

UserRepository

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

JpaRepository 를 상속받으면 findById, findAll, save, delete 같은 기본 메서드들은 자동으로 제공되지만, 이메일로 유저 찾기는 기능에 없기 때문에 직접 만들어야하는데, JPA가 메서드 이름을 분석해서 자동으로 쿼리를 만들어줌.

Optional findByEmail(String email);

Optional → Java에서 값이 있을 수도 있고 없을 수도 있을 때 쓰는 wrapper 클래스로, 이걸 그냥 User로 반환하면 없을 때 null이 반환되는데, null을 그냥 쓰면 NullPointerException이 터질 수있음. 그래서 Optional<User>로 감싸서 반환하면 값이 있으면 User 꺼내고, 없으면 예외를 던짐.

findBy → 뒤에 필드명을 붙이면 JPA가 알아서 해당 컬럼(email)으로 조회하는 쿼리를 만들어서, String email 값으로 변환해줌.

ScheduleRepository

public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
}

CommentRepository

public interface CommentRepository extends JpaRepository<Comment, Long> {
}

Service

UserService

필드

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

private final PasswordEncoder passwordEncoder;

회원가입을 할 때 비밀번호를 암호화해야하고, 로그인할 때 입력한 비밀번호랑 DB에 저장된 암호화된 비밀번호를 비교해야하기 때문에 passwordEncoder를 필드로 가지고 있음.

PasswordEncoder

@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;
    }
}

@Component

Spring한테 “이 클래스를 Bean으로 등록해줘”라는 뜻

그래야 UserService 에서 @RequiredArgsconstructor 로 주입받아서 쓸 수 있음.

encode() 메서드

회원가입할 때 사용자가 입력한 비밀번호를 암호화해서 저장하는 메서드

rawPassword → 클라이언트가 보낸 평문 비밀번호

BCrypt → 비밀번호를 안전하게 저장하기 위한 해시 알고리즘으로, 비밀번호를 원래 값으로 되돌릴 수 없게 변환

BCrypt.withDefaults() → BCrypt 객체를 기본 옵션으로 가져옴.

.hashToString → 그 객체를 이용해서 비밀번호를 해시 문자열로 만듦.

BCrypt.MIN_COST → “얼마나 복잡하게 계산할거냐”, “얼마나 느리게 해시할 거냐”를 정하는 값으로, 현재 값은 BCrypt에서 가장 낮은 비용 값임. (가장 가볍고, 가장 빠름. 보안 강도는 상대적으로 낮은 편)

rawPassword.toCharArray() → 사용자가 입력한 비밀번호 문자열을 char 배열로 바꾸는 것

.hashToString(BCrypt.MIN_COST, rawPassword.toCharArray()) → 비밀번호를 BCrypt 방식으로 해시해서 문자열로 만듦.

matches() 메서드
로그인 할 때 입력한 비밀번호가 저장된 암호화 비밀번호와 맞는지 검사하는 메서드

BCrypt.verifyer() → 비밀번호 검사용 BCrypt 객체를 가져오는 것 (도구 꺼내오기)

.verify → 비밀번호가 맞는지 검사

.verify(rawPassword.toCharArray(), encodedPassword) → 사용자가 입력한 원본 비밀번호랑 암호화해서 저장되있는 비밀번호랑 비교

BCrypt.Result result → 검사를 완료한 후 그 결과를 객체형태로 result에 담음.

return result.verified; → 검사 성공 여부로, 돌려줘야하는 값은 참/거짓 이기 때문에 결과를 참/거짓으로 변환해서 반환

회원가입 (유저 생성) 로직

@Transactional
    public UserCreateResponseDto save(UserCreateRequestDto requestDto) {
        String encodedPassword = passwordEncoder.encode(requestDto.getPassword());

        User user = new User(
                requestDto.getName(),
                requestDto.getEmail(),
                encodedPassword
        );

        User savedUser = userRepository.save(user);

        return new UserCreateResponseDto(
                savedUser.getId(),
                savedUser.getName(),
                savedUser.getEmail(),
                savedUser.getCreatedAt()
        );
    }

로그인 로직

@Transactional(readOnly = true)
    public UserSessionDto login(UserLoginRequestDto requestDto) {
        User user = userRepository.findByEmail(requestDto.getEmail()).orElseThrow(
                InvalidCredentialsException::new
        );
        if (!passwordEncoder.matches(requestDto.getPassword(), user.getPassword())) {
            throw new InvalidCredentialsException();
        }
        return new UserSessionDto(user.getId(), user.getEmail());
    }

유저 다건 조회 로직

@Transactional(readOnly = true)
    public List<UserGetAllResponseDto> getAll() {

        List<User> userList = userRepository.findAll();

        return userList.stream()
                .map(user -> new UserGetAllResponseDto(
                        user.getId(),
                        user.getName()
                ))
                .collect(Collectors.toList());
    }

유저 단건 조회 로직

@Transactional(readOnly = true)
    public UserGetOneResponseDto getOne(Long userId) {
        User user = userRepository.findById(userId).orElseThrow(
                UserNotFoundException::new
        );

        return new UserGetOneResponseDto(
                user.getId(),
                user.getName(),
                user.getEmail(),
                user.getCreatedAt(),
                user.getUpdatedAt()
        );
    }

유저 수정 로직

@Transactional
    public UserUpdateResponseDto update(Long userId, UserUpdateRequestDto requestDto) {
        User user = userRepository.findById(userId).orElseThrow(
                UserNotFoundException::new
        );

        user.update(requestDto.getName(), requestDto.getEmail());

        return new UserUpdateResponseDto(
                user.getId(),
                user.getName(),
                user.getEmail(),
                user.getUpdatedAt()
        );
    }

유저 삭제 로직

@Transactional
    public void delete(Long userId) {
        boolean existence = userRepository.existsById(userId);
        if (!existence) {
            throw new UserNotFoundException();
        }
        userRepository.deleteById(userId);
    }

ScheduleService

필드

@Service
@RequiredArgsConstructor
public class ScheduleService {

    private final ScheduleRepository scheduleRepository;
    private final UserRepository userRepository;

private final UserRepository userRepository;

유저가 존재해야 일정이 존재할 수 있으므로 SchduleService는 UserRepository를 필드로 가짐.

일정 생성 로직

@Transactional
    public ScheduleCreateResponseDto save(ScheduleCreateRequestDto requestDto) {
        User user = userRepository.findById(requestDto.getUserId()).orElseThrow(
                UserNotFoundException::new
        );

        Schedule schedule = new Schedule(
                requestDto.getTitle(),
                requestDto.getContent(),
                user
        );
        Schedule savedSchedule = scheduleRepository.save(schedule);

        return new ScheduleCreateResponseDto(
                savedSchedule.getId(),
                savedSchedule.getTitle(),
                savedSchedule.getContent(),
                savedSchedule.getUser().getId(),
                savedSchedule.getCreatedAt()
        );
    }

일정 다건 조회 로직

@Transactional(readOnly = true)
    public List<ScheduleGetAllResponseDto> getAll() {

        List<Schedule> scheduleList = scheduleRepository.findAll();

        return scheduleList.stream()
                .map(schedule -> new ScheduleGetAllResponseDto(
                        schedule.getId(),
                        schedule.getTitle(),
                        schedule.getUser().getId(),
                        schedule.getCreatedAt()
                ))
                .collect(Collectors.toList());
    }

일정 단건 조회 로직

@Transactional(readOnly = true)
    public ScheduleGetOneResponseDto getOne(Long scheduleId) {
        Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(
                ScheduleNotFoundException::new
        );

        return new ScheduleGetOneResponseDto(
                schedule.getId(),
                schedule.getTitle(),
                schedule.getContent(),
                schedule.getUser().getId(),
                schedule.getCreatedAt(),
                schedule.getUpdatedAt()
        );
    }

일정 수정 로직

@Transactional
    public ScheduleUpdateResponseDto update(Long scheduleId, ScheduleUpdateRequestDto requestDto) {
        Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(
                ScheduleNotFoundException::new
        );

        schedule.update(requestDto.getTitle(), requestDto.getContent());

        return new ScheduleUpdateResponseDto(
                schedule.getId(),
                schedule.getTitle(),
                schedule.getContent(),
                schedule.getUser().getId(),
                schedule.getUpdatedAt()
        );
    }

일정 삭제 로직

@Transactional
    public void delete(Long scheduleId) {
        boolean existence = scheduleRepository.existsById(scheduleId);
        if (!existence) {
            throw new ScheduleNotFoundException();
        }
        scheduleRepository.deleteById(scheduleId);
    }

일정 페이지 조회 로직

@Transactional(readOnly = true)
    public Page<SchedulePageResponseDto> getPage(int page, int size) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
        return scheduleRepository.findAll(pageable)
                .map(schedule -> new SchedulePageResponseDto(
                        schedule.getTitle(),
                        schedule.getContent(),
                        schedule.getCommentList().size(),
                        schedule.getCreatedAt(),
                        schedule.getUpdatedAt(),
                        schedule.getUser().getName()
                ));
    }

Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));

PageRequest.of → 페이징 조건 객체를 만드는 코드

page → 몇 번째 페이지

size → 한 페이지에 몇 개

Sort.by → 정렬 조건

Sort.Direction.DESC → 내림차순 정렬 (ASC → 오름차순)

"updatedAt" → 정렬 기준 필드명 (즉, 수정일자 기준으로 정렬)

CommnetService

필드

@Service
@RequiredArgsConstructor
public class CommentService {

    private final CommentRepository commentRepository;
    private final UserRepository userRepository;
    private final ScheduleRepository scheduleRepository;

댓글은 유저가 존재해야만 작성될 수 있고, 일정이 존재해야만 작성 할 수 있기때문에 필드로 UserRepository와 ScheduleRepository를 가짐.

댓글 생성 로직

@Transactional
    public CommentCreateResponseDto save(CommentCreateRequestDto requestDto) {
        User user = userRepository.findById(requestDto.getUserId()).orElseThrow(
                UserNotFoundException::new
        );

        Schedule schedule = scheduleRepository.findById(requestDto.getScheduleId()).orElseThrow(
                ScheduleNotFoundException::new
        );

        Comment comment = new Comment(
                requestDto.getContent(),
                user,
                schedule
        );

        Comment savedComment = commentRepository.save(comment);

        return new CommentCreateResponseDto(
                savedComment.getId(),
                savedComment.getSchedule().getId(),
                savedComment.getUser().getId(),
                savedComment.getContent(),
                savedComment.getCreatedAt()
        );
    }

댓글 다건 조회 로직

@Transactional(readOnly = true)
    public List<CommentGetAllResponseDto> getAll() {

        List<Comment> commentList = commentRepository.findAll();

        return commentList.stream()
                .map(comment -> new CommentGetAllResponseDto(
                        comment.getId(),
                        comment.getSchedule().getId(),
                        comment.getUser().getId(),
                        comment.getContent(),
                        comment.getCreatedAt(),
                        comment.getUpdatedAt()
                ))
                .collect(Collectors.toList());
    }

Controller

UserController

필드

@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

회원가입 (유저 생성) API

@PostMapping
    public ResponseEntity<UserCreateResponseDto> userCreate(@Valid @RequestBody UserCreateRequestDto requestDto) {
        return ResponseEntity.status(HttpStatus.CREATED).body(userService.save(requestDto));
    }

UserCreateRequestDto

@Getter
public class UserCreateRequestDto {

    @NotBlank(message = "이름은 필수입니다.")
    @Size(max = 5, message = "이름은 5글자 이내여야 합니다.")
    private String name;

    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "이메일 형식이 올바르지 않습니다.")
    private String email;

    @NotBlank(message = "비밀번호는 필수입니다.")
    @Size(min = 8, message = "비밀번호는 최소 8글자 이상이여야 합니다.")
    private String password;
}

UserCreateResponseDto

@Getter
@RequiredArgsConstructor
public class UserCreateResponseDto {

    private final Long id;
    private final String name;
    private final String email;
    private final LocalDateTime createdAt;
}

로그인 API

@PostMapping("/login")
    public ResponseEntity<String> login(@Valid @RequestBody UserLoginRequestDto requestDto, HttpSession session) {
        UserSessionDto userSessionDto = userService.login(requestDto);
        session.setAttribute("loginUser", userSessionDto);
        return ResponseEntity.ok("로그인 성공!");
    }

session.setAttribute("loginUser", userSessionDto);

.setAttribute("loginUser", userSessionDto) → 세션 안에 값을 저장하는 메서드로, (키 : ”이름”, 값)

UserLoginRequestDto

@Getter
public class UserLoginRequestDto {

    @NotBlank(message = "이메일을 입력해주세요.")
    @Email(message = "이메일 형식이 올바르지 않습니다.")
    private String email;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;
}

UserSessionDto

@Getter
@RequiredArgsConstructor
public class UserSessionDto {

    private final Long id;
    private final String email;
}

로그아웃 API

@PostMapping("/logout")
    public ResponseEntity<String> logout(HttpSession session) {
        session.invalidate();
        return ResponseEntity.ok("로그아웃 성공!");
    }

session.invalidate();

session.invalidate() → 현재 세션을 삭제하는 메서드

유저 다건 조회 API

@GetMapping
    public ResponseEntity<List<UserGetAllResponseDto>> userGetAll() {
        return ResponseEntity.ok(userService.getAll());
    }

UserGetAllResponseDto

@Getter
@RequiredArgsConstructor
public class UserGetAllResponseDto {

    private final Long id;
    private final String name;
}

유저 단건 조회 API

@GetMapping("/{userId}")
    public ResponseEntity<UserGetOneResponseDto> userGetOne(@PathVariable Long userId) {
        return ResponseEntity.ok(userService.getOne(userId));
    }

UserGetOneResponseDto

@Getter
@RequiredArgsConstructor
public class UserGetOneResponseDto {

    private final Long id;
    private final String name;
    private final String email;
    private final LocalDateTime createdAt;
    private final LocalDateTime updatedAt;
}

유저 수정 API

@PutMapping("/{userId}")
    public ResponseEntity<UserUpdateResponseDto> userUpdate(@PathVariable Long userId, @Valid @RequestBody UserUpdateRequestDto requestDto, HttpSession session) {
        UserSessionDto loginUser = (UserSessionDto) session.getAttribute("loginUser");

        if (session.getAttribute("loginUser") == null) {
            throw new UnauthorizedException();
        }
        if (!loginUser.getId().equals(userId)) {
            throw new ForbiddenException();
        }
        return ResponseEntity.ok(userService.update(userId, requestDto));
    }

UserUpdateRequestDto

@Getter
public class UserUpdateRequestDto {

    @NotBlank(message = "이름은 필수입니다.")
    @Size(max = 5, message = "이름은 5글자 이내여야 합니다.")
    private String name;

    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "이메일 형식이 올바르지 않습니다.")
    private String email;
}

UserUpdateResponseDto

@Getter
@RequiredArgsConstructor
public class UserUpdateResponseDto {

    private final Long id;
    private final String name;
    private final String email;
    private final LocalDateTime updatedAt;
}

유저 삭제 API

@DeleteMapping("/{userId}")
    public ResponseEntity<Void> userDelete(@PathVariable Long userId, HttpSession session) {
        UserSessionDto loginUser = (UserSessionDto) session.getAttribute("loginUser");

        if (loginUser == null) {
            throw new ForbiddenException();
        }
        if (session.getAttribute("loginUser") == null) {
            throw new UnauthorizedException();
        }
        userService.delete(userId);
        return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
    }

ScheduleController

필드

@RestController
@RequiredArgsConstructor
@RequestMapping("/schedules")
public class ScheduleController {

    private final ScheduleService scheduleService;

일정 생성 API

@PostMapping
    public ResponseEntity<ScheduleCreateResponseDto> scheduleCreate(@Valid @RequestBody ScheduleCreateRequestDto requestDto, HttpSession session) {
        if (session.getAttribute("loginUser") == null) {
            throw new UnauthorizedException();
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(scheduleService.save(requestDto));
    }

ScheduleCreateRequestDto

@Getter
public class ScheduleCreateRequestDto {

    @NotBlank(message = "제목은 필수입니다.")
    @Size(max = 10, message = "제목은 10글자 이내여야 합니다.")
    private String title;

    private String content;

    @NotNull(message = "유저 아이디는 필수입니다.")
    private Long userId;
}

ScheduleCreateResponseDto

@Getter
@RequiredArgsConstructor
public class ScheduleCreateResponseDto {

    private final Long id;
    private final String title;
    private final String content;
    private final Long userId;
    private final LocalDateTime createdAt;
}

일정 다건 조회 API

@GetMapping
    public ResponseEntity<List<ScheduleGetAllResponseDto>> scheduleGetAll() {
        return ResponseEntity.ok(scheduleService.getAll());
    }

ScheduleGetAllResponseDto

@Getter
@RequiredArgsConstructor
public class ScheduleGetAllResponseDto {

    private final Long id;
    private final String title;
    private final Long userId;
    private final LocalDateTime createdAt;
}

일정 단건 조회 API

@GetMapping("/{scheduleId}")
    public ResponseEntity<ScheduleGetOneResponseDto> scheduleGetOne(@PathVariable Long scheduleId) {
        return ResponseEntity.ok(scheduleService.getOne(scheduleId));
    }

ScheduleGetOneResponseDto

@Getter
@RequiredArgsConstructor
public class ScheduleGetOneResponseDto {

    private final Long id;
    private final String title;
    private final String content;
    private final Long userId;
    private final LocalDateTime createdAt;
    private final LocalDateTime updatedAt;
}

일정 수정 API

@PutMapping("/{scheduleId}")
    public ResponseEntity<ScheduleUpdateResponseDto> scheduleUpdate(@PathVariable Long scheduleId, @Valid @RequestBody ScheduleUpdateRequestDto requestDto, HttpSession session) {
        if (session.getAttribute("loginUser") == null) {
            throw new UnauthorizedException();
        }
        return ResponseEntity.ok(scheduleService.update(scheduleId, requestDto));
    }

ScheduleUpdateRequestDto

@Getter
public class ScheduleUpdateRequestDto {

    @NotBlank(message = "제목은 필수입니다.")
    @Size(max = 10, message = "제목은 10글자 이내여야 합니다.")
    private String title;

    private String content;
}

ScheduleUpdateResponseDto

@Getter
@RequiredArgsConstructor
public class ScheduleUpdateResponseDto {

    private final Long id;
    private final String title;
    private final String content;
    private final Long userId;
    private final LocalDateTime updatedAt;
}

일정 삭제 API

@DeleteMapping("/{scheduleId}")
    public ResponseEntity<Void> scheduleDelete(@PathVariable Long scheduleId, HttpSession session) {
        if (session.getAttribute("loginUser") == null) {
            throw new UnauthorizedException();
        }
        scheduleService.delete(scheduleId);
        return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
    }

일정 페이지 조회 API

@GetMapping("/page")
    public ResponseEntity<Page<SchedulePageResponseDto>> getPage(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        return ResponseEntity.ok(scheduleService.getPage(page, size));
    }

CommentController

필드

@RestController
@RequiredArgsConstructor
@RequestMapping("/comments")
public class CommentController {

    private final CommentService commentService;

댓글 생성 API

@PostMapping
    public ResponseEntity<CommentCreateResponseDto> commentCreate(@Valid @RequestBody CommentCreateRequestDto requestDto, HttpSession session) {
        if (session.getAttribute("loginUser") == null) {
            throw new UnauthorizedException();
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(commentService.save(requestDto));
    }

CommentCreateRequestDto

@Getter
public class CommentCreateRequestDto {

    @NotNull(message = "일정 아이디는 필수입니다.")
    private Long scheduleId;

    @NotNull(message = "유저 아이디는 필수입니다.")
    private Long userId;

    @NotBlank(message = "댓글 내용은 필수입니다.")
    private String content;
}

CommentCreateResponseDto

@Getter
@RequiredArgsConstructor
public class CommentCreateResponseDto {

    private final Long id;
    private final Long scheduleId;
    private final Long userId;
    private final String content;
    private final LocalDateTime createdAt;
}

댓글 다건 조회 API

@GetMapping
    public ResponseEntity<List<CommentGetAllResponseDto>> commentGetAll() {
        return ResponseEntity.ok(commentService.getAll());
    }

CommentGetAllREsponseDto

@Getter
@RequiredArgsConstructor
public class CommentGetAllResponseDto {

    private final Long id;
    private final Long scheduleId;
    private final Long userId;
    private final String content;
    private final LocalDateTime createdAt;
    private final LocalDateTime updatedAt;
}

Exception

ServiceException

@Getter
public class ServiceException extends RuntimeException {

    private final HttpStatus status;

    public ServiceException(HttpStatus status, String message) {
        super(message);
        this.status = status;
    }
}

서비스에서 발생하는 예외에 “상태코드”와 “메시지”를 함께 담기 위한 공통 예외 클래스

extends RuntimeException

RuntimeException → 언체크 예외라서 throws를 안 써도 됨. 그래서 스프링에서는 보통 서비스 예외를 만들 때 RuntimeException을 많이 상속함.

private final HttpStatus status;

HttpStatus → 스프링에서 제공하는 HTTP 상태코드 (enum)

status → 예외가 발생했을 때 어떤 HTTP 상태코드로 응답할지 저장하는 필드

생성자 부분

super(message) → 부모 클래스인 RuntimeException한테 메시지를 넘겨줌.

this.status = status → 매개변수로 받은 상태코드를 현재 객체 필드에 저장함.

UserNotFoundException

public class UserNotFoundException extends ServiceException {
    public UserNotFoundException() {
        super(HttpStatus.NOT_FOUND, "해당 유저가 존재하지 않습니다.");
    }
}

ScheduleNotFoundException

public class ScheduleNotFoundException extends ServiceException {
    public ScheduleNotFoundException() {
        super(HttpStatus.NOT_FOUND, "해당 일정이 존재하지 않습니다.");
    }
}

InvalidCredentialsException

public class InvalidCredentialsException extends ServiceException {
    public InvalidCredentialsException() {
        super(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 일치하지 않습니다.");
    }
}

UnauthorizedException

public class UnauthorizedException extends ServiceException {
    public UnauthorizedException() {
        super(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
    }
}

ForbiddenException

public class ForbiddenException extends ServiceException {
    public ForbiddenException() {
        super(HttpStatus.FORBIDDEN, "본인만 수정/삭제할 수 있습니다.");
    }
}

Handler

GlobalExceptionHandler

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ServiceException.class)
    public ResponseEntity<String> handleServiceException(ServiceException ex) {
        return ResponseEntity.status(ex.getStatus()).body(ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
        String errorMessage = ex.getBindingResult().getFieldErrors().stream()
                .findFirst()
                .map(fieldError -> fieldError.getDefaultMessage())
                .orElse("입력 값이 올바르지 않습니다.");
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorMessage);
    }
}

전역 예외 처리 클래스로, 프로젝트 전체에서 발생한 예외를 한 곳에서 처리

handleServiceException 메서드

@RestControllerAdvice → 애플리케이션 전체에서 발생하는 예외를 한 곳에서 처리해주는 전역 설정 어노테이션으로, 모든 @RestController에 적용됨. (어디서 예외가 터지든 여기로 옴.)

@ExceptionHandler → 특정 예외가 발생했을 때 실행할 메서드를 지정하는 어노테이션

@ExceptionHandler(ServiceException.class) → ServiceException이 발생하면 이 메서드가 대신 처리함.

ResponseEntity<String> → HTTP 응답 객체를 반환하고, 응답 본문(body)이 문자열이라는 뜻

handleMethodArgumentNotValidException 메서드

@ExceptionHandler(MethodArgumentNotValidException.class) → MethodArgumentNotValidException이 발생하면 이 메서드가 대신 처리함.

ex.getBindingResult() → 검증 결과 전체를 가져옴.

.getFieldErrors() → 필드 단위 에러들만 꺼내는 것 (목록처럼 들고 있음.)

.findFirst() → 첫 번째 에러 하나만 가져옴.

.map(fieldError -> fieldError.getDefaultMessage()) → findFirst()로 꺼낸 첫 번째 필드 에러 객체에 들어있는 기본 메시지를 꺼냄.

0개의 댓글