[내일배움캠프 Spring 3기] CH3 숙련 Spring 일정 관리 앱 Develop 필수 기능

jiiim_ni·2026년 2월 12일

이번 과제는 Spring Boot + JPA + MySQL로 일정(Schedule) CRUD를 만들고,
Lv2부터는 User 연관관계, Lv4에서는 Cookie/Session 기반 로그인 인증까지 적용하는 것이 목표였다.


개발 환경

  • Java 17
  • Spring Boot
  • Spring Data JPA
  • MySQL
  • Validation
  • Lombok
  • Postman

프로젝트 패키지 구조

kr.spartaclub.develop_scheduleapp
 ┣ config
 ┣ controller
 ┣ dto
 ┣ entity
 ┣ repository
 ┗ service

공통 설정: JPA Auditing (createdAt / updatedAt 자동)

Lv1부터 일정 생성/수정 시간을 응답에 포함해야 해서 Auditing을 먼저 잡았다.

1) JPA Auditing 활성화

JpaAuditingConfig.java

  • @EnableJpaAuditing 붙이면 JPA가 엔티티 저장/수정 이벤트를 감지해서 시간을 자동으로 채워준다.
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}

2) BaseTimeEntity

BaseTimeEntity.java

  • @MappedSuperclass : 상속한 엔티티 테이블 컬럼으로 포함됨
  • @EntityListeners(AuditingEntityListener.class) : auditing 이벤트 감지
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

Lv1. 일정 CRUD (username 기반)

Lv1은 유저 테이블이 없어서, 일정에 username을 문자열로 저장하는 방식으로 시작했다.

Lv1 요구사항(요약)

  • 일정 CRUD 구현

    • 생성/전체조회/단건조회/수정/삭제
  • createdAt / updatedAt 응답에 포함

Lv1 설계 포인트

  • 아직 User 테이블이 없으니까 작성자(username)는 문자열로 받는다
  • Controller -> Service -> Repository 흐름으로 분리
  • 엔티티는 DB 구조, DTO는 요청/응답 전용으로 분리

Lv1에서 핵심으로 만든 파일들

  • controller/ScheduleController
  • service/ScheduleService
  • repository/ScheduleRepository
  • entity/Schedule, entity/BaseTimeEntity
  • dto/ScheduleRequest, dto/ScheduleResponse, dto/ScheduleUpdateRequest
  • config/JpaAuditingConfig

코드 흐름(요약)

  1. Controller에서 요청 받음
  2. Service에서 비즈니스 로직 처리(저장/조회/수정/삭제)
  3. Repository(JPA) 로 DB 접근
  4. 결과를 Response DTO로 감싸서 반환

Lv1 DTO/Entity 역할 분리

  • ScheduleRequest: 클라이언트가 보내는 값만 받음(username/title/content)
  • ScheduleUpdateRequest: 수정에 필요한 값만 받음(title/content)
  • ScheduleResponse: 클라이언트에게 내려줄 값만 담음(id/username/title/content/createdAt/updatedAt)

이 분리를 해두면, 나중에 Lv2에서 username을 없애고 userId로 바꾸는 작업이 훨씬 편해짐(실제로 그랬음).


API 목록

  • POST /api/schedules : 일정 생성
  • GET /api/schedules : 전체 조회
  • GET /api/schedules/{id} : 단건 조회
  • PATCH /api/schedules/{id} : 수정
  • DELETE /api/schedules/{id} : 삭제

Postman 검증 순서 (Lv1)

1) 일정 생성

POST /api/schedules

{
  "username": "jimin",
  "title": "첫 일정",
  "content": "CRUD 시작!"
}

2) 전체 조회

GET /api/schedules

3) 단건 조회

GET /api/schedules/{id}

4) 수정

PATCH /api/schedules/{id}

{
  "title": "수정된 제목",
  "content": "수정된 내용"
}

5) 삭제

DELETE /api/schedules/{id}


Lv2. User CRUD + Schedule 연관관계 (userId 기반)

Lv2부터는 일정이 username 문자열을 저장하는게 아니라,
User를 참조(FK)하는 구조로 바꿔야 한다.

  • 기존: Schedule.username (String)
  • 변경: Schedule.user (ManyToOne)

핵심 개념: 연관관계(ManyToOne)

Schedule은 작성자(User)를 가진다.
한 명의 유저가 여러 개의 일정을 작성할 수 있으니까 관계는 이렇게 된다.

  • User 1 : Schedule N
    -> Schedule 입장에서는 ManyToOne

Lv2 요구사항

  • User CRUD 추가
  • 일정 생성 시 유저와 연관관계로 저장
  • 일정 응답에서 username은 User에서 가져온 값이어야 함

Lv2에서 바뀐 점

