Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
89c3a69
feat: 토큰 생성 및 검증 기능 추가
mgim9316-a11y Jun 29, 2026
1e6f4cf
feat: 로그인 API 구현
mgim9316-a11y Jun 29, 2026
c348327
feat: 서버의 토큰 발급 확인 테스트 코드
mgim9316-a11y Jun 29, 2026
29152d3
refactor: resorver가 토큰을 받는 형태로 수정
mgim9316-a11y Jun 29, 2026
f084866
refactor: resorver가 토큰을 받는 형태로 수정
mgim9316-a11y Jun 29, 2026
da4f4ec
refactor: 예약 생성 시 ReservationReqeust의 name이 없는 경우 Cookie에 담긴 정보를 활용
mgim9316-a11y Jun 29, 2026
0853489
fix: 2단계 테스트 코드 오류 수정
mgim9316-a11y Jun 29, 2026
40bb963
커밋테스트
mgim9316-a11y Jun 30, 2026
0c8710c
커밋테스트
mgim9316-a11y Jun 30, 2026
0a5c76b
커밋테스트
mgim9316-a11y Jun 30, 2026
cdaddb5
커밋테스트
mgim9316-a11y Jun 30, 2026
50c51c3
커밋테스트
hapdaypy Jun 30, 2026
76ce709
refactor: JWT 생성/검증 책임 클래스 분리
hapdaypy Jul 3, 2026
5b27027
refactor: 네이밍 수정 및 로그인한 회원의 정보를 담는 객체 분리
hapdaypy Jul 3, 2026
7479e06
refactor: 네이밍 수정 및 로그인한 회원의 정보를 담는 객체 분리
hapdaypy Jul 3, 2026
bd2b014
refactor: 쿠키 관련 로직을 분리
hapdaypy Jul 3, 2026
4aff64a
refactor: simpledbcInsert로 변경
hapdaypy Jul 3, 2026
b2e568f
refactor: ReservationDao의 조건 조회를 NamedParameterJdbcTemplate 로 바꿈
hapdaypy Jul 3, 2026
66ce159
feat: “내 예약” 화면이 호출하는 API
hapdaypy Jul 3, 2026
506eceb
feat: 공통 mapper 메서드를 추가
hapdaypy Jul 3, 2026
fbadb3f
refactor: 로그인 실패 조건식 수정
hapdaypy Jul 3, 2026
446bd84
feat: 에러 응답 메시지 추가
hapdaypy Jul 3, 2026
5870f17
refactor: 애노테이션을 옵션으로 분기하지 않고, Optional로 선택 가능성 표현
hapdaypy Jul 3, 2026
a862274
refactor: 인증 패키지 분리
hapdaypy Jul 6, 2026
d3c5540
refactor: 가독성을 위해 코드 분리
hapdaypy Jul 6, 2026
68ab241
refactor: AuthUser, AdminOnly 권한 분리
hapdaypy Jul 6, 2026
765d7a4
refactor: 인텁센터 로직 수정
hapdaypy Jul 6, 2026
b207c49
feat: 예외 처리를 상태코드로 나눔
hapdaypy Jul 7, 2026
ce3992d
refactor: DB 조회 데이터가 0개일 경우 에러를 NotFoundException 로 반환
hapdaypy Jul 7, 2026
cc91e2a
feat: 생성 멱등성을 검증하는 로직 추가
hapdaypy Jul 7, 2026
dbf7630
feat: 일반 사용자 기능과 관리자 기능을 인텁셉터를 도입하여 분리
hapdaypy Jul 7, 2026
6a6e810
refactor: controller의 예외 처리 로직을 service로 반경
hapdaypy Jul 12, 2026
da6a387
refactor: jjwt의 JwtParser 사용
hapdaypy Jul 12, 2026
654fff7
refactor: 사용자의 role을 enum으로 관리
hapdaypy Jul 12, 2026
319ac52
feat: JWT access/refresh token 발급 흐름 추가
hapdaypy Jul 12, 2026
c9f3185
refactor: DTO 성격을 record 형태로 변경 및 record 호출 형태로 수정
hapdaypy Jul 12, 2026
24416b3
refactor: raw type 을 구체적 타입으로 수정
hapdaypy Jul 12, 2026
bace53d
refactor: record 형태로 수정
hapdaypy Jul 12, 2026
87aca69
fix: cherry-pick conflict cleanup
hapdaypy Jul 12, 2026
41e1d3a
refactor:수동 getter를 @Getter 로 정리
hapdaypy Jul 13, 2026
8374a94
feat: @Transactional 사용
hapdaypy Jul 13, 2026
8c10705
feat: @Entity로 바꿈
hapdaypy Jul 13, 2026
737af23
refactor: EntityManager 기반으로 교체
hapdaypy Jul 13, 2026
f6b104e
feat: 테마 기능 서비스 생성
hapdaypy Jul 13, 2026
74b7d5e
refactor: 컨트롤러에서 DAO, 서비스 의존성 제거
hapdaypy Jul 13, 2026
8aebdb7
refactor: optional 추가
hapdaypy Jul 13, 2026
34599e8
refactor: 예약과 회원을 JPA 연관관계로 매핑
hapdaypy Jul 13, 2026
e0ac584
refactor: 상속하는 파일들 패키지로 분리
hapdaypy Jul 14, 2026
e0bca6d
refactor: 에러메시지를 enum으로 관리
hapdaypy Jul 14, 2026
8a2b3fd
refactor: 사용되지 않은 생성자 삭제
hapdaypy Jul 16, 2026
2387c0f
refactor: RuntimeException 전체를 잡아 AuthenticationException으로 변환하는 부분 수정
hapdaypy Jul 18, 2026
45bb709
feat: 토큰 파싱 실패시, AuthenticationException으로 반환
hapdaypy Jul 18, 2026
815c598
refactor: @Value로 secret 주입받아 key를 만들도록 변경
hapdaypy Jul 18, 2026
d914306
refactor: isValidSignature()를 제거
hapdaypy Jul 18, 2026
255dcae
feat: 이메일 컬럼 매핑 추가
hapdaypy Jul 18, 2026
0c00db4
refactor: getSingleResult()에서 발생하는 예외 사용
hapdaypy Jul 18, 2026
f764dd3
refactor: 회원이 없을 때 예외를 던지지 않고Optional<Member>를 반환
hapdaypy Jul 18, 2026
6759fac
refactor: 서비스에서 실제 엔티티를 조회한 뒤 DAO에 넘기도록 변경
hapdaypy Jul 18, 2026
b45a1b0
refactor: join과 DTO projection 을 사용하도록 리팩토링
hapdaypy Jul 18, 2026
39fa068
refactor: id를 받는 생성자 삭제
hapdaypy Jul 18, 2026
c6d7687
merge: sync hapdaypy base
hapdaypy Jul 18, 2026
20eed98
refactor: EntityManager 기반 DAO를 Spring Data JPA로 전환
hapdaypy Jul 20, 2026
07d08eb
feat: 예약 대기자 기능 추가
hapdaypy Jul 20, 2026
1e6d264
refactor: JwtUtils를 roomescape 외부의 jwt 패키지로 이동
hapdaypy Jul 20, 2026
99ba6d9
refactor: schema.sql을 프로파일별 데이터 로더로 전환
hapdaypy Jul 20, 2026
34ae942
refactor: 사용되지 않은 코드 정리
hapdaypy Jul 20, 2026
280d7fc
docs: 리드미 작성
hapdaypy Jul 20, 2026
8101c4b
feat: 액세스 토큰 재발급 시 회원 정보 조회
hapdaypy Jul 20, 2026
a28299c
chore: 주석 삭제
hapdaypy Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# 방탈출 예약 관리

