Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9b93b5c
add(common): BaseTimeEntity 추가
ba2slk Sep 23, 2025
bc0d9f9
edit(attr): 기존 타임스탬프 삭제 > BaseTimeEntity 상속
ba2slk Sep 23, 2025
2d824b5
edit: GenreType Enum 기반 전략으로 변경
ba2slk Sep 23, 2025
7e2d692
add(feat): 영화 찜 엔터티 추가
ba2slk Sep 23, 2025
5ae65fc
add(feat): 영화관 찜 엔터티 추가
ba2slk Sep 23, 2025
c6d2939
add(feat): 스낵바 관련 엔터티 추가
ba2slk Sep 23, 2025
a758ceb
chore: 패키지 구조 정리
ba2slk Sep 23, 2025
9160163
fix(config): lombok 관련 dependency 수정
ba2slk Sep 23, 2025
9a41347
edit: GenreType Enum 으로 변경
ba2slk Sep 23, 2025
f7a185c
add(feat): MovieStatusType Enum 사용 & image 컬럼 추가
ba2slk Sep 23, 2025
c708e98
add(feat): 영화 목록 및 영화 상세 조회 API 구현
ba2slk Sep 23, 2025
17255d9
chore: 주석 추가
ba2slk Sep 23, 2025
aead1a8
edit(uri): 영화 상세 조회 uri 수정
ba2slk Sep 23, 2025
c36a656
add(feat): 영화관 컬럼 추가(상세 설명, 주차 정보)
ba2slk Sep 23, 2025
a26ab58
add(feat): 영화관 목록 및 상세 조회 API 추가
ba2slk Sep 23, 2025
93a3baf
add(feat): 영화 찜 API 추가
ba2slk Sep 23, 2025
8daa659
add(feat): 영화관 찜 API 추가
ba2slk Sep 23, 2025
11fce2b
add(feat): 영화 예매 및 취소 API 추가
ba2slk Sep 24, 2025
a1ec1ed
docs: Swagger 연동
ba2slk Sep 24, 2025
81ad49d
add(common): GlobalExceptionHandler 추가 (확장 필요)
ba2slk Sep 26, 2025
6d7e733
add(feat): 매점 구매 API 추가
ba2slk Sep 26, 2025
ac51d4b
refactor: ItemListDTO 정적 팩토리 메소드를 응답 맥락에 따라 분리
ba2slk Sep 26, 2025
76714f6
add(feat): 회원가입, 로그인, 로그아웃 API 추가
ba2slk Sep 27, 2025
0c7bc7a
edit: ReservationService 임시 userId 파라미터 -> userDetails 기반 인증 사용자 정보 획…
ba2slk Sep 27, 2025
931f08b
add(test): MovieServiceTest 추가
ba2slk Sep 27, 2025
3fcb26e
Update README.md
ba2slk Sep 27, 2025
21f1fa4
docs: README.md 내용 추가
ba2slk Sep 27, 2025
603b5ba
docs: README.md API Enpoint 스크린샷 추가
ba2slk Sep 27, 2025
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
172 changes: 172 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ CEOS 22기 백엔드 스터디 - CGV 클론 코딩 프로젝트

# 모델링 결과
## ERD
v1.0
<img width="1154" height="896" alt="CEOS 22nd CGV" src="https://github.com/user-attachments/assets/b5c3e54c-77aa-4692-a84b-84eb5b4d7aff" />

v2.0 (Latest)
<img width="1334" height="1198" alt="cgv-final" src="https://github.com/user-attachments/assets/561fcba1-2fa0-4463-8b5f-2fe54454eacf" />