Schedule이 더 이상 username(String)을 저장하지 않고, user(User)를 참조하도록 변경


Lv2 핵심 변경 1) User 엔티티 추가

  • entity/User 생성
  • 이메일은 unique 처리(중복 가입 방지)
@Entity
@Table(name="users")
public class User extends BaseTimeEntity {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable=false, length=50)
  private String username;

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

Lv2 핵심 변경 2) Schedule 엔티티에 연관관계 추가

  • Schedule.user 필드 추가
  • @ManyToOne + @JoinColumn(name="user_id") 로 FK 생성
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

여기서 실제 DB에 user_id 컬럼이 생기고, schedules 테이블이 users를 참조하게 됨.


Lv2 핵심 변경 3) ScheduleRequest가 userId를 받도록 변경

기존 Lv1:

  • username을 받음

Lv2:

  • userId를 받음
@NotNull(message="userId는 필수입니다.")
private Long userId;

Lv2 핵심 변경 4) Service에서 userId로 User를 조회해서 Schedule 저장

ScheduleService.create()에서:

  1. userRepository.findById(userId) 로 유저 존재 확인
  2. 그 User로 Schedule 생성 후 save
User user = userRepository.findById(request.getUserId())
    .orElseThrow(() -> new IllegalArgumentException("해당 유저가 존재하지 않습니다. id=" + request.getUserId()));

Schedule saved = scheduleRepository.save(new Schedule(user, request.getTitle(), request.getContent()));
return new ScheduleResponse(saved);

User 생성 API (Lv2)

POST /api/users

{
  "username": "jimin",
  "email": "jimin@test.com"
}


Postman 검증 순서 (Lv2)

1) 유저 생성 후 id 확인

  • 응답에서 id=1 같은 값 확인

2) 일정 생성 (userId로)

POST /api/schedules

{
  "userId": 1,
  "title": "Lv2 일정",
  "content": "유저 연관관계로 생성"
}

3) 일정 전체 조회

GET /api/schedules

4) 유저명 수정 후 일정 다시 조회

PATCH /api/users/1

{
  "username": "jimin2",
  "email": "jimin2@test.com"
}

그 다음 GET /api/schedules 다시 실행

  • 일정 응답에서 username이 jimin2로 바뀌어 보이면 Schedule이 username을 저장한게 아니라 User를 참조한다는 증거


Lv3. 유저에 password 포함 / 회원가입 형태 강화

Lv3에서 나는 회원가입 입력에 password를 추가했다.
(이후 Lv4 로그인 구현을 위해 필요)

  • User에 password 컬럼 추가
  • UserRequest DTO에 password 추가

Lv3 요구사항

  • 회원가입 형태 강화(로그인 준비 단계)

Lv3에서 추가된 것

  • User에 password 컬럼 추가
  • DTO(UserRequest 등)에 password 필드 추가 + Validation
@Column(nullable=false)
private String password;

정상 회원가입 테스트

비밀번호 4자리만 입력했을 경우: 오류

-> 오류메시지는 도전 기능에서 구현 예정

비밀번호 입력하지 않았을 경우: 오류

-> 오류메시지는 도전 기능에서 구현 예정

이 단계에서 400이 뜰 수도 있는데,
예외처리는 Lv5에서 하므로 “지금은 400만 확인되면 OK”로 진행했다.


Lv4. 로그인(인증) - Cookie/Session 기반

Lv4 핵심은 세션(HttpSession)을 활용해서 로그인 상태를 유지하는 것.

  • 로그인 성공하면 서버가 세션을 만들고 클라이언트(Postman)에게 JSESSIONID 쿠키를 내려준다
  • 이후 요청마다 클라이언트가 쿠키를 같이 보내면 서버는 아, 이 사용자는 로그인 되어 있구나”를 판단할 수 있다

Lv4 요구사항

  • 이메일 + 비밀번호로 로그인
  • Cookie/Session으로 로그인 상태 유지
  • 필요한 API에서 세션을 활용해서 인증 체크

Lv4에서 추가된 파일들

  • controller/AuthController
  • dto/LoginRequest
  • service/AuthService 또는 UserService에 로그인 로직 추가

Lv4 핵심 구현 1) 로그인 API

  • POST /api/auth/login

  • email/password 검증 성공 시

    • HttpSession에 로그인 유저 id 저장

예시 흐름:

session.setAttribute(LOGIN_USER_ID, user.getId());

Lv4 핵심 구현 2) 로그아웃 API

  • POST /api/auth/logout
  • 세션 무효화
session.invalidate();

Lv4 핵심 구현 3) 일정 생성에 세션 인증 적용

