[Spring Data JPA_일정 관리 앱 Develop] 필수 & 도전 기능 구현 기록 🖋️

2025. 2. 13. 13:27·Dev Projects

개요

CH 3 일정 관리 앱 Develop 필수/도전 기능을 구현하면서 고민했던 부분과 트러블 슈팅 과정에 대해 작성해보겠습니다.

 

📌 필수/도전 기능 가이드

더보기

필수 기능

Lv 1. 일정 CRUD

JPA를 이용해 CRUD를 구현했습니다.

이전에 Spring JDBC로 일정 관리 앱을 구현했던 것과 비교했을 때, JPA를 활용하니 더 편리했습니다.

차이점 Spring JDBC Spring Data JPA
SQL 작성 여부 직접 SQL 작성 SQL 작성 불필요
CRUD 메서드 구현 직접 작성 sava, findById 등 기본 제공
조회 결과 RowMapper로 결과 매핑 엔티티 매핑 자동 처리

 

JPA Auditing을 활용한 작성일, 수정일 자동 관리

`@EnableJpaAuditing`을 Application 클래스에 추가해서 JPA Auditing을 사용하도록 했습니다.

@EnableJpaAuditing
@SpringBootApplication
public class SpringJpaScheduleApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringJpaScheduleApplication.class, args);
    }

}

 

공통 필드를 관리할 클래스로 BaseEntity를 생성했습니다.

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

 

BaseEntity를 상속받도록 하여 Schedule 엔티티에서 생성일, 수정일을 관리할 수 있도록 했습니다.

@Getter
@Entity
@Table(name = "schedule")
public class Schedule extends BaseEntity { ... }

Lv 2. 유저 CRUD

Schedule 엔티티와 Member 엔티티가 연관관계를 가지도록 구현했습니다.

@Getter
@Entity
@Table(name = "schedule")
public class Schedule extends BaseEntity {
    /* 일정 필드 생략 */
    @Setter
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;
}


Lv 3. 회원가입

Member 엔티티 필드에 비밀번호를 추가하고, 멤버 등록 API의 매핑을 sign-up으로 변경했습니다.

회원가입에 필요한 RequestDto와 ResponseDto를 생성해서 사용했습니다.


Lv 4. 로그인(인증)

세션 기반 로그인(인증)을 구현했습니다.

📌 전체 흐름

  1. 사용자가 로그인 요청 → AuthController → AuthService
  2. 이메일, 비밀번호 검증 → 로그인 성공 시 세션(Session)에 회원 정보 저장
  3. 로그인 이후 API 접근 시 로그인 여부 확인 → LoginFilter
  4. 세션이 없거나 로그인 정보 없으면 401 응답

 

LoginFilter – API 요청 시 로그인 여부 확인