## 도메인별 Entity 소개
### Movie
1. `Movie` : 영화 (제목, 감독, 관람 등급 정보 등)
Expand All @@ -45,3 +49,171 @@ CEOS 22기 백엔드 스터디 - CGV 클론 코딩 프로젝트
- 같은 좌석이라도 상영 시간에 따라 다른 상품으로 보아야 하기 때문이다.
- 이러한 N:M 관계를 해소하기 위해 도입
- 구매/취소의 경우 모두 `ReservationSeat` 엔터티를 통해 관리할 수 있다.
### Snack
1. UserOrder
- User의 주문 정보
3. Inventory
- Theater와 Item 간의 N:M 관계를 해소하고 극장 별 재고 관리 확장성을 고려해 추가
4. Item
- 매점 상품 정보
5. OrderItem
- 구체적인 주문 명세로, Order와 Item 간의 N:M 관계를 해소하기 위해 도입
---
# API Endpoints
<img width="1438" height="1305" alt="image" src="https://github.com/user-attachments/assets/abed7b23-8e26-462a-a5b9-f73d01868259" />


---
# 인증/인가: 4가지 방법
## 1. 세션 & 쿠키
### 시나리오
1. 사용자 로그인
2. *서버*가 회원 DB로부터 가입된 사용자인지 확인
3. 해당 회원에게 고유 ID를 부여하여 *세션 저장소*에 저장
4. *세션 저장소*가고유 ID와 연결되는 Session ID 발급
5. 서버가 응답 헤더에 Session ID를 함께 보냄
6. 이후 사용자가 데이터를 요청할 때마다 Session ID가 담긴 쿠키를 실어 보냄
7. 서버는 세션 저장소에서 쿠키를 검증하고 세션 정보를 획득함 (인증 완료)
8. 요청한 데이터와 함께 응답

### 장점
- Session ID가 담긴 쿠키가 탈취되어도, 해당 값에는 의미가 없기 떄문에 쿠키에 사용자 정보를 직접 담는 것보다 안전함.
- 각 사용자가 고유의 Session ID를 발급받기 때문에 회원 정보를 하나하나 확인할 필요가 없으므로 서버 자원에 접근하기 용이함.

### 단점
- 세션 하이재킹 공격
- 쿠키의 내용은 아무런 뜻도 없지만, 그 자체로 세션 저장소를 통과할 수 있는 출입증 역할을 하기 때문에, 해당 쿠키와 함께 서버에 HTTP 요청을 보낼 경우 인증에 성공할 수 있음
- <해결> 만료 시간 부여
- 서버 측의 세션 저장소 운영 부담

## 2. Access Token을 이용한 인증
### JWT (JSON Web Token)
#### 구성 요소
1. Header
- alg: 암호화 방식
- typ: 토큰 유형
2. Payload : 토큰에 담을 정보(claim)
- sub:
- name: 이름
- admin: 역할
- iat:
3. Verify Signature: Payload가 위변조되지 않았다는 사실을 증명하는 문자열
- Base64 기반 인코딩 헤더, Payload, 임의의 Secret Key 조합
- SECRET_KEY를 알아야 복호화할 수 있음

Choose a reason for hiding this comment

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

p2. 보통은 복호화 하지 못할거에요

- 공격자가 JWT를 훔쳐서 인증에 이용하려고 해도, Verify Signature가 해커의 정보가 아닌 일반 사용자의 정보 + SECRET_KEY에 기반으로 암호화되었기 때문에 유효하지 않음