Lv2까지 일정 생성은 userId를 body로 받았는데
Lv4부터는 로그인 세션에서 userId를 꺼내서 사용하도록 변경.

  • 클라이언트는 userId를 보내지 않는다
  • 서버가 세션에서 꺼낸 id로만 생성한다
@PostMapping
public ResponseEntity<ScheduleResponse> create(@RequestBody ScheduleRequest request, HttpSession session) {

    Long loginUserId = (Long) session.getAttribute(AuthController.LOGIN_USER_ID);
    if (loginUserId == null) {
        throw new IllegalArgumentException("로그인이 필요합니다.");
    }

    return ResponseEntity.ok(scheduleService.create(loginUserId, request));
}

Postman 검증 순서 (Lv4)

1) 회원가입 (password 포함)

POST /api/users

{
  "username": "jimin3",
  "email": "jimin3@test.com",
  "password": "11223344"
}

2) 로그인 (세션 쿠키 발급)

POST /api/auth/login

{
  "email": "jimin3@test.com",
  "password": "11223344"
}

응답 헤더에서 Set-Cookie: JSESSIONID=... 확인

3) 일정 생성 (이제 userId 보내지 않음)

POST /api/schedules

{
  "title": "세션 일정",
  "content": "로그인 상태로 생성"
}

4) 로그아웃 후 일정 생성 재시도

POST /api/auth/logout

그 다음 다시 POST /api/schedules 요청


-> 오류 발생하는 것을 확인할 수 있음


트러블슈팅 정리

1) Auditing인데 updatedAt이 안 바뀌는 것처럼 보임

현상

PATCH 후 응답을 봤는데 updatedAtcreatedAt이랑 똑같이 보였다.

원인(가능성)

  • 요청을 너무 빨리 연속으로 보내서 시간이 같은 초로 찍힘
  • 혹은 update가 실제로 flush/dirty checking 되기 전에 응답을 만든 경우

해결/확인 방법

  • Postman에서 수정 요청을 몇 초 텀을 두고 다시 실행
  • DB에서 직접 확인 (select) 혹은 GET 조회로 updatedAt 변화 확인

2) @NotBlank를 Long(userId)에 걸어서 터짐 (HV000030)

현상

No validator could be found for constraint 'NotBlank' validating type 'Long'

원인

  • @NotBlank문자열(String) 전용이다.
  • 숫자(Long)에 쓰면 검증기가 없어서 예외 발생.

해결

  • Long에는 @NotNull을 써야 한다.
@NotNull(message="userId는 필수입니다.")
private Long userId;

3) 500 Internal Server Error로 일정 생성이 실패

현상

Postman에서 일정 생성 시 500이 발생했다.

체크했던 것

  • userId가 실제 존재하는지 (GET /api/users)
  • DB 테이블 구조 변경이 반영되었는지 (user_id 컬럼 존재 여부)
  • Schedule 생성자가 (User, title, content)로 맞게 되어 있는지

결론

대부분 이런 500은

  • 엔티티/DTO 필드 타입 불일치
  • FK 컬럼 미생성/스키마 미반영
  • 존재하지 않는 userId로 생성 시도

이 셋 중 하나였다.


4) 회원가입 시 Duplicate entry (email unique)

현상

Duplicate entry 'jimin2@test.com' for key 'users...'

원인

  • User.email에 unique=true가 걸려있어서
  • 같은 이메일로 다시 회원가입 시 DB에서 막힘

해결

  • 이메일을 다른 값으로 테스트
  • 혹은 기존 유저 레코드 삭제 후 재시도

회고

사실 지난 과제 때는 독감 때문에 제대로 구현하지 못해서 아쉬움이 많이 남았었다.
구조를 깊게 고민하기보다는 일단 돌아가게 만드는 것에 집중할 수밖에 없었고, 완성도에 대한 아쉬움이 컸다.

이번 과제도 시간이 넉넉하진 않았지만,
그래도 저번보다는 훨씬 신경 써서 구조를 잡고, 레벨별 요구사항을 정확히 이해하고, 단계적으로 구현해나갈 수 있었던 점이 스스로 만족스럽다.

특히 이번 과제를 통해 깨달은 점은

  • 처음 설계를 어떻게 하느냐에 따라 이후 수정 난이도가 완전히 달라진다

  • DTO와 Entity를 분리해두는 것이 얼마나 중요한지 체감했다

  • 연관관계를 적용하면 단순 문자열 저장과는 차원이 다르다

  • 세션 인증은 개념으로 알던 것과 실제 구현은 완전히 다르다

  • 에러를 마주쳤을 때 당황하지 않고 하나씩 원인을 좁혀가는 과정이 중요하다

이제 도전 기능을 통해 예외처리와 Validation까지 정리하면서
프로젝트를 더 다듬어보고 싶다.

0개의 댓글