Repository: diff-by-devs/1-velog-clone-back-Sooamazing
Pull request #14: [#13]Feat(Entity): ERD에 맞춰 모든 Entity 생성 및 관련 설정 추가
Author: login: Sooamazing
Labels: name: Feature
description: 기능 개발
Created: 2025-05-12T23:17:20Z | Merged: 2025-05-15T03:24:28Z

## 작업 요약
- 프로젝트에서 사용하는 전체 Entity 생성 작업
  -  ERD와 맞추는 데 가장 신경을 썼습니다.
  - 원래 ddl-auto를 사용하려고 했는데, ddl-auto를 염두에 두긴 두되(ORM 매핑 방식 확인 위해), 테이블 생성해 놓고, 진행하려고 합니다.
- 의존성은 진행하면서 하나씩 추가합니다. 이번에 필요한 jpa, validation 추가했고, jpa config도 추가했습니다.
- javadoc 방식의 주석과 여러 줄 주석이 혼재되어 있는데, 여러 줄 주석을 공부, TODO 정리 목적으로 달아 놓은 거고, 코드 설명 등은 Javadoc 주석을 참고해 주세요.
- jpa 연결하며 github actions test가 깨지는 걸 고치기 위해 좀 시도했는데... 안 됐습니다........ 혹시 방법을 아시면... 공유 부탁드립니다....................... 우선, h2로 임시로 변경해 두었습니다. 관련 파일은 무시하셔도 됩니다. 

resolve #13 

## 작업 설명

### AS-IS

X

### TO-BE

- 공통 Entity 포함 전체 Entity 구현
- jpa, validation 의존성 추가, jpa config 추가
- Javadoc 주석 작성
- ERD의 적절성, Entity 작성 방식(자료 구조, 방향의 적절성 등)