이 저장소는 Spring MVC와 JPA를 공부하며 만든 방탈출 예약 서비스입니다. JDBC와 직접 작성한 DAO로 시작해 Spring Data JPA, 로그인 인증, 예약 대기 기능을 차례로 붙였습니다. 학습 단계마다 코드를 고쳐 온 흔적이 남아 있어 기능이 늘면서 구조가 어떻게 달라졌는지 살펴볼 수 있습니다.

## 주요 기능

- 회원가입과 로그인
- Access Token과 Refresh Token을 사용한 인증
- 관리자 전용 예약, 테마, 시간 관리
- 예약 생성과 멱등성 키를 이용한 중복 요청 방지
- 내 예약 목록 조회
- 예약 대기 등록과 대기 순번 조회
- 테마와 시간의 소프트 삭제
- 프로파일에 따른 초기 데이터 등록

## 기술 스택

- Java 17
- Spring Boot 3.1
- Spring MVC
- Spring Data JPA
- H2 Database
- JWT
- Thymeleaf
- Gradle
- RestAssured, JUnit 5, AssertJ

## 프로젝트 구조

```text
src/main/java
├── jwt JWT 생성과 파싱
└── roomescape
├── auth 인증, 인가, 쿠키 처리
├── exception 예외와 오류 코드
├── loader 실행 환경별 초기 데이터
├── member 회원
├── reservation 예약
├── theme 테마
├── time 예약 시간
└── waiting 예약 대기
```

패키지는 기능 단위로 나눴습니다. 컨트롤러는 HTTP 요청과 응답을 받고 서비스는 유스케이스와 트랜잭션을 처리합니다. 데이터 접근은 `JpaRepository`를 상속한 저장소가 맡습니다.

