Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
228 changes: 228 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# 📒 FlipNote — User Service

**FlipNote 서비스의 유저 도메인 백엔드 레포지토리입니다.**

![Spring Boot](https://img.shields.io/badge/Spring_Boot-6DB33F?logo=springboot&logoColor=white)
![Java](https://img.shields.io/badge/Java_21-007396?logo=openjdk&logoColor=white)
![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)
![Redis](https://img.shields.io/badge/Redis-FF4438?logo=redis&logoColor=white)
![Deploy](https://img.shields.io/badge/Deploy-GHCR%20%2B%20Docker-2496ED?logo=docker&logoColor=white)

---

## 📑 목차

- [시작하기](#-시작하기)
- [환경 변수](#-환경-변수)
- [실행 및 배포](#-실행-및-배포)
- [프로젝트 구조](#-프로젝트-구조)
Comment on lines +17 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

TOC 앵커(fragment) 깨짐 가능성이 있습니다.

이모지가 포함된 제목(## 🖥️ 실행 및 배포)을 #-실행-및-배포로 직접 참조하면 GitHub slug와 불일치할 수 있어 목차 링크가 동작하지 않을 수 있습니다. 명시적 HTML 앵커를 두고 그 앵커를 링크하도록 고정하는 방식이 안전합니다.

수정 예시
- - [실행 및 배포](`#-실행-및-배포`)
+ - [실행 및 배포](`#run-and-deploy`)

- ## 🖥️ 실행 및 배포
+ <a id="run-and-deploy"></a>
+ ## 🖥️ 실행 및 배포
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 17-17: Link fragments should be valid

(MD051, link-fragments)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 17 - 18, The TOC fragment link may break because the
heading uses emoji and GitHub's slug can differ; fix by adding an explicit HTML
anchor before the heading (e.g., add <a name="execution-and-deployment"></a>
immediately above the "## 🖥️ 실행 및 배포" heading) and update the TOC entry link to
point to that anchor (change the link target in the list entry that currently
references "#-실행-및-배포" to the new "#execution-and-deployment"); do the same for
"## 프로젝트 구조" if needed, using clear anchor names to ensure stable TOC links.


---

## 🚀 시작하기

### 사전 요구사항

- **Java** 21 이상
- **Gradle** 8 이상
- **MySQL** 8 이상
- **Redis** 6 이상
- Google OAuth2 클라이언트 생성 및 API 키 발급
- Resend 계정 생성 및 API 키 발급

### 설치

```bash
# 의존성 설치 및 빌드
./gradlew build -x test
```

---

## 🔐 환경 변수

`application.yml`에서 참조하는 환경 변수 목록입니다. 로컬 실행 시 `.env` 또는 IDE 실행 구성에 아래 변수를 설정합니다.

```
# ─── Database ───────────────────────────────────────────
DB_URL=jdbc:mysql://localhost:3306/flipnote_user
DB_USERNAME=
DB_PASSWORD=

# ─── Redis ──────────────────────────────────────────────
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=

# ─── JPA ────────────────────────────────────────────────
# create | create-drop | update | validate | none
DDL_AUTO=update

# ─── gRPC ───────────────────────────────────────────────
GRPC_PORT=9092

# ─── JWT ────────────────────────────────────────────────
JWT_SECRET=
# 액세스 토큰 만료 시간 (ms), 기본값 900000 (15분)
JWT_ACCESS_EXPIRATION=900000
# 리프레시 토큰 만료 시간 (ms), 기본값 604800000 (7일)
JWT_REFRESH_EXPIRATION=604800000

# ─── Email (Resend) ─────────────────────────────────────
APP_RESEND_API_KEY=

# ─── Client ─────────────────────────────────────────────
# 프론트엔드 URL (CORS, 리다이렉트에 사용)
APP_CLIENT_URL=http://localhost:3000

# ─── Google OAuth2 ──────────────────────────────────────
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```
Comment on lines +46 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

펜스 코드블록에 언어를 지정해 주세요.

현재 여러 코드블록에 language 지정이 없어 린트 경고(MD040)가 발생합니다. 최소 bash/text를 지정해 주세요.

수정 예시
- ```
+ ```bash
# ─── Database ───────────────────────────────────────────
...
- ```
+ ```

-    ```
+    ```text
    src/main/java/flipnote/user/
    ...
-    ```
+    ```

- ```
+ ```text
FlipNote-User/
...
- ```
+ ```

Also applies to: 148-154, 156-228

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 46-46: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 46 - 81, Several fenced code blocks in the README are
missing a language specifier (causing MD040 lint warnings); update each
triple-backtick block that contains shell/env/text or file listings to include
an appropriate language tag such as bash or text (e.g., change ``` to ```bash
for environment variable blocks and to ```text for file path examples). Locate
the blocks around the env vars and the file/path examples (the blocks containing
DB_URL/REDIS/GIT/JWT/APP_CLIENT_URL and the blocks showing src/main/java/... and
FlipNote-User/) and add the language tokens consistently to silence the linter.


> **⚠️ 주의**: 환경 변수 파일은 절대 git에 커밋하지 마세요. `.gitignore`에 포함되어 있는지 반드시 확인하세요.

---

## 🖥️ 실행 및 배포

### 로컬 개발 서버 실행

```bash
./gradlew bootRun
```

기본적으로 `http://localhost:8081`에서 실행됩니다.
Swagger UI는 `http://localhost:8081/users/swagger-ui.html`에서 확인할 수 있습니다.

Comment on lines +95 to +97
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

로컬 기본 포트 안내와 Docker 포트 매핑이 서로 다릅니다.

문서상 기본 실행 포트는 8081인데 Docker 예시는 8081:8080으로 되어 있어, 독자가 그대로 따라 하면 접속 실패를 겪을 수 있습니다. 애플리케이션 컨테이너 내부 포트와 설명을 동일하게 맞춰 주세요.

수정 예시 (앱이 8081로 뜨는 경우)
-docker run -p 8081:8080 \
+docker run -p 8081:8081 \
  -e DB_URL=... \
  -e JWT_SECRET=... \
  flipnote-user

Also applies to: 119-123

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 95 - 97, The README's stated local port
(http://localhost:8081 and Swagger URL /users/swagger-ui.html) conflicts with
the Docker example which maps 8081:8080; update the Docker run/compose example
to map the same internal port (e.g., change 8081:8080 to 8081:8081) or change
the README URLs so they match the container mapping, and apply the same fix to
the other occurrence of the port mismatch later in the file (the other block
that references the local port and Docker mapping).

### 프로덕션 빌드

```bash
./gradlew bootJar
```

`build/libs/user-0.0.1-SNAPSHOT.jar` 파일이 생성됩니다.

### 테스트 실행

```bash
./gradlew test
```

### Docker 이미지 빌드 및 실행

```bash
# 이미지 빌드
docker build -t flipnote-user .

# 컨테이너 실행
docker run -p 8081:8080 \
-e DB_URL=... \
-e JWT_SECRET=... \
flipnote-user
```

### 배포 (GitHub Actions)

`main` 브랜치에 push 시 GitHub Actions가 자동으로 아래 과정을 실행합니다.

**CI** (`push` / `pull_request` → `main`)
1. JDK 21 설치
2. `./gradlew build -x test` — 빌드 검증
3. `./gradlew test` — 테스트 실행
4. Dependency-Check — 취약점 분석 리포트 생성

**CD** (`push` → `main`)
1. GitHub Container Registry(GHCR) 로그인
2. Docker 이미지 빌드
3. `ghcr.io/dungbik/flipnote-user` 이미지 Push

> 배포에 필요한 시크릿(`ORG_PAT`)은 GitHub Repository → Settings → Secrets and variables → Actions에 등록해야 합니다.

---

## 📁 프로젝트 구조

- 간략화 버전

```
src/main/java/flipnote/user/
├── domain/ # 도메인 레이어 (엔티티, 레포지토리, 에러코드, 이벤트)
├── application/ # 애플리케이션 레이어 (서비스)
├── infrastructure/ # 인프라 레이어 (JWT, Redis, 메일, OAuth, 설정)
└── interfaces/ # 인터페이스 레이어 (HTTP, gRPC 진입점)
```

```
FlipNote-User/
├── src/
│ ├── main/
│ │ ├── java/flipnote/user/
│ │ │ ├── UserApplication.java
│ │ │ │
│ │ │ ├── domain/ # 도메인 레이어
│ │ │ │ ├── common/ # 도메인 공통
│ │ │ │ │ ├── ErrorCode.java
│ │ │ │ │ ├── BizException.java
│ │ │ │ │ └── EmailSendException.java
│ │ │ │ ├── entity/ # JPA 엔티티
│ │ │ │ │ ├── User.java
│ │ │ │ │ ├── OAuthLink.java
│ │ │ │ │ └── BaseEntity.java
│ │ │ │ ├── repository/ # 레포지토리 인터페이스
│ │ │ │ │ ├── UserRepository.java
│ │ │ │ │ └── OAuthLinkRepository.java
│ │ │ │ ├── event/ # 도메인 이벤트
│ │ │ │ │ ├── EmailVerificationSendEvent.java
│ │ │ │ │ └── PasswordResetCreateEvent.java
│ │ │ │ ├── AuthErrorCode.java
│ │ │ │ ├── UserErrorCode.java
│ │ │ │ ├── ImageErrorCode.java
│ │ │ │ ├── TokenClaims.java
│ │ │ │ ├── TokenPair.java
│ │ │ │ ├── PasswordResetConstants.java
│ │ │ │ └── VerificationConstants.java
│ │ │ │
│ │ │ ├── application/ # 애플리케이션 레이어
│ │ │ │ ├── AuthService.java
│ │ │ │ ├── OAuthService.java
│ │ │ │ └── UserService.java
│ │ │ │
│ │ │ ├── infrastructure/ # 인프라 레이어
│ │ │ │ ├── config/ # 범용 설정 (App, JPA, Swagger, gRPC 클라이언트)
│ │ │ │ ├── jwt/ # JWT 발급/검증 + 설정
│ │ │ │ ├── mail/ # 메일 발송 서비스 + 설정
│ │ │ │ ├── oauth/ # Google OAuth2 클라이언트 + 설정
│ │ │ │ ├── redis/ # Redis 저장소 (토큰, 인증코드 등)
│ │ │ │ └── listener/ # 도메인 이벤트 리스너
│ │ │ │
│ │ │ └── interfaces/ # 인터페이스 레이어
│ │ │ ├── http/ # HTTP 진입점
│ │ │ │ ├── AuthController.java # 인증 (회원가입, 로그인, 비밀번호 등)
│ │ │ │ ├── OAuthController.java # 소셜 로그인 (Google OAuth2)
│ │ │ │ ├── UserController.java # 유저 정보 조회/수정
│ │ │ │ ├── dto/
│ │ │ │ │ ├── request/ # Request DTO
│ │ │ │ │ └── response/ # Response DTO
│ │ │ │ └── common/ # ApiResponse, 예외 처리, 쿠키 유틸
│ │ │ └── grpc/ # gRPC 진입점
│ │ │ ├── GrpcUserQueryService.java # 유저 조회 gRPC 서비스
│ │ │ └── GrpcExceptionHandlerImpl.java # gRPC 전역 예외 처리
│ │ │
│ │ ├── proto/ # gRPC proto 파일
│ │ │ ├── user_query.proto
│ │ │ └── image.proto
│ │ │
│ │ └── resources/
│ │ ├── application.yml
│ │ └── templates/email/ # 이메일 HTML 템플릿 (Thymeleaf)
│ │ ├── email-verification.html
│ │ └── password-reset.html
│ │
│ └── test/
│ └── java/flipnote/user/
├── Dockerfile
├── build.gradle.kts
└── settings.gradle.kts
```
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
package flipnote.user.auth.application;

import flipnote.user.auth.domain.AuthErrorCode;
import flipnote.user.auth.domain.TokenClaims;
import flipnote.user.auth.domain.TokenPair;
import flipnote.user.auth.domain.event.EmailVerificationSendEvent;
import flipnote.user.auth.domain.event.PasswordResetCreateEvent;
import flipnote.user.auth.infrastructure.jwt.JwtProvider;
import flipnote.user.auth.infrastructure.redis.EmailVerificationRepository;
import flipnote.user.auth.infrastructure.redis.PasswordResetRepository;
import flipnote.user.auth.infrastructure.redis.PasswordResetTokenGenerator;
import flipnote.user.auth.infrastructure.redis.SessionInvalidationRepository;
import flipnote.user.auth.infrastructure.redis.TokenBlacklistRepository;
import flipnote.user.auth.infrastructure.redis.VerificationCodeGenerator;
import flipnote.user.auth.presentation.dto.request.ChangePasswordRequest;
import flipnote.user.auth.presentation.dto.request.LoginRequest;
import flipnote.user.auth.presentation.dto.request.SignupRequest;
import flipnote.user.auth.presentation.dto.response.SocialLinksResponse;
import flipnote.user.auth.presentation.dto.response.TokenValidateResponse;
import flipnote.user.auth.presentation.dto.response.UserResponse;
import flipnote.user.global.config.ClientProperties;
import flipnote.user.global.exception.BizException;
import flipnote.user.user.domain.OAuthLink;
import flipnote.user.user.domain.OAuthLinkRepository;
import flipnote.user.user.domain.User;
import flipnote.user.user.domain.UserErrorCode;
import flipnote.user.user.domain.UserRepository;
package flipnote.user.application;

import flipnote.user.domain.AuthErrorCode;
import flipnote.user.domain.entity.OAuthLink;
import flipnote.user.domain.repository.OAuthLinkRepository;
import flipnote.user.domain.TokenClaims;
import flipnote.user.domain.TokenPair;
import flipnote.user.domain.entity.User;
import flipnote.user.domain.UserErrorCode;
import flipnote.user.domain.repository.UserRepository;
import flipnote.user.domain.common.BizException;
import flipnote.user.domain.event.EmailVerificationSendEvent;
import flipnote.user.domain.event.PasswordResetCreateEvent;
import flipnote.user.infrastructure.config.ClientProperties;
import flipnote.user.infrastructure.jwt.JwtProvider;
import flipnote.user.infrastructure.redis.EmailVerificationRepository;
import flipnote.user.infrastructure.redis.PasswordResetRepository;
import flipnote.user.infrastructure.redis.PasswordResetTokenGenerator;
import flipnote.user.infrastructure.redis.SessionInvalidationRepository;
import flipnote.user.infrastructure.redis.TokenBlacklistRepository;
import flipnote.user.infrastructure.redis.VerificationCodeGenerator;
import flipnote.user.interfaces.http.dto.request.ChangePasswordRequest;
import flipnote.user.interfaces.http.dto.request.LoginRequest;
import flipnote.user.interfaces.http.dto.request.SignupRequest;
import flipnote.user.interfaces.http.dto.response.SocialLinksResponse;
import flipnote.user.interfaces.http.dto.response.TokenValidateResponse;
import flipnote.user.interfaces.http.dto.response.UserResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
package flipnote.user.auth.application;

import flipnote.user.auth.domain.AuthErrorCode;
import flipnote.user.auth.domain.TokenPair;
import flipnote.user.auth.infrastructure.jwt.JwtProvider;
import flipnote.user.auth.infrastructure.oauth.OAuthApiClient;
import flipnote.user.auth.infrastructure.oauth.OAuth2UserInfo;
import flipnote.user.auth.infrastructure.oauth.PkceUtil;
import flipnote.user.auth.infrastructure.redis.SocialLinkTokenRepository;
import flipnote.user.global.config.OAuthProperties;
import flipnote.user.global.constants.HttpConstants;
import flipnote.user.global.exception.BizException;
import flipnote.user.user.domain.OAuthLink;
import flipnote.user.user.domain.OAuthLinkRepository;
import flipnote.user.user.domain.User;
import flipnote.user.user.domain.UserErrorCode;
import flipnote.user.user.domain.UserRepository;

package flipnote.user.application;

import flipnote.user.domain.AuthErrorCode;
import flipnote.user.domain.entity.OAuthLink;
import flipnote.user.domain.repository.OAuthLinkRepository;
import flipnote.user.domain.TokenPair;
import flipnote.user.domain.entity.User;
import flipnote.user.domain.UserErrorCode;
import flipnote.user.domain.repository.UserRepository;
import flipnote.user.domain.common.BizException;
import flipnote.user.infrastructure.oauth.OAuthProperties;
import flipnote.user.infrastructure.jwt.JwtProvider;
import flipnote.user.infrastructure.oauth.OAuthApiClient;
import flipnote.user.infrastructure.oauth.OAuth2UserInfo;
import flipnote.user.infrastructure.oauth.PkceUtil;
import flipnote.user.infrastructure.redis.SocialLinkTokenRepository;
import flipnote.user.interfaces.http.common.HttpConstants;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseCookie;
Expand Down Expand Up @@ -43,7 +43,8 @@ public record AuthorizationRedirect(String authorizeUri, ResponseCookie verifier

private static final int VERIFIER_COOKIE_MAX_AGE = 180;

public AuthorizationRedirect getAuthorizationUri(String providerName, Long userId) {
public AuthorizationRedirect getAuthorizationUri(String providerName, HttpServletRequest request,
Long userId) {
OAuthProperties.Provider provider = resolveProvider(providerName);

String codeVerifier = pkceUtil.generateCodeVerifier();
Expand All @@ -55,7 +56,7 @@ public AuthorizationRedirect getAuthorizationUri(String providerName, Long userI
socialLinkTokenRepository.save(userId, state);
}

String authorizeUri = oAuthApiClient.buildAuthorizeUri(provider, codeChallenge, state);
String authorizeUri = oAuthApiClient.buildAuthorizeUri(request, provider, codeChallenge, state);

ResponseCookie verifierCookie = ResponseCookie.from(HttpConstants.OAUTH_VERIFIER_COOKIE, codeVerifier)
.httpOnly(true)
Expand All @@ -68,8 +69,9 @@ public AuthorizationRedirect getAuthorizationUri(String providerName, Long userI
return new AuthorizationRedirect(authorizeUri, verifierCookie);
}

public TokenPair socialLogin(String providerName, String code, String codeVerifier) {
OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier);
public TokenPair socialLogin(String providerName, String code, String codeVerifier,
HttpServletRequest request) {
OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request);

OAuthLink oAuthLink = oAuthLinkRepository
.findByProviderAndProviderIdWithUser(userInfo.getProvider(), userInfo.getProviderId())
Expand All @@ -80,13 +82,13 @@ public TokenPair socialLogin(String providerName, String code, String codeVerifi

@Transactional
public void linkSocialAccount(String providerName, String code, String state,
String codeVerifier) {
String codeVerifier, HttpServletRequest request) {
Long userId = socialLinkTokenRepository.findUserIdByState(state)
.orElseThrow(() -> new BizException(AuthErrorCode.INVALID_SOCIAL_LINK_TOKEN));

socialLinkTokenRepository.delete(state);

OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier);
OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request);

if (oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId(
userId, userInfo.getProvider(), userInfo.getProviderId())) {
Expand All @@ -105,9 +107,9 @@ public void linkSocialAccount(String providerName, String code, String state,
}

private OAuth2UserInfo getOAuth2UserInfo(String providerName, String code,
String codeVerifier) {
String codeVerifier, HttpServletRequest request) {
OAuthProperties.Provider provider = resolveProvider(providerName);
String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier);
String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request);
Map<String, Object> attributes = oAuthApiClient.requestUserInfo(provider, accessToken);
return oAuthApiClient.createUserInfo(providerName, attributes);
}
Expand Down
Loading
Loading