전체 구현 코드는 아래 GitHub 저장소에서 확인할 수 있습니다!
이번 프로젝트에서는 Spring Boot와 JPA를 활용하여 일정 관리 API를 구현했다. 기본적으로 일정 CRUD와 유저 CRUD를 구현했고, 이후에 Schedule과 User를 단방향 @ManyToOne 관계로 연결했다.
처음에는 일정에 작성자 이름을 문자열로 저장했지만, 유저 CRUD와 연관관계가 추가되면서 일정은 user_id를 통해 유저를 참조하는 구조로 변경했다. 이를 통해 일정 테이블에 작성자 이름을 직접 저장하지 않고, 연결된 유저 정보에서 작성자 이름을 가져오도록 만들었다. 실제 코드에서도 Schedule Entity는 User를 참조하며, schedule.user_id가 users.id를 참조하는 구조로 구현했다.
이후에 Cookie/Session 기반 로그인·로그아웃을 구현했다. 로그인 성공 시 세션에 loginUserId를 저장하고, AuthInterceptor를 통해 로그인하지 않은 사용자의 보호 API 접근을 차단했다. 추가로 비밀번호를 BCrypt로 해시하여 저장하고, 유저와 일정 수정·삭제는 본인만 가능하도록 권한 검사를 추가했다.
이번 프로젝트의 기본 요청 흐름은 다음과 같다.
Client
↓
Controller
↓
Service
↓
Repository
↓
MySQL
| 계층 | 역할 |
|---|---|
| Controller | HTTP 요청을 받고 응답을 반환 |
| Service | 실제 기능 처리와 비즈니스 규칙 담당 |
| Repository | DB 접근 담당 |
| Entity | DB 테이블과 매핑되는 객체 |
| DTO | 요청/응답 데이터 전달 객체 |
기능이 단순할 때는 Controller에 모든 코드를 넣어도 동작은 할 수 있다. 하지만 인증, 검증, 예외 처리, DB 접근이 늘어나면 코드가 한곳에 섞인다. 그래서 요청을 받는 일은 Controller, 실제 규칙을 처리하는 일은 Service, DB 접근은 Repository가 담당하도록 나누었다.
Lv1에서는 일정에 작성자 이름을 문자열로 저장했다.
Schedule
- username
- title
- contents
하지만 이 방식에는 문제가 있다.
| 문제 | 설명 |
|---|---|
| 데이터 불일치 | 유저 이름이 바뀌어도 기존 일정의 작성자 이름은 그대로 남을 수 있음 |
| 존재하지 않는 작성자 가능 | 실제 유저가 없어도 문자열만 넣으면 일정 생성 가능 |
| 유저 정보 확장 어려움 | 작성자의 id, email 등 추가 정보 조회가 불편 |
이를 해결하기 위해 일정이 작성자 이름을 직접 저장하지 않고, User를 참조하도록 변경했다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
User 1 : N Schedule 관계에서 외래키는 N쪽인 schedules 테이블에 둔다. 그래서 DB에는 username이 아니라 user_id가 저장된다.
schedules.user_id → users.id
응답에서 작성자 이름이 필요하면 연결된 User에서 가져온다.
schedule.getUser().getUsername()
즉, 일정 테이블에는 작성자 이름을 직접 저장하지 않고, 유저 테이블을 참조해서 최신 유저 정보를 응답에 포함한다.