public class LoginFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { 
        /* 생략 */
    }
}
  • /api/* 경로에 접근할 때 실행
  • 화이트리스트(로그인, 회원가입 등) 외에는 세션 존재 여부 확인
  • 세션 없거나 로그인 정보 없으면 401 Unauthorized 응답 반환

 

WebConfig – 필터 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Bean
    public FilterRegistrationBean<LoginFilter> loginFilter() {
        /* 생략 */
    }
}
  • LoginFilter를 Bean 등록
  • /api/* 경로에 적용

도전 기능

Lv 5. 다양한 예외처리 적용하기

Spring JDBC로 예외처리를 적용했던 것과 마찬가지로 예외 처리를 적용했습니다.

 

Member 엔티티의 email은 로그인시 사용자를 체크하는 필드이기때문에 Unique 제약조건을 추가했습니다.

@Column(nullable = false, unique = true, length = 50)
private String email;


Lv 6. 비밀번호 암호화

BCrypt hashing을 적용해서 비밀번호를 암호화했습니다.


Lv 7. 댓글 CRUD

Member 엔티티를 참조해서 댓글 작성자를 저장하도록하고, Schedule 엔티티를 참조해서 댓글을 단 일정을 알 수 있도록 연관관계를 설정했습니다.

@Getter
@Entity
@Table(name = "comment")
public class Comment extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String contents;

    @Setter
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @Setter
    @ManyToOne
    @JoinColumn(name = "schedule_id")
    private Schedule schedule;
}

Lv 8. 일정 페이징 조회

Pageable과 Page 인터페이스를 활용하여 페이지네이션을 구현했습니다.

 

PaginationResponse

Page<T> 객체의 불필요한 내부 필드를 추려서 반환하기 위해서, PaginationResponse 생성하여 공통 페이징 응답 형식을 적용시켰습니다.

@Getter
public class PaginationResponse<T> {
    private final List<T> content;

    private final int pageNumber;

    private final int pageSize;

    private final long totalElements;

    private final int totalPages;

    private final boolean isLast;

    private final boolean isFirst;

    private final boolean isSorted;

    public PaginationResponse(Page<T> page) {
        this.content = page.getContent();
        this.pageNumber = page.getNumber();
        this.pageSize = page.getSize();
        this.totalElements = page.getTotalElements();
        this.totalPages = page.getTotalPages();
        this.isLast = page.isLast();
        this.isFirst = page.isFirst();
        this.isSorted = page.getSort().isSorted();
    }
}

 

ScheduleDetailResponseDto

일정을 조회할 때,
할일 제목, 할일 내용, 댓글 개수, 일정 작성일, 일정 수정일, 일정 작성 유저명 필드를 조회하도록 반환 Dto를 수정했습니다.

@Getter
@AllArgsConstructor
public class ScheduleDetailResponseDto {
    private final Long id;

    private final String title;

    private final String contents;

    private final String memberName;

    private final long commentCount;

    private final LocalDateTime createdAt;

    private final LocalDateTime updatedAt;
}

 

JPQL(@Query) 활용하여 Join으로 댓글 개수(count) 포함 조회

@Query와 JPQL을 활용하여 JOIN으로 일정(Schedule)과 댓글(Comment) 개수를 한 번에 조회하도록 했습니다.

@Query("SELECT new com.example.schedule.dto.schedule.ScheduleDetailResponseDto(" +
    "s.id, s.title, s.contents, m.name, COUNT(c), s.createdAt, s.updatedAt) " +
    "FROM Schedule s " +
    "JOIN s.member m " +
    "LEFT JOIN Comment c ON c.schedule = s " +
    "GROUP BY s.id, s.title, s.contents, m.name, s.createdAt, s.updatedAt")
Page<ScheduleDetailResponseDto> findAllWithCommentCount(Pageable pageable);
  • new 패키지명.Dto명() 사용 ➡️ DTO로 직접 결과 매핑
  • JOIN 사용 
    • Schedule과 Member JOIN
    • Comment는 LEFT JOIN (Comment가 없는 Schedule도 있기 때문에)
  • COUNT()로 댓글 개수 집계
    • COUNT를 사용하기 위해서 GROUP BY 필요 (모든 필드에 대해서)

추가 기능

목록 조회 시 필터링 추가

일정과 댓글 목록을 조회할 때 RequestParam으로 필터 조건을 전달해주도록 했습니다.

 

Controller

@GetMapping
public ResponseEntity<PaginationResponse<ScheduleDetailResponseDto>> getSchedules(
        @RequestParam(required = false) String title,
        @RequestParam(required = false) String name,
        @RequestParam(required = false) String updatedAt,
        @PageableDefault(size = 10, page = 0, sort = "updatedAt", direction = Sort.Direction.DESC) Pageable pageable
) {
    Page<ScheduleDetailResponseDto> schedulePage = scheduleService.getSchedules(
            title,
            name,
            updatedAt,
            pageable
    );
    PaginationResponse<ScheduleDetailResponseDto> schedulePageResponse = new PaginationResponse<>(schedulePage);

    return new ResponseEntity<>(schedulePageResponse, HttpStatus.OK);
}

 

Repository

@Query("SELECT new com.example.schedule.dto.schedule.ScheduleDetailResponseDto(" +
        "s.id, s.title, s.contents, m.name, COUNT(c), s.createdAt, s.updatedAt) " +
        "FROM Schedule s " +
        "JOIN s.member m " +
        "LEFT JOIN Comment c ON c.schedule = s " +
        "WHERE (:title IS NULL OR s.title LIKE %:title%) " +
        "AND (:name IS NULL OR m.name LIKE %:name%) " +
        "AND (:updatedAt IS NULL OR FUNCTION('DATE_FORMAT', s.updatedAt, '%Y-%m-%d') = :updatedAt) " +
        "GROUP BY s.id, s.title, s.contents, m.name, s.createdAt, s.updatedAt")
Page<ScheduleDetailResponseDto> findAllWithCommentCount(
        @Param("title") String title,
        @Param("name") String name,
        @Param("updatedAt") String updatedAt,
        Pageable pageable
);
  • 일정 제목과 작성자 이름 같은 경우는 LIKE로 조회

 

결과 


본인 확인 로직 추가: 세션의 유저 정보 활용

기존에는 회원, 일정, 댓글을 수정 및 삭제 시 PathVariable로 ID를 직접 받아서 처리했지만,

이 방식은 사용자가 URL에 임의로 ID를 넣을 수 있기 때문에, 로그인한 상태라면 세션에서 사용자 정보를 가져와 활용하도록 수정했습니다.

 

0. AuthService에 사용자 정보 조회 메서드 추가

public Long getLoggedInMemberId(HttpSession session) {
    Long memberId = (Long) session.getAttribute(Const.LOGIN_MEMBER);

    if (memberId == null) {
        throw new UnauthenticatedException();
    }

    return memberId;
}
  • 세션에서 LOGIN_MEMBER 키로 사용자 ID를 가져옴
  • 없으면 예외처리! (401 Unauthorized)

 

1. 회원/일정/댓글 수정 또는 삭제 시 본인 확인

예: 일정 삭제 API 서비스단

@Transactional
public void deleteSchedule(Long id, HttpSession session) {
    Long loggedInMemberId = authService.getLoggedInMemberId(session);
    Schedule findSchedule = scheduleRepository.findByIdOrElseThrow(id);

    if (!findSchedule.getMember().getId().equals(loggedInMemberId)) {
        throw new UnauthorizedException("본인이 작성한 일정만 삭제할 수 있습니다.");
    }

    scheduleRepository.delete(findSchedule);
}
  • 세션에서 로그인한 유저 ID 조회
  • 일정을 작성한 유저 ID와 비교하여 다를경우 예외처리! (403 Forbidden)

 

2. 일정/댓글 작성 시 로그인한 사용자 ID 사용

예: 일정 생성 API 서비스단

@Transactional
public ScheduleResponseDto createSchedule(String title, String contents, HttpSession session) {
    Long loggedInMemberId = authService.getLoggedInMemberId(session);
    Member findMember = memberRepository.findByIdOrElseThrow(loggedInMemberId);

    Schedule schedule = new Schedule(title, contents);
    schedule.setMember(findMember);

    Schedule savedSchedule = scheduleRepository.save(schedule);

    return ScheduleResponseDto.toDto(savedSchedule);
}
  • 세션에서 로그인한 사용자 ID를 가져와서 작성한 유저 ID로 설정

 

3. 유저 수정/삭제 시 PathVariable 제거

예: 유저 삭제 API 컨트롤러단

  • PathVariable로 id를 전달하던것을 제거하고, session만 전달해서 현재 로그인한 사용자를 삭제하도록 수정

@Transactional 어노테이션 적용

Transactional 어노테이션을 서비스의 모든 메서드에 적용하는게 맞는지 헷갈려서 내용을 정리하고 적용해봤습니다.

2025.02.12 - [Java Study/Frameworks] - [Spring] @Transactional을 어디에 붙여야 할까🤔❔

 

[Spring] @Transactional을 어디에 붙여야 할까🤔❔

Spring JPA로 일정 관리 앱을 만들어보면서, @Transactionla을 붙이는 기준을 공부해봤다. @Transactional`@Transactional`은 데이터베이스의 일관성을 보장하기 위해 사용된다.쓰기(INSERT, UPDATE, DELETE) 연

mannakingdom.tistory.com


해결 못한 부분

1. 수정 후 updatedAt이 반영이 안되는 문제

Schedule 엔티티에 updateSchedule 메서드를 호출해서, 제목과 내용을 변경하고, 변경된 엔티티를 다시 조회하여 응답하도록 했습니다.

그런데 실행해보니 일정 수정 API의 response로 updatedAt이 반영이 안된걸 확인했습니다.

@Transactional
public ScheduleResponseDto updateSchedule(Long id, ScheduleUpdateRequestDto requestDto, HttpSession session) {
    Long loggedInMemberId = authService.getLoggedInMemberId(session);
    Schedule findSchedule = scheduleRepository.findByIdOrElseThrow(id);

    if (!findSchedule.getMember().getId().equals(loggedInMemberId)) {
        throw new UnauthorizedException("본인이 작성한 일정만 수정할 수 있습니다.");
    }

    findSchedule.updateSchedule(requestDto.getTitle(), requestDto.getContents());

    Schedule updatedSchedule = scheduleRepository.findByIdOrElseThrow(id);

    return ScheduleResponseDto.toDto(updatedSchedule);
}

수정할 일정
수정 API response와 조회 API reponse의 updatedAt 차이

 

JPA의 영속성 컨텍스트 때문에 변경된 엔티티를 조회하는게 의미가 없나 싶어서 (그냥 추측입니다,,)

수정후 엔티티를 따로 조회하지 않고 그냥 반환해도 똑같이 updatedAt이 반영이 안됩니다.

@Transactional
public ScheduleResponseDto updateSchedule(Long id, ScheduleUpdateRequestDto requestDto, HttpSession session) {
    Long loggedInMemberId = authService.getLoggedInMemberId(session);
    Schedule findSchedule = scheduleRepository.findByIdOrElseThrow(id);

    if (!findSchedule.getMember().getId().equals(loggedInMemberId)) {
        throw new UnauthorizedException("본인이 작성한 일정만 수정할 수 있습니다.");
    }

    findSchedule.updateSchedule(requestDto.getTitle(), requestDto.getContents());
    
    return ScheduleResponseDto.toDto(findSchedule);
}

수정할 일정
수정 API response와 조회 API reponse의 updatedAt 차이

아직 왜 반영이 안되는지 확인을 못했습니다🥲

확인 후 수정할 예정입니다 ✅,,,


2. 연관관계 (양방향? 단방향?)

멤버(Member), 일정(Schedule), 댓글(Comment) 엔티티가 서로 연관관계가 있는데, 
멤버에 작성된 일정이나 댓글이 있으면 멤버 삭제가 되지 않아서 ON DELETE CASCADE를 적용하려고 cascade = REMOVE와 orphanRemoval = true를 사용하여 양방향 연관관계를 설정했습니다.

@OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Schedule> schedules = new ArrayList<>();

@OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

 

그런데 과제 설명을 다시 보니 "JPA 연관관계는 단방향입니다. 정말 필요한 경우에만 양방향 적용!" 이라는 안내 문구를 확인하게 되었습니다.

 

단방향 관계를 유지하면서도, 멤버 삭제 시 관련 일정이나 댓글을 삭제하는 일반적인 처리 방식을 알아봐야겠습니다 🤓


마무리

헷갈리고 모르겠는 내용이 많아서 나름 정리하면서 했는데도 뇌에 차곡차곡 안쌓여서 괴롭지만,,,

과제 완성하고 나니 확실히 처음보다는 뭔가 알 것 같은 느낌이 들기도 합니다. 야호〰️

 

시간 부족으로 해결하지 못한 부분들을 공부/질문 해보고 과제 피드백도 반영하여 개선을 꼭 해보겠습니다❗❗

 

https://github.com/mannaKim/spring-jpa-schedule

 

GitHub - mannaKim/spring-jpa-schedule: 일정 관리 앱 Develop - Spring JPA

일정 관리 앱 Develop - Spring JPA. Contribute to mannaKim/spring-jpa-schedule development by creating an account on GitHub.

github.com

저작자표시 비영리 변경금지 (새창열림)

'Dev Projects' 카테고리의 다른 글

[table-now] Refresh Token 저장소를 Redis로 교체하며 인증 구조 고도화하기  (0) 2025.04.21
[table-now] 소셜 로그인(Kakao, Naver) 통합 구현기  (1) 2025.04.21
[Spring JDBC_일정 관리 앱 만들기] 회고 및 트러블 슈팅  (1) 2025.02.04
[Java Project_키오스크 과제] 도전 기능 구현 및 트러블 슈팅  (0) 2025.01.20
[Java Project_키오스크 과제] 필수 기능 구현 및 단계별 설계  (0) 2025.01.15
'Dev Projects' 카테고리의 다른 글
  • [table-now] Refresh Token 저장소를 Redis로 교체하며 인증 구조 고도화하기
  • [table-now] 소셜 로그인(Kakao, Naver) 통합 구현기
  • [Spring JDBC_일정 관리 앱 만들기] 회고 및 트러블 슈팅
  • [Java Project_키오스크 과제] 도전 기능 구현 및 트러블 슈팅
기만나🐸
기만나🐸
공부한 내용을 기록합시다 🔥🔥🔥
  • 기만나🐸
    기만나의 공부 기록 🤓
    기만나🐸
  • 전체
    오늘
    어제
    • ALL (147)
      • TIL (Today I Learned) (56)
      • Dev Projects (15)
      • Algorithm Solving (67)
        • Java (52)
        • SQL (15)
      • Certifications (8)
        • 정보처리기사 실기 (8)
  • 인기 글

  • 태그

    다이나믹프로그래밍
    java
    백준
    jQuery
    BFS
    자료구조
    시뮬레이션
    Subquery
    CSS
    Google Fonts
    greedy
    join
    javascript
    jwt
    백트래킹
    그리디
    GROUP BY
    BOJ
    bootstrap
    완전탐색
    programmers
    dp
    프로그래머스
    websocket
    sql
    Firebase
    DFS
    mysql
    jpa
    HTML
  • 최근 글

  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.3
기만나🐸
[Spring Data JPA_일정 관리 앱 Develop] 필수 & 도전 기능 구현 기록 🖋️
상단으로

티스토리툴바