diff --git a/.gitignore b/.gitignore
index c2065bc2..55608a8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
+.env
### STS ###
.apt_generated
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..d9a2bc16
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,4 @@
+FROM openjdk:17
+ARG JAR_FILE=build/libs/*.jar
+COPY ${JAR_FILE} app.jar
+ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=prod","-Duser.timezone=Asia/Seoul"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 57f9a465..e546f405 100644
--- a/README.md
+++ b/README.md
@@ -2,40 +2,67 @@
김최양 사이드 프로젝트 FitaPet 백엔드 Repository 입니다.
- 기획&디자인: [김유빈](https://github.com/youvebeen09)
-- [프론트엔드](https://github.com/heejinnn/fit-a-pet-frontend): [최희진](https://github.com/heejinnn)
+- [프론트엔드](https://github.com/KCY-Fit-a-Pet/fit-a-pet-client): [최희진](https://github.com/heejinnn)
- 백엔드: [양재서](https://github.com/psychology50)
+## [ Contents ]
+- [Project Summary](#project-summary)
+- [Version Control](#version-control)
+- [Dev Environment](#dev-environment)
+- [Tech Stack](#tech-stack)
+ - [Framework & Library](#framework--library)
+ - [Build tool](#build-tool)
+ - [Database](#database)
+ - [Infra](#infra)
+- [Project Check List](#project-check-list)
+- [System Architecture](#system-architecture)
+- [WAS Architecture](#was-architecture)
+- [ERD](#erd)
+- [Branch Convention](#branch-convention)
+
+## Version Control
+| Version # | Revision Date | Description | Author |
+|:---------:|:-------------:|:--------------------|:------:|
+| v0.0.1 | 2023.10.1 | 프로젝트 기본 기능 구현 및 배포 | 양재서 |
+
## Dev Environment
- IntelliJ 2023.1.2
-- Postman
-- GitHub
-- Virtual Machine (Linux)
+- Postman 10.18.9
+- GitHub
+- Windows 11
- Notion
## Tech Stack
### Framework & Library
-- Java (버전 미정)
-- SpringBoot 3.0.1
+- JDK 11
+- SpringBoot 3.1.0
- SpringBoot Security
- Spring Data JPA
-- Swagger
-- Lombok
+- Spring Doc Open API
+- Lombok
+- JUnit5
+- jjwt 0.11.5
+- httpclient 4.5.14 & httpclient5 5.1.4
### Build tool
- Gradle
### Database
-- Maria DB
+- MySQL8
- Redis
### Infra
-- AWS EC2
-- AWS S3
-- AWS CodeDeploy
-- AWS Route53
-- Docker & Kubernetes
+- AWS EC2 (for Build Server)
+- Docker & Docker-compose
- Jenkins
-- Github Todo bot
+- GitHub Todo bot
+- GitHub Action
+- Kakao Talk
+- Naver Cloud Platform Server (for WAS)
+- Naver Cloud Platform Cloud DB for Redis
+- Naver Cloud Platform Object Storage
+- Naver Cloud Platform Simple & Easy Notification Service
+- Goorm IDE (for DB Server)
## Project Check List
- [ ] 실제 서비스를 공개적으로 배포하고 운영하는 경험을 해보았다.
@@ -50,16 +77,17 @@
- [ ] 타인과의 협업을 효율적으로 하기 위한 고민을 해보았다.
## System Architecture
-
+
## WAS Architecture
+
+- WAS Server 내부에 Nginx를 통해 Reverse Proxy를 구현했습니다.
## ERD
+
-
-## Projcet Board
-
+- 현재 많은 부분이 수정되었고, 앞으로도 계속 수정될 예정입니다.
## Branch Convention
@@ -68,22 +96,12 @@ main ── develop ── feature
└── hotfix
```
-| Brach name | description |
-| --- | --- |
-| main | 배포 중인 서비스 브랜치
-• 실제 서비스가 이루어지는 브랜치입니다.
-• 해당 브랜치를 기준으로 develop 브랜치가 분기됩니다.
-• 긴급 수정 안건에 대해서는 hotfix 브랜치에서 처리합니다. |
-| develop | 작업 브랜치
-• 개발, 테스트, 릴리즈 등 배포 전 단계의 기준이 되는 브랜치입니다.
-• 프로젝트의 default 브랜치입니다.
-• 해당 브랜치에서 feature 브랜치가 분기됩니다. |
-| feature | 기능 단위 구현
-• 개별 개발자가 맡은 작업을 개발하는 브랜치입니다.
-• feature/(feature-name)처럼 머릿말-꼬릿말(개발하는 기능)으로 명명합니다.
-• kebab-case 네이밍 규칙을 준수합니다. |
-| hotfix | 서비스 중 긴급 수정 사항 처리
-• main에서 분기합니다. |
+| Brach name | description |
+| --- |-------------------------------------------------------------------------------------------------------------------------------------------|
+| main | 배포 중인 서비스 브랜치
• 실제 서비스가 이루어지는 브랜치입니다.
• 해당 브랜치를 기준으로 develop 브랜치가 분기됩니다.
• 긴급 수정 안건에 대해서는 hotfix 브랜치에서 처리합니다. |
+| develop | 작업 브랜치
• 개발, 테스트, 릴리즈 등 배포 전 단계의 기준이 되는 브랜치입니다.
• 프로젝트의 default 브랜치입니다.
• 해당 브랜치에서 feature 브랜치가 분기됩니다. |
+| feature | 기능 단위 구현
• 개별 개발자가 맡은 작업을 개발하는 브랜치입니다.
• feature/(feature-name)처럼 머릿말-꼬릿말(개발하는 기능)으로 명명합니다.
• kebab-case 네이밍 규칙을 준수합니다. |
+| hotfix | 서비스 중 긴급 수정 사항 처리
• main에서 분기합니다. |
## Commit Convention
@@ -122,8 +140,3 @@ main ── develop ── feature
```
-
-## Version Control
-
-
-
diff --git a/build.gradle b/build.gradle
index 4609500a..1ad06af1 100644
--- a/build.gradle
+++ b/build.gradle
@@ -6,7 +6,7 @@ plugins {
group = 'com.kcy'
version = '0.0.1-SNAPSHOT'
-sourceCompatibility = '20'
+sourceCompatibility = '17'
configurations {
compileOnly {
@@ -21,10 +21,20 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
+ implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'com.jcraft:jsch:0.1.55'
+
+ implementation 'org.apache.httpcomponents:httpclient:4.5.14'
+ implementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1'
+ implementation 'org.apache.httpcomponents:httpcore:4.4.14'
+
+ // swagger
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
// jwt
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
@@ -35,6 +45,7 @@ dependencies {
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'mysql:mysql-connector-java:8.0.28'
annotationProcessor 'org.projectlombok:lombok'
+ annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..dac34262
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,27 @@
+version: '3.7'
+services:
+ proxy:
+ image: jaeseo/nginx:latest
+ restart: always
+ ports:
+ - "80:80"
+ - "443:443"
+ networks:
+ - was-net
+ depends_on:
+ - api
+
+ fitapet-api:
+ image: jaeseo/fitapet:latest
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ env_file:
+ - .env
+ networks:
+ - was-net
+
+networks:
+ was-net:
+ name: fitapet
+ external: true
diff --git a/proxy/Dockerfile b/proxy/Dockerfile
new file mode 100644
index 00000000..6901bced
--- /dev/null
+++ b/proxy/Dockerfile
@@ -0,0 +1,3 @@
+FROM nginx
+
+ADD nginx.conf /etc/nginx/nginx.conf
diff --git a/proxy/nginx.conf b/proxy/nginx.conf
new file mode 100644
index 00000000..65070f7b
--- /dev/null
+++ b/proxy/nginx.conf
@@ -0,0 +1,57 @@
+user nginx;
+worker_processes auto;
+error_log /var/log/nginx/error.log;
+pid /var/run/nginx.pid;
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types; # 옵션 항목을 설정해둔 파일의 경로
+ default_type application/octet-stream; # 옥텟 스트림 기반의 http를 사용
+
+ # 백엔드 upstream 설정 (nginx가 downstream)
+ upstream docker-server {
+ server fitapet-api:8080; # nginx가 요청을 전달할 서버를 정의하는 지시자 (여기서는 WAS, 웹 어플리케이션 서버 host주소:포트)
+ }
+
+ server {
+ listen 80; # 서버가 리스닝할 포트를 설정하는 지시자 (server 블록 하나 당 하나의 웹 사이트 선언)
+ listen [::]:80;
+
+ server_name localhost; # 서버의 도메인 이름을 설정하는 지시자 (request header의 host와 비교하여 일치하는 경우에만 처리)
+
+ location / {
+ root /usr/share/nginx/html; # root 지시자는 요청이 들어왔을 때, 해당 요청을 처리할 파일의 기본 경로를 설정
+ index index.html index.htm; # index 지시자는 root 지시자에서 설정한 경로에서 찾을 파일의 이름을 설정
+ try_files $uri $uri/ /index.html =404; # try_files 지시자는 파일을 찾을 수 없는 경우의 처리 방법을 설정
+ }
+
+ location /api {
+ proxy_pass http://docker-server; # proxy_pass 지시자는 요청을 전달할 서버의 주소를 설정
+ proxy_redirect off; # proxy_redirect 지시자는 리다이렉션을 설정
+ proxy_set_header Host $host; # proxy_set_header 지시자는 요청 헤더의 값을 변경
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header 지시자는 요청 헤더의 값을 변경
+ proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header 지시자는 요청 헤더의 값을 변경
+ }
+
+# location /socket {
+# proxy_pass http://docker-server;
+# proxy_http_version 1.1; # proxy_http_version 지시자는 HTTP 버전을 설정
+# proxy_set_header Upgrade $http_upgrade; # proxy_set_header 지시자는 요청 헤더의 값을 변경
+# proxy_set_header Connection "upgrade"; # proxy_set_header 지시자는 요청 헤더의 값을 변경
+# proxy_set_header Host $host; # proxy_set_header 지시자는 요청 헤더의 값을 변경
+# }
+ }
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"'; # log_format 지시자는 로그의 형식을 설정
+ access_log /var/log/nginx/access.log main; # access_log 지시자는 로그 파일의 경로와 형식을 설정
+
+ sendfile on; # sendfile 지시자는 파일 전송 방식을 설정
+ server_tokens off; # server_tokens 지시자는 응답 헤더의 Server 값을 설정
+ keepalive_timeout 65; # keepalive_timeout 지시자는 keep-alive 연결의 타임아웃 시간을 설정
+ include /etc/nginx/conf.d/*.conf; # include 지시자는 외부 설정 파일을 포함
+}
\ No newline at end of file
diff --git a/src/main/java/com/kcy/fitapet/FitapetApplication.java b/src/main/java/com/kcy/fitapet/FitapetApplication.java
index 8bd185a1..afbc1e60 100644
--- a/src/main/java/com/kcy/fitapet/FitapetApplication.java
+++ b/src/main/java/com/kcy/fitapet/FitapetApplication.java
@@ -5,7 +5,6 @@
@SpringBootApplication
public class FitapetApplication {
-
public static void main(String[] args) {
SpringApplication.run(FitapetApplication.class, args);
}
diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/Care.java b/src/main/java/com/kcy/fitapet/domain/care/domain/Care.java
new file mode 100644
index 00000000..5a5f305a
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/care/domain/Care.java
@@ -0,0 +1,48 @@
+package com.kcy.fitapet.domain.care.domain;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "CARE")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"careName", "dtype"})
+public class Care extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ @Column(name = "care_name")
+ private String careName;
+ @Column(name = "dtype")
+ @Convert(converter = CareTypeConverter.class)
+ private CareType dtype;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "author_id", updatable = false)
+ private Member author;
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "last_editor_id")
+ private Member lastEditor;
+ @OneToMany(mappedBy = "care", cascade = CascadeType.ALL)
+ private List careDetails = new ArrayList<>();
+
+ @Builder
+ private Care(String careName, CareType dtype) {
+ this.careName = careName;
+ this.dtype = dtype;
+ }
+
+ public static Care of(String careName, CareType dtype) {
+ return Care.builder()
+ .careName(careName)
+ .dtype(dtype)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/CareDetail.java b/src/main/java/com/kcy/fitapet/domain/care/domain/CareDetail.java
new file mode 100644
index 00000000..43468d7e
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/care/domain/CareDetail.java
@@ -0,0 +1,69 @@
+package com.kcy.fitapet.domain.care.domain;
+
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.hibernate.annotations.ColumnDefault;
+
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+
+@Entity
+@Table(name = "CARE_DETAIL")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"careDetailName", "careTime", "limitTime", "isDone", "clearedAt"})
+public class CareDetail extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.AUTO)
+ private Long id;
+ @Column(name = "care_detail_name")
+ private String careDetailName;
+ @Temporal(TemporalType.TIME)
+ @Column(name = "care_time")
+ private LocalTime careTime;
+ @Temporal(TemporalType.TIME)
+ @Column(name = "limit_time")
+ private LocalTime limitTime;
+ @Column(name = "is_done") @ColumnDefault("false")
+ private Boolean isDone;
+ @Temporal(TemporalType.TIMESTAMP)
+ @Column(name = "cleared_at")
+ private LocalDateTime clearedAt;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "care_id")
+ private Care care;
+ @OneToOne(mappedBy = "careDetail")
+ private DayOfWeek dayOfWeek;
+
+ @Builder
+ private CareDetail(String careDetailName, LocalTime careTime, LocalTime limitTime, Boolean isDone, LocalDateTime clearedAt) {
+ this.careDetailName = careDetailName;
+ this.careTime = careTime;
+ this.limitTime = limitTime;
+ this.isDone = isDone;
+ this.clearedAt = clearedAt;
+ }
+
+ public static CareDetail of(String careDetailName, LocalTime careTime, LocalTime limitTime, Boolean isDone, LocalDateTime clearedAt) {
+ return CareDetail.builder()
+ .careDetailName(careDetailName)
+ .careTime(careTime)
+ .limitTime(limitTime)
+ .isDone(isDone)
+ .clearedAt(clearedAt)
+ .build();
+ }
+
+ public void isDone() {
+ this.isDone = Boolean.TRUE;
+ this.clearedAt = LocalDateTime.now();
+ }
+
+ public void cancelDone() {
+ this.isDone = Boolean.FALSE;
+ this.clearedAt = null;
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/CareType.java b/src/main/java/com/kcy/fitapet/domain/care/domain/CareType.java
new file mode 100644
index 00000000..49cad6f1
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/care/domain/CareType.java
@@ -0,0 +1,38 @@
+package com.kcy.fitapet.domain.care.domain;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.kcy.fitapet.global.common.util.converter.LegacyCommonType;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static java.util.stream.Collectors.toMap;
+
+@RequiredArgsConstructor
+public enum CareType implements LegacyCommonType {
+ DAILY("1", "일상관리"),
+ WEEKLY("2", "주간관리");
+
+ private final String code;
+ private final String type;
+
+ private static final Map stringToEnum =
+ Stream.of(values()).collect(toMap(Object::toString, e -> e));
+
+ @Override public String getCode() {
+ return code;
+ }
+ @JsonValue public String getType() {
+ return type;
+ }
+
+ @JsonCreator
+ public static CareType fromString(String type) {
+ return stringToEnum.get(type.toUpperCase());
+ }
+ @Override public String toString() {
+ return type;
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/CareTypeConverter.java b/src/main/java/com/kcy/fitapet/domain/care/domain/CareTypeConverter.java
new file mode 100644
index 00000000..d9ff624d
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/care/domain/CareTypeConverter.java
@@ -0,0 +1,13 @@
+package com.kcy.fitapet.domain.care.domain;
+
+import com.kcy.fitapet.global.common.util.converter.AbstractLegacyEnumAttributeConverter;
+import jakarta.persistence.Converter;
+
+@Converter
+public class CareTypeConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "돌봄타입";
+
+ public CareTypeConverter() {
+ super(CareType.class, false, ENUM_NAME);
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/DayOfWeek.java b/src/main/java/com/kcy/fitapet/domain/care/domain/DayOfWeek.java
new file mode 100644
index 00000000..e9146af2
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/care/domain/DayOfWeek.java
@@ -0,0 +1,50 @@
+package com.kcy.fitapet.domain.care.domain;
+
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.ColumnDefault;
+
+@Entity
+@Table(name = "DAY_OF_WEEK")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@ToString(exclude = {"id", "careDetail"})
+public class DayOfWeek extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.AUTO)
+ private Long id;
+ @ColumnDefault("false")
+ private boolean mon;
+ @ColumnDefault("false")
+ private boolean tue;
+ @ColumnDefault("false")
+ private boolean wed;
+ @ColumnDefault("false")
+ private boolean thu;
+ @ColumnDefault("false")
+ private boolean fri;
+ @ColumnDefault("false")
+ private boolean sat;
+ @ColumnDefault("false")
+ private boolean sun;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "care_detail_id")
+ private CareDetail careDetail;
+
+ @Builder
+ private DayOfWeek(boolean mon, boolean tue, boolean wed, boolean thu, boolean fri, boolean sat, boolean sun) {
+ this.mon = mon;
+ this.tue = tue;
+ this.wed = wed;
+ this.thu = thu;
+ this.fri = fri;
+ this.sat = sat;
+ this.sun = sun;
+ }
+
+ public static DayOfWeek of(boolean mon, boolean tue, boolean wed, boolean thu, boolean fri, boolean sat, boolean sun) {
+ return DayOfWeek.builder()
+ .mon(mon).tue(tue).wed(wed).thu(thu).fri(fri).sat(sat).sun(sun).build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/api/MemberApi.java b/src/main/java/com/kcy/fitapet/domain/member/api/MemberApi.java
new file mode 100644
index 00000000..e2adaa29
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/api/MemberApi.java
@@ -0,0 +1,177 @@
+package com.kcy.fitapet.domain.member.api;
+
+import com.kcy.fitapet.domain.member.dto.auth.SignInReq;
+import com.kcy.fitapet.domain.member.dto.auth.SignUpReq;
+import com.kcy.fitapet.domain.member.dto.sms.SmsReq;
+import com.kcy.fitapet.domain.member.dto.sms.SmsRes;
+import com.kcy.fitapet.domain.member.service.component.MemberAuthService;
+import com.kcy.fitapet.global.common.response.ErrorResponse;
+import com.kcy.fitapet.global.common.response.FailureResponse;
+import com.kcy.fitapet.global.common.response.SuccessResponse;
+import com.kcy.fitapet.global.common.response.code.ErrorCode;
+import com.kcy.fitapet.global.common.security.authentication.CustomUserDetails;
+import com.kcy.fitapet.global.common.util.cookie.CookieUtil;
+import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo;
+import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorCode;
+import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+import static com.kcy.fitapet.global.common.util.jwt.AuthConstants.*;
+
+@Tag(name = "유저 관리 API", description = "유저 인증과 관련된 API")
+@RestController
+@RequestMapping("/api/v1/members")
+@RequiredArgsConstructor
+@Slf4j
+public class MemberApi {
+ private final MemberAuthService memberAuthService;
+ private final CookieUtil cookieUtil;
+
+ @Operation(summary = "회원가입", description = "유저 닉네임, 패스워드를 입력받고 유효하다면 액세스 토큰(헤더)과 리프레시 토큰(쿠키)을 반환합니다.")
+ @Parameters({
+ @Parameter(name = "Authorization", description = "전화번호 인증 후 받은 토큰", in = ParameterIn.HEADER),
+ @Parameter(name = "dto", description = "회원가입 정보", schema = @Schema(implementation = SignUpReq.class))
+ })
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "회원가입 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))),
+ @ApiResponse(responseCode = "400", description = "회원가입 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))),
+ @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ })
+ @PostMapping("/register")
+ public ResponseEntity> register(@RequestHeader("Authorization") String accessToken, @RequestBody @Valid SignUpReq dto) {
+ Map tokens = memberAuthService.register(accessToken, dto);
+ return getResponseEntity(tokens);
+ }
+
+ @Operation(summary = "전화번호 인증", description = "전화번호를 입력받고 인증번호를 전송합니다.")
+ @Parameters({
+ @Parameter(name = "phone", description = "전화번호", in = ParameterIn.QUERY, required = true),
+ @Parameter(name = "code", description = "인증번호", in = ParameterIn.QUERY)
+ })
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "인증번호 전송 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))),
+ @ApiResponse(responseCode = "200", description = "인증번호 전송 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))),
+ @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ })
+ @GetMapping("/sms")
+ public ResponseEntity> smsAuthorization(
+ @RequestParam(value = "phone") String phoneNumber,
+ @RequestParam(value = "code", required = false) String code) {
+ if (code == null) {
+ SmsRes smsRes = memberAuthService.sendCertificationNumber(new SmsReq(phoneNumber));
+ return ResponseEntity.ok(SuccessResponse.from(smsRes));
+ }
+
+ String token = memberAuthService.checkCertificationNumber(phoneNumber, code);
+
+ return (token.equals(""))
+ ? ResponseEntity.ok(FailureResponse.of("code", ErrorCode.INVALID_AUTH_CODE.getMessage()))
+ : ResponseEntity.ok()
+ .header(ACCESS_TOKEN.getValue(), token)
+ .body(SuccessResponse.from(Map.of("code", "인증 성공")));
+ }
+
+ @Operation(summary = "로그인", description = "유저 닉네임, 패스워드를 입력받고 유효하다면 액세스 토큰(헤더)과 리프레시 토큰(쿠키)을 반환합니다.")
+ @Parameters({
+ @Parameter(name = "dto", description = "로그인 정보", schema = @Schema(implementation = SignInReq.class))
+ })
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))),
+ @ApiResponse(responseCode = "400", description = "로그인 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))),
+ @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ })
+ @PostMapping("/login")
+ public ResponseEntity> login(@RequestBody @Valid SignInReq dto) {
+ Map tokens = memberAuthService.login(dto);
+ return getResponseEntity(tokens);
+ }
+
+ @Operation(summary = "로그아웃", description = "액세스 토큰과 리프레시 토큰을 만료시킵니다.")
+ @Parameters({
+ @Parameter(name = "Authorization", description = "액세스 토큰", in = ParameterIn.HEADER),
+ @Parameter(name = "refreshToken", description = "리프레시 토큰", in = ParameterIn.COOKIE)
+ })
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "로그아웃 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))),
+ @ApiResponse(responseCode = "400", description = "로그아웃 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))),
+ @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ })
+ @GetMapping("/logout")
+ public ResponseEntity> logout(@CookieValue("refreshToken") @Valid String refreshToken, HttpServletRequest request, HttpServletResponse response) {
+ memberAuthService.logout(request.getHeader(AUTH_HEADER.getValue()), refreshToken);
+ ResponseCookie cookie = cookieUtil.deleteCookie(request, response, REFRESH_TOKEN.getValue())
+ .orElseThrow(() -> new AuthErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND, "존재하지 않는 쿠키입니다."));
+
+ return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(SuccessResponse.noContent());
+ }
+
+ @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 이용해 액세스 토큰과 리프레시 토큰을 갱신합니다.")
+ @Parameter(name = "refreshToken", description = "리프레시 토큰", in = ParameterIn.COOKIE)
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))),
+ @ApiResponse(responseCode = "400", description = "토큰 갱신 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))),
+ @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ })
+ @GetMapping("/refresh")
+ public ResponseEntity> refresh(@CookieValue("refreshToken") @Valid String refreshToken) {
+ if (refreshToken == null) {
+ throw new AuthErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND, "존재하지 않는 쿠키입니다.");
+ }
+ Map tokens = memberAuthService.refresh(refreshToken);
+
+ return getResponseEntity(tokens);
+ }
+
+ @Operation(summary = "인증 테스트", description = "인증 테스트")
+ @Parameter(name = "Authorization", description = "액세스 토큰", in = ParameterIn.HEADER)
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "인증 테스트 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))),
+ @ApiResponse(responseCode = "400", description = "인증 테스트 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))),
+ @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ })
+ @GetMapping("/authentication")
+ public ResponseEntity> authenticationTest(@AuthenticationPrincipal CustomUserDetails securityUser, Authentication authentication) {
+ log.info("type: {}", authentication.getPrincipal());
+ JwtUserInfo user = securityUser.toJwtUserInfo();
+ log.info("user: {}", user);
+
+ return ResponseEntity.ok(user);
+ }
+
+ /**
+ * 액세스 토큰과 리프레시 토큰을 반환합니다.
+ * @param tokens : 액세스 토큰과 리프레시 토큰
+ * @return ResponseEntity>
+ */
+ private ResponseEntity> getResponseEntity(Map tokens) {
+ log.debug("access token: {}", tokens.get(ACCESS_TOKEN.getValue()));
+ log.debug("refresh token: {}", tokens.get(REFRESH_TOKEN.getValue()));
+ ResponseCookie cookie = cookieUtil.createCookie(REFRESH_TOKEN.getValue(), tokens.get(REFRESH_TOKEN.getValue()), 60 * 60 * 24 * 7);
+
+ return ResponseEntity.ok()
+ .header(HttpHeaders.SET_COOKIE, cookie.toString())
+ .header(ACCESS_TOKEN.getValue(), tokens.get(ACCESS_TOKEN.getValue()))
+ .body(SuccessResponse.noContent());
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/dao/MemberRepository.java b/src/main/java/com/kcy/fitapet/domain/member/dao/MemberRepository.java
new file mode 100644
index 00000000..deb80523
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/dao/MemberRepository.java
@@ -0,0 +1,13 @@
+package com.kcy.fitapet.domain.member.dao;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface MemberRepository extends JpaRepository {
+ Optional findByUid(String uid);
+ boolean existsByUid(String uid);
+ boolean existsByEmail(String email);
+ boolean existsByPhone(String phone);
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/Master.java b/src/main/java/com/kcy/fitapet/domain/member/domain/Master.java
new file mode 100644
index 00000000..fd4a4499
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/domain/Master.java
@@ -0,0 +1,42 @@
+package com.kcy.fitapet.domain.member.domain;
+
+import com.kcy.fitapet.domain.pet.domain.Pet;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.ColumnDefault;
+import org.springframework.data.annotation.CreatedDate;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "MEMBER_PET")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Master {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ColumnDefault("false")
+ private boolean hidden;
+
+ @CreatedDate
+ @Column(name = "joined_at", nullable = false, updatable = false)
+ private LocalDateTime joinedAt;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "pet_id")
+ private Pet pet;
+
+ public void hide() {
+ this.hidden = true;
+ }
+
+ public void show() {
+ this.hidden = false;
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/Member.java b/src/main/java/com/kcy/fitapet/domain/member/domain/Member.java
new file mode 100644
index 00000000..49370dec
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/domain/Member.java
@@ -0,0 +1,98 @@
+package com.kcy.fitapet.domain.member.domain;
+
+import com.kcy.fitapet.domain.model.Auditable;
+import com.kcy.fitapet.domain.notification.domain.Notification;
+import com.kcy.fitapet.domain.notification.domain.NotificationSetting;
+import com.kcy.fitapet.domain.oauthid.domain.OAuthID;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.ColumnDefault;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Entity
+@Table(name = "MEMBER")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"id", "name", "phone", "email", "role"})
+public class Member extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Getter
+ private Long id;
+
+ @Getter
+ private String uid;
+ private String name;
+ private String password;
+ @Getter
+ private String phone;
+ @Getter
+ private String email;
+ @Column(name = "profile_img")
+ private String profileImg;
+ @Column(name = "account_locked") @ColumnDefault("false")
+ private Boolean accountLocked;
+ @Convert(converter = RoleTypeConverter.class)
+ @Getter
+ private RoleType role;
+
+ @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
+ private Set oauthIDs = new HashSet<>();
+ @OneToMany(mappedBy = "from", cascade = CascadeType.ALL)
+ private Set fromMemberNickname = new HashSet<>();
+ @OneToMany(mappedBy = "to", cascade = CascadeType.ALL)
+ private Set toMemberNickname = new HashSet<>();
+ @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
+ private List notifications = new ArrayList<>();
+ @OneToOne(mappedBy = "member", cascade = CascadeType.ALL)
+ private NotificationSetting notificationSetting;
+ @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
+ private List pets = new ArrayList<>();
+
+ @Builder
+ private Member(String uid, String name, String password, String phone, String email, String profileImg, Boolean accountLocked, RoleType role) {
+ this.uid = uid;
+ this.name = name;
+ this.password = password;
+ this.phone = phone;
+ this.email = email;
+ this.profileImg = profileImg;
+ this.accountLocked = accountLocked;
+ this.role = role;
+ }
+
+ public static Member of(String uid, String name, String password, String phone, String email, String profileImg, Boolean accountLocked, RoleType role) {
+ return Member.builder()
+ .uid(uid)
+ .name(name)
+ .password(password)
+ .phone(phone)
+ .email(email)
+ .profileImg(profileImg)
+ .accountLocked(accountLocked)
+ .role(role)
+ .build();
+ }
+
+ /**
+ * 비밀번호 암호화
+ * @param passwordEncoder : 비밀번호 암호화 객체
+ * @return 변경된 유저 객체
+ */
+ public void encodePassword(PasswordEncoder passwordEncoder) {
+ this.password = passwordEncoder.encode(this.password);
+ }
+
+ /**
+ * 비밀번호 확인
+ * @param plainPassword : 평문 비밀번호
+ * @param passwordEncoder : 비밀번호 암호화 객체
+ * @return true or false
+ */
+ public boolean checkPassword(String plainPassword, PasswordEncoder passwordEncoder) {
+ return passwordEncoder.matches(passwordEncoder.encode(plainPassword), this.password);
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/MemberNickname.java b/src/main/java/com/kcy/fitapet/domain/member/domain/MemberNickname.java
new file mode 100644
index 00000000..216633be
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/domain/MemberNickname.java
@@ -0,0 +1,34 @@
+package com.kcy.fitapet.domain.member.domain;
+
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "MEMBER_NICKNAME")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"id", "nickname"})
+public class MemberNickname extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ private String nickname;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "from")
+ private Member from;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "to")
+ private Member to;
+
+ @Builder
+ private MemberNickname(String nickname) {
+ this.nickname = nickname;
+ }
+
+ public static MemberNickname of(String nickname) {
+ return MemberNickname.builder()
+ .nickname(nickname)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/RoleType.java b/src/main/java/com/kcy/fitapet/domain/member/domain/RoleType.java
new file mode 100644
index 00000000..8d34376c
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/domain/RoleType.java
@@ -0,0 +1,35 @@
+package com.kcy.fitapet.domain.member.domain;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.kcy.fitapet.global.common.util.converter.LegacyCommonType;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static java.util.stream.Collectors.toMap;
+
+@RequiredArgsConstructor
+public enum RoleType implements LegacyCommonType {
+ ADMIN("1", "ROLE_ADMIN"),
+ USER("2", "ROLE_USER");
+
+ private final String code;
+ private final String role;
+ private static final Map stringToEnum =
+ Stream.of(values()).collect(toMap(Object::toString, e -> e));
+
+
+ @JsonValue
+ public String getRole() { return role; }
+ @Override
+ public String getCode() { return code; }
+
+ @JsonCreator
+ public static RoleType fromString(String role) {
+ return stringToEnum.get(role.toUpperCase());
+ }
+
+ @Override public String toString() { return role; }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/RoleTypeConverter.java b/src/main/java/com/kcy/fitapet/domain/member/domain/RoleTypeConverter.java
new file mode 100644
index 00000000..37903e46
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/domain/RoleTypeConverter.java
@@ -0,0 +1,13 @@
+package com.kcy.fitapet.domain.member.domain;
+
+import com.kcy.fitapet.global.common.util.converter.AbstractLegacyEnumAttributeConverter;
+import jakarta.persistence.Converter;
+
+@Converter
+public class RoleTypeConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "유저권한";
+
+ public RoleTypeConverter() {
+ super(RoleType.class, false, ENUM_NAME);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignInReq.java b/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignInReq.java
new file mode 100644
index 00000000..33434bc5
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignInReq.java
@@ -0,0 +1,12 @@
+package com.kcy.fitapet.domain.member.dto.auth;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+
+@Schema(description = "로그인 요청")
+public record SignInReq(
+ @NotNull(message = "아이디를 입력해주세요.") @Schema(description = "아이디")
+ String uid,
+ @NotNull(message = "비밀번호를 입력해주세요.") @Schema(description = "비밀번호")
+ String password) {
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignUpReq.java b/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignUpReq.java
new file mode 100644
index 00000000..d02c709e
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignUpReq.java
@@ -0,0 +1,34 @@
+package com.kcy.fitapet.domain.member.dto.auth;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import com.kcy.fitapet.domain.member.domain.RoleType;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotNull;
+
+@Schema(description = "회원가입 요청")
+public record SignUpReq(
+ @NotNull(message = "Id is is required") @Schema(description = "아이디")
+ String uid,
+ @NotNull(message = "Password is required") @Schema(description = "비밀번호")
+ String password,
+ @NotNull(message = "Nickname is required") @Schema(description = "닉네임")
+ String name,
+ @Email(message = "Invalid Email Format") @Schema(description = "이메일", nullable = true, example = "abc@gmail.com")
+ String email,
+ @Schema(description = "프로필 이미지", nullable = true)
+ String profileImg
+) {
+ public Member toEntity(String phone) {
+ return Member.builder()
+ .uid(uid)
+ .password(password)
+ .name(name)
+ .email(email)
+ .phone(phone)
+ .profileImg(profileImg)
+ .accountLocked(Boolean.FALSE)
+ .role(RoleType.USER)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensReq.java b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensReq.java
new file mode 100644
index 00000000..2981e309
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensReq.java
@@ -0,0 +1,37 @@
+package com.kcy.fitapet.domain.member.dto.sms;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.util.List;
+
+@Builder
+public record SensReq(
+ String type,
+ String contentType,
+ String countryCode,
+ String from,
+ String content,
+ List messages
+) {
+ /**
+ * NCP SMS API 요청 객체 생성
+ * @param type String : SMS | LMS | MMS
+ * @param contentType String : COMM | AD
+ * @param countryCode String : 국가번호
+ * @param from String : 발신번호
+ * @param content String : 메시지 내용
+ * @param messages List : 메시지 정보 (to: 수신번호, subject: 개별 메시지 제목, content: 개별 메시지 내용)
+ * @return SensReq : NCP SMS API 요청 객체
+ */
+ public static SensReq of(String type, String contentType, String countryCode, String from, String content, List messages) {
+ return SensReq.builder()
+ .type(type)
+ .contentType(contentType)
+ .countryCode(countryCode)
+ .from(from)
+ .content(content)
+ .messages(messages)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensRes.java b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensRes.java
new file mode 100644
index 00000000..b8c4218a
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensRes.java
@@ -0,0 +1,31 @@
+package com.kcy.fitapet.domain.member.dto.sms;
+
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * Client에서 받은 SMS API 요청에 대한 응답 객체
+ * @param requestId String : 요청 ID
+ * @param requestTime LocalDateTime : 요청 시간
+ * @param statusCode String : 응답 코드
+ * @param statusName String : 응답 상태
+ */
+@Builder
+public record SensRes(
+ String requestId,
+ LocalDateTime requestTime,
+ String statusCode,
+ String statusName
+) {
+
+
+ public static SensRes of(String requestId, LocalDateTime requestTime, String statusCode, String statusName) {
+ return SensRes.builder()
+ .requestId(requestId)
+ .requestTime(requestTime)
+ .statusCode(statusCode)
+ .statusName(statusName)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsReq.java b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsReq.java
new file mode 100644
index 00000000..4ceba7fe
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsReq.java
@@ -0,0 +1,16 @@
+package com.kcy.fitapet.domain.member.dto.sms;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+/**
+ * Client에서 SMS 인증 요청 시 사용되는 객체
+ * @param to String : 수신번호
+ */
+@Builder
+@Schema(description = "SMS 인증 요청")
+public record SmsReq(
+ @Schema(description = "수신번호")
+ String to
+) {
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsRes.java b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsRes.java
new file mode 100644
index 00000000..d38913ce
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsRes.java
@@ -0,0 +1,31 @@
+package com.kcy.fitapet.domain.member.dto.sms;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * SMS 인증번호 발송 응답 객체
+ * @param to String : 수신자 번호
+ * @param sendTime LocalDateTime : 발송 시간
+ * @param expireTime LocalDateTime : 만료 시간 (default: 3분)
+ */
+@Builder
+@Schema(description = "SMS 인증번호 발송 응답")
+public record SmsRes(
+ @Schema(description = "수신자 번호")
+ String to,
+ @Schema(description = "발송 시간")
+ LocalDateTime sendTime,
+ @Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00")
+ LocalDateTime expireTime
+) {
+ public static SmsRes of(String to, LocalDateTime sendTime, LocalDateTime expireTime) {
+ return SmsRes.builder()
+ .to(to)
+ .sendTime(sendTime)
+ .expireTime(expireTime)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/service/component/MemberAuthService.java b/src/main/java/com/kcy/fitapet/domain/member/service/component/MemberAuthService.java
new file mode 100644
index 00000000..02e7f61a
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/service/component/MemberAuthService.java
@@ -0,0 +1,164 @@
+package com.kcy.fitapet.domain.member.service.component;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import com.kcy.fitapet.domain.member.dto.auth.SignInReq;
+import com.kcy.fitapet.domain.member.dto.auth.SignUpReq;
+import com.kcy.fitapet.domain.member.dto.sms.SensRes;
+import com.kcy.fitapet.domain.member.dto.sms.SmsReq;
+import com.kcy.fitapet.domain.member.dto.sms.SmsRes;
+import com.kcy.fitapet.domain.member.service.module.MemberSaveService;
+import com.kcy.fitapet.domain.member.service.module.MemberSearchService;
+import com.kcy.fitapet.domain.member.service.module.SmsService;
+import com.kcy.fitapet.global.common.response.code.ErrorCode;
+import com.kcy.fitapet.global.common.response.exception.GlobalErrorException;
+import com.kcy.fitapet.global.common.util.jwt.JwtUtil;
+import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo;
+import com.kcy.fitapet.global.common.util.redis.forbidden.ForbiddenTokenService;
+import com.kcy.fitapet.global.common.util.redis.refresh.RefreshToken;
+import com.kcy.fitapet.global.common.util.redis.refresh.RefreshTokenService;
+import com.kcy.fitapet.global.common.util.redis.sms.SmsCertificationService;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+import static com.kcy.fitapet.global.common.util.jwt.AuthConstants.ACCESS_TOKEN;
+import static com.kcy.fitapet.global.common.util.jwt.AuthConstants.REFRESH_TOKEN;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MemberAuthService {
+ private final MemberSearchService memberSearchService;
+ private final MemberSaveService memberSaveService;
+ private final SmsService smsService;
+
+ private final RefreshTokenService refreshTokenService;
+ private final ForbiddenTokenService forbiddenTokenService;
+ private final SmsCertificationService smsCertificationService;
+ private final JwtUtil jwtUtil;
+
+ private final PasswordEncoder bCryptPasswordEncoder;
+
+ @Transactional
+ public Map register(String authHeader, SignUpReq dto) {
+ String parsedToken = jwtUtil.resolveToken(authHeader);
+ String authenticatedPhone = jwtUtil.getPhoneNumberFromToken(parsedToken);
+
+ if (!smsCertificationService.isCorrectCertificationNumber(authenticatedPhone, parsedToken))
+ throw new GlobalErrorException(ErrorCode.INVALID_AUTH_CODE);
+ smsCertificationService.removeCertificationNumber(authenticatedPhone);
+
+ Member requestMember = dto.toEntity(authenticatedPhone);
+ requestMember.encodePassword(bCryptPasswordEncoder);
+ log.debug("회원가입 요청: {}", requestMember);
+ validateMember(requestMember);
+
+ Member registeredMember = memberSaveService.saveMember(requestMember);
+ log.debug("회원가입 완료: {}", registeredMember);
+ JwtUserInfo jwtUserInfo = JwtUserInfo.from(registeredMember);
+
+ return generateToken(jwtUserInfo);
+ }
+
+ @Transactional
+ public Map login(SignInReq dto) {
+ Member member = memberSearchService.getMemberByUid(dto.uid());
+ if (member.checkPassword(dto.password(), bCryptPasswordEncoder))
+ throw new GlobalErrorException(ErrorCode.NOT_MATCH_PASSWORD_ERROR);
+
+ JwtUserInfo jwtUserInfo = JwtUserInfo.from(member);
+
+ return generateToken(jwtUserInfo);
+ }
+
+ @Transactional
+ public void logout(String authHeader, String requestRefreshToken) {
+ String accessToken = jwtUtil.resolveToken(authHeader);
+ Long userId = jwtUtil.getUserIdFromToken(accessToken);
+
+ refreshTokenService.logout(requestRefreshToken);
+ forbiddenTokenService.register(accessToken, userId);
+ }
+
+ @Transactional
+ public Map refresh(String requestRefreshToken) {
+ RefreshToken refreshToken = refreshTokenService.refresh(requestRefreshToken);
+
+ Long memberId = refreshToken.getUserId();
+ JwtUserInfo dto = JwtUserInfo.from(memberSearchService.getMemberById(memberId));
+ String accessToken = jwtUtil.generateAccessToken(dto);
+
+ return Map.of(ACCESS_TOKEN.getValue(), accessToken, REFRESH_TOKEN.getValue(), refreshToken.getToken());
+ }
+
+ @Transactional
+ public SmsRes sendCertificationNumber(SmsReq dto) {
+ if (memberSearchService.isExistMemberByPhone(dto.to()))
+ throw new GlobalErrorException(ErrorCode.DUPLICATE_PHONE_ERROR);
+ String certificationNumber = smsCertificationService.issueCertificationNumber(dto.to());
+
+ SensRes sensRes;
+ try {
+ sensRes = smsService.sendCertificationNumber(dto, certificationNumber);
+ } catch (Exception e) {
+ log.error("SMS 발송 실패: {}", e.getMessage());
+ smsCertificationService.removeCertificationNumber(dto.to());
+ throw new GlobalErrorException(ErrorCode.SMS_SEND_ERROR);
+ }
+
+ checkSmsStatus(dto.to(), sensRes);
+
+ LocalDateTime expireTime = smsCertificationService.getExpiredTime(dto.to());
+ log.info("인증번호 만료 시간: {}", expireTime);
+ return SmsRes.of(dto.to(), sensRes.requestTime(), expireTime);
+ }
+
+ @Transactional
+ public String checkCertificationNumber(String requestPhone, String requestCertificationNumber) {
+ String token = null;
+ if (smsCertificationService.isCorrectCertificationNumber(requestPhone, requestCertificationNumber)) {
+ log.info("인증번호 일치");
+ token = smsCertificationService.issueSmsAuthToken(requestPhone);
+ }
+
+ return (token != null) ? token : "";
+ }
+
+ private void checkSmsStatus(String requestPhone, SensRes sensRes) {
+ if (sensRes.statusCode().equals("404")) {
+ log.error("존재하지 않는 수신자: {}", requestPhone);
+ smsCertificationService.removeCertificationNumber(requestPhone);
+ throw new GlobalErrorException(ErrorCode.INVALID_RECEIVER);
+ } else if (sensRes.statusName().equals("fail")) {
+ log.error("SMS API 응답 실패: {}", sensRes);
+ smsCertificationService.removeCertificationNumber(requestPhone);
+ throw new GlobalErrorException(ErrorCode.SMS_SEND_ERROR);
+ }
+ log.info("SMS 발송 성공");
+ }
+
+ // TODO: Query문 하나로 압축
+ private void validateMember(Member member) {
+ if (memberSearchService.isExistMemberByUid(member.getUid()))
+ throw new GlobalErrorException(ErrorCode.DUPLICATE_NICKNAME_ERROR);
+
+ if (memberSearchService.isExistMemberByEmail(member.getEmail()))
+ throw new GlobalErrorException(ErrorCode.DUPLICATE_EMAIL_ERROR);
+
+ if (memberSearchService.isExistMemberByPhone(member.getPhone()))
+ throw new GlobalErrorException(ErrorCode.DUPLICATE_PHONE_ERROR);
+ }
+
+ private Map generateToken(JwtUserInfo jwtUserInfo) {
+ String accessToken = jwtUtil.generateAccessToken(jwtUserInfo);
+ String refreshToken = refreshTokenService.issueRefreshToken(accessToken);
+ log.debug("accessToken : {}, refreshToken : {}", accessToken, refreshToken);
+
+ return Map.of(ACCESS_TOKEN.getValue(), accessToken, REFRESH_TOKEN.getValue(), refreshToken);
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSaveService.java b/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSaveService.java
new file mode 100644
index 00000000..98a4874f
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSaveService.java
@@ -0,0 +1,16 @@
+package com.kcy.fitapet.domain.member.service.module;
+
+import com.kcy.fitapet.domain.member.dao.MemberRepository;
+import com.kcy.fitapet.domain.member.domain.Member;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class MemberSaveService {
+ private final MemberRepository memberRepository;
+
+ public Member saveMember(Member member) {
+ return memberRepository.save(member);
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSearchService.java b/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSearchService.java
new file mode 100644
index 00000000..92b6866c
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSearchService.java
@@ -0,0 +1,36 @@
+package com.kcy.fitapet.domain.member.service.module;
+
+import com.kcy.fitapet.domain.member.dao.MemberRepository;
+import com.kcy.fitapet.domain.member.domain.Member;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class MemberSearchService {
+ private final MemberRepository memberRepository;
+
+ public Member getMemberById(Long id) {
+ return memberRepository.findById(id).orElseThrow(
+ () -> new IllegalArgumentException("해당 회원이 존재하지 않습니다.")
+ );
+ }
+
+ public Member getMemberByUid(String uid) {
+ return memberRepository.findByUid(uid).orElseThrow(
+ () -> new IllegalArgumentException("해당 회원이 존재하지 않습니다.")
+ );
+ }
+
+ public boolean isExistMemberByUid(String uid) {
+ return memberRepository.existsByUid(uid);
+ }
+
+ public boolean isExistMemberByEmail(String email) {
+ return memberRepository.existsByEmail(email);
+ }
+
+ public boolean isExistMemberByPhone(String phone) {
+ return memberRepository.existsByPhone(phone);
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/member/service/module/SmsService.java b/src/main/java/com/kcy/fitapet/domain/member/service/module/SmsService.java
new file mode 100644
index 00000000..8979cd77
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/member/service/module/SmsService.java
@@ -0,0 +1,101 @@
+package com.kcy.fitapet.domain.member.service.module;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.kcy.fitapet.domain.member.dto.sms.SensReq;
+import com.kcy.fitapet.domain.member.dto.sms.SensRes;
+import com.kcy.fitapet.domain.member.dto.sms.SmsReq;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.codec.binary.Base64;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestTemplate;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.List;
+
+@Service
+@Slf4j
+public class SmsService {
+ private final String accessKey;
+ private final String secretKey;
+ private final String serviceId;
+ private final String phone;
+
+ public SmsService(
+ @Value("${ncp.api-key}") String accessKey,
+ @Value("${ncp.secret-key}") String secretKey,
+ @Value("${ncp.sms.service-key}") String serviceId,
+ @Value("${ncp.sms.sender-phone}") String phone
+ ) {
+ this.accessKey = accessKey;
+ this.secretKey = secretKey;
+ this.serviceId = serviceId;
+ this.phone = phone;
+ }
+
+ public SensRes sendCertificationNumber(SmsReq smsReq, String certificationNumber)
+ throws JsonProcessingException, RestClientException, InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
+ long now = System.currentTimeMillis();
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("x-ncp-apigw-timestamp", String.valueOf(now));
+ headers.set("x-ncp-iam-access-key", accessKey);
+ headers.set("x-ncp-apigw-signature-v2", makeSignature(now));
+
+ List messages = List.of(smsReq);
+
+ SensReq request = SensReq.of("SMS", "COMM", "82", phone, createAuthCodeMessage(certificationNumber), messages);
+
+ ObjectMapper objectMapper = new ObjectMapper();
+ String body = objectMapper.writeValueAsString(request);
+ HttpEntity httpEntity = new HttpEntity<>(body, headers);
+
+ RestTemplate restTemplate = new RestTemplate();
+ restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
+
+ return restTemplate.postForObject("https://sens.apigw.ntruss.com/sms/v2/services/" + serviceId + "/messages", httpEntity, SensRes.class);
+ }
+
+ private String makeSignature(long now) throws NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException {
+ String space = " ";
+ String newLine = "\n";
+ String method = "POST";
+ String url = "/sms/v2/services/" + this.serviceId + "/messages"; // url (include query string)
+ String timestamp = String.valueOf(now);
+ String accessKey = this.accessKey;
+ String secretKey = this.secretKey;
+
+ String message = new StringBuilder()
+ .append(method)
+ .append(space)
+ .append(url)
+ .append(newLine)
+ .append(timestamp)
+ .append(newLine)
+ .append(accessKey)
+ .toString();
+
+ SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256");
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(signingKey);
+
+ byte[] rawHmac = mac.doFinal(message.getBytes("UTF-8"));
+
+ return Base64.encodeBase64String(rawHmac);
+ }
+
+ private String createAuthCodeMessage(String code) {
+ return "[Fit a Pet] 인증번호 [" + code + "]를 입력해주세요.";
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/memo/domain/Category.java b/src/main/java/com/kcy/fitapet/domain/memo/domain/Category.java
new file mode 100644
index 00000000..206ff3f3
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/memo/domain/Category.java
@@ -0,0 +1,42 @@
+package com.kcy.fitapet.domain.memo.domain;
+
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "CATEGORY")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"categoryName"})
+public class Category extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ @Column(name = "category_name")
+ private String categoryName;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "parent_id")
+ private Category parent;
+
+ @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
+ private List children = new ArrayList<>();
+
+ @Builder
+ private Category(String categoryName, Category parent) {
+ this.categoryName = categoryName;
+ this.parent = parent;
+ }
+
+ public static Category of(String categoryName, Category parent) {
+ return Category.builder()
+ .categoryName(categoryName)
+ .parent(parent)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/memo/domain/Memo.java b/src/main/java/com/kcy/fitapet/domain/memo/domain/Memo.java
new file mode 100644
index 00000000..b8b88e6b
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/memo/domain/Memo.java
@@ -0,0 +1,49 @@
+package com.kcy.fitapet.domain.memo.domain;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import com.kcy.fitapet.domain.model.Auditable;
+import com.kcy.fitapet.domain.pet.domain.Pet;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.util.List;
+
+@Entity
+@Table(name = "MEMO")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Memo extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private String title;
+ private String content;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "pet_id")
+ private Pet pet;
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "author_id", updatable = false)
+ private Member author;
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "last_editor_id")
+ private Member lastEditor;
+ @OneToMany(mappedBy = "memo", cascade = CascadeType.ALL)
+ private List memoImages;
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "category_id")
+ private Category category;
+
+ @Builder
+ private Memo(String title, String content) {
+ this.title = title;
+ this.content = content;
+ }
+
+ public static Memo of(String title, String content) {
+ return Memo.builder()
+ .title(title)
+ .content(content)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/memo/domain/MemoImage.java b/src/main/java/com/kcy/fitapet/domain/memo/domain/MemoImage.java
new file mode 100644
index 00000000..dd45dc75
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/memo/domain/MemoImage.java
@@ -0,0 +1,32 @@
+package com.kcy.fitapet.domain.memo.domain;
+
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "MEMO_IMAGE")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class MemoImage extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "img_url")
+ private String imgUrl;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "memo_id")
+ private Memo memo;
+
+ @Builder
+ private MemoImage(String imgUrl) {
+ this.imgUrl = imgUrl;
+ }
+
+ public static MemoImage of(String imgUrl) {
+ return MemoImage.builder()
+ .imgUrl(imgUrl).build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/model/Auditable.java b/src/main/java/com/kcy/fitapet/domain/model/Auditable.java
new file mode 100644
index 00000000..d71e49c8
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/model/Auditable.java
@@ -0,0 +1,24 @@
+package com.kcy.fitapet.domain.model;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Getter
+@EntityListeners(AuditingEntityListener.class)
+@MappedSuperclass
+public abstract class Auditable {
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/notification/domain/Notification.java b/src/main/java/com/kcy/fitapet/domain/notification/domain/Notification.java
new file mode 100644
index 00000000..01a43c43
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/notification/domain/Notification.java
@@ -0,0 +1,40 @@
+package com.kcy.fitapet.domain.notification.domain;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.ColumnDefault;
+
+@Entity
+@Table(name = "NOTIFICATION")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"title", "content", "ctype", "checked"})
+public class Notification extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private String title;
+ private String content;
+ @Convert(converter = NotificationTypeConverter.class)
+ private NotificationType ctype;
+ @ColumnDefault("false")
+ private boolean checked;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @Builder
+ private Notification(String title, String content, NotificationType ctype) {
+ this.title = title;
+ this.content = content;
+ this.ctype = ctype;
+ }
+
+ public static Notification of(String title, String content, NotificationType ctype) {
+ return Notification.builder()
+ .title(title)
+ .content(content)
+ .ctype(ctype)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationSetting.java b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationSetting.java
new file mode 100644
index 00000000..cd79113e
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationSetting.java
@@ -0,0 +1,41 @@
+package com.kcy.fitapet.domain.notification.domain;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.ColumnDefault;
+
+@Entity
+@Table(name = "NOTIFICATION_SETTING")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"isCare", "isMemo", "isSchedule"})
+public class NotificationSetting extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ @Column(name = "is_care") @ColumnDefault("false")
+ private boolean isCare;
+ @Column(name = "is_memo") @ColumnDefault("false")
+ private boolean isMemo;
+ @Column(name = "is_schedule") @ColumnDefault("false")
+ private boolean isSchedule;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @Builder
+ private NotificationSetting(boolean isCare, boolean isMemo, boolean isSchedule) {
+ this.isCare = isCare;
+ this.isMemo = isMemo;
+ this.isSchedule = isSchedule;
+ }
+
+ public static NotificationSetting of(boolean isCare, boolean isMemo, boolean isSchedule) {
+ return NotificationSetting.builder()
+ .isCare(isCare)
+ .isMemo(isMemo)
+ .isSchedule(isSchedule)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationType.java b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationType.java
new file mode 100644
index 00000000..8cbdcf11
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationType.java
@@ -0,0 +1,35 @@
+package com.kcy.fitapet.domain.notification.domain;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.kcy.fitapet.global.common.util.converter.LegacyCommonType;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static java.util.stream.Collectors.toMap;
+
+@RequiredArgsConstructor
+public enum NotificationType implements LegacyCommonType {
+ NOTICE("1", "공지사항"),
+ CARE("2", "케어활동"),
+ MEMO("3", "일기"),
+ SCHEDULE("4", "스케줄");
+
+ private final String code;
+ private final String type;
+
+ private static final Map stringToEnum =
+ Stream.of(values()).collect(toMap(Object::toString, e -> e));
+
+ @Override
+ public String getCode() { return code; }
+ @JsonValue
+ public String getType() { return type; }
+ @JsonCreator
+ public static NotificationType fromString(String type) {
+ return stringToEnum.get(type.toUpperCase());
+ }
+ @Override public String toString() { return type; }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationTypeConverter.java b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationTypeConverter.java
new file mode 100644
index 00000000..2badae26
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationTypeConverter.java
@@ -0,0 +1,13 @@
+package com.kcy.fitapet.domain.notification.domain;
+
+import com.kcy.fitapet.global.common.util.converter.AbstractLegacyEnumAttributeConverter;
+import jakarta.persistence.Converter;
+
+@Converter
+public class NotificationTypeConverter extends AbstractLegacyEnumAttributeConverter {
+ private static final String ENUM_NAME = "알림타입";
+
+ public NotificationTypeConverter() {
+ super(NotificationType.class, false, ENUM_NAME);
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/oauthid/domain/OAuthID.java b/src/main/java/com/kcy/fitapet/domain/oauthid/domain/OAuthID.java
new file mode 100644
index 00000000..383ba905
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/oauthid/domain/OAuthID.java
@@ -0,0 +1,40 @@
+package com.kcy.fitapet.domain.oauthid.domain;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import com.kcy.fitapet.domain.model.Auditable;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "OAUTH_ID")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"id", "provider"})
+public class OAuthID extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "access_token")
+ private String accessToken;
+ private String provider;
+ @Column(name = "expired_time")
+ private Long expired_time;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @Builder
+ private OAuthID(String accessToken, String provider, Long expired_time) {
+ this.accessToken = accessToken;
+ this.provider = provider;
+ this.expired_time = expired_time;
+ }
+
+ public static OAuthID of(String accessToken, String provider, Long expired_time) {
+ return OAuthID.builder()
+ .accessToken(accessToken)
+ .provider(provider)
+ .expired_time(expired_time)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/pet/domain/Pet.java b/src/main/java/com/kcy/fitapet/domain/pet/domain/Pet.java
new file mode 100644
index 00000000..a3463ba5
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/pet/domain/Pet.java
@@ -0,0 +1,77 @@
+package com.kcy.fitapet.domain.pet.domain;
+
+import com.kcy.fitapet.domain.care.domain.Care;
+import com.kcy.fitapet.domain.member.domain.Master;
+import com.kcy.fitapet.domain.memo.domain.Memo;
+import com.kcy.fitapet.domain.model.Auditable;
+import com.kcy.fitapet.domain.schedule.domain.Schedule;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.ColumnDefault;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "PET")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"petName", "gender", "birth", "species"})
+public class Pet extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ @Column(name = "pet_name")
+ private String petName;
+ private boolean gender;
+ @Column(name = "pet_profile_img")
+ private String petProfileImg;
+ @ColumnDefault("false")
+ private boolean neutered;
+ @Temporal(TemporalType.TIMESTAMP)
+ private LocalDateTime birth;
+ private Integer age;
+ private String species;
+ private String feed;
+
+ @OneToMany(mappedBy = "pet", cascade = CascadeType.ALL)
+ private List masters = new ArrayList<>();
+ @ManyToMany
+ @JoinTable(name = "PET_SCHEDULE",
+ joinColumns = @JoinColumn(name = "pet_id"),
+ inverseJoinColumns = @JoinColumn(name = "schedule_id"))
+ private List schedules = new ArrayList<>();
+ @ManyToMany
+ @JoinTable(name = "PET_CARE",
+ joinColumns = @JoinColumn(name = "pet_id"),
+ inverseJoinColumns = @JoinColumn(name = "care_id"))
+ private List cares = new ArrayList<>();
+ @OneToMany(mappedBy = "pet", cascade = CascadeType.ALL)
+ private List memos = new ArrayList<>();
+
+ @Builder
+ private Pet(String petName, boolean gender, String petProfileImg, boolean neutered, LocalDateTime birth,
+ Integer age, String species, String feed) {
+ this.petName = petName;
+ this.gender = gender;
+ this.petProfileImg = petProfileImg;
+ this.neutered = neutered;
+ this.birth = birth;
+ this.age = age;
+ this.species = species;
+ this.feed = feed;
+ }
+
+ public static Pet of(String petName, boolean gender, String petProfileImg, boolean neutered, LocalDateTime birth,
+ Integer age, String species, String feed) {
+ return Pet.builder()
+ .petName(petName)
+ .gender(gender)
+ .petProfileImg(petProfileImg)
+ .neutered(neutered)
+ .birth(birth)
+ .age(age)
+ .species(species)
+ .feed(feed).build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/domain/schedule/domain/Schedule.java b/src/main/java/com/kcy/fitapet/domain/schedule/domain/Schedule.java
new file mode 100644
index 00000000..f26b57a8
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/domain/schedule/domain/Schedule.java
@@ -0,0 +1,67 @@
+package com.kcy.fitapet.domain.schedule.domain;
+
+import com.kcy.fitapet.domain.member.domain.Member;
+import com.kcy.fitapet.domain.model.Auditable;
+import com.kcy.fitapet.domain.pet.domain.Pet;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.hibernate.annotations.ColumnDefault;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "SCHEDULE")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@ToString(of = {"scheduleName", "location", "reservationDt", "notifyDt", "isDone"})
+public class Schedule extends Auditable {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "schedule_name")
+ private String scheduleName;
+ private String location;
+
+ @Temporal(TemporalType.TIMESTAMP)
+ @Column(name = "reservation_dt")
+ private LocalDateTime reservationDt;
+ @Temporal(TemporalType.TIMESTAMP)
+ @Column(name = "notify_dt")
+ private LocalDateTime notifyDt;
+
+ @Column(name = "is_done") @ColumnDefault("false")
+ private boolean isDone;
+
+ @ManyToMany(mappedBy = "schedules")
+ private List pets = new ArrayList<>();
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "author_id", updatable = false)
+ private Member author;
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "last_editor_id")
+ private Member lastEditor;
+
+
+ @Builder
+ private Schedule(String scheduleName, String location, LocalDateTime reservationDt, LocalDateTime notifyDt, boolean isDone) {
+ this.scheduleName = scheduleName;
+ this.location = location;
+ this.reservationDt = reservationDt;
+ this.notifyDt = notifyDt;
+ this.isDone = isDone;
+ }
+
+ public static Schedule of(String scheduleName, String location, LocalDateTime reservationDt, LocalDateTime notifyDt, boolean isDone) {
+ return Schedule.builder()
+ .scheduleName(scheduleName)
+ .location(location)
+ .reservationDt(reservationDt)
+ .notifyDt(notifyDt)
+ .isDone(isDone)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/global/common/response/BasicResponse.java b/src/main/java/com/kcy/fitapet/global/common/response/BasicResponse.java
new file mode 100644
index 00000000..a3fca831
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/global/common/response/BasicResponse.java
@@ -0,0 +1,11 @@
+package com.kcy.fitapet.global.common.response;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface BasicResponse {
+}
diff --git a/src/main/java/com/kcy/fitapet/global/common/response/ErrorResponse.java b/src/main/java/com/kcy/fitapet/global/common/response/ErrorResponse.java
new file mode 100644
index 00000000..ba34e7c0
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/global/common/response/ErrorResponse.java
@@ -0,0 +1,19 @@
+package com.kcy.fitapet.global.common.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+
+@BasicResponse
+@Builder
+@Schema(description = "API 응답 - 에러")
+public record ErrorResponse(
+ @Schema(description = "응답 상태", defaultValue = "error") String status,
+ @Schema(description = "에러 메시지", example = "reason") String message
+) {
+ public static ErrorResponse of(String message) {
+ return ErrorResponse.builder()
+ .status("error")
+ .message(message)
+ .build();
+ }
+}
diff --git a/src/main/java/com/kcy/fitapet/global/common/response/FailureResponse.java b/src/main/java/com/kcy/fitapet/global/common/response/FailureResponse.java
new file mode 100644
index 00000000..b9c25c8d
--- /dev/null
+++ b/src/main/java/com/kcy/fitapet/global/common/response/FailureResponse.java
@@ -0,0 +1,70 @@
+package com.kcy.fitapet.global.common.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+import org.springframework.validation.BindingResult;
+import org.springframework.validation.FieldError;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@BasicResponse
+@Getter
+@Schema(description = "API 응답 - 실패")
+public class FailureResponse {
+ @Schema(description = "응답 상태", defaultValue = "fail")
+ private final String status = "fail";
+ @Schema(description = "응답 데이터", example = "{\"field\":\"reason\"}")
+ private final Object data;
+
+ @Builder
+ private FailureResponse(final Object data) {
+ this.data = data;
+ }
+
+ /**
+ * 단일 필드 에러를 응답으로 변환한다.
+ * @param field : 에러가 발생한 필드
+ * @param reason : 에러 이유
+ * @return FailureResponse
+ */
+ public static FailureResponse of(String field, String reason) {
+ return FailureResponse.builder()
+ .data(Map.of(field, reason))
+ .build();
+ }
+
+ /**
+ * BindingResult를 통해 발생한 에러를 응답으로 변환한다.
+ * @param bindingResult : BindingResult
+ * @return FailureResponse
+ */
+ public static FailureResponse from(final BindingResult bindingResult) {
+ Map fieldErrors = new HashMap<>();
+ for (FieldError fieldError : bindingResult.getFieldErrors()) {
+ fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
+ }
+
+ if (fieldErrors.size() == 1) {
+ Map.Entry entry = fieldErrors.entrySet().iterator().next();
+ return of(entry.getKey(), entry.getValue());
+ } else if (fieldErrors.size() > 1) {
+ return multiFieldError(fieldErrors);
+ }
+ return null;
+ }
+
+ private static > FailureResponse multiFieldError(final T fieldErrors) {
+ List