Schedule-User ERD
users와schedules가 1:N 관계이며,schedules.user_id가users.id를 참조하는 구조를 보여준다.
HTTP 요청 하나가 끝나면 이전 요청을 기억하지 않는다. 그래서 로그인 상태를 유지하려면 별도 장치가 필요하다.
이번 프로젝트에서는 Cookie/Session 방식을 사용했다.
| 개념 | 역할 |
|---|---|
| Session | 서버가 로그인 사용자를 기억하는 저장 공간 |
| Cookie | 클라이언트가 세션을 찾기 위해 들고 다니는 번호표 |
| JSESSIONID | Spring이 세션을 식별하기 위해 사용하는 쿠키 값 |
로그인 성공 시 서버 세션에 로그인한 유저 id를 저장했다.
session.setAttribute("loginUserId", userId);
이후 클라이언트는 JSESSIONID쿠키를 가지고 요청을 보낸다. 서버는 이 쿠키를 기준으로 세션을 찾고, 세션 안의 loginUserId로 로그인 유저를 확인한다.
비유하면, 세션은 서버가 들고 있는 출입 명단이고, 쿠키는 사용자가 들고 다니는 번호표다. 중요한 정보는 서버 세션에 저장되고, 클라이언트는 세션을 찾기 위한 식별값만 가진다.
로그인 여부를 모든 Controller에서 매번 검사하면 코드가 반복된다.
Controller마다 session 확인
→ 같은 코드 반복
→ 유지보수 어려움
그래서 AuthInterceptor를 사용했다. Interceptor는 Controller가 실행되기 전에 요청을 먼저 검사한다.
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("loginUserId") == null) {
throw new UnauthorizedException("로그인이 필요합니다.");
}
request.getSession(false)는 기존 세션이 있으면 가져오고, 없으면 새로 만들지 않는다. 로그인 여부를 확인할 때 새 세션을 만들면 의미가 없기 때문에 false를 사용했다.
요청 흐름은 다음과 같다.
요청
↓
AuthInterceptor
↓
세션 확인
↓
로그인 상태면 Controller 통과
로그인 상태가 아니면 401 Unauthorized
/login, /users/signup은 로그인 없이 접근 가능해야 하므로 인증 검사에서 제외했다. Interceptor는 Controller 실행 전 요청을 가로채 검사할 수 있는 Spring MVC 기능이다.


로그인 성공 후 JSESSIONID 쿠키 생성
로그인 성공 시 Postman에서JSESSIONID쿠키가 생성된 것을 보여준다.
이번 과제에서 가장 중요하게 이해한 부분은 로그인 여부 확인과 로그인한 사용자를 실제 데이터 처리에 사용하는 것은 다르다는 점이다.
처음에는 일정 생성 요청에서 userId를 Body로 받았다.
{
"userId": 2,
"title": "할 일 제목",
"contents": "할 일 내용"
}
이 방식은 위험하다. 1번 유저로 로그인했더라도 Body에 userId: 2를 넣으면 2번 유저 이름으로 일정이 생성될 수 있기 때문이다.
즉,
인증은 되었지만 인증된 신원을 실제 작성자 결정에 사용하지 않은 상태였다.
문제를 해결하기 위해 ScheduleRequestDto에서 userId를 제거했다.
{
"title": "할 일 제목",
"contents": "할 일 내용"
}
그리고 Controller에서 세션의 loginUserId를 꺼내 Service로 전달했다.
Long loginUserId = (Long) session.getAttribute("loginUserId");
ScheduleResponseDto responseDto =
scheduleService.createSchedule(loginUserId, requestDto);
Service에서는 loginUserId로 User를 조회한 뒤 그 User를 작성자로 사용했다. 실제 구현에서도 findUser(loginUserId)로 세션의 유저 id를 기준으로 작성자를 결정한다.
| 구분 | 기존 방식 | 수정 후 |
|---|---|---|
| 작성자 결정 기준 | 요청 Body의 userId | 세션의 loginUserId |
| 조작 가능성 | 있음 | 낮음 |
| 신뢰 대상 | 클라이언트 입력값 | 서버 세션 값 |