## 이 PR이 merge된 후에 해야 할 일
- intelliJ 환경 변수 설정이 필요합니다. [참고](https://lahezy.tistory.com/105) -> 보류


## 리뷰 요구사항
- Javadoc 주석을 적절하게 잘 적었는지? 
  - 기준은... 음... 스프링..?... 보면 이게 어떤 일을 하는 코드구나 알 수 있다?
- 메서드, 변수, 테이블, 컬럼 등의 이름이 적절한지?
  - 예.. 너무 어렵고요... ㅠㅠ... 우우.. 어려워요... 저 대신 고민 감사... 의문 제기도 감사...
- ERD의 적절성, Entity 작성 방식(자료 구조, 방향의 적절성 등)
  - ERD는 정규화를 잘해보자! 였습니다. ([영감](https://rastalion.dev/%ED%9A%8C%EC%9B%90-%EA%B0%80%EC%9E%85-%EB%B0%8F-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%9D%84-%EC%9C%84%ED%95%9C-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%84%A4%EA%B3%84/) - 회원 쪽은 이게 거의 전부입니다. ㅎㅅㅎ) context 단위로 테이블 분리를 해 보고 싶었습니다. 해서 ERD 정규화가 잘 됐는지, 자료형은 적절한지, 관계성은 적절한지를 중점으로 봐 주시고, Entity는 ERD에 맞추어 잘 구현됐는지, 매핑은 적절한지 등을 중점으로 봐 주시면 좋겠습니다.  
  - 제가 방향을 까먹기도 했고, 우선 단방향 구현했고, 원래도 헷갈려 해서 양방향 등은 구현하면서 필요한 사람이 추가한다 생각하고 작업했습니다. (물론 그게 저임.)


## 참고
- [ERD CLOUD - ERD](https://www.erdcloud.com/d/ZY7YvtGTigQthp2RF) - 전체 공개임.
- [노션 - Entity 구현하며 기록한 내용](https://www.notion.so/hannanana/Entity-1ec33717f91380a79d82cca2296c0093?pvs=4) - 근데, 이건.. 정리는 못했습니다.. 후루루루루루 적기만...
- Entity는... 테스트할 방법을 모르기도 하는데, 우선은 큰 로직이 없어서 두었습니다.
- 먼저, TODO 주석을 지우지 않고, 올린 점 양해 부탁드립니다. 원래라면 지우고 올려야 했겠지만... 봐주세요. 이런 거 저런 거 확인하면서 고쳐가면서 하려고 하니까 노션 문서도 문서인데, 코드에 적어놓는 것도 좋겠다는 생각이 들었습니다. 특히 예외 확인 같은 것들이라 더더욱 그랬습니다.
- 저 재밌는 얘기 있습니다. 이해할 수 있게 정리해서 주석을 쓰다 보니, 설계가 이상한 부분이 보여서 ERD도 변경했지 뭐예요. 하하하. 주석의... 나름의 순기능인 것 같아 신기하고 재밌었습니다.
- 이건 나중의 일인데, 전 원래 도메인과 Enitity 분리하지 않고, Entity에서 비즈니스로직 맡도록 하고, 얼마나 구린지 보자! 했는데... 하다 보니 어렵긴 하더라구요..?... 무튼... 어찌저찌 해 보고 공유하겠슴다.

## 확인 사항

- [x] 내 코드에 대해 self-review를 진행했는지?
- [x] 내 코드가 project 규칙에 만족하는지?
- [x] sonarlint, IDE, log 등에서 새로운 warning이 없는 걸 확인했는지? 사용한 적 없음, Javadoc 주석 등에 대한 내용만 확인.
- [ ] 적절한 테스트 코드를 추가했는지?
- [x] 모든 테스트에 통과하는 걸 확인했는지?
- [x] 필요한 문서 생성/수정이 진행되었는지?


Linked issue #13: 초기 Entity 생성
## 어떤 기능인가요?
- ERD에 맞춰 Entity 작성

## 작업 상세 내용
- [x] Entity 작성
- [ ] DDL 쿼리 ERD와 맞는지 확인하기
- [x] 주석 작성

## 참고
- [노션-Entity](https://www.notion.so/hannanana/Entity-1ec33717f91380a79d82cca2296c0093?pvs=4) 
- [ERD CLOUD - ERD](https://www.erdcloud.com/d/ZY7YvtGTigQthp2RF)

## 이슈 생성 시 확인하기
- [ ] Assignees, labe, project, milestone 할당했나요?


Diff:
diff --git a/.github/workflows/automation-test.yml b/.github/workflows/automation-test.yml
index 01601b2..24590ba 100644
--- a/.github/workflows/automation-test.yml
+++ b/.github/workflows/automation-test.yml
@@ -15,22 +15,35 @@ jobs:
   build:
 
     runs-on: ubuntu-latest
+    env:
+      spring.datasource.username: ${{secrets.DB_USER}}
+      spring.datasource.password: ${{secrets.DB_PASSWORD}}
 
     steps:
-    - uses: actions/checkout@v4
-    - name: Set up JDK 21
-      uses: actions/setup-java@v4
-      with:
-        java-version: '17'
-        distribution: 'temurin'
+      - uses: actions/checkout@v4
+      - name: Set up JDK 21
+        uses: actions/setup-java@v4
+        with:
+          java-version: '21'
+          distribution: 'temurin'
+      #
+      #      - name: Shutdown Ubuntu MySQL (SUDO)
+      #        run: sudo service mysql stop # Shutdown the Default MySQL, "sudo" is necessary, please not remove it
+      #
+      #      - uses: mirromutth/mysql-action@v1.1
+      #        with:
+      #          host port: 3306
+      #          container port: 3306
+      #          mysql database: 'velog_clone'
+      #          mysql root password: ${{ secrets.DB_PASSWORD }}
 
-    # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies.
-    # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md
-    - name: Setup Gradle
-      uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
+      # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies.
+      # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
 
-    - name: Build with Gradle Wrapper
-      run: ./gradlew build
+      - name: Build with Gradle Wrapper
+        run: ./gradlew build
 
     # NOTE: The Gradle Wrapper is the default and recommended way to run Gradle (https://docs.gradle.org/current/userguide/gradle_wrapper.html).
     # If your project does not have the Gradle Wrapper configured, you can use the following configuration to run Gradle with a specified version.
@@ -43,7 +56,7 @@ jobs:
     # - name: Build with Gradle 8.9
     #   run: gradle build
 
-  test:   
+  test:
 
     runs-on: ubuntu-latest
 
@@ -57,33 +70,33 @@ jobs:
       pull-requests: write
 
     steps:
-    - uses: actions/checkout@v4
-    - name: JDK 21 세팅 
-      uses: actions/setup-java@v4
-      with:
-        java-version: '21' 
-        distribution: 'temurin' 
-
-    - name: gradlew 실행 권한 설정 
-      run: chmod +x gradlew
- 
-    - name: 테스트 진행 
-      run: ./gradlew --info test
-      
-    - name: 테스트 결과 Report 
-      uses: EnricoMi/publish-unit-test-result-action@v2
-      if: always()  # 테스트가 실패해도 Report를 보기 위해 `always`로 설정
-      with:
-         files: 'build/test-results/**/*.xml'
-    
-    - name: 테스트 Comment - clone repo
-      uses: mikepenz/action-junit-report@v5
-      if: ${{ always() &&  (github.event.pull_request.head.repo.full_name == github.repository) }}
-      with:
-        report_paths: '**/build/test-results/test/TEST-*.xml'
-
-    - name: Write out Unit Test report annotation for forked repo
-      if: ${{ failure() &&  (github.event.pull_request.head.repo.full_name != github.repository) }}
-      uses: mikepenz/action-junit-report@v5
-      with:
-        annotate_only: true # forked repo cannot write to checks so just do annotations
\ No newline at end of file
+      - uses: actions/checkout@v4
+      - name: JDK 21 세팅
+        uses: actions/setup-java@v4
+        with:
+          java-version: '21'
+          distribution: 'temurin'
+
+      - name: gradlew 실행 권한 설정
+        run: chmod +x gradlew
+
+      - name: 테스트 진행
+        run: ./gradlew --info test
+
+      - name: 테스트 결과 Report
+        uses: EnricoMi/publish-unit-test-result-action@v2
+        if: always()  # 테스트가 실패해도 Report를 보기 위해 `always`로 설정
+        with:
+          files: 'build/test-results/**/*.xml'
+
+      - name: 테스트 Comment - clone repo
+        uses: mikepenz/action-junit-report@v5
+        if: ${{ always() &&  (github.event.pull_request.head.repo.full_name == github.repository) }}
+        with:
+          report_paths: '**/build/test-results/test/TEST-*.xml'
+
+      - name: Write out Unit Test report annotation for forked repo
+        if: ${{ failure() &&  (github.event.pull_request.head.repo.full_name != github.repository) }}
+        uses: mikepenz/action-junit-report@v5
+        with:
+          annotate_only: true # forked repo cannot write to checks so just do annotations
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 9258d3f..0e8cf14 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,28 +1,59 @@
 plugins {
-	id 'java'
-	id 'org.springframework.boot' version '3.4.5'
-	id 'io.spring.dependency-management' version '1.1.7'
+    id 'java'
+    id 'org.springframework.boot' version '3.4.5'
+    id 'io.spring.dependency-management' version '1.1.7'
 }
 
 group = 'diffbydevs'
 version = '0.0.1-SNAPSHOT'
 
 java {
-	toolchain {
-		languageVersion = JavaLanguageVersion.of(21)
-	}
+    toolchain {
+        languageVersion = JavaLanguageVersion.of(21)
+    }
 }
 
 repositories {
-	mavenCentral()
+    mavenCentral()
 }
 
 dependencies {
-	implementation 'org.springframework.boot:spring-boot-starter'
-	testImplementation 'org.springframework.boot:spring-boot-starter-test'
-	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+    implementation 'org.springframework.boot:spring-boot-starter'
+    testImplementation 'org.springframework.boot:spring-boot-starter-test'
+    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+    // getter, Args 사용 위해 LOMBOK 추가
+    // gradle 설치: https://projectlombok.org/setup/gradle
+    // 어노테이션: https://projectlombok.org/features/
+    compileOnly("org.projectlombok:lombok:1.18.38")
+    annotationProcessor("org.projectlombok:lombok:1.18.38")
+
+    testCompileOnly("org.projectlombok:lombok:1.18.38")
+    testAnnotationProcessor("org.projectlombok:lombok:1.18.38")
+
+    // entity 등 ORM 기능 사용 위해 jpa 설치
+    // https://spring.io/projects/spring-data-jpa#overview
+    // https://mvnrepository.com/artifact/org.springframework.data/spring-data-jpa
+    // implementation 'org.springframework.data:spring-data-jpa'
+    // build error: package javax.persistence does not exist
+    // (공부) data와 boot의 어떤 차이 때문에 build error나는지, data는 언제 사용하는지 공부하기.
+    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+
+    // https://mvnrepository.com/artifact/mysql/mysql-connector-java
+    // implementation 'mysql:mysql-connector-java'
+    // properties의 driver과 연동이 안 됨.
+    // (공부) mysql 버전은 내 로컬 mysql 버전과 관계 없는 건지. 내 로컬에 맞춰서 connector-j 사용.
+    // https://kimslab01.tistory.com/23
+//    implementation 'com.mysql:mysql-connector-j'
+
+    // @Size, @NotNull 등 제약 조건 사용 위해 validation 추가
+    // Hibernate에서 제공하는 @Length 내부도 보면 validation 사용하고 있음.
+    implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+    // mysql 대신 H2 사용
+    runtimeOnly 'com.h2database:h2'
 }
 
 tasks.named('test') {
-	useJUnitPlatform()
+    useJUnitPlatform()
 }
diff --git a/src/main/java/diffbydevs/velog_clone/authentication/domain/AuthPassword.java b/src/main/java/diffbydevs/velog_clone/authentication/domain/AuthPassword.java
new file mode 100644
index 0000000..2a95923
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/authentication/domain/AuthPassword.java
@@ -0,0 +1,79 @@
+package diffbydevs.velog_clone.authentication.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntityOnlyUpdatedAt;
+import diffbydevs.velog_clone.member.domain.Member;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+/*
+TODO
+1. salt 정책
+2. SHA-512 단방향 암호화 방식 사용?
+3. 비밀번호, Salt 길이 적절성 확인
+4. TODO 검증을 생성할 때 파라미터에도 넣어야 하는 걸까? 우선 값 객체에 넣는 방식을 고민해 보자.
+ */
+
+/**
+ * 비밀 번호 형식으로 회원 가입한 회원의 인증 정보입니다.
+ * 암호화 방식을 적용해 비밀 번호를 저장하고, 로그인 시 확인합니다.
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class AuthPassword extends BaseEntityOnlyUpdatedAt {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@OneToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "member_id")
+	private Member member;
+
+	/**
+	 * SHA-512 단방향 암호화 적용한 비밀번호
+	 */
+	@NotNull
+	@Size(max = 128)
+	@Column(columnDefinition = "CHAR(128)")
+	private String password;
+
+	/**
+	 * 암호화에 사용하는 salt 값
+	 */
+	@NotNull
+	@Size(max = 128)
+	@Column(columnDefinition = "CHAR(128)")
+	private String salt;
+
+	/**
+	 * 비밀 번호로 회원 가입합니다.
+	 *
+	 * @param member (방금 가입한) 회원
+	 * @param password 비밀번호
+	 * @return 비밀번호 방식으로 가입한 회원 정보
+	 */
+	public static AuthPassword signUpWithPassword(
+		Member member, String password
+	) {
+		AuthPassword authPassword = new AuthPassword();
+
+		authPassword.member = member;
+		authPassword.password = password; // TODO 암호화 진행 후 저장
+		authPassword.salt = "salt"; // TODO 암호화 방식이 정해지면, 내부에서 salt 값 생성 후 저장하기로.
+
+		return authPassword;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/blog/domain/Blog.java b/src/main/java/diffbydevs/velog_clone/blog/domain/Blog.java
new file mode 100644
index 0000000..5171433
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/blog/domain/Blog.java
@@ -0,0 +1,81 @@
+package diffbydevs.velog_clone.blog.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntity;
+import diffbydevs.velog_clone.member.domain.Member;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+1. blog 이름 중복 시 정책 고민 필요. 존재하는 값이면 -UUID(6) 이런 식으로 생성?
+ */
+
+/**
+ * 회원 가입시 자동으로 생성되는 블로그 메타 데이터입니다.
+ * <p>
+ *     현재는 회원 한 명 당 하나만 생성 가능합니다.
+ * </p>
+ *
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Blog extends BaseEntity {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@OneToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "member_id")
+	private Member member;
+
+	/**
+	 * 블로그 이름
+	 * slug로도 사용됨.
+	 * 영어 소문자, 숫자, - 사용 가능.
+	 */
+	@NotNull
+	@Pattern(regexp = "^[a-z0-9-]*$")
+	@Size(max = 60)
+	@Column(unique = true, length = 60)
+	private String name;
+
+	/**
+	 * 블로그 소개
+	 * <p>
+	 *     회원 가입 시에는 작성하지 않음.
+	 * </p>
+	 */
+	private String introduce;
+
+	/**
+	 * 블로그를 개설합니다.
+	 *
+	 * @param member 가입한 회원
+	 * @param name 블로그 이름
+	 * @return 생성된 블로그 메타 데이터
+	 */
+	public static Blog openBlog(Member member, String name) {
+		Blog blog = new Blog();
+
+		blog.member = member;
+		blog.name = name;
+
+		return blog;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/comment/domain/Comment.java b/src/main/java/diffbydevs/velog_clone/comment/domain/Comment.java
new file mode 100644
index 0000000..9047e26
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/comment/domain/Comment.java
@@ -0,0 +1,139 @@
+package diffbydevs.velog_clone.comment.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntity;
+import diffbydevs.velog_clone.member.domain.Member;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+1. @Nullable은 안 되겠지? validation이 아니던데... 나중에 확인해 보기.
+2. Entity 에러는... Entity 생성이 아니고 저장 위해 쿼리 날리 때 나는 거겠지?
+3. Level +1 하고, 안 되는지 확인하는 건 어디서 책임을 질까?
+4. 게시글, 상위 댓글이 삭제됐는지도 확인해.
+5. ParentComment 쿼리 null 맞는지 확인하기
+6. (공부) comment는 Mysql 예약어라 테이블 이름이 따로 필요함.
+ */
+
+/**
+ * 회원이 남긴 댓글입니다.
+ */
+@Getter
+@Entity
+@Table(name = "commments")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Comment extends BaseEntity {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "post_id")
+	private Post post;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "commenter_id")
+	private Member commenter;
+
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "parent_comment_id")
+	private Comment parentComment;
+
+	/**
+	 * 댓글 내용
+	 */
+	@NotNull
+	@Column(columnDefinition = "varchar(1000)")
+	private String content;
+
+	/**
+	 * 댓글 계층 - {@link CommentLevel#COMMENT 댓글},
+	 * {@link CommentLevel#REPLY 대댓글},
+	 * {@link CommentLevel#LAST_REPLY 대대댓글}
+	 * @see CommentLevel
+	 */
+	@NotNull
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int level;
+
+	/**
+	 * 댓글 삭제 여부
+	 * @see CommentActiveStatus
+	 */
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int isDeleted;
+
+	/**
+	 * (최상위) 댓글을 답니다.
+	 *
+	 * @param post 댓글 달 게시글
+	 * @param commenter 댓글 다는 회원
+	 * @param content 댓글 내용
+	 * @return 남긴 댓글
+	 */
+	public static Comment leaveComment(
+		Post post,
+		Member commenter,
+		String content
+	) {
+		Comment comment = new Comment();
+
+		comment.post = post;
+		comment.commenter = commenter;
+		comment.content = content;
+
+		// 기본 값 설정
+		comment.parentComment = null;
+		comment.level = CommentLevel.COMMENT.getValueInDB();
+		comment.isDeleted = CommentActiveStatus.ACTIVE.getValueInDB();
+
+		return comment;
+	}
+
+	/**
+	 * 대댓글, 대대댓글을 답니다.
+	 *
+	 * @param post 댓글 달 게시글
+	 * @param commenter 댓글 다는 회원
+	 * @param parentComment 상위 레벨의 댓글
+	 * @param content 댓글 내용
+	 * @param level 댓글 계층
+	 * @return 남긴 댓글
+	 */
+	public static Comment leaveComment(
+		Post post,
+		Member commenter,
+		Comment parentComment,
+		String content,
+		CommentLevel level
+	) {
+		Comment comment = new Comment();
+
+		comment.post = post;
+		comment.commenter = commenter;
+		comment.parentComment = parentComment;
+		comment.content = content;
+		comment.level = level.getValueInDB();
+
+		// 기본 값 설정
+		comment.isDeleted = CommentActiveStatus.ACTIVE.getValueInDB();
+
+		return comment;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/comment/domain/CommentActiveStatus.java b/src/main/java/diffbydevs/velog_clone/comment/domain/CommentActiveStatus.java
new file mode 100644
index 0000000..097169e
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/comment/domain/CommentActiveStatus.java
@@ -0,0 +1,32 @@
+package diffbydevs.velog_clone.comment.domain;
+
+import lombok.Getter;
+
+/**
+ * 댓글 활성/삭제 상태를 나타냅니다.
+ * <p>
+ *     코드의 가독성을 위해 어플리케이션 코드에서만 사용합니다.
+ *     실제 db에서는 정규화 문제로 ENUM이 아닌 정수형으로 관리합니다.
+ * </p>
+ * @see Comment#isDeleted
+ */
+@Getter
+public enum CommentActiveStatus {
+
+	/**
+	 * 댓글 게시 중
+	 */
+	ACTIVE(0),
+	/**
+	 * 댓글 삭제됨
+	 */
+	DELETED(1);
+	/**
+	 * 실제로 db에 들어가게 되는 값
+	 */
+	private final int valueInDB;
+
+	CommentActiveStatus(int valueInDB) {
+		this.valueInDB = valueInDB;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/comment/domain/CommentLevel.java b/src/main/java/diffbydevs/velog_clone/comment/domain/CommentLevel.java
new file mode 100644
index 0000000..9cf1bbf
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/comment/domain/CommentLevel.java
@@ -0,0 +1,37 @@
+package diffbydevs.velog_clone.comment.domain;
+
+import lombok.Getter;
+
+/**
+ * 댓글의 계층을 나타냅니다. 댓글, 대댓글, 대대댓글이 있습니다.
+ * <p>
+ *     코드의 가독성을 위해 어플리케이션 코드에서만 사용합니다.
+ *     실제 db에서는 정규화 문제로 ENUM이 아닌 정수형으로 관리합니다.
+ * </p>
+ * @see Comment#level
+ */
+@Getter
+public enum CommentLevel {
+
+	/**
+	 * 댓글, 최상위 댓글
+	 */
+	COMMENT(0),
+	/**
+	 * 대댓글
+	 */
+	REPLY(1),
+	/**
+	 * 대대댓글
+	 */
+	LAST_REPLY(2);
+
+	/**
+	 * 실제로 db에 들어가게 되는 값
+	 */
+	private final int valueInDB;
+
+	CommentLevel(int valueInDB) {
+		this.valueInDB = valueInDB;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/global/config/JpaConfig.java b/src/main/java/diffbydevs/velog_clone/global/config/JpaConfig.java
new file mode 100644
index 0000000..3029a51
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/global/config/JpaConfig.java
@@ -0,0 +1,26 @@
+package diffbydevs.velog_clone.global.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+/* # 공부용 주석
+ * ## JPA Auditing
+ * - JPA를 감시하며 엔티티 관련 이벤트를 추적하고 기록함.
+ * ## Main이 아니고 config인 이유
+ * - 스프링 컨테이너가 필요한 거 할 때 JPA metamodel must not be empty! 오류 발생(<a href="https://tao-tech.tistory.com/14">참고</a>
+ * )
+ * - DataJpaTest 시 Main을 import하게 되면, 단위 테스트의 의미가 없어짐.
+ */
+
+/**
+ * JPA 설정을 관리합니다.
+ * <p>
+ *     생성일, 수정일 자동 관리 기능을 이용하기 위해 JPA Auditing을 설정합니다.
+ *    설정을 각각 관리하고, 모아서 확인하기 위해 main이 아니라 config로 관리합니다.
+ * </p>
+ * @see diffbydevs.velog_clone.global.domain.BaseEntity  AuditingEntityListener 사용한 BaseEntity 예시
+ */
+@Configuration
+@EnableJpaAuditing
+public class JpaConfig {
+}
diff --git a/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntity.java b/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntity.java
new file mode 100644
index 0000000..4ce8cbe
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntity.java
@@ -0,0 +1,39 @@
+package diffbydevs.velog_clone.global.domain;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+
+/*
+TODO
+ 1. CRUD 구현 이후, 테이블이 이미 만들어진 후에도 Column의 nullable, updatable이 필요한지 확인하기.
+ 2. jpa 말고, 하이버네이트 구현체인 createtimestamp 사용하는 경우와 영속성 등 차이 비교.
+ */
+
+/**
+ * {@link EntityListeners}를 사용해 생성일시, 수정일시를 자동으로 관리합니다.
+ * <p>
+ *     Entity에서 상속받기만 하면 자동으로 사용 가능합니다.
+ *    {@link MappedSuperclass}를 이용해 별개의 entity(테이블)는 생성하지 않습니다.
+ * </p>
+ */
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseEntity {
+
+	@Column(nullable = false, updatable = false)
+	@CreatedDate
+	private LocalDateTime createdAt;
+
+	@Column(nullable = false)
+	@LastModifiedDate
+	private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntityOnlyCreatedAt.java b/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntityOnlyCreatedAt.java
new file mode 100644
index 0000000..55aa8f8
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntityOnlyCreatedAt.java
@@ -0,0 +1,28 @@
+package diffbydevs.velog_clone.global.domain;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+
+/**
+ * {@link EntityListeners}를 사용해 생성일시를 자동으로 관리합니다.
+ * <p>
+ *     Entity에서 상속받기만 하면 자동으로 사용 가능합니다.
+ *    {@link MappedSuperclass}를 이용해 별개의 entity(테이블)는 생성하지 않습니다.
+ * </p>
+ */
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseEntityOnlyCreatedAt {
+
+	@Column(nullable = false, updatable = false)
+	@CreatedDate
+	private LocalDateTime createdAt;
+}
diff --git a/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntityOnlyUpdatedAt.java b/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntityOnlyUpdatedAt.java
new file mode 100644
index 0000000..ee2a6ca
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/global/domain/BaseEntityOnlyUpdatedAt.java
@@ -0,0 +1,28 @@
+package diffbydevs.velog_clone.global.domain;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+
+/**
+ * {@link EntityListeners}를 사용해 수정일시를 자동으로 관리합니다.
+ * <p>
+ *     Entity에서 상속받기만 하면 자동으로 사용 가능합니다.
+ *    {@link MappedSuperclass}를 이용해 별개의 entity(테이블)는 생성하지 않습니다.
+ * </p>
+ */
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseEntityOnlyUpdatedAt {
+
+	@Column(nullable = false)
+	@LastModifiedDate
+	private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/diffbydevs/velog_clone/like/domain/Like.java b/src/main/java/diffbydevs/velog_clone/like/domain/Like.java
new file mode 100644
index 0000000..c58b665
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/like/domain/Like.java
@@ -0,0 +1,66 @@
+package diffbydevs.velog_clone.like.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntityOnlyCreatedAt;
+import diffbydevs.velog_clone.member.domain.Member;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+# 공부
+likes는 mysql 예약어라 테이블 이름이 따로 필요함.
+ */
+/**
+ * 회원이 게시글에 좋아요하는 기록입니다.
+ */
+@Getter
+@Entity
+@Table(name = "likes")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Like extends BaseEntityOnlyCreatedAt {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "member_id")
+	private Member member;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "post_id")
+	private Post post;
+
+	/**
+	 * 게시글에 좋아요를 누릅니다.
+	 * <p>
+	 *     본인 게시글에도 좋아요를 누를 수 있습니다.
+	 * </p>
+	 *
+	 * @param member 좋아요 누른 회원
+	 * @param post 좋아요할 게시글 메타 데이터
+	 * @return 좋아요한 날짜를 포함한 정보
+	 */
+	public static Like likePost(Member member, Post post) {
+		Like like = new Like();
+
+		like.member = member;
+		like.post = post;
+
+		return like;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/member/domain/Member.java b/src/main/java/diffbydevs/velog_clone/member/domain/Member.java
index 6decdba..d8a211a 100644
--- a/src/main/java/diffbydevs/velog_clone/member/domain/Member.java
+++ b/src/main/java/diffbydevs/velog_clone/member/domain/Member.java
@@ -1,4 +1,65 @@
 package diffbydevs.velog_clone.member.domain;
 
-public class Member {
+import diffbydevs.velog_clone.global.domain.BaseEntityOnlyUpdatedAt;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+- query에서 Status에 check 제약도 함께 걸리는지 확인 필요.
+- 회원 CRUD 진행 시 Profile 등 생성 방식 고민 필요.
+ */
+
+/**
+ * 회원의 메타 데이터입니다.
+ * <p>
+ *     회원에 대해 외부와 통신 시 해당 id로 통신합니다.
+ * </p>
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Member extends BaseEntityOnlyUpdatedAt {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	/**
+	 * 회원 가입, 로그인 등 인증 시 사용하는 이메일 형식의 아이디
+	 */
+	@NotNull
+	@Email
+	@Column(unique = true, length = 150)
+	private String accountId;
+
+	/**
+	 * 회원의 활성, 휴면, 탈퇴 상태
+	 * @see MemberStatus
+	 */
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int status;
+
+	/**
+	 * 회원 가입합니다.
+	 * @return 회원 가입한 회원의 메타 데이터
+	 */
+	public static Member signUp(String accountId) {
+		Member member = new Member();
+
+		member.accountId = accountId;
+		member.status = MemberStatus.ACTIVE.getValueInDB();
+
+		return member;
+	}
+
 }
diff --git a/src/main/java/diffbydevs/velog_clone/member/domain/MemberProfile.java b/src/main/java/diffbydevs/velog_clone/member/domain/MemberProfile.java
new file mode 100644
index 0000000..1e262ce
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/member/domain/MemberProfile.java
@@ -0,0 +1,102 @@
+package diffbydevs.velog_clone.member.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+- 공부:
+ 1. 쿼리 확인. Lazy 먹히는지, 양방향으로 필요할지, 주인은 누구로 할지 고민.
+ 2. 소개에 Emoji 들어가는 걸 고려해 length validator 만들기
+ - https://melonturtle.netlify.app/hibernate-column-size/
+  -https://melonturtle.netlify.app/java-count-emoji/
+ 3. Pattern을 Entity 내부에 두는 게 적절할지 고민
+ */
+
+/**
+ * 회원이 직접 수정 가능한 회원 정보입니다.
+ * <p>
+ *     가입일을 확인할 수 있습니다.
+ * </p>
+ *
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class MemberProfile extends BaseEntity {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@OneToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "member_id")
+	private Member member;
+
+	/**
+	 * 별명.
+	 * slug로 사용, 영어 소문자, 숫자, - 사용 가능.
+	 */
+	@NotNull
+	@Pattern(regexp = "^[a-z0-9-]*$")
+	@Size(max = 50)
+	@Column(length = 50, unique = true)
+	private String nickname;
+
+	/**
+	 * 회원에 대한 간단한 소개
+	 */
+	private String introduce;
+
+	/**
+	 * 회원 가입하며 회원 프로필을 전부 작성합니다.
+	 *
+	 * @param member 회원
+	 * @param nickname 별명
+	 * @param introduce 회원 소개
+	 * @return 회원 가입 시 작성한 프로필
+	 */
+	public static MemberProfile createProfile(Member member, String nickname, String introduce) {
+		MemberProfile memberProfile = new MemberProfile();
+
+		memberProfile.member = member;
+		memberProfile.nickname = nickname;
+		memberProfile.introduce = introduce;
+
+		return memberProfile;
+	}
+
+	/**
+	 * 회원 가입하며 회원 프로필에 별명만 작성합니다.
+	 *
+	 * @param member 회원
+	 * @param nickname 별명
+	 * @return 회원 가입 시 작성한 프로필
+	 */
+	public static MemberProfile createProfile(Member member, String nickname) {
+		MemberProfile memberProfile = new MemberProfile();
+
+		memberProfile.member = member;
+		memberProfile.nickname = nickname;
+
+		// 기본값
+		memberProfile.introduce = null;
+
+		return memberProfile;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/member/domain/MemberRelation.java b/src/main/java/diffbydevs/velog_clone/member/domain/MemberRelation.java
new file mode 100644
index 0000000..2fa0216
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/member/domain/MemberRelation.java
@@ -0,0 +1,55 @@
+package diffbydevs.velog_clone.member.domain;
+
+import org.springframework.data.domain.Persistable;
+
+import diffbydevs.velog_clone.global.domain.BaseEntityOnlyCreatedAt;
+import jakarta.persistence.EmbeddedId;
+import jakarta.persistence.Entity;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+1. 복합 PK는 인덱스에 좋은 영향을 못 준다. 특히 하나씩 조회할 때. -> 비교해 보기.
+2. IdClass, Embeddable 성능, 사용 방식 등 비교
+3. TODO 본인이 본인을 팔로우할 수 없음.
+4. EmbeddedId 여기에도 NotNull이 필요할지 의문. 우선, pk는 Notnull이고, 생성할 때 무조건 PK를 만들고, 어플리케이션에서는 거기에서도 검사하기 때문에 따로 걸지 않았음.
+ */
+
+/**
+ * 회원끼리의 팔로우 관계입니다.
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class MemberRelation extends BaseEntityOnlyCreatedAt implements Persistable<MemberRelationPK> {
+
+	@EmbeddedId
+	private MemberRelationPK id;
+
+	/**
+	 * 회원이 다른 회원을 팔로우합니다.
+	 *
+	 * @param follower 팔로우 요청한 회원
+	 * @param following 팔로우 대상 회원
+	 * @return 팔로우 맺어진 회원들
+	 */
+	public static MemberRelation follow(
+		Member follower, Member following) {
+		MemberRelation memberRelation = new MemberRelation();
+
+		memberRelation.id = MemberRelationPK.createFollowPK(follower, following);
+
+		return memberRelation;
+	}
+
+	/**
+	 * GeneratedValue를 사용하지 않고 복합키 사용 시, 이미 id 값을 가지고 있어 이미 있던 entity로 판단할 수 있기에 jpa의 새로운 엔티티 판단 전략을 재정의합니다.
+	 * @return 새롭다면 true, 있던 거라면 false
+	 */
+	@Override
+	public boolean isNew() {
+		return super.getCreatedAt() == null;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/member/domain/MemberRelationPK.java b/src/main/java/diffbydevs/velog_clone/member/domain/MemberRelationPK.java
new file mode 100644
index 0000000..ce702c9
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/member/domain/MemberRelationPK.java
@@ -0,0 +1,44 @@
+package diffbydevs.velog_clone.member.domain;
+
+import java.io.Serializable;
+
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+공부
+1. package-private으로 두고 싶어서 PACKAGE, 그냥 class로 지정.
+module과 package level은 동일하다고 함.
+2. (궁금) Serialize는 왜 구현해야 할까?
+ */
+
+/**
+ * 회원 간 팔로우 관계의 복합 PK입니다.
+ * package-private 설정해 해당 패키지 외부에서 PK에 접근할 수 없습니다.
+ */
+
+@Getter
+@Embeddable
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(staticName = "createFollowPK")
+public class MemberRelationPK implements Serializable {
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "follower_id")
+	private Member follower;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "following_id")
+	private Member following;
+}
diff --git a/src/main/java/diffbydevs/velog_clone/member/domain/MemberSocial.java b/src/main/java/diffbydevs/velog_clone/member/domain/MemberSocial.java
new file mode 100644
index 0000000..698ed7b
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/member/domain/MemberSocial.java
@@ -0,0 +1,107 @@
+package diffbydevs.velog_clone.member.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntityOnlyUpdatedAt;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+공부용 기록
+- 이메일: 사용자의 99퍼가 63자 이내로 사용한다고 해 150으로 설정.
+- homepage: bit.ly 등 줄일 방법이 다양해 최대 255로 설정.
+- 기본값 255는 따로 기입하지 않음.
+- 최초 작성할 때 create, make 등을 사용한다고도 해서 create로 사용.
+- id는 다른 홈페이지 제한 모르겠어서 그냥 255로 둠.
+
+TODO
+1. id, username, homepage url은 어떤 제약 조건이 들어가면 될까..?
+ */
+
+/**
+ * 회원이 관리하고, 외부에 공개하는 social 정보입니다.
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class MemberSocial extends BaseEntityOnlyUpdatedAt {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@OneToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "member_id")
+	private Member member;
+
+	/**
+	 * social 정보로 노출할 email. 이 이메일은 인증과는 관련 없음.
+	 */
+	@Email
+	@Size(max = 150)
+	@Column(length = 150)
+	private String email;
+
+	/**
+	 * github에서 @ㅇㅇㅇ으로 사용하는 id
+	 */
+	@Size(max = 255)
+	private String githubId;
+
+	/**
+	 * twitter에서 @ㅇㅇㅇ으로 사용하는 id
+	 */
+	@Size(max = 255)
+	private String twitterId;
+
+	/**
+	 * www.facebook.com/ㅇㅇㅇ 여기서 ㅇㅇㅇ이 username
+	 */
+	@Size(max = 255)
+	private String facebookUsername;
+
+	/**
+	 * 보여주고 싶은 Homepage url
+	 */
+	@Size(max = 255)
+	private String homepage;
+
+	/**
+	 * 회원의 social 정보를 작성합니다.
+	 *
+	 * @param member 회원
+	 * @param email email
+	 * @param githubId github에서 사용하는 id
+	 * @param twitterId twitter에서 사용하는 id
+	 * @param facebookUsername facebook uri 뒤에 붙은 username
+	 * @param homepage 보여주고 싶은 홈페이지
+	 * @return MemberSocial 회원의 소셜 정보
+	 */
+	public static MemberSocial createMemberSocial(
+		Member member, String email, String githubId, String twitterId
+		, String facebookUsername, String homepage
+	) {
+		MemberSocial memberSocial = new MemberSocial();
+
+		memberSocial.member = member;
+		memberSocial.email = email;
+		memberSocial.githubId = githubId;
+		memberSocial.twitterId = twitterId;
+		memberSocial.facebookUsername = facebookUsername;
+		memberSocial.homepage = homepage;
+
+		return memberSocial;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/member/domain/MemberStatus.java b/src/main/java/diffbydevs/velog_clone/member/domain/MemberStatus.java
new file mode 100644
index 0000000..bdc9d81
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/member/domain/MemberStatus.java
@@ -0,0 +1,36 @@
+package diffbydevs.velog_clone.member.domain;
+
+import lombok.Getter;
+
+/**
+ * 회원의 활성 상태입니다.
+ * <p>
+ *     코드의 가독성을 위해 어플리케이션 코드에서만 사용합니다.
+ *     실제 db에서는 정규화 문제로 ENUM이 아닌 정수형으로 관리합니다.
+ * </p>
+ * @see Member#status
+ */
+@Getter
+public enum MemberStatus {
+	/**
+	 * 활성 상태, 회원 가입 이후~휴면/탈퇴 전까지의 상태
+	 */
+	ACTIVE(0),
+	/**
+	 * 휴면 상태
+	 */
+	DORMANT(1),
+	/**
+	 * 탈퇴 상태
+	 */
+	WITHDRAWAL(2);
+
+	/**
+	 * 실제로 db에 들어가게 되는 값
+	 */
+	private final int valueInDB;
+
+	MemberStatus(int valueInDB) {
+		this.valueInDB = valueInDB;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/post/domain/Post.java b/src/main/java/diffbydevs/velog_clone/post/domain/Post.java
new file mode 100644
index 0000000..a35d513
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/post/domain/Post.java
@@ -0,0 +1,129 @@
+package diffbydevs.velog_clone.post.domain;
+
+import java.time.LocalDateTime;
+
+import diffbydevs.velog_clone.blog.domain.Blog;
+import diffbydevs.velog_clone.member.domain.Member;
+import diffbydevs.velog_clone.series.domain.Series;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+공부
+TODO
+1. 이거 null인 것도 내가… 생성자에서 꼭 설정을 해 줘야 했나..?
+2. publishedAt -> not null query 확인 필요
+3. publishedAt -> 한번 저장하면 이후로 바꿀 수 없게 할 수 있는 방법은 없나?
+ */
+
+/**
+ * 게시글의 메타 데이터입니다.
+ * <p>
+ *     외부와 통신 시 해당 id로 소통합니다.
+ * </p>
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Post {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "blog_id")
+	private Blog blog;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "author_id")
+	private Member author;
+
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "series_id")
+	private Series series;
+
+	/**
+	 * 발행 상태
+	 * @see PostPublishedStatus
+	 */
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int publishedStatus;
+
+	/**
+	 * 발행일. 임시 저장이면 발행일은 없고, 발행 이후엔 변경 불가함.
+	 */
+	private LocalDateTime publishedAt;
+
+	/**
+	 * 게시글을 시리즈에 포함해 임시 저장 혹은 발행합니다.
+	 *
+	 * @param blog 글을 작성할 블로그
+	 * @param author 글을 작성한 회원
+	 * @param series 글이 소속될 시리즈
+	 * @param publishedStatus 발행 상태
+	 * @return 게시글의 메타 데이터
+	 */
+	public static Post createPost(
+		Blog blog,
+		Member author,
+		Series series,
+		PostPublishedStatus publishedStatus
+	) {
+		Post post = new Post();
+
+		post.blog = blog;
+		post.author = author;
+		post.series = series;
+		post.publishedStatus = publishedStatus.getValueInDB();
+
+		// 기본값
+		post.publishedAt = null;
+		if (publishedStatus == PostPublishedStatus.PUBLISHED) {
+			post.publishedAt = LocalDateTime.now();
+		}
+
+		return post;
+	}
+
+	/**
+	 * 게시글을 시리즈 없이 임시 저장 혹은 발행한다.
+	 *
+	 * @param blog 글을 작성할 블로그
+	 * @param author 글을 작성한 회원
+	 * @param publishedStatus 발행 상태
+	 * @return 게시글의 메타 데이터
+	 */
+	public static Post createPost(
+		Blog blog,
+		Member author,
+		PostPublishedStatus publishedStatus
+	) {
+		Post post = new Post();
+
+		post.blog = blog;
+		post.author = author;
+		post.series = null;
+		post.publishedStatus = publishedStatus.getValueInDB();
+		post.publishedAt = null;
+
+		if (publishedStatus == PostPublishedStatus.PUBLISHED) {
+			post.publishedAt = LocalDateTime.now();
+		}
+
+		return post;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/post/domain/PostContent.java b/src/main/java/diffbydevs/velog_clone/post/domain/PostContent.java
new file mode 100644
index 0000000..f5dffd0
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/post/domain/PostContent.java
@@ -0,0 +1,54 @@
+package diffbydevs.velog_clone.post.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntityOnlyUpdatedAt;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.Lob;
+import jakarta.persistence.OneToOne;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 게시글 내용입니다.
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class PostContent extends BaseEntityOnlyUpdatedAt {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@OneToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "post_id")
+	private Post post;
+
+	@Lob
+	private String content;
+
+	/**
+	 * 게시글 내용을 작성합니다.
+	 *
+	 * @param post 게시글 메타 데이터
+	 * @param content 작성할 게시글 내용
+	 * @return 작성한 게시글 내용
+	 */
+	public static PostContent createPostContent(Post post, String content) {
+		PostContent postContent = new PostContent();
+
+		postContent.post = post;
+		postContent.content = content;
+
+		return postContent;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/post/domain/PostPublicStatus.java b/src/main/java/diffbydevs/velog_clone/post/domain/PostPublicStatus.java
new file mode 100644
index 0000000..e150526
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/post/domain/PostPublicStatus.java
@@ -0,0 +1,32 @@
+package diffbydevs.velog_clone.post.domain;
+
+import lombok.Getter;
+
+/**
+ * 게시글의 공개 상태입니다.
+ * <p>
+ *     코드의 가독성을 위해 어플리케이션 코드에서만 사용합니다.
+ *     실제 db에서는 정규화 문제로 ENUM이 아닌 정수형으로 관리합니다.
+ * </p>
+ * @see PostSetting#isPublic
+ */
+@Getter
+public enum PostPublicStatus {
+	/**
+	 * 비공개
+	 */
+	PRIVATE(0),
+	/**
+	 * 공개
+	 */
+	PUBLIC(1);
+
+	/**
+	 * 실제로 db에 들어가게 되는 값
+	 */
+	private final int valueInDB;
+
+	PostPublicStatus(int valueInDB) {
+		this.valueInDB = valueInDB;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/post/domain/PostPublishedStatus.java b/src/main/java/diffbydevs/velog_clone/post/domain/PostPublishedStatus.java
new file mode 100644
index 0000000..c0085b6
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/post/domain/PostPublishedStatus.java
@@ -0,0 +1,32 @@
+package diffbydevs.velog_clone.post.domain;
+
+import lombok.Getter;
+
+/**
+ * 게시글의 발행 상태입니다.
+ * <p>
+ *     코드의 가독성을 위해 어플리케이션 코드에서만 사용합니다.
+ *     실제 db에서는 정규화 문제로 ENUM이 아닌 정수형으로 관리합니다.
+ * </p>
+ * @see Post#publishedStatus
+ */
+@Getter
+public enum PostPublishedStatus {
+	/**
+	 * 임시 저장
+	 */
+	DRAFT(0),
+	/**
+	 * 발행
+	 */
+	PUBLISHED(1);
+
+	/**
+	 * 실제로 db에 들어가게 되는 값
+	 */
+	private final int valueInDB;
+
+	PostPublishedStatus(int valueInDB) {
+		this.valueInDB = valueInDB;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/post/domain/PostSetting.java b/src/main/java/diffbydevs/velog_clone/post/domain/PostSetting.java
new file mode 100644
index 0000000..a219710
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/post/domain/PostSetting.java
@@ -0,0 +1,105 @@
+package diffbydevs.velog_clone.post.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+1. brieflyIntroduce 는 여기서 검증하면 글 내용 불러와야 하니까 이전에 확인하고 가져오기로.
+2. slug 중복 확인 후, 있는 거면 -UUID(6)로 slug 저장하는 로직은 어디에 둘지 결정 필요.
+ */
+
+/**
+ * 게시글 관리 설정입니다.
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class PostSetting extends BaseEntity {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@OneToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "post_id")
+	private Post post;
+
+	/**
+	 * 글 제목
+	 */
+	@NotNull
+	@Size(max = 100)
+	@Column(length = 100)
+	private String title;
+
+	/**
+	 * 미리 보기 등에서 활용할 글에 대한 짧은 소개
+	 */
+	@NotNull
+	@Size(max = 150)
+	@Column(length = 150)
+	private String brieflyIntroduce;
+
+	/**
+	 * 공개, 비공개 설정
+	 * @see PostPublicStatus
+	 */
+	@NotNull
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int isPublic;
+
+	/*
+	 * 글의 고유 slug.
+	 * 한글, 영어 소문자, 숫자, - 허용.
+	 * 작성하지 않으면, 기본으로 제목으로 시도.
+	 */
+	@NotNull
+	@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-]*$")
+	@Size(max = 110)
+	@Column(unique = true, length = 110)
+	private String slug;
+
+	/**
+	 * 게시글 관리 환경을 설정합니다.
+	 *
+	 * @param post 관리할 게시글
+	 * @param title 게시글 제목
+	 * @param brieflyIntroduce 게시글 간단 소개
+	 * @param postPublicStatus 공개/비공개
+	 * @param slug uri에 사용하는 slug
+	 * @return 설정한 게시글 환경
+	 */
+	public static PostSetting setPostEnvironment(
+		Post post,
+		String title,
+		String brieflyIntroduce,
+		PostPublicStatus postPublicStatus,
+		String slug
+	) {
+		PostSetting postSetting = new PostSetting();
+
+		postSetting.post = post;
+		postSetting.title = title;
+		postSetting.brieflyIntroduce = brieflyIntroduce;
+		postSetting.isPublic = postPublicStatus.getValueInDB();
+		postSetting.slug = slug;
+
+		return postSetting;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/series/domain/Series.java b/src/main/java/diffbydevs/velog_clone/series/domain/Series.java
new file mode 100644
index 0000000..1901b65
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/series/domain/Series.java
@@ -0,0 +1,63 @@
+package diffbydevs.velog_clone.series.domain;
+
+import diffbydevs.velog_clone.blog.domain.Blog;
+import diffbydevs.velog_clone.global.domain.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 블로그 내 글을 모을 수 있는 시리즈입니다.
+ * <p>
+ *     블로그 주인이 직접 관리합니다.
+ * </p>
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Series extends BaseEntity {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "blog_id")
+	private Blog blog;
+
+	/**
+	 * 시리즈 이름, 고유 slug로 사용.
+	 */
+	@NotNull
+	@Size(max = 50)
+	@Column(unique = true, length = 50)
+	private String name;
+
+	/**
+	 * 새로운 시리즈를 만듭니다.
+	 *
+	 * @param blog 시리즈가 속할 블로그
+	 * @param name 시리즈 이름
+	 * @return 새로운 시리즈
+	 */
+	public static Series createSeries(Blog blog, String name) {
+		Series series = new Series();
+
+		series.blog = blog;
+		series.name = name;
+
+		return series;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTag.java b/src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTag.java
new file mode 100644
index 0000000..6e5d226
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTag.java
@@ -0,0 +1,55 @@
+package diffbydevs.velog_clone.tag.domain;
+
+import org.springframework.data.domain.Persistable;
+
+import diffbydevs.velog_clone.blog.domain.Blog;
+import diffbydevs.velog_clone.global.domain.BaseEntityOnlyCreatedAt;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.EmbeddedId;
+import jakarta.persistence.Entity;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+1. Persistable 구현 안 하면 Select 쿼리가 한 번 더 나가는 게 맞나 확인 필요
+참고:  https://rachel0115.tistory.com/entry/JPA-%EB%B3%B5%ED%95%A9%ED%82%A4-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0-EmbeddedId-MapsId-isNew
+ */
+/**
+ * 블로그의 게시글 태그입니다.
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class BlogPostTag extends BaseEntityOnlyCreatedAt implements Persistable<BlogPostTagPK> {
+
+	@EmbeddedId
+	private BlogPostTagPK id;
+
+	/**
+	 * 블로그 게시글에 태그를 추가합니다.
+	 *
+	 * @param tag 게시글에 추가할 태그
+	 * @param blog 게시글을 작성할 블로그
+	 * @param post 태그를 추가할 게시글
+	 * @return 블로그 게시글에 추가한 태그
+	 */
+	public static BlogPostTag addTagTo(
+		Tag tag, Blog blog, Post post) {
+		BlogPostTag blogPostTag = new BlogPostTag();
+
+		blogPostTag.id = BlogPostTagPK.createBlogPostTagPK(tag, blog, post);
+
+		return blogPostTag;
+	}
+
+	/**
+	 * GeneratedValue를 사용하지 않고 복합키 사용 시, 이미 id 값을 가지고 있어 이미 있던 entity로 판단할 수 있기에 jpa의 새로운 엔티티 판단 전략을 재정의합니다.
+	 * @return 새롭다면 true, 있던 거라면 false
+	 */
+	@Override
+	public boolean isNew() {
+		return super.getCreatedAt() == null;
+	}
+}
diff --git a/src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTagPK.java b/src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTagPK.java
new file mode 100644
index 0000000..c9c20e6
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTagPK.java
@@ -0,0 +1,43 @@
+package diffbydevs.velog_clone.tag.domain;
+
+import java.io.Serializable;
+
+import diffbydevs.velog_clone.blog.domain.Blog;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 볼로그 게시글 태그의 복합 PK입니다.
+ * package-private 설정해 해당 패키지 외부에서 PK에 접근할 수 없습니다.
+ */
+@Getter
+@Embeddable
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(staticName = "createBlogPostTagPK")
+public class BlogPostTagPK implements Serializable {
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "tag_id")
+	private Tag tag;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "blog_id")
+	private Blog blog;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "post_id")
+	private Post post;
+}
diff --git a/src/main/java/diffbydevs/velog_clone/tag/domain/Tag.java b/src/main/java/diffbydevs/velog_clone/tag/domain/Tag.java
new file mode 100644
index 0000000..6c24523
--- /dev/null
+++ b/src/main/java/diffbydevs/velog_clone/tag/domain/Tag.java
@@ -0,0 +1,73 @@
+package diffbydevs.velog_clone.tag.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntityOnlyCreatedAt;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+1. unique 로직 어디서 어떻게 관리할 건지 고민하기. Set 자료구조?
+2. 모든 글에서 떨어져 나온 태그 관리? 배치로 Delete query?
+3. 태그의 이름과 slug가 다른 경우는 언제가 있을까?
+ */
+
+/**
+ * 게시글에 추가된 적 있는 태그입니다.
+ */
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Tag extends BaseEntityOnlyCreatedAt {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	/**
+	 * 고유한 태그 이름.
+	 * 영어 소문자, 숫자, 한글, -만 허용.
+	 */
+	@NotNull
+	@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-]*$")
+	@Size(max = 100)
+	@Column(unique = true, length = 100)
+	private String name;
+
+	/*
+	 * 태그의 고유 slug.
+	 * 한글, 영어 소문자, 숫자, - 허용.
+	 * 기본적으로는 태그의 이름과 같음.
+	 */
+	@NotNull
+	@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-]*$")
+	@Size(max = 100)
+	@Column(unique = true, length = 100)
+	private String slug;
+
+	/**
+	 * 새로운 태그를 생성합니다.
+	 * 태그 이름이 slug가 됩니다.
+	 * 같은 slug가 있다면, 뒤에 -hash(6)를 더합니다.
+	 *
+	 * @param tagName 태그 이름
+	 * @return 생성된 태그
+	 */
+	public static Tag createTag(String tagName) {
+		Tag tag = new Tag();
+
+		tag.name = tagName;
+		tag.slug = tagName;
+
+		return tag;
+	}
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index 79582b3..0000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-spring.application.name=velog-clone
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..1eb2eb0
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,20 @@
+spring:
+  application:
+    name: velog-clone
+  datasource:
+    driver-class-name: org.h2.Driver
+    url: jdbc:h2:mem:testdb;MODE=MYSQL
+    username: sa
+    password:
+  h2:
+    console:
+      enabled: true
+      path: /h2-console
+
+  jpa:
+    hibernate:
+      ddl-auto: create
+    properties:
+      hibernate:
+        show_sql: true
+        format_sql: true
\ No newline at end of file


Reviews:
user: login: Sooamazing
body: 셀프 코드 리뷰 완료했습니다. ERD cloud에도 변경 사항 반영해 두었습니다.
그럼에도... 찾아내지 못한 부분이 있으면 편히 댓글 달아 주세요. 
감사합니다.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:29:35Z
user: login: Yuurim98
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T03:36:53Z
user: login: Sooamazing
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T03:48:08Z
user: login: Yuurim98
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T03:49:01Z
user: login: Yuurim98
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T04:00:50Z
user: login: Yuurim98
body: 1. 엔티티 대부분이 기본 생성자로 객체를 생성하는 것 같은데 AllArgsConstructor 를 따로 설정하신 이유가 궁금합니다. 
2. MemberRelationPK 등 복합키 클래스에 접근 제어자가 따로 명시되지 않았던 것 같은데 의도하신 건가요 ?
3. 복합키 클래스에 Embeddable 어노테이션을 달아주는 것 같은데 지금 설계하신 부분에서는 없는 거 같아서요. 없어도 상관 없나요? 아니면 다른 이유가 있으신가요? (잘 모름)
[참고1](https://syk531.tistory.com/94)
[참고2](https://www.baeldung.com/spring-jpa-embedded-method-parameters)
4. 프로젝트 규모에 비해 테이블 수가 비교적 많은 편인 것 같은데 이러한 정규화가 어떤 이점이 있는지 모르겠습니다. 어떤 걸 고려하신 건지 궁금합니다.
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T04:07:13Z
user: login: Sooamazing
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T08:00:59Z
user: login: Sooamazing
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T08:01:26Z
user: login: Yuurim98
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T11:32:34Z
user: login: Yuurim98
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T11:37:48Z
user: login: Sooamazing
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T12:42:07Z
user: login: Yuurim98
body: 너무 어렵네요 고생하셨습니다..!
state: APPROVED
author_association: NONE
submitted_at: 2025-05-15T03:11:58Z

Review comments:
diff_hunk: @@ -15,22 +15,35 @@ jobs:
   build:
path: .github/workflows/automation-test.yml
user: login: Sooamazing
body: Mysql 연결 때문에 GitHub Actions test가 안 돼서 힘써 본 부분입니다. 넘기셔도 됩니다.
음... H2가 좋은 이유에 연결을 안 해도 돼서 좋았다? 그 말이 무슨 말인지 사실 아직도 잘은 모르지만, 이해는 됐습니다. ㅎㅎ... 
혹시나 해서 지금 H2로 바꾸니 저런 설정 하나도 안 해도... 잘 되더라구요... 껄껄
created_at: 2025-05-13T13:40:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,28 +1,59 @@
 plugins {
path: build.gradle
user: login: Sooamazing
body: lombok, jpa, validation, h2 변경 사항입니다.
다른 부분은 코드 서식 정렬 자동화를 하니 자동으로 변경된 부분입니다. 신경쓰지 않으셔도 됩니다.
created_at: 2025-05-13T13:41:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,141 @@
+package diffbydevs.velog_clone.comment.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntity;
+import diffbydevs.velog_clone.member.domain.Member;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+1. @Nullable은 안 되겠지? validation이 아니던데... 나중에 확인해 보기.
+2. Entity 에러는... Entity 생성이 아니고 저장 위해 쿼리 날리 때 나는 거겠지?
+3. Level +1 하고, 안 되는지 확인하는 건 어디서 책임을 질까?
+4. 게시글, 상위 댓글이 삭제됐는지도 확인해.
+5. ParentComment 쿼리 null 맞는지 확인하기
+6. (공부) comment는 Mysql 예약어라 테이블 이름이 따로 필요함.
+ */
+
+/**
+ * 회원이 남긴 댓글입니다.
+ */
+@Getter
+@Entity
+@Table(name = "commments")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class Comment extends BaseEntity {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "post_id")
+	private Post post;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "commenter_id")
+	private Member commenter;
+
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "parent_comment_id")
+	private Comment parentComment;
+
+	/**
+	 * 댓글 내용
+	 */
+	@NotNull
+	@Column(columnDefinition = "varchar(1000)")
+	private String content;
+
+	/**
+	 * 댓글 계층 - {@link CommentLevel#COMMENT 댓글},
+	 * {@link CommentLevel#REPLY 대댓글},
+	 * {@link CommentLevel#LAST_REPLY 대대댓글}
+	 * @see CommentLevel
+	 */
+	@NotNull
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int level;
+
+	/**
+	 * 댓글 삭제 여부
+	 * @see CommentActiveStatus
+	 */
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int isDeleted;
+
+	/**
+	 * (최상위) 댓글을 답니다.
+	 *
+	 * @param post 댓글 달 게시글
+	 * @param commenter 댓글 다는 회원
+	 * @param content 댓글 내용
+	 * @return 남긴 댓글
+	 */
+	public static Comment leaveComment(
+		Post post,
+		Member commenter,
+		String content
+	) {
+		Comment comment = new Comment();
+
+		comment.post = post;
+		comment.commenter = commenter;
+		comment.content = content;
+
+		// 기본 값 설정
+		comment.parentComment = null;
+		comment.level = CommentLevel.COMMENT.getValueInDB();
+		comment.isDeleted = CommentActiveStatus.ACTIVE.getValueInDB();
+
+		return comment;
+	}
+
+	/**
+	 * 대댓글, 대대댓글을 답니다.
+	 *
+	 * @param post 댓글 달 게시글
+	 * @param commenter 댓글 다는 회원
+	 * @param parentComment 상위 레벨의 댓글
+	 * @param content 댓글 내용
+	 * @param level 댓글 계층
path: src/main/java/diffbydevs/velog_clone/comment/domain/Comment.java
user: login: Yuurim98
body: 대댓글, 대대댓글을 생성할 때는 프론트에서 level 값을 받아오는 건가요 ??
created_at: 2025-05-14T03:36:52Z
author_association: NONE
diff_hunk: @@ -0,0 +1,141 @@
+package diffbydevs.velog_clone.comment.domain;
+
+import diffbydevs.velog_clone.global.domain.BaseEntity;
+import diffbydevs.velog_clone.member.domain.Member;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+1. @Nullable은 안 되겠지? validation이 아니던데... 나중에 확인해 보기.
+2. Entity 에러는... Entity 생성이 아니고 저장 위해 쿼리 날리 때 나는 거겠지?
+3. Level +1 하고, 안 되는지 확인하는 건 어디서 책임을 질까?
+4. 게시글, 상위 댓글이 삭제됐는지도 확인해.
+5. ParentComment 쿼리 null 맞는지 확인하기
+6. (공부) comment는 Mysql 예약어라 테이블 이름이 따로 필요함.
+ */
+
+/**
+ * 회원이 남긴 댓글입니다.
+ */
+@Getter
+@Entity
+@Table(name = "commments")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class Comment extends BaseEntity {
+
+	@Id
+	@Column(columnDefinition = "int unsigned")
+	@GeneratedValue(strategy = GenerationType.IDENTITY)
+	private Integer id;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "post_id")
+	private Post post;
+
+	@NotNull
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "commenter_id")
+	private Member commenter;
+
+	@ManyToOne(fetch = FetchType.LAZY)
+	@JoinColumn(name = "parent_comment_id")
+	private Comment parentComment;
+
+	/**
+	 * 댓글 내용
+	 */
+	@NotNull
+	@Column(columnDefinition = "varchar(1000)")
+	private String content;
+
+	/**
+	 * 댓글 계층 - {@link CommentLevel#COMMENT 댓글},
+	 * {@link CommentLevel#REPLY 대댓글},
+	 * {@link CommentLevel#LAST_REPLY 대대댓글}
+	 * @see CommentLevel
+	 */
+	@NotNull
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int level;
+
+	/**
+	 * 댓글 삭제 여부
+	 * @see CommentActiveStatus
+	 */
+	@Column(columnDefinition = "tinyint(1) unsigned default 0")
+	private int isDeleted;
+
+	/**
+	 * (최상위) 댓글을 답니다.
+	 *
+	 * @param post 댓글 달 게시글
+	 * @param commenter 댓글 다는 회원
+	 * @param content 댓글 내용
+	 * @return 남긴 댓글
+	 */
+	public static Comment leaveComment(
+		Post post,
+		Member commenter,
+		String content
+	) {
+		Comment comment = new Comment();
+
+		comment.post = post;
+		comment.commenter = commenter;
+		comment.content = content;
+
+		// 기본 값 설정
+		comment.parentComment = null;
+		comment.level = CommentLevel.COMMENT.getValueInDB();
+		comment.isDeleted = CommentActiveStatus.ACTIVE.getValueInDB();
+
+		return comment;
+	}
+
+	/**
+	 * 대댓글, 대대댓글을 답니다.
+	 *
+	 * @param post 댓글 달 게시글
+	 * @param commenter 댓글 다는 회원
+	 * @param parentComment 상위 레벨의 댓글
+	 * @param content 댓글 내용
+	 * @param level 댓글 계층
path: src/main/java/diffbydevs/velog_clone/comment/domain/Comment.java
user: login: Sooamazing
body: 넵 맞습니다.

API 응답 자체에서 Json으로 {[{}]} 이런 식으로 그 자체로 계층을 보여주는 것과 그냥 저 내용을 다 적는 방식이 떠올라 고민했는데, 둘 중 어느 방식을 사용하더라도 계층을 따로 내려주는 게 프론트에서 값을 관리하기 편할 것 같다고 생각했습니다.

level을 따로 저장하지 않고, 내려줄 때 계산해서 주는 것도 고민했는데, 당장 어떻게 하면 좋을지 잘 떠오르지 않았습니다. 이 고민이 우선 순위가 아니기도 하고, 제가 찾아봤을 때는 Level이 있는 걸로 설계한 곳들도 꽤 있길래 우선 넣고, 고민해 보자 생각 했습니다. (음... 기록을 한다고 했는데, 이걸 참고한 곳은 기록은 안 했나 보네요.. 첨부하려고 보니... 없네요.. 하핳하)

적다 보니 API 문서를 첨부하면 좋았겠다 생각이 드네요.. 나중에 작성하면 그때는 같이 공유하겠습니다!
created_at: 2025-05-14T03:48:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+package diffbydevs.velog_clone.global.domain;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+ 1. CRUD 구현 이후, 테이블이 이미 만들어진 후에도 Column의 nullable, updatable이 필요한지 확인하기.
+ 2. jpa 말고, 하이버네이트 구현체인 createtimestamp 사용하는 경우와 영속성 등 차이 비교.
+ */
+
+/**
+ * {@link EntityListeners}를 사용해 생성일시, 수정일시를 자동으로 관리합니다.
+ * <p>
+ *     Entity에서 상속받기만 하면 자동으로 사용 가능합니다.
+ *    {@link MappedSuperclass}를 이용해 별개의 entity(테이블)는 생성하지 않습니다.
+ * </p>
+ */
+@Getter
+@MappedSuperclass
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
path: src/main/java/diffbydevs/velog_clone/global/domain/BaseEntity.java
user: login: Yuurim98
body: 저는 NoArgsConstructor 를 붙이지 않았던 것 같은데 붙이신 이유가 궁금합니다.
created_at: 2025-05-14T03:49:01Z
author_association: NONE
diff_hunk: @@ -0,0 +1,39 @@
+package diffbydevs.velog_clone.tag.domain;
+
+import diffbydevs.velog_clone.blog.domain.Blog;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 볼로그 게시글 태그의 복합 PK입니다.
+ * package-private 설정해 해당 패키지 외부에서 PK에 접근할 수 없습니다.
+ */
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PACKAGE, staticName = "addTagTo")
+@EqualsAndHashCode
+class BlogPostTagPK {
path: src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTagPK.java
user: login: Yuurim98
body: 얘는 Serializable 구현하지 않아도 되나요?
created_at: 2025-05-14T04:00:49Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+package diffbydevs.velog_clone.global.domain;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+ 1. CRUD 구현 이후, 테이블이 이미 만들어진 후에도 Column의 nullable, updatable이 필요한지 확인하기.
+ 2. jpa 말고, 하이버네이트 구현체인 createtimestamp 사용하는 경우와 영속성 등 차이 비교.
+ */
+
+/**
+ * {@link EntityListeners}를 사용해 생성일시, 수정일시를 자동으로 관리합니다.
+ * <p>
+ *     Entity에서 상속받기만 하면 자동으로 사용 가능합니다.
+ *    {@link MappedSuperclass}를 이용해 별개의 entity(테이블)는 생성하지 않습니다.
+ * </p>
+ */
+@Getter
+@MappedSuperclass
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
path: src/main/java/diffbydevs/velog_clone/global/domain/BaseEntity.java
user: login: Sooamazing
body: 헉 사실 이것이야말로 저의 관습적 붙이기인 것 같습니다. 하하. 그래서 질문을 받고 꼭 필요한 건가 생각해 봤는데요.

 jpa에서 Entity를 만들면, 기본 생성자가 필요한데, 드러내고 싶지 않으니 매번 Private으로 만들어야 한다! -> 만 생각했네요.

 이 경우, abstract class 가 아니었다면, No Args(Protected)는 필요했을 것 같지만, abstract니까 어차피 상속해야만 사용이 가능하네요.
 
 그럼 abstract class는 기본 생성자가 자동으로 protected로 사용되는 걸까? 궁금해져서 빌드 파일을 보니까 생성자가 public으로 되더라구요. 사실 추상 클래스에서 기본 생성자가 public인 경우와 protected로 놓는 경우 어떤 차이가 있는지 모르겠긴 합니다. (어차피 상속받아야만 기본 생성자를 쓸 수 있다는 점에서요.) 이 부분이 궁금해서 찾아 봤는데, abstract class는 생성자를 Protected로 하는 걸 권장한다는 말 외에는... 둘의 동작이 어떻게 차이난다는 글은 없네요. 우선 그냥 제가 이해하기로는 현상은 같을지언정 abtract와 public은 맥락이 일치하지 않기 때문에 protected로 사용한다 정도인 것 같습니다. ([의견1](https://deepsource.com/directory/java/issues/JAVA-W1094), [의견2](https://stackoverflow.com/questions/56948041/why-is-the-need-of-protected-constructor-in-abstract-class-in-java)) 그리고 그런 거면, 어노테이션을 붙이지 않는 게 더 나을 것 같습니다. 

 ``` java
@MappedSuperclass
@EntityListeners({AuditingEntityListener.class})
public abstract class BaseEntity {
    @Column(
        nullable = false,
        updatable = false
    )
    @CreatedDate
    private LocalDateTime createdAt;
    @Column(
        nullable = false
    )
    @LastModifiedDate
    private LocalDateTime updatedAt;

    public BaseEntity() {
    }

    @Generated
    public LocalDateTime getCreatedAt() {
        return this.createdAt;
    }

    @Generated
    public LocalDateTime getUpdatedAt() {
        return this.updatedAt;
    }
}
```

변경: [2f43e8e](https://github.com/diff-by-devs/1-velog-clone-back-Sooamazing/pull/14/commits/2f43e8e74e19222c97abc77551373d149df62d0c)
created_at: 2025-05-14T08:00:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+package diffbydevs.velog_clone.tag.domain;
+
+import diffbydevs.velog_clone.blog.domain.Blog;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 볼로그 게시글 태그의 복합 PK입니다.
+ * package-private 설정해 해당 패키지 외부에서 PK에 접근할 수 없습니다.
+ */
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PACKAGE, staticName = "addTagTo")
+@EqualsAndHashCode
+class BlogPostTagPK {
path: src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTagPK.java
user: login: Sooamazing
body: 오 감사합니다! 와 복합키는 처음 해 봐서 놓쳤네요.

근데, 왜 됐지? 하고 보니까 테이블 생성이랑 제약 조건은 원하는 대로 된 것 같네요....(member relationship도 저거랑 똑같이 생성됨) 정말.. 저것의 존재 의의는 뭔지 다시 궁금해집니다.. 나중에 CRUD할 때 오류가 터질 수 있을까요? 나중에 테스트해 볼게요.

```
Hibernate: 
    create table blog_post_tag (
        blog_id int unsigned not null,
        post_id int unsigned not null,
        tag_id int unsigned not null,
        created_at timestamp(6) not null,
        primary key (blog_id, post_id, tag_id)
    )
Hibernate: 
    alter table if exists blog_post_tag 
       add constraint FKnx0fm6xcm1yagn7298xd2ra24 
       foreign key (blog_id) 
       references blog
Hibernate: 
    alter table if exists blog_post_tag 
       add constraint FK6bsiblobqoelgu4mkjubsbhl5 
       foreign key (tag_id) 
       references tag
Hibernate: 
    alter table if exists blog_post_tag 
       add constraint FKealsfx0mbedmnpxo3arsf2rwn 
       foreign key (post_id) 
       references post
```
변경: [edf6df5](https://github.com/diff-by-devs/1-velog-clone-back-Sooamazing/pull/14/commits/edf6df5a45bfced245f7144103f4d87551f3f0c3) -> 이렇게 바꿔도 처음 ddl 쿼리는 동일하긴 하네요. 신기방기.
created_at: 2025-05-14T08:01:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+package diffbydevs.velog_clone.global.domain;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+ 1. CRUD 구현 이후, 테이블이 이미 만들어진 후에도 Column의 nullable, updatable이 필요한지 확인하기.
+ 2. jpa 말고, 하이버네이트 구현체인 createtimestamp 사용하는 경우와 영속성 등 차이 비교.
+ */
+
+/**
+ * {@link EntityListeners}를 사용해 생성일시, 수정일시를 자동으로 관리합니다.
+ * <p>
+ *     Entity에서 상속받기만 하면 자동으로 사용 가능합니다.
+ *    {@link MappedSuperclass}를 이용해 별개의 entity(테이블)는 생성하지 않습니다.
+ * </p>
+ */
+@Getter
+@MappedSuperclass
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
path: src/main/java/diffbydevs/velog_clone/global/domain/BaseEntity.java
user: login: Yuurim98
body: BaseEntity 사용 예시에는 다 붙이지 않아서 저도 안 붙이고 사용했던 것 같아요
보내주신 자료 보니까 생성에 초점을 두는게 아니라 접근 자체에 초점을 두고 있는 것 같네요 어렵네요 훔
created_at: 2025-05-14T11:32:34Z
author_association: NONE
diff_hunk: @@ -0,0 +1,39 @@
+package diffbydevs.velog_clone.tag.domain;
+
+import diffbydevs.velog_clone.blog.domain.Blog;
+import diffbydevs.velog_clone.post.domain.Post;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.validation.constraints.NotNull;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 볼로그 게시글 태그의 복합 PK입니다.
+ * package-private 설정해 해당 패키지 외부에서 PK에 접근할 수 없습니다.
+ */
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PACKAGE, staticName = "addTagTo")
+@EqualsAndHashCode
+class BlogPostTagPK {
path: src/main/java/diffbydevs/velog_clone/tag/domain/BlogPostTagPK.java
user: login: Yuurim98
body: 복합키 기반으로 CRUD 할 때 발생할 수도 있을 것 같아요
저도 복합키는 안 해 봐서 잘 모르겠지만.. 나중에 공유해 주시면 감사하겠습니다 ㅎ
created_at: 2025-05-14T11:37:48Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+package diffbydevs.velog_clone.global.domain;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/*
+TODO
+ 1. CRUD 구현 이후, 테이블이 이미 만들어진 후에도 Column의 nullable, updatable이 필요한지 확인하기.
+ 2. jpa 말고, 하이버네이트 구현체인 createtimestamp 사용하는 경우와 영속성 등 차이 비교.
+ */
+
+/**
+ * {@link EntityListeners}를 사용해 생성일시, 수정일시를 자동으로 관리합니다.
+ * <p>
+ *     Entity에서 상속받기만 하면 자동으로 사용 가능합니다.
+ *    {@link MappedSuperclass}를 이용해 별개의 entity(테이블)는 생성하지 않습니다.
+ * </p>
+ */
+@Getter
+@MappedSuperclass
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
path: src/main/java/diffbydevs/velog_clone/global/domain/BaseEntity.java
user: login: Sooamazing
body: 생성에 초점을 둔다는 말이 어떤 의미인지 모르겠슴다.

무튼.. 네.. 진짜 접근 제한자니까 그래야지~ 같은 느낌이고, 딱히.. 그 외의 설명은.. 못 찾았어요. 딱히 jpa 동작과도 큰 관련 없어 보여서 그냥 뺐습니다. ㅋㅋ
created_at: 2025-05-14T12:42:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: Sooamazing
created_at: 2025-05-14T08:02:27Z
body: @Yuurim98  [인용한 댓글 참고](https://github.com/diff-by-devs/1-velog-clone-back-Sooamazing/pull/14#pullrequestreview-2838658654)

1. 오... 저 사실 이거야말로 진짜 맨 처음에 배우고, 그 이후로 그냥 습관적으로 써서 새까맣게 잊고 있었는데, 마침 어제 궁금해서 찾아봤거든요. [참고](https://pinokio0702.tistory.com/176) -> 헐.. 근데.... ㅎㅎ 방금 알았어요. 어 진짜 쓸모가 없네요? 원래는 정적 팩토리 메서드를 강제하기 위해서 설정하는데, 엔티티에서는 어떤 누구도... 전체를 받는 생성자를 만들어야지 생각하지 않겠네요!(만약, 만들려고 하면 롬복과 겹쳐서 에러나니 그걸 제한한다 생각했습니다. 그리고, 내부에서 new 생성자로 만들 수도 있어서요.) 오 감사합니다.
변경: [04eaf08](https://github.com/diff-by-devs/1-velog-clone-back-Sooamazing/pull/14/commits/04eaf08ca41440fc92adf64b9a78c8963a4b3e12)
> 엔티티 대부분이 기본 생성자로 객체를 생성하는 것 같은데 AllArgsConstructor 를 따로 설정하신 이유가 궁금합니다.

2. 아! 이게 제가 주석에 적어놓은 package-private입니다. 제 리포지토리 다운 받은 거 아직 갖고 계시면, 테스트해 보실 수 있는데요. 다른 패키지에서(예를 들어 domain의 blog, global 쪽 등) 해당 PK를 만들려고 하면 접근할 수 없을 거예요! 이렇게 한 이유는 해당 PK는 오직 이것들을 위해 존재하는 거라서 다른 곳에서 접근하지 않길 바라서이고, 이걸... 다른 곳에서 상속을 받든 뭘 하든 쓸 일이 생기면 그건 그거대로 설계가 이상할 것 같아서입니다. 
 IDE가 자동으로 사용할 수 있는 걸 추천해 주는데, 거기서 뜨면서 실수할 가능성을 없애고 싶다고 있습니다. 제가 실수를 너무 많이 해서 혹시나요.. ㅋㅋㅋ [이 글](https://hyeon9mak.github.io/Java-dont-use-package-private/)에서는 '대부분의 경우 package-private을 제대로 활용하기가 어려운 편이다. 특히나 규모가 계속해서 커지는 프로젝트의 경우, 패키지 구조가 변경될 때마다 접근제한자가 발목을 잡을 가능성이 매우 높다는 디메리트가 있었다.' 이렇다고도 합니다. [이런 글](https://weights-learn-develop.tistory.com/68)도 있습니다. 음.. 근데, 그럴 거면 Entity 내부의 내장 클래스로 만들어야 했던 건가 싶기는 하네요.. 어떤 게. .적절한 걸까요? 하하
> MemberRelationPK 등 복합키 클래스에 접근 제어자가 따로 명시되지 않았던 것 같은데 의도하신 건가요 ?

3. 헉 감사합니다. 그 부분은 제가 빼 먹었습니다.. 오, 저 [참고1](https://syk531.tistory.com/94) 보고 참고했는데, [참고2](https://www.baeldung.com/spring-jpa-embedded-method-parameters)를 보니 나중에 쿼리할 때 오류가 났겠구나 싶네요. ddl에서 복합키 자체는 그냥 @EmbeddedId랑 객체로만으로도 생성이 되나 봅니다.(제가 됐던 걸 보니) 무튼, 제가 나중에 오류 내 보고 공유하겠습니다. ㅋㅋㅋ -> 헐! SonarLint가 한 건 해냈습니다! 지금 생성도 되고, ddl도 만드는데! 알고 보니 저렇게 매핑하는 게 아니래요!([참고](https://rachel0115.tistory.com/entry/JPA-%EB%B3%B5%ED%95%A9%ED%82%A4-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0-EmbeddedId-MapsId-isNew)) ㅋㅋㅋ 와... Embeddable 어노테이션 다니까 알려주네요. 와 감사합니다. 저 또 오류 사이에서 홀로 허우적댈 뻔했어요. 은인... 감사합니다...
변경: [edf6df5](https://github.com/diff-by-devs/1-velog-clone-back-Sooamazing/pull/14/commits/edf6df5a45bfced245f7144103f4d87551f3f0c3)
> 복합키 클래스에 Embeddable 어노테이션을 달아주는 것 같은데 지금 설계하신 부분에서는 없는 거 같아서요. 없어도 상관 없나요? 아니면 다른 이유가 있으신가요? (잘 모름)

4. 먼저, 저도 기본을 고민해 보고 싶었습니다. ㅋㅋㅋㅋ 저는 [이 글](https://rastalion.dev/%ED%9A%8C%EC%9B%90-%EA%B0%80%EC%9E%85-%EB%B0%8F-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%9D%84-%EC%9C%84%ED%95%9C-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%84%A4%EA%B3%84/)을 아주 많이 참고했습니다(따라 했죠.). 
항상 DB는 어떻게 설계해야 하는지 궁금했거든요. 그런데, 10개의 비슷한 예시 중 1개의 다른 예시가 나와서 눈길을 끈 게 첫번째입니다. ㅋㅋ 
그런데, 마침 항상 정규화를 잘 해 보고 싶다는 생각과 보안을 고려해 보고 싶다는 욕심을 부추기지 뭐예요? 그리고, 기본적으로 sqld도 그렇고, db를 배울 때 정규화를 하라고 하잖아요? 그런데, 여태껏 한 번도 제대로 정규화를 한 적이 없는 것 같더라고요. 지금도 정규화가 제대로 된 건지는 사실 모르겠고요.(ㅠㅠ) 그리고, 정규화가 모두들 기본이라고 하니, 정규화를 지키고, 쿼리 등을 보면서 역정규화를 고민해 보면 '적절한' DB 설계에 대해 여태와 다른 방식으로도 고민해 볼 수 있지 않을까 싶었습니다. 무튼, 그래서 정규화의 이점이라기보단.. 저한테는 뭔가 DB는 정규화를 해야 하는 것이다(정확히는 이렇게 배웠는데, 내가 못하고 있다?) 라는 생각이 있어서 이렇게 해 보았습니다. 
> 프로젝트 규모에 비해 테이블 수가 비교적 많은 편인 것 같은데 이러한 정규화가 어떤 이점이 있는지 모르겠습니다. 어떤 걸 고려하신 건지 궁금합니다.
author_association: CONTRIBUTOR
user: login: Yuurim98
created_at: 2025-05-14T12:22:33Z
body: @Sooamazing 

2. 특정 도메인 외부에서 직접 생성하지 못하도록 설계하신 부분은 캡슐화 측면에서 좋은 접근인 것 같습니다.
저도 잘 모르지만 JPA에서 리플렉션을 통해 인스턴스를 생성하는 경우가 있을 텐데 package-private 접근 제어자는 일부 상황에서 예외가 발생하진 않을까요? 
[문서](https://jakarta.ee/specifications/persistence/3.1/jakarta-persistence-spec-3.1) 에서 아래 내용을 찾았습니다

> The primary key class must be public and must have a public no-arg constructor.

4. 멋집니다 짱!
author_association: NONE
user: login: Sooamazing
created_at: 2025-05-14T17:22:51Z
body: @Yuurim98 
2. 
감사합니다..^^.... 여기서 시작해서 많은 일이 있었는데요.. 우선, 결론부터 말하자면 class여서 발생하는 에러가 있는지는 모르겠습니다. public이 아니라 package-private으로 repository save 진행했을 때는 우선 에러가 나지는 않더라구요. 다만, 다른 이유로 public으로 변경했습니다. 바로바로~ repository는... domain 계층과 다른 곳에 있다는 것이지요~! 하하하!!!!!!!! 

그리고, 가장 놀라운 점은 아까 그 코드로는.... ddl은 생겼지만, entity save가 되지 않는다는 점입니다^__^! 너무 당황스러워서 사용하는 코드를 찾았는데, 어디서도 찾지 못했고... 이거저거 실험하다가... 진짜 제 맨 처음 가정 코드가 동작한다는 것을 알았답니다!

와 정말로.... 협업하는 거였으면 저 진짜.. 진땀 뻘뻘 흘리면서 정신 Out일 뻔 했네요... 지금 이걸 알게 되어 참으로 다행이란 생각을 했습니다... 

아직도... 다른 사람들은... 어떻게... 그게 됐다는 건지 궁금합니다..(다들 성공 쿼리도 올리던데 말예요.. 허허..) 과연 entity를 어떻게 생성했을까요? 그 코드도 공유해 줬다면 비교할 수 있었을 텐데 그게 참 아쉽네요...


무튼... 여전히 공식 문서에서 왜 Public으로 하라고 했는지 이유는 모르겠지만,

이런 교훈을 얻었고, 꽤나... .... 값진 교훈을 얻었네요... 허허허....
1. 역시 블로그를 다 믿지 말자는 교훈과(프로젝트의 목표로 삼았던 바로 그걸 증명했어요^^!!!!!!!) 
2. Entity만 만든 거여도 새로운 방식을 사용했으면, Repository를 만들어서 테스트 코드를 올리는 것도 좋겠다
3. ddl 생성과 crud 로직의 성공 여부는 별개다! 

**와 여기는 뭐 참고했다고 올릴 것도 없네요.. 진짜... 블로그에서 복합키... 쿼리 Dsl 말고 그냥 Jpa로 만든 걸 쓴 곳을 못 봐서요... 아, [공식 문서](https://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#identifiers-composite-aggregated)에서 PK 내부에 컬럼 연결하는 방식을 참고하긴 했네요. 이게 없었다면... 영영 그 방식에 갇힐 뻔했는데, 다행입니다... (이렇게 사용하는 블로그 진짜 못찾아서)

++ 
그래도 와중에.. 복합키는 insert 전에 select 쿼리가 나가고, Persistable을 구현하면 된다는 건 잘 써먹었습니다. ㅎㅎ 

참고: [6e713f8](https://github.com/diff-by-devs/1-velog-clone-back-Sooamazing/pull/14/commits/6e713f8a0b017e0f3bdcc438a0bfc49c93cab78a), [2d8f420](https://github.com/diff-by-devs/1-velog-clone-back-Sooamazing/pull/14/commits/2d8f420bc1041104ae30d29a4ba4abd107b287df), dc77115
author_association: CONTRIBUTOR