## 실행 방법

Java 17이 필요합니다. 데이터베이스를 따로 설치하지 않아도 H2 인메모리 데이터베이스로 실행할 수 있습니다.

Windows:

```bash
gradlew.bat bootRun
```

macOS 또는 Linux:

```bash
./gradlew bootRun
```

실행 후 [http://localhost:8080](http://localhost:8080)으로 접속하면 됩니다. H2 콘솔 주소는 [http://localhost:8080/h2-console](http://localhost:8080/h2-console)입니다.

기본 계정은 다음과 같습니다.

| 구분 | 이메일 | 비밀번호 |
| --- | --- | --- |
| 관리자 | `admin@email.com` | `password` |
| 사용자 | `brown@email.com` | `password` |

## 초기 데이터

초기 데이터는 `schema.sql`이 아니라 `CommandLineRunner`로 등록합니다.

- 기본 환경에서는 `DataLoader`가 회원 정보를 등록합니다.
- `test` 프로파일에서는 `TestDataLoader`가 회원, 테마, 시간, 예약 데이터를 등록합니다.

테이블은 JPA 엔티티를 보고 Hibernate가 생성합니다. 현재 H2 인메모리 데이터베이스와 `create-drop` 설정을 사용하므로 애플리케이션을 다시 실행하면 데이터도 초기화됩니다.

## API

### 회원과 인증

| Method | URI | 설명 |
| --- | --- | --- |
| `POST` | `/members` | 회원가입 |
| `POST` | `/login` | 로그인 |
| `POST` | `/token/refresh` | Access Token 재발급 |
| `GET` | `/login/check` | 로그인 상태 확인 |
| `POST` | `/logout` | 로그아웃 |

### 예약과 대기

| Method | URI | 설명 |
| --- | --- | --- |
| `GET` | `/reservations` | 전체 예약 조회 |
| `POST` | `/reservations` | 예약 생성 |
| `DELETE` | `/reservations/{id}` | 예약 삭제 |
| `GET` | `/reservations-mine` | 내 예약과 예약 대기 조회 |
| `POST` | `/waitings` | 예약 대기 등록 |

### 테마와 시간

| Method | URI | 설명 |
| --- | --- | --- |
| `GET` | `/themes` | 테마 목록 조회 |
| `POST` | `/themes` | 테마 생성 |
| `DELETE` | `/themes/{id}` | 테마 삭제 |
| `GET` | `/times` | 시간 목록 조회 |
| `POST` | `/times` | 시간 생성 |
| `DELETE` | `/times/{id}` | 시간 삭제 |
| `GET` | `/available-times` | 날짜와 테마별 예약 가능 시간 조회 |

예약, 테마, 시간 관리 API 일부는 관리자 권한이 필요합니다.

## 구현하면서 바꾼 점

### Spring Data JPA

직접 `EntityManager`를 다루던 DAO를 `JpaRepository`로 바꿨습니다. 단순 조회에는 파생 쿼리를 씁니다. 여러 엔티티를 조인해 DTO로 반환하는 예약 조회에는 JPQL 생성자 표현식을 사용했습니다.

### 소프트 삭제

테마와 시간은 행을 바로 지우지 않고 `deleted` 상태만 바꿉니다. 예약을 생성할 때는 삭제되지 않은 테마와 시간만 조회합니다.

### 예약 대기 순번

같은 날짜, 테마, 시간에 먼저 등록된 대기 건수를 JPQL 서브쿼리로 계산합니다. 조회된 건수에 1을 더해 `1번째 예약대기`처럼 응답합니다.

### JWT

JWT 라이브러리를 직접 다루는 코드는 `roomescape` 패키지 밖의 `JwtUtils`에 모았습니다. 이 클래스에는 컴포넌트 어노테이션을 붙이지 않고 `JwtConfig`에서 빈으로 등록했습니다. 토큰에서 회원 식별 정보와 권한을 읽기 때문에 토큰 확인 과정에서는 회원 데이터베이스를 다시 조회하지 않습니다.

## 테스트

Windows:

```bash
gradlew.bat test
```

macOS 또는 Linux:

```bash
./gradlew test
```

테스트는 `test` 프로파일로 실행됩니다. 로그인, 예약 생성, 멱등성 키, 예약 대기 순번, JWT 설정을 REST API 기준으로 확인합니다.

4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

implementation 'dev.akkinoc.spring.boot:logback-access-spring-boot-starter:4.0.0'

Expand Down
40 changes: 40 additions & 0 deletions src/main/java/jwt/JwtUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Map;

public class JwtUtils {
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기는 따로 빈 등록을 하지 않은 이유가 있나요??

private final Key signingKey;

public JwtUtils(String secretKey) {
this.signingKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}

public String createToken(String subject, Map<String, Object> claims, long expirationTime) {
Date now = new Date();
Date expiration = new Date(now.getTime() + expirationTime);

return Jwts.builder()
.setSubject(subject)
.addClaims(claims)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(signingKey, SignatureAlgorithm.HS256)
.compact();
}

public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
4 changes: 0 additions & 4 deletions src/main/java/roomescape/AuthenticationException.java

This file was deleted.

64 changes: 50 additions & 14 deletions src/main/java/roomescape/ExceptionController.java
Original file line number Diff line number Diff line change
@@ -1,24 +1,68 @@
package roomescape;

import lombok.Getter;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import roomescape.exception.AuthenticationException;
import roomescape.exception.AuthorizationException;
import roomescape.exception.ConflictException;
import roomescape.exception.ErrorCode;
import roomescape.exception.NotFoundException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
@ControllerAdvice // 아 컨트롤러에서 발생한 예외를 가로챔 ~ ㅋ
public class ExceptionController {
private static final Logger log = LoggerFactory.getLogger(ExceptionController.class);

@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException e) {
return ResponseEntity.status(401)
.body(new ErrorResponse(401, "로그인이 필요합니다."));
return error(e.getErrorCode());
}

@ExceptionHandler(AuthorizationException.class)
public ResponseEntity<ErrorResponse> handleAuthorizationException(AuthorizationException e) {
return error(e.getErrorCode());
}

@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFoundException(NotFoundException e) {
log.warn("Resource not found: {}", e.getMessage());
return error(e.getErrorCode());
}

@ExceptionHandler({ConflictException.class, DataIntegrityViolationException.class})
public ResponseEntity<ErrorResponse> handleConflictException(Exception e) {
if (e instanceof ConflictException conflictException) {
return error(conflictException.getErrorCode());
}
return error(ErrorCode.CONFLICT_REQUEST);
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e) {
return error(HttpStatus.BAD_REQUEST, e.getMessage());
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(Exception e) {
public ResponseEntity<ErrorResponse> handleException(Exception e) {
e.printStackTrace();
return ResponseEntity.badRequest()
.body(new ErrorResponse(400, "잘못된 요청입니다."));
return error(ErrorCode.INTERNAL_SERVER_ERROR);
}

private ResponseEntity<ErrorResponse> error(ErrorCode errorCode) {
return error(errorCode.getStatus(), errorCode.getMessage());
}

private ResponseEntity<ErrorResponse> error(HttpStatus status, String message) {
return ResponseEntity.status(status)
.body(new ErrorResponse(status.value(), message));
}

@Getter
public static class ErrorResponse {
private final int statusCode;
private final String message;
Expand All @@ -27,13 +71,5 @@ public ErrorResponse(int statusCode, String message) {
this.statusCode = statusCode;
this.message = message;
}

public int getStatusCode() {
return statusCode;
}

public String getMessage() {
return message;
}
}
}
5 changes: 5 additions & 0 deletions src/main/java/roomescape/PageController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,30 @@

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import roomescape.auth.AdminOnly;

@Controller
public class PageController {
@GetMapping("/admin")
@AdminOnly
public String admin() {
return "admin/index";
}

@GetMapping("/admin/reservation")
@AdminOnly
public String adminReservation() {
return "admin/reservation";
}

@GetMapping("/admin/theme")
@AdminOnly
public String adminTheme() {
return "admin/theme";
}

@GetMapping("/admin/time")
@AdminOnly
public String adminTime() {
return "admin/time";
}
Expand Down
22 changes: 15 additions & 7 deletions src/main/java/roomescape/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,30 @@

import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import roomescape.auth.LoginMemberArgumentResolver;
import roomescape.auth.AdminInterceptor;
import roomescape.auth.AuthUserArgumentResolver;

import java.util.List;

@Configuration
@Configuration // Spring MVC 설정을 추가함
public class WebConfig implements WebMvcConfigurer {
private final LoginMemberArgumentResolver loginMemberArgumentResolver;
private final AuthUserArgumentResolver authUserArgumentResolver;
private final AdminInterceptor adminInterceptor;

public WebConfig(LoginMemberArgumentResolver loginMemberArgumentResolver) {
this.loginMemberArgumentResolver = loginMemberArgumentResolver;
public WebConfig(AuthUserArgumentResolver authUserArgumentResolver, AdminInterceptor adminInterceptor) {
this.authUserArgumentResolver = authUserArgumentResolver;
this.adminInterceptor = adminInterceptor;
}

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginMemberArgumentResolver);
// 로그인에 이 resolver를 사용해라
resolvers.add(authUserArgumentResolver);
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminInterceptor);
}
}
Loading