userId 조작 방지 테스트
Body에userId: 999를 넣어도 응답에는 로그인한 유저의userId가 들어간 것을 보여준다.
이번 구현에서 인증과 인가의 차이를 코드로 구분했다.
| 구분 | 질문 | 실패 시 상태 코드 | 예시 |
|---|---|---|---|
| 인증 | 로그인했는가? | 401 Unauthorized | 로그인 없이 API 접근 |
| 인가 | 이 작업을 할 권한이 있는가? | 403 Forbidden | 남의 유저/일정 수정 |
Interceptor는 로그인 여부만 확인한다. 즉, "누구세요??"를 확인하는 인증 단계다.
하지만 로그인한 사용자가 남의 데이터를 수정하거나 삭제하는 것은 별도의 권한 검사로 막아야 한다. 이 부분은 Service에서 처리했다.
유저 수정/삭제는 로그인 유저 id와 요청 대상 유저 id를 비교했다.
private void checkOwner(Long loginUserId, Long targetUserId) {
if (!loginUserId.equals(targetUserId)) {
throw new ForbiddenException("본인만 수정/삭제할 수 있습니다.");
}
}
일정 수정/삭제는 로그인 유저 id와 일정 작성자 id를 비교했다.
private void checkOwner(Schedule schedule, Long loginUserId) {
if (!schedule.getUser().getId().equals(loginUserId)) {
throw new ForbiddenException("본인이 작성한 일정만 수정/삭제할 수 있습니다.");
}
}
실제 코드에서도 유저 수정/삭제와 일정 수정/삭제 모두 소유자 검사를 통해 본인이 아닌 요청을 차단한다.
처음에는 비밀번호를 평문으로 저장했다. 하지만 평문 비밀번호는 DB가 유출되면 모든 유저의 실제 비밀번호가 그대로 노출된다.
이를 막기 위해 BCrypt를 적용했다. 정확히는 복호화 가능한 암호화라기보다, 복원할 수 없는 단방향 해시 방식에 가깝다. BCryptPasswordEncoder는 encode()로 비밀번호를 해시하고, matches()로 입력 비밀번호와 저장된 해시값을 검증하는 기능을 제공한다.
회원가입 시에는 평문 비밀번호를 해시해서 저장했다.
String encodedPassword = passwordEncoder.encode(requestDto.getPassword());
로그인 시에는 입력한 평문 비밀번호와 DB에 저장된 해시값을 matches()로 비교했다.
if (!passwordEncoder.matches(requestDto.getPassword(), user.getPassword())) {
throw new UnauthorizedException("이메일 또는 비밀번호가 일치하지 않습니다.");
}
구현 코드에서도 회원가입 시 passwordEncoder.encode()로 비밀번호를 저장하고, 로그인 시 passwordEncoder.matches()로 검증한다.
DB에서 password 컬럼이 2$10~.. 형태로 저장되는 것을 확인했다.