### 시나리오
1. 사용자 로그인
2. 회원 DB로부터 사용자 확인
3. JWT 발급
4. 응답 → Access Token 발급
5. 매 데이터 요청마다 요청 헤더에 JWT (Authorization: Bearer (JWT)를 실어 보냄
6. Access Token 검증
7. 응답 + 요청 데이터

### 장점
- 간편하다. 발급 → 인증 프로세스만 존재. 별도의 세션 저장소 운영 부담이 없음.
- 뛰어난 확장성

### 단점
- 한 번 발급되면 유효기간이 만료될 때까지 계속 사용이 가능, 중간에 삭제가 불가능함. 따라서 JWT가 탈취될 경우 대처가 불가능
- <해결> Refresh Token을 추가로 발급
- Payload 정보는 암호화되지 않기 때문에 중요한 정보를 저장할 수 없음.
- JWT 길이가 길기 때문에 요청이 많아지면 서버 자원 낭비 발생

## 3. Access Token + Refresh Token
가장 단순한 형태의 JWT 인증 방식은
1. 만료 시간 전까지 해당 토큰이 유효하다는 점
2. 발급된 토큰을 수정할 수 없다는 점
3. 이로 인해 공격자에 의해 탈취된 토큰에 대한 대처가 어렵다는 점
과 같은 단점이 있다. 이를 극복하기 위해 Refresh Token 사용

Choose a reason for hiding this comment

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

p4. Refresh Token의 형식은? Refresh Token이 탈취 당하면 어떻게 되나요?


너무 자주 로그인 → UX 악영향
유효 기간 늘리기 → 탈취 취약점

=> Refresh Token을 같이 쓰자!

### 시나리오
1. 사용자 로그인
2. 회원 DB 확인 후 Access Token & Refresh Token 발급
3. 데이터 요청 (+ Access Token)
4. Access Token 검증 → 응답
5. 만약 Access Token이 만료된 경우
1. 사용자가 만료된 Access Token과 함께 데이터 요청
2. 서버가 사용자에게 Access Token이 만료되었다는 응답
3. 사용자가 Access Token 재발급 요청 + Refresh Token 같이 보냄
4. Refresh Token 유효성 확인 → 만료 기한 내인 경우 새로운 Access Token 발급

### 장점
- 기존 취약점 보완. Access Token의 유효기간이 짧음

### 단점
- 복잡한 구현
- Access Token이 만료될 때마다 새로 발급하는 과정에서 서버 자원 낭비 (짧은 주기)

## OAuth 2.0
### OAuth
외부 서비스의 인증 및 권한부여를 관리하는 범용 프로토콜

### OAuth 2.0
- 현재 범용적으로 사용
- 특징
- 모바일 사용 용이
- 반드시 HTTPS 사용 → 보안 강화
- Access Token 만료 기간 도입

### 인증 시나리오
<img width="820" height="478" alt="Pasted image 20250927222709" src="https://github.com/user-attachments/assets/b01008fa-59f5-4dc7-be40-7313ec237a19" />

1. Resource Owner (사용자)의 인증 요청
2. Client (서버)가 인증 페이지 제공
3. 사용자가 인증 진행
4. 인증 완료 신호로 Authorization Grant를 URL에 실어 Client(=서버)에게 보냄
5. 서버는 해당 권한 증서를 Authorization Server에 보냄
6. Authorization Server가 해당 증서 확인. 유저가 맞다면 Client에게 Access Token, Refresh Token,그리고 유저의 정보를 발급해줌.
7. Client는 해당 Access Token을 DB에 저장 또는 Resource Owner에게 넘김
8. Resource Owner가 Resource Server 자원이 필요하면, Client가 Access Token을 담아 Resource Server에 대신 요청
9. Resource Server는 Access Token의 유효성을 확인 → Client에게 자원을 보냄
- 만약 Access Token 만료 or 위조 시, Client는 Authorization Server에 Refresh Token을 같이 보내서 Access Token 재발급
- 다시 Resource Server에 자원 요청
- Refresh Token도 만료된 경우 Resource Owner는 새로운 Authorization Grant를 Client에게 넘겨야 함. → 즉, Client가 다시 인증 페이지를 제공, 사용자 인증 과정 수행

## 4. SNS 로그인
OAuth 2.0이랑 비슷
→ 단, Authorization Server에서 받은 **고유 ID**를 이용해 DB에서 회원 인증

이후 세션/쿠키 or 토큰 기반 인증 진행

---

# 토큰 기반 API
## 영화 예매/취소
적용 전
<img width="1215" height="811" alt="Pasted image 20250927212357" src="https://github.com/user-attachments/assets/7fad0176-2951-4bae-aa4c-d82ee00b1719" />

적용 후
<img width="1239" height="1282" alt="Pasted image 20250927212809" src="https://github.com/user-attachments/assets/01cb7380-1df1-48ab-a876-cdd1e295deaf" />

---
# 트러블 슈팅
1. 문제 상황
: Swagger UI 접속 시 `Failed to load API definition` 에러 발생.
<img width="591" height="288" alt="Pasted image 20250927000546" src="https://github.com/user-attachments/assets/4b70f13a-46f3-4e17-b931-9edd24cba665" />

2. 문제 원인
`/v3/api-docs` 요청에 대해 GlobalExceptionHandler가 모든 예외를 잡아 `ResponseEntity<String>` 등 JSON이 아닌 텍스트 응답을 반환.
- Swagger는 OpenAPI JSON 구조를 기대함 → 텍스트가 오면 파싱 실패 → 500 에러 발생.

3. 해결
: SpringDoc에서 Controller의 응답을 분석할 때 Generic Response 형식으로 덮어쓸지 여부를 false로 설정\
```yml
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
path: /swagger-ui.html
override-with-generic-response: false
```


Copy link

Choose a reason for hiding this comment

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

수고 많으셨습니다!! 시나리오, 트러블 슈팅 다루는 부분까지 적어주신 거 좋은 듯 합니다.
배워갑니다!!

16 changes: 14 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,22 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.projectlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter-security'

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

testImplementation 'org.springframework.boot:spring-boot-starter-test'
runtimeOnly 'com.mysql:mysql-connector-j'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

runtimeOnly 'com.mysql:mysql-connector-j'

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

tasks.named('test') {
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/com/ceos22/cgvclone/common/code/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.ceos22.cgvclone.common.code;

import lombok.Getter;

@Getter
public enum ErrorCode {

// 잘못된 요청
BAD_REQUEST_ERROR(400, "G001", "Bad Request Exception"),

// 인증/인가 관련
UNAUTHORIZED_ERROR(401, "G002", "Unauthorized Exception"),

// 허용되지 않은 접근
FORBIDDEN_ERROR(403, "G003", "Forbidden Exception"),

// 리소스 부재
NOT_FOUND_ERROR(404, "G004", "Not Found Exception"),

// TODO: 임시 -> 도메인별 시나리오를 고려한 설계
INTERNAL_SERVER_ERROR(500, "G005", "Internal Server Error");

private final int status;
private final String divisionCode;
Comment on lines +23 to +24

Choose a reason for hiding this comment

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

p3. 이 둘을 구분한 이유가 어떻게 되나요?

Copy link
Author

Choose a reason for hiding this comment

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

도메인별 맥락이나 예외의 세부 내용을 내부적으로 더 구체화하기 위해 divisionCode를 두어 실제 HTTP 상태 코드를 나타내는 status와 구분했습니다!

private final String message;

ErrorCode(final int status, final String divisionCode, final String message) {
this.status = status;
this.divisionCode = divisionCode;
this.message = message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.ceos22.cgvclone.common.response;

import com.ceos22.cgvclone.common.code.ErrorCode;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {
private int status;
private String divisionCode;
private String resultMessage;
private String reason;

public static ErrorResponse of(ErrorCode errorCode, String reason) {
ErrorResponse response = new ErrorResponse();
response.status = errorCode.getStatus();
response.divisionCode = errorCode.getDivisionCode();
response.resultMessage = errorCode.getMessage();
response.reason = reason;
return response;
}
}
62 changes: 62 additions & 0 deletions src/main/java/com/ceos22/cgvclone/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.ceos22.cgvclone.config;

import com.ceos22.cgvclone.domain.auth.jwt.JwtAuthenticationFilter;
import com.ceos22.cgvclone.domain.auth.jwt.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

private final TokenProvider tokenProvider;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth->
auth.requestMatchers(
"/auth/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/api/movies/**",
"/api/theaters/**"
)
.permitAll()
.anyRequest()
.authenticated()
)
.addFilterBefore(
new JwtAuthenticationFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}



}
27 changes: 27 additions & 0 deletions src/main/java/com/ceos22/cgvclone/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.ceos22.cgvclone.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;

@Configuration
@EnableWebMvc
public class SwaggerConfig {

@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.components(new Components())
.info(apiInfo());
}

private Info apiInfo() {
return new Info()
.title("My CGV API")
.description("CEOS 22nd CGV Clone Coding")
.version("1.0.0");
}
}
Loading