diff --git a/build.gradle b/build.gradle index deca6ed..505f1fd 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' @@ -35,6 +37,14 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // JWT (jjwt 0.12.3 버전) + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' } tasks.named('test') { diff --git "a/docs/6\354\243\274\354\260\250_Spring_Data_JPA_\354\240\225\353\246\254.md" "b/docs/6\354\243\274\354\260\250_Spring_Data_JPA_\354\240\225\353\246\254.md" new file mode 100644 index 0000000..1eac85f --- /dev/null +++ "b/docs/6\354\243\274\354\260\250_Spring_Data_JPA_\354\240\225\353\246\254.md" @@ -0,0 +1,258 @@ +# 6️⃣ Spring Data JPA & 서비스 아키텍처 — 학습 정리 & 과제 + +> 멋쟁이사자처럼 6주차 세션 정리 문서 + + +> 주제: Spring Data JPA가 실제로 어떻게 동작하는지, 그리고 계층형 서비스 아키텍처 + +--- + +## 📌 목차 + +1. [오늘 배운 개념 정리 (본인 말로)](#1-오늘-배운-개념-정리-본인-말로) + - [1-1. ORM과 Spring Data JPA](#1-1-orm과-spring-data-jpa) + - [1-2. 영속성 컨텍스트와 Dirty Checking](#1-2-영속성-컨텍스트와-dirty-checking) + - [1-3. JpaRepository 뜯어보기](#1-3-jparepository-뜯어보기) + - [1-4. 쿼리 메서드 & @Query](#1-4-쿼리-메서드--query) + - [1-5. 계층형 아키텍처와 @Transactional](#1-5-계층형-아키텍처와-transactional) + - [1-6. [번외] Drizzle ORM — JPA 말고 다른 ORM이 궁금해서 찾아봄](#1-6-번외-drizzle-orm--jpa-말고-다른-orm이-궁금해서-찾아봄) +2. [실습 결과](#2-실습-결과-필수-요건-캡쳐) +3. [회고](#3-회고) + +--- + +## 1. 오늘 배운 개념 정리 + +### 1-1. ORM과 Spring Data JPA + +**ORM(Object-Relational Mapping)** 은 자바 객체와 DB 테이블을 자동으로 연결해 주는 기술이다. +예전 JDBC 방식에서는 `INSERT INTO boards (title, content) VALUES (?, ?)` 같은 SQL을 직접 손으로 짜야 했지만, +JPA를 쓰면 `boardRepository.save(board)` 한 줄이면 JPA가 알아서 SQL을 생성해 실행해 준다. +**SQL을 직접 안 써도 되니까 생산성이 크게 올라간다**는 게 핵심이라고 이해했다. + +용어가 헷갈렸는데 정리하면 이렇다. + +| 용어 | 한 줄 정의 | 비유 | +| --- | --- | --- | +| **JPA** | 자바 표준 ORM **명세(인터페이스 모음)** | "이렇게 동작해야 한다"는 규격서 | +| **Hibernate** | JPA 명세의 **실제 구현체** | 규격서대로 만든 진짜 제품 | +| **Spring Data JPA** | JPA를 더 쉽게 쓰도록 Spring이 한 번 더 감싼 것 | 제품을 더 편하게 쓰는 리모컨 | + +→ 우리가 `JpaRepository`만 상속받아도 `save()`, `findAll()`, `findById()`가 공짜로 생기는 이유가 바로 **Spring Data JPA** 덕분이다. + +### 1-2. 영속성 컨텍스트와 Dirty Checking + +가장 인상 깊었던 부분. **영속성 컨텍스트(Persistence Context)** 는 JPA가 엔티티 객체를 관리하는 메모리 공간(1차 캐시)이다. + +`@Transactional` 안에서 일어나는 일을 순서대로 보면: + +```text +1. findById(id) 호출 + → DB에서 Board 조회 + → 영속성 컨텍스트에 저장 (이때 "스냅샷"도 같이 떠 둔다) + +2. board.update(title, content) 호출 + → 영속성 컨텍스트 안의 Board 객체 값만 바뀐다 + +3. 트랜잭션 종료(commit) + → 처음 떠 둔 스냅샷 vs 현재 객체 상태를 비교 + → 달라진 컬럼 발견 → UPDATE SQL 자동 실행! +``` + +이게 바로 **Dirty Checking(변경 감지)** 이다. +실제 우리 `BoardService.update()` 코드를 보면 값만 바꾸고 **`save()`를 호출하지 않는다.** + +```java +// src/main/java/com/likelion/session/service/BoardService.java +public BoardResponse update(Long id, BoardUpdateRequest request) { + Board board = boardRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id)); + + board.update(request.getTitle(), request.getContent()); // 값만 변경 + // boardRepository.save(board) ← 없음! 그래도 UPDATE 됨 + + return BoardResponse.builder() /* ... */ .build(); +} +``` + +`save()`를 안 했는데도 UPDATE 쿼리가 나가는 걸 콘솔에서 직접 확인했다 → [실습 2 캡쳐](#-실습-2--dirty-checking-savesave-호출-없이-update-반영) + +> 참고: `@Transactional(readOnly = true)` 를 조회 메서드에 붙이면 JPA가 Dirty Checking을 **비활성화**해서 스냅샷을 안 떠 두므로 성능이 좋아진다. 그래서 조회 전용 메서드(`findAll`, `searchByTitle` 등)에는 전부 `readOnly = true`를 붙였다. + +### 1-3. JpaRepository 뜯어보기 + +`extends JpaRepository` 한 줄이면 아래 메서드를 그냥 쓸 수 있다. + +| 메서드 | 설명 | +| --- | --- | +| `save(entity)` | 저장 또는 수정 (id 없으면 INSERT, 있으면 UPDATE) | +| `findById(id)` | id로 단건 조회 → `Optional` 반환 | +| `findAll()` | 전체 조회 | +| `delete(entity)` / `deleteById(id)` | 삭제 | +| `count()` | 전체 개수 | +| `existsById(id)` | 존재 여부 확인 | + +`findById`가 `Optional`을 반환하기 때문에 우리 코드에서 `.orElseThrow(...)`로 "없으면 예외" 처리를 깔끔하게 할 수 있었다. + +### 1-4. 쿼리 메서드 & @Query + +**쿼리 메서드**: 메서드 **이름**만 규칙대로 지으면 Spring Data JPA가 SQL을 자동 생성한다. 규칙은 `find + By + 필드명 + 조건`. + +이번 실습에서 `BoardRepository`에 직접 추가한 코드: + +```java +// src/main/java/com/likelion/session/repository/BoardRepository.java +public interface BoardRepository extends JpaRepository { + + // 제목에 keyword가 "포함"된 글 → LIKE '%keyword%' + List findByTitleContaining(String keyword); + + // 작성자가 "정확히 일치"하는 글 → WHERE writer = ? + List findByWriter(String writer); + + // 너무 복잡해서 직접 JPQL 작성 (@Query) + @Query("SELECT b FROM Board b WHERE b.title LIKE %:keyword% OR b.content LIKE %:keyword% ORDER BY b.createdAt DESC") + List searchByKeyword(@Param("keyword") String keyword); + + // 페이지네이션 적용 검색 (Pageable 파라미터 추가) + Page findByTitleContaining(String keyword, Pageable pageable); +} +``` + +알게 된 키워드 정리: + +| 키워드 | 의미 | 예시 | +| --- | --- | --- | +| `Containing` | `LIKE '%값%'` | `findByTitleContaining` | +| `StartingWith` / `EndingWith` | `LIKE '값%'` / `LIKE '%값'` | `findByTitleStartingWith` | +| `OrderBy...Desc` | 정렬 | `findByWriterOrderByCreatedAtDesc` | +| `And` / `Or` | 조건 결합 | `findByTitleAndWriter` | + +**`@Query`** 는 쿼리 메서드 이름으로 표현하기 너무 복잡할 때 직접 JPQL을 쓰는 방법. +JPQL은 SQL과 비슷하지만 **테이블명이 아니라 엔티티 클래스명(`Board`)** 을 쓴다는 게 포인트. + +### 1-5. 계층형 아키텍처와 @Transactional + +**Controller → Service → Repository** 로 역할을 쪼개는 이유를 코드로 체감했다. + +| 계층 | 역할 | +| --- | --- | +| **Controller** | HTTP 요청/응답만 담당. 비즈니스 로직 X. Service에 위임만. | +| **Service** | 실제 비즈니스 로직. `@Transactional` 관리. **여러 Repository 조합 가능.** | +| **Repository** | DB 접근/쿼리 실행만. | + +이번 실습에서 `delete()`가 좋은 예시였다. 게시글을 지우기 전에 **삭제 이력을 남기려고 Service에서 Repository 두 개를 조합**했다. + +```java +// src/main/java/com/likelion/session/service/BoardService.java +public void delete(Long id) { + Board board = boardRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id)); + + // 삭제 이력 저장 (Repository 2개를 한 트랜잭션에서 조합!) + DeleteLog log = new DeleteLog(board.getId(), board.getTitle(), board.getWriter()); + deleteLogRepository.save(log); + + boardRepository.delete(board); // 게시글 삭제 +} +``` + +Controller는 이 안에서 Repository를 몇 개 쓰는지 **전혀 몰라도 된다.** 이게 계층 분리의 힘. + +**@Transactional** 핵심 정리: + +- 한 메서드 안의 여러 DB 작업을 "전부 성공 or 전부 롤백"으로 묶어 **데이터 일관성**을 보장한다. + (게시글 저장은 성공했는데 알림 저장이 실패하면 → 전체 롤백되어 게시글도 저장 안 됨) +- `readOnly = true` 는 조회 전용 트랜잭션임을 알려 Dirty Checking을 끄고 성능을 높인다. 조회 메서드엔 항상 붙이는 게 권장. + +### 1-6. [번외] Drizzle ORM — JPA 말고 다른 ORM이 궁금해서 찾아봄 + +> 보조강사를 하면서 다른 분들 코드를 보다가 ORM 이야기가 나왔는데, "ORM = Java의 JPA"라고만 생각하고 있던 게 좀 걸렸다. ORM은 특정 언어의 것이 아니라 **개념**이라, 다른 생태계에선 어떻게 생겼는지 궁금해서 TypeScript 진영에서 요즘 많이 쓴다는 **Drizzle ORM**을 찾아봤다. + +**Drizzle ORM이 뭔가?** + +- **TypeScript 전용 ORM**. Node.js / 서버리스(Edge) 환경에서 SQL DB(PostgreSQL · MySQL · SQLite 등)를 다룰 때 쓴다. +- 핵심 철학은 **"If you know SQL, you know Drizzle"** — 쿼리 빌더가 SQL 문법에 거의 1:1로 대응돼서, JPQL처럼 추상화된 별도 쿼리 언어를 새로 배울 필요가 거의 없다. +- **코드 우선(code-first)**: 스키마를 별도 파일/언어가 아니라 **TypeScript 코드로 직접 정의**한다. 스키마를 고치면 타입이 즉시 따라 바뀌고, 별도 코드 생성(generate) 단계가 없다. +- **가볍다**: 쿼리 엔진 같은 무거운 레이어 없이 SQL 문자열을 바로 생성 → ORM 오버헤드가 작고 번들 크기도 작다. 그래서 Edge/서버리스 런타임(Neon, Cloudflare D1, PlanetScale, Turso 등)에서 잘 돌아간다. + +**JPA(우리가 배운 것)와 비교해서 이해해 본 것:** + +| 관점 | Spring Data JPA (Java) | Drizzle ORM (TypeScript) | +| --- | --- | --- | +| 추상화 수준 | 높음 — 영속성 컨텍스트·Dirty Checking 등 ORM이 많이 대신해 줌 | 낮음 — SQL에 가깝게, 개발자가 쿼리를 거의 그대로 통제 | +| 스키마 정의 | `@Entity` 붙인 자바 클래스 | TypeScript 코드로 테이블 정의 | +| 쿼리 방식 | 쿼리 메서드 / JPQL(`@Query`) | SQL 같은 쿼리 빌더 + 관계형 쿼리 API | +| 변경 감지 | **Dirty Checking 있음** (`save()` 없이 UPDATE) | 그런 자동 감지 개념 없음 — `update()`를 명시적으로 호출 | +| 마이그레이션 | Hibernate `ddl-auto` 등 | Drizzle Kit | + +> 같은 TypeScript 진영의 **Prisma**는 반대로 자체 스키마 언어(PSL)를 쓰고 추상화가 더 높은 편이라, 오히려 **JPA의 감성에 더 가깝다**고 느꼈다. 2026년 기준 분위기는 "서버리스/Edge 위주면 Drizzle, 복잡한 관계형·모놀리식이면 Prisma"로 갈리는 듯. +> +> **결론적으로 배운 점**: Dirty Checking처럼 "ORM이 알아서 해 주는 마법"은 JPA(영속성 컨텍스트)의 특징이지 모든 ORM의 공통 기능이 아니다. Drizzle은 일부러 그 마법을 빼고 SQL에 가깝게 가는 방향을 택했다. ORM이라는 같은 단어 안에도 **"얼마나 추상화할 것인가"** 라는 스펙트럼이 있다는 걸 알게 됐다. + +> 출처: +> [Prisma 공식 문서 — Prisma ORM vs Drizzle](https://www.prisma.io/docs/orm/more/comparisons/prisma-and-drizzle) · +> [Drizzle vs Prisma in 2026 (Encore)](https://encore.dev/articles/drizzle-vs-prisma) · +> [Drizzle vs Prisma ORM in 2026 (MakerKit)](https://makerkit.dev/blog/tutorials/drizzle-vs-prisma) + +--- + +## 2. 실습 결과 + +### ✅ 실습 1 — 검색 API 3종 정상 응답 + +#### ① `GET /boards/search/title` — 제목으로 검색 + +`keyword`가 **제목에 포함**된 게시글을 조회. (`findByTitleContaining`) +응답 `200`, `title`에 키워드가 포함된 게시글이 정상 반환됨. + +![제목으로 검색 결과](../src/main/resources/search-title.png) + +#### ② `GET /boards/search/writer` — 작성자로 검색 + +`writer`가 **정확히 일치**하는 게시글을 조회. (`findByWriter`) +응답 `200`, `writer: "원종윤"` 게시글이 정상 반환됨. + +![작성자로 검색 결과](../src/main/resources/search-writer.png) + +#### ③ `GET /boards/search` — 제목+내용 통합 검색 + +`@Query`(JPQL)로 **제목 또는 내용**에 키워드가 포함된 글을 검색. (`searchByKeyword`) +응답 `200`, `keyword=p`로 검색 시 정상 반환됨. + +![통합 검색 결과](../src/main/resources/search.png) + +### ✅ 실습 2 — Dirty Checking (`save()` 호출 없이 UPDATE 반영) + +`PUT /boards/{id}` 호출 후 IntelliJ 콘솔. +`BoardService.update()`에 `boardRepository.save()`가 **없는데도** Hibernate가 +`update boards set content=?, title=?, updated_at=?, writer=? where id=?` 쿼리를 자동으로 날렸다. +→ **변경 감지(Dirty Checking) 동작 확인 완료** ✅ + +![Dirty Checking - 콘솔 UPDATE SQL 출력](../src/main/resources/dirty-checking.png) + +### ✅ 실습 3 — 여러 Repository 조합: 삭제 이력 남기기 + +게시글을 `DELETE` 한 뒤 `GET /boards/delete-logs` 호출. +`boardId`, `boardTitle`, `writer`, `deletedAt` 필드가 모두 정상적으로 담겨 반환됨. +(Service에서 `boardRepository` + `deleteLogRepository`를 조합한 결과) + +![삭제 이력 조회 응답](../src/main/resources/delete-logs.png) + +### ✅ 실습 4 — MySQL Workbench에서 직접 확인 + +`SELECT * FROM delete_logs;` 실행 결과. +DB의 `delete_logs` 테이블에도 삭제 이력이 실제로 INSERT 된 것을 확인. + +![MySQL Workbench - delete_logs 조회](../src/main/resources/delete-logs-mysql.png) + +--- + +## 3. 회고 + +- `save()`를 호출하지 않았는데 UPDATE 쿼리가 콘솔에 찍히는 걸 직접 보니 **영속성 컨텍스트 / Dirty Checking**이 머리에 확실히 박혔다. "스냅샷을 떠 두고 commit 시점에 비교한다"는 흐름이 핵심. +- 쿼리 메서드는 이름 규칙만 알면 SQL을 안 짜도 돼서 편하지만, 조금만 복잡해지면 `@Query`로 JPQL을 직접 쓰는 게 낫다는 걸 `searchByKeyword`에서 체감했다. JPQL은 **테이블이 아니라 엔티티명**을 쓴다는 점을 주의. +- `delete()`에서 Repository 2개를 한 Service 메서드 안에서 조합하면서, Controller가 내부 구현을 전혀 몰라도 되는 **계층 분리의 장점**을 실제로 느꼈다. 나중에 DB나 API 경로가 바뀌어도 해당 계층만 고치면 된다. +- 조회 메서드에 `@Transactional(readOnly = true)`를 빠짐없이 붙이는 습관을 들여야겠다고 생각했다. + +--- diff --git "a/docs/7\354\243\274\354\260\250_Spring_Security_JWT_\354\240\225\353\246\254.md" "b/docs/7\354\243\274\354\260\250_Spring_Security_JWT_\354\240\225\353\246\254.md" new file mode 100644 index 0000000..f943cf7 --- /dev/null +++ "b/docs/7\354\243\274\354\260\250_Spring_Security_JWT_\354\240\225\353\246\254.md" @@ -0,0 +1,126 @@ +# 7️⃣ Spring Security & JWT — 학습 정리 & 과제 + +## 📌 핵심 개념 한 줄 정리 + +- **인증(Authentication)**: "너 누구야?" — 로그인 +- **인가(Authorization)**: "너 이거 해도 돼?" — 권한 검사 +- **Spring Security**: 경비 시스템 전체 (필터, 인증 매니저, 암호화 다 포함) +- **JWT**: 그 경비 시스템에서 쓰는 출입증 한 장 (토큰 자체) + +--- + +## 1. 로그인 흐름 — 본인 언어로 + +```text +[Client] + ↓ POST /login (username, password) +[LoginFilter] - 우리가 만든 커스텀 필터 + ↓ 토큰에 담아 검증 요청 +[AuthenticationManager] + ↓ +[CustomUserDetailsService] - DB 조회 + ↓ +[비밀번호 비교 - BCrypt] + ↓ 성공 +[LoginFilter.successfulAuthentication] + ↓ JWT 발급 +[Response Header: Authorization: Bearer xxx] +``` + +### 단계별로 다시 보기 + +**① Client → LoginFilter** +사용자가 `POST /login`으로 username/password를 보내면, 컨트롤러까지 가기 전에 **LoginFilter가 가로챈다**. 그래서 `LoginController`는 사실 텅 빈 껍데기 — Swagger 노출용으로만 존재함. 진짜 로직은 필터에 있음. + +**② LoginFilter → AuthenticationManager** +필터가 username/password를 그대로 매니저에 못 넘긴다. Spring Security의 규칙이 **"검증은 토큰 형태로 주고받자"** 이기 때문. 그래서 `UsernamePasswordAuthenticationToken`에 담아서 `authenticationManager.authenticate(token)` 호출. + +**③ AuthenticationManager → CustomUserDetailsService** +매니저는 우리 DB 구조를 모름. 그래서 "사용자 정보는 여기서 가져와" 라고 알려주는 통로가 필요한데, 그게 `UserDetailsService` 인터페이스. 우리는 `CustomUserDetailsService`로 구현해서 `userRepository.findByUsername()`으로 DB에서 user를 꺼낸 뒤 `CustomUserDetails`로 감싸 반환한다. +→ 왜 User를 그대로 안 쓰고 감싸지? **Spring Security는 자기 규격(`UserDetails`)만 인식**하기 때문. 일종의 어댑터 패턴. + +**④ BCrypt 비밀번호 비교** +DB에 저장된 비번은 평문이 아니라 BCrypt로 해싱된 값. `DaoAuthenticationProvider`가 사용자가 입력한 평문을 같은 방식으로 해싱한 뒤 DB 해시와 매칭. BCrypt는 단방향이라 복호화는 불가능하지만, **같은 입력은 같은 해시**가 나오니까 비교는 가능. + +**⑤ successfulAuthentication → JWT 발급** +검증 성공하면 `LoginFilter.successfulAuthentication()`이 실행됨. 여기서 `JWTUtil.createJwt(username, role, 만료ms)`로 토큰 만들고, **응답 헤더 `Authorization: Bearer xxx`** 에 담아서 클라이언트에 돌려준다. +→ `Bearer` 뒤의 공백은 HTTP 표준이라 절대 빼면 안 됨. +→ 실패 시엔 `unsuccessfulAuthentication()`에서 401 반환. + +--- + +## 2. 인증된 API 요청 흐름 (보너스) + +로그인 이후 JWT를 받았다면, 이후 모든 요청은 이 흐름을 탄다: + +```text +[Client] + ↓ GET /boards (Authorization: Bearer xxx) +[JWTFilter] - OncePerRequestFilter 상속, 매 요청마다 1회 실행 + ↓ 토큰 추출 → 만료 검증 → username/role 꺼냄 + ↓ User 객체를 즉석에서 만들어 SecurityContext에 등록 (DB 안 감!) +[BoardController] + ↓ 정상 응답 +``` + +**핵심 깨달음**: JWTFilter는 **DB에 가지 않는다.** 서명 검증을 통과했다는 것 = "우리 서버가 발급한 토큰이 맞다"는 증명. 매 요청마다 DB 가면 부담만 커지니까, 토큰 자체를 신뢰 증거로 쓰는 게 JWT의 핵심 철학이다. + +**왜 JWTFilter를 LoginFilter `Before`에 끼우는가?** +일반 API 요청은 JWT가 있으니까 JWTFilter가 먼저 검증해야 하고, 로그인 요청은 JWT가 없으니까 JWTFilter는 통과시키고 LoginFilter가 처리해야 한다. 두 시나리오를 모두 만족시키려면 **JWTFilter가 더 앞**에 있어야 함. + +--- + +## 3. 실습 결과 + +### 로그인 성공 → JWT 발급 + +![로그인 응답 헤더에 JWT 담겨옴](./화면%20캡처%202026-05-24%20225140.png) + +응답 본문은 비어있고 **Response Headers의 `authorization`** 에 토큰이 담긴다. (처음엔 Body만 보고 "토큰 어디 있지?" 했음) + +### Authorize에 토큰 넣고 게시글 생성 + +![JWT 인증 통과 후 게시글 생성 성공](./화면%20캡처%202026-05-24%20225929.png) + +Swagger 우상단 `Authorize` 버튼에 토큰 붙여넣고 `POST /boards` 호출 → 200 OK. + +--- + +## 4. Swagger 테스트하면서 만난 에러 (디버깅 기록) + +깔끔하게 끝났으면 좋았는데 두 번 막혔다. 이 과정에서 오히려 Spring Security 동작이 더 잘 이해됨. + +### 🚨 에러 1: `/login`에서 401 — `NonUniqueResultException` + +**증상**: 비번 맞는데도 401. + +**원인**: DB에 `boo` 유저가 **3명**이나 쌓여 있었음. `findByUsername`은 단일 User 반환이라 예외 발생 → 매니저 내부에서 던져진 예외 → `unsuccessfulAuthentication()` → 401. + +왜 3명이나? `DataInitializer`에 버그가 있었음: +```java +if (userRepository.existsByUsername("test")) return; // ← "test" 체크 +user.setUsername("boo"); // ← 실제로는 "boo" 생성 +``` +체크 이름과 생성 이름이 달라서 재시작할 때마다 boo가 새로 생성됨. 게다가 username에 unique 제약도 없었음. + +**해결**: ① 버그 수정 (체크/생성 이름 일치) ② User 엔티티에 `@Column(unique = true)` 추가 ③ MySQL에서 중복 row DELETE. + +**배운 점**: **401이 무조건 "비번 틀림"이 아니다.** 인증 단계에서 어떤 예외든 던져지면 401로 떨어짐. 서버 로그를 봐야 진짜 원인이 보임. + +### 🚨 에러 2: `/boards`에서 403 — JWT가 36초만에 만료 + +**증상**: 로그인 성공해서 토큰 넣었는데 403. + +**원인**: 토큰 디코드해보니 `exp - iat = 36초`. `LoginFilter`에: +```java +String token = jwtUtil.createJwt(username, role, 60*60*10L); //36000, 10시간 +``` +`60*60*10 = 36,000` 인데 `expiredMs`는 **밀리초(ms) 단위**라서 실제론 **36초**. 주석은 "10시간"이라 적혀있지만 `1000`을 빠뜨린 단위 변환 실수. + +**왜 401이 아니라 403?**: JJWT 0.12부터 만료된 토큰을 만나면 `parseSignedClaims()`가 `ExpiredJwtException`을 throw → 필터 밖으로 던져져서 Spring Security가 **"토큰은 있는데 유효하지 않음"** = 인가 거부 = **403**으로 처리. + +**해결**: `60*60*10*1000L`로 수정 (= 36,000,000 ms = 10시간). + +**배운 점**: **401(인증 실패) vs 403(인가 실패) 차이를 실전으로 체득.** +- 401: 너 누군지 모르겠어 → 로그인부터 다시 +- 403: 너 누군진 알겠는데 이거 하면 안 돼 → 권한 부족 / 토큰 만료 diff --git "a/docs/\355\231\224\353\251\264 \354\272\241\354\262\230 2026-05-24 225140.png" "b/docs/\355\231\224\353\251\264 \354\272\241\354\262\230 2026-05-24 225140.png" new file mode 100644 index 0000000..8b09166 Binary files /dev/null and "b/docs/\355\231\224\353\251\264 \354\272\241\354\262\230 2026-05-24 225140.png" differ diff --git "a/docs/\355\231\224\353\251\264 \354\272\241\354\262\230 2026-05-24 225929.png" "b/docs/\355\231\224\353\251\264 \354\272\241\354\262\230 2026-05-24 225929.png" new file mode 100644 index 0000000..ebe99cd Binary files /dev/null and "b/docs/\355\231\224\353\251\264 \354\272\241\354\262\230 2026-05-24 225929.png" differ diff --git a/src/main/java/com/likelion/session/config/DataInitializer.java b/src/main/java/com/likelion/session/config/DataInitializer.java new file mode 100644 index 0000000..97ee53e --- /dev/null +++ b/src/main/java/com/likelion/session/config/DataInitializer.java @@ -0,0 +1,33 @@ +package com.likelion.session.config; + +import com.likelion.session.domain.User; +import com.likelion.session.repository.UserRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class DataInitializer implements CommandLineRunner { + + private final UserRepository userRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + public DataInitializer(UserRepository userRepository, + BCryptPasswordEncoder bCryptPasswordEncoder) { + this.userRepository = userRepository; + this.bCryptPasswordEncoder = bCryptPasswordEncoder; + } + + @Override + public void run(String... args) { + if (userRepository.existsByUsername("boo")) return; // 이미 boo가 있으면 생성 안 함 + + User user = new User(); + user.setUsername("boo"); + user.setPassword(bCryptPasswordEncoder.encode("1105")); + user.setRole("ROLE_ADMIN"); + userRepository.save(user); + + System.out.println("✅ 테스트 사용자 생성"); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/config/SecurityConfig.java b/src/main/java/com/likelion/session/config/SecurityConfig.java new file mode 100644 index 0000000..140da54 --- /dev/null +++ b/src/main/java/com/likelion/session/config/SecurityConfig.java @@ -0,0 +1,76 @@ +package com.likelion.session.config; + +import com.likelion.session.jwt.JWTFilter; +import com.likelion.session.jwt.JWTUtil; +import com.likelion.session.jwt.LoginFilter; +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.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + // 만든 필터는 여기를 거친다 + + private final AuthenticationConfiguration authenticationConfiguration; + //JWTUtil 주입 + private final JWTUtil jwtUtil; + + public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) { + this.authenticationConfiguration = authenticationConfiguration; + this.jwtUtil = jwtUtil; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + + return configuration.getAuthenticationManager(); + } + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + + http + .csrf((auth) -> auth.disable()); + + http + .formLogin((auth) -> auth.disable()); + + http + .httpBasic((auth) -> auth.disable()); + + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers( "/login","/", "/join", "/boards/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated()); + + //JWTFilter 등록 + http + .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); + // 왜 before인가 + + // 기존 필더를 우리가 만든 필터로 교체. 인자도 잘 추가됨 + http + .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class); + + http + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/config/SwaggerConfig.java b/src/main/java/com/likelion/session/config/SwaggerConfig.java new file mode 100644 index 0000000..48ff0bc --- /dev/null +++ b/src/main/java/com/likelion/session/config/SwaggerConfig.java @@ -0,0 +1,32 @@ +package com.likelion.session.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + String jwtSchemeName = "jwtAuth"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .info(new Info().title("Session & Board API") + .description("게시판 및 JWT 로그인 API 명세서") + .version("1.0.0")) + .addSecurityItem(securityRequirement) + .components(components); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/controller/BoardController.java b/src/main/java/com/likelion/session/controller/BoardController.java index 2af74e7..395712d 100644 --- a/src/main/java/com/likelion/session/controller/BoardController.java +++ b/src/main/java/com/likelion/session/controller/BoardController.java @@ -9,14 +9,19 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +// 6주차 추가 +import com.likelion.session.dto.BoardPageResponse; + +import com.likelion.session.dto.DeleteLogResponse; + import java.util.List; -@RestController -@RequestMapping("/boards") -@RequiredArgsConstructor +@RestController // JSON 형태로 응답을 보내는 컨트롤러 +@RequestMapping("/boards") // 이 컨트롤러의 모든 URL에 /boards를 붙여줌 +@RequiredArgsConstructor // final이 붙은 필드(boardService)를 생성자로 자동 주입해줌 public class BoardController { - private final ; + private final BoardService boardService; /* 게시글 생성 @@ -36,8 +41,9 @@ public class BoardController { summary = "게시글 생성", description = "새로운 게시글을 생성합니다." ) - @ + @PostMapping // 데이터 생성 public ResponseEntity create(@RequestBody BoardCreateRequest request) { + // @RequestBody: 클라이언트가 보낸 JSON 데이터를 자바 객체로 변환해줌 BoardResponse response = boardService.create(request); return ResponseEntity.ok(response); } @@ -47,7 +53,7 @@ public ResponseEntity create(@RequestBody BoardCreateRequest requ summary = "게시글 전체 조회", description = "등록된 모든 게시글을 조회합니다." ) - @ + @GetMapping // 데이터 조회 public ResponseEntity> findAll() { List response = boardService.findAll(); return ResponseEntity.ok(response); @@ -60,6 +66,7 @@ public ResponseEntity> findAll() { ) @GetMapping("/{id}") public ResponseEntity findById(@PathVariable Long id) { + // @PathVariable: URL 경로에 있는 {id} 값을 파라미터 변수로 받아옴 BoardResponse response = boardService.findById(id); return ResponseEntity.ok(response); } @@ -69,7 +76,7 @@ public ResponseEntity findById(@PathVariable Long id) { summary = "게시글 수정", description = "id로 특정 게시글의 제목과 내용을 수정합니다." ) - @("/{id}") + @PutMapping("/{id}") // 데이터 수정 public ResponseEntity update(@PathVariable Long id, @RequestBody BoardUpdateRequest request) { BoardResponse response = boardService.update(id, request); @@ -81,9 +88,64 @@ public ResponseEntity update(@PathVariable Long id, summary = "게시글 삭제", description = "id로 특정 게시글을 삭제합니다." ) - @("/{id}") + @DeleteMapping("/{id}") // 데이터 삭제 public ResponseEntity delete(@PathVariable Long id) { boardService.delete(id); return ResponseEntity.noContent().build(); } + + // BoardController.java 에 아래 메서드들 추가 + + // 제목으로 검색 + @Operation( + summary = "제목으로 게시글 검색", + description = "제목에 키워드가 포함된 게시글을 조회합니다." + ) + @GetMapping("/search/title") // 경로만 다르고 패턴 동일 + public ResponseEntity> searchByTitle(@RequestParam String keyword) { + List response = boardService.searchByTitle(keyword); + return ResponseEntity.ok(response); + } + + // 작성자로 검색 + @Operation( + summary = "작성자로 게시글 검색", + description = "특정 작성자의 게시글을 조회합니다." + ) + @GetMapping("/search/writer") // 경로만 다르고 패턴 동일 + public ResponseEntity> searchByWriter(@RequestParam String writer) { + List response = boardService.searchByWriter(writer); + return ResponseEntity.ok(response); + } + + // 통합 검색 + @Operation( + summary = "게시글 통합 검색", + description = "제목 또는 내용에 키워드가 포함된 게시글을 조회합니다." + ) + @GetMapping("/search") // 경로만 다르고 패턴 동일 + public ResponseEntity> search(@RequestParam String keyword) { + List response = boardService.search(keyword); + return ResponseEntity.ok(response); + } + + // 페이지네이션 전체 조회 + @Operation( + summary = "게시글 페이지 조회", + description = "페이지네이션이 적용된 게시글 목록을 조회합니다." + ) + @GetMapping("/page") // 경로만 다르고 패턴 동일 + public ResponseEntity findAllWithPage( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + BoardPageResponse response = boardService.findAllWithPage(page, size); + return ResponseEntity.ok(response); + } + + // 삭제 이력 조회 + @Operation(summary = "삭제 이력 조회", description = "삭제된 게시글의 이력을 조회합니다.") + @GetMapping("/delete-logs") + public ResponseEntity> findAllDeleteLogs() { + return ResponseEntity.ok(boardService.findAllDeleteLogs()); + } } \ No newline at end of file diff --git a/src/main/java/com/likelion/session/controller/LoginController.java b/src/main/java/com/likelion/session/controller/LoginController.java new file mode 100644 index 0000000..1dce710 --- /dev/null +++ b/src/main/java/com/likelion/session/controller/LoginController.java @@ -0,0 +1,23 @@ +package com.likelion.session.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "Authentication", description = "인증 관련 API") +public class LoginController { + + @Operation(summary = "로그인", description = "username과 password를 입력하여 JWT 토큰을 발급받습니다.") + @PostMapping(value = "/login", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public void login( + @Parameter(description = "아이디") @RequestParam("username") String username, + @Parameter(description = "비밀번호") @RequestParam("password") String password + ) { + // Spring Security LoginFilter가 처리 + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/domain/Board.java b/src/main/java/com/likelion/session/domain/Board.java index cb873de..ea1aced 100644 --- a/src/main/java/com/likelion/session/domain/Board.java +++ b/src/main/java/com/likelion/session/domain/Board.java @@ -8,36 +8,38 @@ import java.time.LocalDateTime; -@Getter // getter 메서드 자동 생성 +@Getter // getter 메서드 자동 생성, Lombok 어노테이션 @Entity // 해당 클래스 DB 테이블로 인식하고 관리 -@Table(name = "boards") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor +@Table(name = "boards") // 실제 DB에 생성될 테이블 이름 지정. 없으면 클래스 이름(Board)이 테이블 이름이 됨. +@NoArgsConstructor(access = AccessLevel.PROTECTED) // 파라미터 없는 기본 생성자를 만들어줌. +// PROTECTED로 설정하는 이유: 외부에서 new Board()로 마음대로 빈 객체를 만들지 못하게 막기 위해서. JPA 내부에서는 필요하기 때문에 완전히 막지는 않음. +@AllArgsConstructor // 모든 필드를 파라미터로 받는 생성자를 자동 생성. public class Board { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long ; + @Id // 이 필드가 테이블의 기본키(Primary Key)임을 나타냄. + @GeneratedValue(strategy = GenerationType.IDENTITY) // id 값을 DB가 자동으로 1, 2, 3... 순서대로 증가시켜 직접 1, 2, 3, ..을 입력할 필요 없음 + private Long id; // 게시글 제목 - @Column(nullable = false, length = 100) - private String ; + @Column(nullable = false, length = 100) // 해당 컬럼의 옵션 설정 + // nullable = false는 NULL 불가, length = 100은 최대 100자 제한. + private String title; // 게시글 내용 @Column(nullable = false, columnDefinition = "TEXT") - private String ; + private String content; // 작성자 @Column(nullable = false, length = 30) - private String ; + private String writer; // 생성 시간 @Column(nullable = false) - private LocalDateTime ; + private LocalDateTime createdAt; // 수정 시간 @Column(nullable = false) - private LocalDateTime ; + private LocalDateTime updatedAt; public Board(String title, String content, String writer) { @@ -46,13 +48,15 @@ public Board(String title, String content, String writer) { this.writer = writer; } - @PrePersist + @PrePersist // DB에 저장되기 직전에 자동으로 실행되는 메서드. + // createdAt(생성 시간)을 현재 시간으로 자동으로 채워줌. public void prePersist() { this.createdAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now(); } - @PreUpdate + @PreUpdate // DB에서 수정되기 직전에 자동으로 실행됨. + // updatedAt(수정 시간)을 갱신. public void preUpdate() { this.updatedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/likelion/session/domain/DeleteLog.java b/src/main/java/com/likelion/session/domain/DeleteLog.java new file mode 100644 index 0000000..09ca409 --- /dev/null +++ b/src/main/java/com/likelion/session/domain/DeleteLog.java @@ -0,0 +1,31 @@ +package com.likelion.session.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AccessLevel; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "delete_logs") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DeleteLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long boardId; // 삭제된 게시글 id + private String boardTitle; // 삭제된 게시글 제목 + private String writer; // 작성자 + private LocalDateTime deletedAt; // 삭제 시간 + + public DeleteLog(Long boardId, String boardTitle, String writer) { + this.boardId = boardId; + this.boardTitle = boardTitle; + this.writer = writer; + this.deletedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/domain/User.java b/src/main/java/com/likelion/session/domain/User.java new file mode 100644 index 0000000..a2a355b --- /dev/null +++ b/src/main/java/com/likelion/session/domain/User.java @@ -0,0 +1,25 @@ +package com.likelion.session.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Setter +@Getter +public class User{ + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column(unique = true, nullable = false) + private String username; + private String password; + + private String role; +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/dto/BoardCreateRequest.java b/src/main/java/com/likelion/session/dto/BoardCreateRequest.java index 62b1821..445021e 100644 --- a/src/main/java/com/likelion/session/dto/BoardCreateRequest.java +++ b/src/main/java/com/likelion/session/dto/BoardCreateRequest.java @@ -5,13 +5,16 @@ import lombok.NoArgsConstructor; import lombok.Setter; +// @Getter와 @Setter가 둘 다 붙어 있음. @Getter @Setter -@NoArgsConstructor -@AllArgsConstructor +// Request DTO는 클라이언트가 보낸 JSON 값을 객체에 넣어주는 과정에서 @Setter가 필요함. +// @Setter: setTitle(), setContent() 같은 메서드 자동 생성 +@NoArgsConstructor // 파라미터가 없는 기본 생성자 자동 생성 +@AllArgsConstructor // 모든 필드를 파라미터로 받는 생성자 자동 생성 public class BoardCreateRequest { // 넘겨주고 싶은 정보: 제목(title), 내용(content), 작성자(writer) - private String ; - private String ; - private String ; + private String title; + private String content; + private String writer; } \ No newline at end of file diff --git a/src/main/java/com/likelion/session/dto/BoardPageResponse.java b/src/main/java/com/likelion/session/dto/BoardPageResponse.java new file mode 100644 index 0000000..2037c6b --- /dev/null +++ b/src/main/java/com/likelion/session/dto/BoardPageResponse.java @@ -0,0 +1,19 @@ +package com.likelion.session.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder // 만들어주는거니까 +@AllArgsConstructor +public class BoardPageResponse { + // 페이지네이션 + private List content; // 게시글 목록 + private int totalPages; // 전체 페이지 수 + private long totalElements; // 전체 게시글 수 + private int currentPage; // 현재 페이지 + private int size; // 페이지당 개수 +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/dto/BoardResponse.java b/src/main/java/com/likelion/session/dto/BoardResponse.java index dc6e74f..12dc7d5 100644 --- a/src/main/java/com/likelion/session/dto/BoardResponse.java +++ b/src/main/java/com/likelion/session/dto/BoardResponse.java @@ -6,11 +6,12 @@ import java.time.LocalDateTime; -@Getter -@AllArgsConstructor -@Builder +@Getter // 데이터 조회를 위한 getter 메서드 자동 생성 +@AllArgsConstructor // 모든 필드(id, title 등)를 포함한 생성자 자동 생성 +@Builder // Service에서 .builder().id(xxx).title(xxx)...build() 식으로 필드를 선택적으로 세팅할 수 있음. public class BoardResponse { + // BoardResponse에는 @Setter 없이 @Builder만 있음 // 돌려주고 싶은 응답: id, title, content, writer, createdAt, updatedAt private Long id; private String title; diff --git a/src/main/java/com/likelion/session/dto/BoardSearchRequest.java b/src/main/java/com/likelion/session/dto/BoardSearchRequest.java new file mode 100644 index 0000000..4a7e152 --- /dev/null +++ b/src/main/java/com/likelion/session/dto/BoardSearchRequest.java @@ -0,0 +1,13 @@ +package com.likelion.session.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class BoardSearchRequest { + private String keyword; // 검색어 + private String writer; // 작성자 +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/dto/BoardUpdateRequest.java b/src/main/java/com/likelion/session/dto/BoardUpdateRequest.java index d1e98e4..597274f 100644 --- a/src/main/java/com/likelion/session/dto/BoardUpdateRequest.java +++ b/src/main/java/com/likelion/session/dto/BoardUpdateRequest.java @@ -7,15 +7,15 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -@Getter -@Setter -@NoArgsConstructor +@Getter // 데이터 조회를 위한 getter 메서드 자동 생성 +@Setter // @Setter: setTitle(), setContent() 같은 메서드 자동 생성 +@NoArgsConstructor // 파라미터가 없는 기본 생성자 자동 생성 public class BoardUpdateRequest { - @NotBlank(message = "제목은 필수입니다.") - @Size(max = 100, message = "제목은 100자 이하로 입력해주세요.") + @NotBlank(message = "제목은 필수입니다.") // null, "", " " 모두 허용하지 않음 + @Size(max = 100, message = "제목은 100자 이하로 입력해주세요.") // 문자열 길이를 제한함 private String title; - @NotBlank(message = "내용은 필수입니다.") + @NotBlank(message = "내용은 필수입니다.") // null, "", " " 모두 허용하지 않음 private String content; } diff --git a/src/main/java/com/likelion/session/dto/CustomUserDetails.java b/src/main/java/com/likelion/session/dto/CustomUserDetails.java new file mode 100644 index 0000000..79fdd20 --- /dev/null +++ b/src/main/java/com/likelion/session/dto/CustomUserDetails.java @@ -0,0 +1,77 @@ +package com.likelion.session.dto; + +import com.likelion.session.domain.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +// Spring Security는 자기가 가진 규격에 대한 정보만 인식. 그 규격이 UserDetails +public class CustomUserDetails implements UserDetails { + //User를 UserDetails 형식으로 바꾸어 줌. 바꾸어 주어서 어답터라고 부름 + + private final User userEntity; + + public CustomUserDetails(User userEntity) { + + this.userEntity = userEntity; + } + + + @Override + public Collection getAuthorities() { + // getAuthorities는 사용자의 권한 목록을 반환해주는 메소드 + + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + + @Override + public String getAuthority() { + + return userEntity.getRole(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + // User Password 반환 + + return userEntity.getPassword(); + } + + @Override + public String getUsername() { + // User Name 반환 + + return userEntity.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + // 계정 만료 여부 확인 + return true; // 메소드 이름에 non이 붙어 있어서 true 일 경우 계정 만료되지 않은 것 + } + + @Override + public boolean isAccountNonLocked() { + // 계정 잠김 여부 확인 + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + // 계정이 활성화 상태인지 확인 + return true; + } + + @Override + public boolean isEnabled() { + + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/dto/DeleteLogResponse.java b/src/main/java/com/likelion/session/dto/DeleteLogResponse.java new file mode 100644 index 0000000..fdcc167 --- /dev/null +++ b/src/main/java/com/likelion/session/dto/DeleteLogResponse.java @@ -0,0 +1,18 @@ +package com.likelion.session.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class DeleteLogResponse { + private Long id; + private Long boardId; + private String boardTitle; + private String writer; + private LocalDateTime deletedAt; +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/jwt/JWTFilter.java b/src/main/java/com/likelion/session/jwt/JWTFilter.java new file mode 100644 index 0000000..0c13765 --- /dev/null +++ b/src/main/java/com/likelion/session/jwt/JWTFilter.java @@ -0,0 +1,78 @@ +package com.likelion.session.jwt; + +import com.likelion.session.domain.User; +import com.likelion.session.dto.CustomUserDetails; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +// “한 요청당 한 번만 실행되는 필터” — 같은 필터가 중복 호출되는 걸 방지합니다. +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + + public JWTFilter(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + //request에서 Authorization 헤더를 찾음 + String authorization= request.getHeader("Authorization"); + + //Authorization 헤더 검증 + if (authorization == null || !authorization.startsWith("Bearer ")) { + + System.out.println("token null"); + filterChain.doFilter(request, response); + + // 조건이 해당되면 메소드 종료 (필수) + return; + } + + System.out.println("authorization now"); + // Bearer 부분 제거 후 순수 토큰만 획득 >> 한칸의 공백 이용!(HTTP 표준) + String token = authorization.split(" ")[1]; + + // 토큰 소멸 시간 검증 + if (jwtUtil.isExpired(token)) { + + System.out.println("token expired"); + filterChain.doFilter(request, response); + + // 조건이 해당되면 메소드 종료 (필수) + return; + } + + // 토큰에서 username과 role 획득 + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + + // user를 생성하여 값 set + // DB에 접근을 안하고 이미 인증이 된거라 검증이 되서 굳이 접근 안해서 좋다 + User userEntity = new User(); + userEntity.setUsername(username); + userEntity.setPassword("temppassword"); + userEntity.setRole(role); + + // UserDetails에 회원 정보 객체 담기 + CustomUserDetails customUserDetails = new CustomUserDetails(userEntity); + + // 스프링 시큐리티 인증 토큰 생성 + Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + // 세션에 사용자 등록 + SecurityContextHolder.getContext().setAuthentication(authToken); + + // 다음 필터로 넘김 + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/jwt/JWTUtil.java b/src/main/java/com/likelion/session/jwt/JWTUtil.java new file mode 100644 index 0000000..d5915bd --- /dev/null +++ b/src/main/java/com/likelion/session/jwt/JWTUtil.java @@ -0,0 +1,48 @@ +package com.likelion.session.jwt; + +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component // Bean으로 등록한다 - 다른 클래스도 사용할 수 있다. +public class JWTUtil { + // 헤더.페이로드.시그니처 + + private SecretKey secretKey; // 비밀 키 + + public JWTUtil(@Value("${spring.jwt.secret}")String secret) { + //생성자, secretKey 객체로 변환 + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class); + } + + public String getRole(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + public Boolean isExpired(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } + + public String createJwt(String username, String role, Long expiredMs) { + + return Jwts.builder() + .claim("username", username) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/jwt/LoginFilter.java b/src/main/java/com/likelion/session/jwt/LoginFilter.java new file mode 100644 index 0000000..fec6c50 --- /dev/null +++ b/src/main/java/com/likelion/session/jwt/LoginFilter.java @@ -0,0 +1,69 @@ +package com.likelion.session.jwt; + +import com.likelion.session.dto.CustomUserDetails; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.util.Collection; +import java.util.Iterator; + +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + //JWTUtil 주입 + private final JWTUtil jwtUtil; + + public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) { + this.authenticationManager = authenticationManager; + this.jwtUtil = jwtUtil; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + //클라이언트 요청에서 username, password 추출 + String username = obtainUsername(request); + String password = obtainPassword(request); + + //스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함 + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null); + + //token에 담은 검증을 위한 AuthenticationManager로 전달 + return authenticationManager.authenticate(authToken); + } + + //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨) + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) { + //UserDetails + CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal(); + + String username = customUserDetails.getUsername(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + String token = jwtUtil.createJwt(username, role, 60*60*10*1000L); //36,000,000 ms = 10시간 + + response.addHeader("Authorization", "Bearer " + token); // 응답 헤더에 담아서 클라이언트에게 보냄, Bearer 뒤에 공백 무조건 있어야 함(Http의 표준) + } + + + + //로그인 실패시 실행하는 메소드 + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) { + //로그인 실패시 401 응답 코드 반환 + response.setStatus(401); + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/repository/BoardRepository.java b/src/main/java/com/likelion/session/repository/BoardRepository.java index 65a424d..061e83e 100644 --- a/src/main/java/com/likelion/session/repository/BoardRepository.java +++ b/src/main/java/com/likelion/session/repository/BoardRepository.java @@ -1,7 +1,26 @@ package com.likelion.session.repository; import com.likelion.session.domain.Board; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface BoardRepository extends JpaRepository { + + // 제목으로 검색 (포함) - 그냥 일반적인 검색 같은 느낌인가? + List findByTitleContaining(String keyword); + + // 작성자로 검색 - 정확히 일치하는 값만 가져옴 + List findByWriter(String writer); + + // 제목 또는 내용으로 통합 검색 (@Query 사용 - 복잡해서) + @Query("SELECT b FROM Board b WHERE b.title LIKE %:keyword% OR b.content LIKE %:keyword% ORDER BY b.createdAt DESC") + List searchByKeyword(@Param("keyword") String keyword); + + // 페이지네이션 적용 검색 - 페이지처럼? + Page findByTitleContaining(String keyword, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/likelion/session/repository/DeleteLogRepository.java b/src/main/java/com/likelion/session/repository/DeleteLogRepository.java new file mode 100644 index 0000000..0bd3f96 --- /dev/null +++ b/src/main/java/com/likelion/session/repository/DeleteLogRepository.java @@ -0,0 +1,7 @@ +package com.likelion.session.repository; + +import com.likelion.session.domain.DeleteLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DeleteLogRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/likelion/session/repository/UserRepository.java b/src/main/java/com/likelion/session/repository/UserRepository.java new file mode 100644 index 0000000..c61d897 --- /dev/null +++ b/src/main/java/com/likelion/session/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.likelion.session.repository; + +import com.likelion.session.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + boolean existsByUsername(String username); // SQL 자동으로 메소드 이름 보고 만들음 + + //username을 받아 DB 테이블에서 회원을 조회하는 메소드 작성 + User findByUsername(String username); +} diff --git a/src/main/java/com/likelion/session/service/BoardService.java b/src/main/java/com/likelion/session/service/BoardService.java index 1bc58af..a958adc 100644 --- a/src/main/java/com/likelion/session/service/BoardService.java +++ b/src/main/java/com/likelion/session/service/BoardService.java @@ -10,15 +10,27 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +// 6주차 +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import com.likelion.session.dto.BoardPageResponse; + +import com.likelion.session.repository.DeleteLogRepository; +import com.likelion.session.domain.DeleteLog; + +import com.likelion.session.dto.DeleteLogResponse; + import java.util.List; -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional +@Slf4j // 로그를 남기기 위한 Logger 객체를 자동으로 생성해줌 (log.info() 등 사용 가능) +@Service // 이 클래스가 비즈니스 로직 담당임을 Spring에 알려줌. +@RequiredArgsConstructor // final이 붙은 필드(boardRepository)를 생성자로 자동 주입해줌 / 의존성 주입, DI라고 부름 +@Transactional // DB 작업 도중 에러가 나면 모든 작업을 이전으로 롤백함 public class BoardService { - private final ; + private final BoardRepository boardRepository; /* 게시글 생성 @@ -51,7 +63,7 @@ public BoardResponse create(BoardCreateRequest request) { - DB에 있는 모든 게시글을 가져옴 - Entity 리스트를 Response DTO 리스트로 변환 */ - @Transactional(readOnly = true) + @Transactional(readOnly = true) // 조회 전용 설정으로 성능 최적화에 씀. public List findAll() { return boardRepository.findAll() .stream() @@ -112,11 +124,130 @@ public BoardResponse update(Long id, BoardUpdateRequest request) { 게시글 삭제 - id로 게시글을 찾고 - 있으면 삭제 + + public void delete(Long id) { + Board board = boardRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id)); + + boardRepository.delete(board); + } */ + + // BoardService에 DeleteLogRepository 의존성 추가 + private final DeleteLogRepository deleteLogRepository; + + // 기존 delete() 메서드 수정 public void delete(Long id) { Board board = boardRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id)); + // 삭제 이력 저장 (Service에서 두 Repository를 조합!) + DeleteLog log = new DeleteLog(board.getId(), board.getTitle(), board.getWriter()); + deleteLogRepository.save(log); + + // 게시글 삭제 boardRepository.delete(board); } + + // BoardService.java 에 아래 메서드들 추가 + + /* + 제목으로 검색 + - keyword가 포함된 게시글 목록 반환 + */ + @Transactional(readOnly = true) + public List searchByTitle(String keyword) { + return boardRepository.findByTitleContaining(keyword) + .stream() + .map(board -> BoardResponse.builder() + .id(board.getId()) + .title(board.getTitle()) + .content(board.getContent()) + .writer(board.getWriter()) + .createdAt(board.getCreatedAt()) + .updatedAt(board.getUpdatedAt()) + .build()) + .toList(); + } + + /* + 작성자로 검색 + */ + @Transactional(readOnly = true) + public List searchByWriter(String writer) { + return boardRepository.findByWriter(writer) + .stream() + .map(board -> BoardResponse.builder() + .id(board.getId()) + .title(board.getTitle()) + .content(board.getContent()) + .writer(board.getWriter()) + .createdAt(board.getCreatedAt()) + .updatedAt(board.getUpdatedAt()) + .build()) + .toList(); + } + + /* + 통합 검색 (제목 + 내용) + */ + @Transactional(readOnly = true) + public List search(String keyword) { + return boardRepository.searchByKeyword(keyword) + .stream() + .map(board -> BoardResponse.builder() + .id(board.getId()) + .title(board.getTitle()) + .content(board.getContent()) + .writer(board.getWriter()) + .createdAt(board.getCreatedAt()) + .updatedAt(board.getUpdatedAt()) + .build()) + .toList(); + } + + /* + 페이지네이션 적용 전체 조회 + - page: 몇 번째 페이지 (0부터 시작) + - size: 페이지당 몇 개 + */ + @Transactional(readOnly = true) + public BoardPageResponse findAllWithPage(int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Page boardPage = boardRepository.findAll(pageable); + + List content = boardPage.getContent() + .stream() + .map(board -> BoardResponse.builder() + .id(board.getId()) + .title(board.getTitle()) + .content(board.getContent()) + .writer(board.getWriter()) + .createdAt(board.getCreatedAt()) + .updatedAt(board.getUpdatedAt()) + .build()) + .toList(); + + return BoardPageResponse.builder() + .content(content) + .totalPages(boardPage.getTotalPages()) + .totalElements(boardPage.getTotalElements()) + .currentPage(boardPage.getNumber()) + .size(boardPage.getSize()) + .build(); + } + + @Transactional(readOnly = true) + public List findAllDeleteLogs() { + return deleteLogRepository.findAll() + .stream() + .map(log -> DeleteLogResponse.builder() + .id(log.getId()) + .boardId(log.getBoardId()) + .boardTitle(log.getBoardTitle()) + .writer(log.getWriter()) + .deletedAt(log.getDeletedAt()) + .build()) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/com/likelion/session/service/CustomUserDetailsService.java b/src/main/java/com/likelion/session/service/CustomUserDetailsService.java new file mode 100644 index 0000000..91007e5 --- /dev/null +++ b/src/main/java/com/likelion/session/service/CustomUserDetailsService.java @@ -0,0 +1,35 @@ +package com.likelion.session.service; + +import com.likelion.session.domain.User; +import com.likelion.session.dto.CustomUserDetails; +import com.likelion.session.repository.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + // UserDetailsService 구현. 사용자 정보 여기서 가져와 라고 알려주는 통로 + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + //DB에서 조회 + User userData = userRepository.findByUsername(username); + + if (userData != null) { + + //UserDetails에 담아서 return하면 AutneticationManager가 검증 함 + return new CustomUserDetails(userData); + } + + return new CustomUserDetails(userData); + } +} diff --git a/src/main/resources/delete-logs-mysql.png b/src/main/resources/delete-logs-mysql.png new file mode 100644 index 0000000..7f1bc3d Binary files /dev/null and b/src/main/resources/delete-logs-mysql.png differ diff --git a/src/main/resources/delete-logs.png b/src/main/resources/delete-logs.png new file mode 100644 index 0000000..e04927e Binary files /dev/null and b/src/main/resources/delete-logs.png differ diff --git a/src/main/resources/dirty-checking.png b/src/main/resources/dirty-checking.png new file mode 100644 index 0000000..1666331 Binary files /dev/null and b/src/main/resources/dirty-checking.png differ diff --git a/src/main/resources/search-title.png b/src/main/resources/search-title.png new file mode 100644 index 0000000..a21e46b Binary files /dev/null and b/src/main/resources/search-title.png differ diff --git a/src/main/resources/search-writer.png b/src/main/resources/search-writer.png new file mode 100644 index 0000000..7a22d02 Binary files /dev/null and b/src/main/resources/search-writer.png differ diff --git a/src/main/resources/search.png b/src/main/resources/search.png new file mode 100644 index 0000000..ff24d48 Binary files /dev/null and b/src/main/resources/search.png differ