BCrypt 적용 후 password 컬럼
DB의 password 컬럼에 평문이 아니라$2a$10$~..형태의 BCrypt 해시값이 저장된 것을 보여준다.
처음에는 일정 테이블에 username 컬럼이 있었다. 이후 User와 Schedule을 연관관계로 연결하면서 username 대신 user_id를 사용해야 했다.
문제는 기존 테이블이 남아 있으면 Java Entity와 DB 테이블 구조가 달라질 수 있다는 점이었다.
Lv1 schedules 테이블
- username
- title
- contents
Lv2 schedules 테이블
- user_id
- title
- contents
기존 schedules 테이블을 삭제한 뒤, JPA가 변경된 Entity 기준으로 다시 테이블을 생성하게 해서 해결했다.
DROP TABLE IF EXISTS schedules;
Entity 구조를 바꿀 때는 Java 코드만 볼 것이 아니라 DB 테이블 구조도 함께 확인해야 한다는 점을 배웠다.
수정 API를 호출했을 때 데이터는 바뀌었지만, 응답 Body의 modifiedAt이 이전 값으로 보인 적이 있었다.
원인은 JPA의 변경 감지와 Auditing 값 반영이 flush/commit 시점에 이루어지기 때문이다. 그런데 DTO는 그보다 먼저 생성되고 있었다. Spring Data JPA Auditing은 @CreatedDate, @LastModifiedDate를 통해 생성·수정 시각을 기록할 수 있다.
해결 방법은 DTO를 만들기 전에 flush()를 호출하는 것이었다.
userRepository.flush();
scheduleRepository.flush();
이를 통해 수정 응답에서도 최신 modifiedAt을 확인할 수 있었다.
회원가입을 같은 이메일로 여러 번 요청했을 때 500 에러가 발생했다.
콘솔에는 다음과 같은 메시지가 있었다.
Duplicate entry ... for key ...
원인은 email 컬럼에 unique = true 제약조건이 있었기 때문이다. 즉, 같은 이메일을 가진 유저는 중복 저장될 수 없다.
이 경험으로 Postman의 응답 화면만 보지 말고, 서버 콘솔의 에러 메시지를 함께 확인해야 한다는 점을 알게 되었다.
DTO 필드명을 잘못 작성하면 Lombok이 생성하는 getter 이름도 함께 달라진다.
예를 들어 password를 오타로 작성하면 getPassword()가 생성되지 않는다. 그러면 Service에서 requestDto.getPassword()를 호출할 때 “심볼을 찾을 수 없음” 에러가 발생한다.
이 문제는 단순 오타지만, DTO → Service 흐름 전체에 영향을 줄 수 있었다.
Entity를 만들었는데 IntelliJ Database 탭에서 테이블이 보이지 않거나, @Table(name = "schedules")에 빨간 밑줄이 보인 적이 있었다.
이때 실제 DB에 테이블이 없는 경우도 있지만, Database 탭이 아직 새로고침되지 않은 경우도 있었다.
해결 방법은 DB 콘솔에서 직접 확인하는 것이었다.
SHOW TABLES;
그리고 Database 탭을 새로고침하니 테이블이 정상적으로 보였다.
IDE 패널이 항상 실시간 상태를 보여주는 것은 아니므로, 의심스러울 때는 DB에 직접 질의해야 한다는 점을 배웠다.
| 테스트 | 기대 결과 |
|---|---|
| 회원가입 성공 | 201 Created |
| 비밀번호 8자 미만 | 400 Bad Request |
| 로그인 성공 | 200 OK + JSESSIONID 발급 |
| 잘못된 비밀번호 로그인 | 401 Unauthorized |
| 로그인 없이 보호 API 접근 | 401 Unauthorized |
| Body에 userId 조작 후 일정 생성 | 세션의 loginUserId 기준으로 작성 |
| 남의 유저 수정/삭제 | 403 Forbidden |
| 남의 일정 수정/삭제 | 403 Forbidden |
| BCrypt 저장 확인 | DB password 컬럼이 $2a$10$... 형태로 저장 |
@ManyToOne(fetch = FetchType.LAZY)를 적용하면 User를 즉시 조회하지 않고 필요할 때 조회한다고 이해했다. 다만 정확히 getUser()를 호출하는 순간 SELECT가 발생하는지, 아니면 getUser().getUsername()처럼 실제 필드에 접근할 때 발생하는지는 더 확인이 필요하다.
현재 이해로는 프록시 객체 자체는 getUser()로 가져올 수 있고, 실제 User 필드에 접근하는 시점에 SELECT가 발생할 수 있다고 정리했다.
AuthInterceptor는 직접 만든 클래스이므로 @Component로 등록했다. 반면 BCryptPasswordEncoder는 외부 라이브러리 객체라 직접 설정 클래스에서 @Bean으로 등록했다.
@Component
→ 내가 만든 클래스를 Spring이 자동으로 Bean 등록
@Bean
→ 설정 클래스에서 직접 생성한 객체를 Bean으로 등록
두 방식 모두 Bean 등록이라는 결과는 같지만, 사용하는 상황이 다르다는 점을 더 익혀야 한다.
이번 프로젝트를 통해 단순 CRUD API에서 끝나는 것이 아니라, 실제 서비스에 가까운 인증과 인가 흐름을 함께 고민할 수 있었다. 특히 로그인 여부를 확인하는 것과 로그인한 사용자를 실제 데이터 처리에 사용하는 것은 다르다는 점을 직접 경험했다.
처음에는 Interceptor로 로그인 여부만 확인하면 충분하다고 생각했지만, 일정 생성에서 요청 Body의 userId를 그대로 사용하면 작성자 조작이 가능하다는 문제가 있었다. 이를 세션의 loginUserId를 사용하는 방식으로 수정하면서 인증과 인가의 차이를 더 명확히 이해했다.
또한 JPA 연관관계, JPA Auditing, Cookie/Session, Interceptor, BCrypt, 권한 검사를 하나의 프로젝트 안에서 연결해보면서 Spring 애플리케이션이 왜 Controller, Service, Repository 계층으로 나뉘어 동작하는지 조금 더 체감할 수 있었다.