이번 과제는 Spring Boot + JPA + MySQL로 일정(Schedule) CRUD를 만들고,
Lv2부터는 User 연관관계, Lv4에서는 Cookie/Session 기반 로그인 인증까지 적용하는 것이 목표였다.
kr.spartaclub.develop_scheduleapp
┣ config
┣ controller
┣ dto
┣ entity
┣ repository
┗ service
Lv1부터 일정 생성/수정 시간을 응답에 포함해야 해서 Auditing을 먼저 잡았다.
JpaAuditingConfig.java
@EnableJpaAuditing 붙이면 JPA가 엔티티 저장/수정 이벤트를 감지해서 시간을 자동으로 채워준다.@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}
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은 유저 테이블이 없어서, 일정에 username을 문자열로 저장하는 방식으로 시작했다.
일정 CRUD 구현
createdAt / updatedAt 응답에 포함
controller/ScheduleControllerservice/ScheduleServicerepository/ScheduleRepositoryentity/Schedule, entity/BaseTimeEntitydto/ScheduleRequest, dto/ScheduleResponse, dto/ScheduleUpdateRequestconfig/JpaAuditingConfigScheduleRequest: 클라이언트가 보내는 값만 받음(username/title/content)ScheduleUpdateRequest: 수정에 필요한 값만 받음(title/content)ScheduleResponse: 클라이언트에게 내려줄 값만 담음(id/username/title/content/createdAt/updatedAt)이 분리를 해두면, 나중에 Lv2에서 username을 없애고 userId로 바꾸는 작업이 훨씬 편해짐(실제로 그랬음).
/api/schedules : 일정 생성/api/schedules : 전체 조회/api/schedules/{id} : 단건 조회/api/schedules/{id} : 수정/api/schedules/{id} : 삭제POST /api/schedules
{
"username": "jimin",
"title": "첫 일정",
"content": "CRUD 시작!"
}

GET /api/schedules

GET /api/schedules/{id}

PATCH /api/schedules/{id}
{
"title": "수정된 제목",
"content": "수정된 내용"
}

DELETE /api/schedules/{id}

Lv2부터는 일정이 username 문자열을 저장하는게 아니라,
User를 참조(FK)하는 구조로 바꿔야 한다.
Schedule.username (String)Schedule.user (ManyToOne)Schedule은 작성자(User)를 가진다.
한 명의 유저가 여러 개의 일정을 작성할 수 있으니까 관계는 이렇게 된다.
Schedule이 더 이상 username(String)을 저장하지 않고, user(User)를 참조하도록 변경
entity/User 생성@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;
}
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를 참조하게 됨.
기존 Lv1:
username을 받음Lv2:
userId를 받음@NotNull(message="userId는 필수입니다.")
private Long userId;
ScheduleService.create()에서:
userRepository.findById(userId) 로 유저 존재 확인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);
POST /api/users
{
"username": "jimin",
"email": "jimin@test.com"
}

id=1 같은 값 확인POST /api/schedules
{
"userId": 1,
"title": "Lv2 일정",
"content": "유저 연관관계로 생성"
}

GET /api/schedules

PATCH /api/users/1
{
"username": "jimin2",
"email": "jimin2@test.com"
}
그 다음 GET /api/schedules 다시 실행
jimin2로 바뀌어 보이면 Schedule이 username을 저장한게 아니라 User를 참조한다는 증거

Lv3에서 나는 회원가입 입력에 password를 추가했다.
(이후 Lv4 로그인 구현을 위해 필요)
password 컬럼 추가password 컬럼 추가@Column(nullable=false)
private String password;

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

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

이 단계에서 400이 뜰 수도 있는데,
예외처리는 Lv5에서 하므로 “지금은 400만 확인되면 OK”로 진행했다.
Lv4 핵심은 세션(HttpSession)을 활용해서 로그인 상태를 유지하는 것.
JSESSIONID 쿠키를 내려준다controller/AuthControllerdto/LoginRequestservice/AuthService 또는 UserService에 로그인 로직 추가POST /api/auth/login
email/password 검증 성공 시
HttpSession에 로그인 유저 id 저장예시 흐름:
session.setAttribute(LOGIN_USER_ID, user.getId());
POST /api/auth/logoutsession.invalidate();
Lv2까지 일정 생성은 userId를 body로 받았는데
Lv4부터는 로그인 세션에서 userId를 꺼내서 사용하도록 변경.
@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));
}
POST /api/users
{
"username": "jimin3",
"email": "jimin3@test.com",
"password": "11223344"
}

POST /api/auth/login
{
"email": "jimin3@test.com",
"password": "11223344"
}

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

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

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



-> 오류 발생하는 것을 확인할 수 있음
PATCH 후 응답을 봤는데 updatedAt이 createdAt이랑 똑같이 보였다.

@NotBlank를 Long(userId)에 걸어서 터짐 (HV000030)No validator could be found for constraint 'NotBlank' validating type 'Long'
@NotBlank는 문자열(String) 전용이다.@NotNull을 써야 한다.@NotNull(message="userId는 필수입니다.")
private Long userId;
Postman에서 일정 생성 시 500이 발생했다.
대부분 이런 500은
이 셋 중 하나였다.
Duplicate entry 'jimin2@test.com' for key 'users...'
unique=true가 걸려있어서사실 지난 과제 때는 독감 때문에 제대로 구현하지 못해서 아쉬움이 많이 남았었다.
구조를 깊게 고민하기보다는 일단 돌아가게 만드는 것에 집중할 수밖에 없었고, 완성도에 대한 아쉬움이 컸다.
이번 과제도 시간이 넉넉하진 않았지만,
그래도 저번보다는 훨씬 신경 써서 구조를 잡고, 레벨별 요구사항을 정확히 이해하고, 단계적으로 구현해나갈 수 있었던 점이 스스로 만족스럽다.
특히 이번 과제를 통해 깨달은 점은
처음 설계를 어떻게 하느냐에 따라 이후 수정 난이도가 완전히 달라진다
DTO와 Entity를 분리해두는 것이 얼마나 중요한지 체감했다
연관관계를 적용하면 단순 문자열 저장과는 차원이 다르다
세션 인증은 개념으로 알던 것과 실제 구현은 완전히 다르다
에러를 마주쳤을 때 당황하지 않고 하나씩 원인을 좁혀가는 과정이 중요하다
이제 도전 기능을 통해 예외처리와 Validation까지 정리하면서
프로젝트를 더 다듬어보고 싶다.