Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,27 @@
- 생성자 주입을 사용하고 의존성을 명시적으로 드러낸다.
- 전체 검증은 `./gradlew check`로 실행한다. 테스트만 다시 실행할 때는 `./gradlew test`를 사용한다.

## 도메인 엔티티 구현

- 도메인 모델과 JPA 엔티티를 별도 클래스로 나누지 않고 기능별 `domain` 패키지에서 하나의 클래스로 관리한다.
- JPA로 영속화하는 속성은 주 생성자에 `private var`로 선언하고 이름 앞에 `_`를 붙인다. JPA 애노테이션은 `@field:` 사용 지점 대상으로 해당 속성에 선언한다.
- `_id`는 생성자의 첫 번째 속성으로 배치하고 `Long? = null`을 기본값으로 사용한다. `@Id`, `@GeneratedValue(strategy = GenerationType.IDENTITY)`,
`@Column(name = "id")`을 명시한다.
- `_` 접두사가 데이터베이스 컬럼명에 반영되지 않도록 `@Column`, `@JoinColumn` 등에 실제 컬럼명을 명시한다.
- 영속 속성은 클래스 본문에서 접두사가 없는 읽기 전용 `val` getter로 공개한다. 공개 또는 보호된 setter를 만들지 않는다.
- 엔티티의 상태는 setter 대신 의도를 나타내는 도메인 함수로 변경한다. 유효성 검사와 비즈니스 규칙도 엔티티 내부에서 처리한다.
- 연관관계도 같은 방식으로 `private var` 필드에 매핑하고 읽기 전용 getter만 제공한다. 기본 조회 방식은 필요한 이유가 없다면 `FetchType.LAZY`를 사용한다.
- 생성 및 수정 시각은 `BaseEntity`를 상속해 관리하며 `TIMESTAMPTZ`에 대응하는 `Instant`를 사용한다.
- JPA가 엔티티를 생성할 수 있도록 공개 또는 보호된 인자 없는 생성자가 제공되는지 테스트한다. Kotlin JPA 플러그인이 생성한 생성자를 포함해 확인한다.
- 엔티티 변경 시 영속화, 읽기 전용 getter, 주요 도메인 함수, 공개 setter 부재를 테스트한다.

## Git 작업 규칙

### 브랜치

- 기능 개발 브랜치는 `feature/{작업-내용}` 형식으로 생성한다.
- 설정, 빌드, 문서, 의존성 등 기능 외 작업 브랜치는 `chore/{작업-내용}` 형식으로 생성한다.
- `{작업-내용}`은 영문 소문자와 하이픈(`-`)을 사용해 짧고 구체적으로 작성한다.
- `{작업-내용}`은 영문 소문자와 하이픈 (`-`)을 사용해 짧고 구체적으로 작성한다.
- 예: `feature/global-exception-handler`, `chore/github-actions-ci`

### 커밋
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies {
implementation 'org.jetbrains.kotlin:kotlin-reflect'
runtimeOnly 'org.postgresql:postgresql'
testAndDevelopmentOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation platform('io.kotest:kotest-bom:6.1.5')
testImplementation 'io.kotest:kotest-runner-junit5'
Expand Down
25 changes: 17 additions & 8 deletions src/main/kotlin/com/mogumogu/momogo/global/entity/BaseEntity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,29 @@ package com.mogumogu.momogo.global.entity
import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import org.hibernate.annotations.ColumnDefault
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import java.time.Instant

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity {
abstract class BaseEntity(
@field:CreatedDate
@field:ColumnDefault("CURRENT_TIMESTAMP")
@field:Column(name = "created_at", nullable = false, updatable = false)
private var _createdAt: Instant? = null,

@CreatedDate
@Column(updatable = false, nullable = false)
lateinit var createdAt: LocalDateTime
@field:LastModifiedDate
@field:ColumnDefault("CURRENT_TIMESTAMP")
@field:Column(name = "updated_at", nullable = false)
private var _updatedAt: Instant? = null,
) {

@LastModifiedDate
@Column(nullable = false)
lateinit var updatedAt: LocalDateTime
val createdAt: Instant
get() = checkNotNull(_createdAt) { "생성 시각이 아직 설정되지 않았습니다." }

val updatedAt: Instant
get() = checkNotNull(_updatedAt) { "수정 시각이 아직 설정되지 않았습니다." }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions src/main/kotlin/com/mogumogu/momogo/group/infra/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions src/main/kotlin/com/mogumogu/momogo/photo/infra/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

70 changes: 70 additions & 0 deletions src/main/kotlin/com/mogumogu/momogo/user/domain/LoginAccount.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.mogumogu.momogo.user.domain

import com.mogumogu.momogo.global.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.ForeignKey
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table

@Entity
@Table(name = "login_account")
class LoginAccount(
@field:Id
@field:GeneratedValue(strategy = GenerationType.IDENTITY)
@field:Column(name = "id")
private var _id: Long? = null,

@field:ManyToOne(fetch = FetchType.LAZY, optional = false)
@field:JoinColumn(
name = "user_id",
nullable = false,
updatable = false,
foreignKey = ForeignKey(name = "fk_login_account_user"),
)
private var _user: User,

@field:Enumerated(EnumType.STRING)
@field:Column(
name = "provider",
nullable = false,
updatable = false,
length = 255,
columnDefinition = "VARCHAR(255)",
)
private var _provider: LoginProvider,

@field:Column(name = "provider_id", nullable = false, updatable = false, length = 255)
private var _providerId: String,
) : BaseEntity() {

val id: Long?
get() = _id

val user: User
get() = _user

val provider: LoginProvider
get() = _provider

val providerId: String
get() = _providerId

init {
validateProviderId(_providerId)
}

private companion object {
fun validateProviderId(providerId: String) {
require(providerId.isNotBlank()) { "로그인 제공자 회원 ID는 비어 있을 수 없습니다." }
require(providerId.length <= 255) { "로그인 제공자 회원 ID는 255자를 초과할 수 없습니다." }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.mogumogu.momogo.user.domain

enum class LoginProvider {
GUEST,
KAKAO,
NAVER,
APPLE,
}
13 changes: 13 additions & 0 deletions src/main/kotlin/com/mogumogu/momogo/user/domain/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# User Domain

회원, 로그인 계정, 리프레시 토큰과 관련된 도메인 규칙을 관리하는 패키지다.
도메인 모델과 JPA 엔티티는 하나의 클래스로 구성하며, 엔티티가 자신의 상태와 비즈니스 규칙을 직접 관리한다.

## 구성

- `User`: 회원 정보와 닉네임 변경 규칙
- `LoginAccount`: 회원과 로그인 제공자 계정의 연결
- `LoginProvider`: 지원하는 로그인 제공자
- `RefreshToken`: 리프레시 토큰의 만료 및 폐기 상태

외부에서는 읽기 전용 getter로 상태를 조회하고, 상태 변경은 엔티티가 제공하는 도메인 함수를 통해서만 수행한다.
85 changes: 85 additions & 0 deletions src/main/kotlin/com/mogumogu/momogo/user/domain/RefreshToken.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.mogumogu.momogo.user.domain

import com.mogumogu.momogo.global.entity.BaseEntity
import jakarta.persistence.*
import java.time.Instant

@Entity
@Table(
name = "refresh_token",
uniqueConstraints = [
UniqueConstraint(
name = "uq_refresh_token_token_hash",
columnNames = ["token_hash"],
),
],
)
class RefreshToken(
@field:Id
@field:GeneratedValue(strategy = GenerationType.IDENTITY)
@field:Column(name = "id")
private var _id: Long? = null,

@field:ManyToOne(fetch = FetchType.LAZY, optional = false)
@field:JoinColumn(
name = "user_id",
nullable = false,
updatable = false,
foreignKey = ForeignKey(name = "fk_refresh_token_user"),
)
private var _user: User,

@field:Column(
name = "token_hash",
nullable = false,
updatable = false,
length = 64,
columnDefinition = "CHAR(64)",
)
private var _tokenHash: String,

@field:Column(name = "expires_at", nullable = false, updatable = false)
private var _expiresAt: Instant,

@field:Column(name = "revoked_at")
private var _revokedAt: Instant? = null,
) : BaseEntity() {

val id: Long?
get() = _id

val user: User
get() = _user

val tokenHash: String
get() = _tokenHash

val expiresAt: Instant
get() = _expiresAt

val revokedAt: Instant?
get() = _revokedAt

init {
validateTokenHash(_tokenHash)
}

fun revoke(at: Instant) {
if (_revokedAt == null) {
_revokedAt = at
}
}

fun isActive(at: Instant): Boolean =
_revokedAt == null && _expiresAt.isAfter(at)

private companion object {
private val SHA_256_PATTERN = Regex("^[0-9a-fA-F]{64}$")

fun validateTokenHash(tokenHash: String) {
require(SHA_256_PATTERN.matches(tokenHash)) {
"리프레시 토큰 해시는 64자리 16진수여야 합니다."
}
}
}
}
44 changes: 44 additions & 0 deletions src/main/kotlin/com/mogumogu/momogo/user/domain/User.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.mogumogu.momogo.user.domain

import com.mogumogu.momogo.global.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table

@Entity
@Table(name = "\"user\"")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 운영 스키마에 새 엔티티 테이블을 추가하세요

devprod 프로필은 ddl-auto: validate를 사용하지만, 저장소 전체를 확인해도 user, login_account, refresh_token 테이블을 만드는 SQL 또는 마이그레이션 도구 설정이 없습니다. 따라서 이전 커밋의 데이터베이스로 이 버전을 시작하면 Hibernate 스키마 검증에서 필수 테이블이 없다는 오류가 발생해 애플리케이션이 기동되지 않으므로, 엔티티와 함께 운영 스키마 마이그레이션을 제공해야 합니다.

AGENTS.md reference: AGENTS.md:L88-L88

Useful? React with 👍 / 👎.

class User(
@field:Id
@field:GeneratedValue(strategy = GenerationType.IDENTITY)
@field:Column(name = "id")
private var _id: Long? = null,

@field:Column(name = "nickname", nullable = false, length = 255)
private var _nickname: String,
) : BaseEntity() {

val id: Long?
get() = _id

val nickname: String
get() = _nickname

init {
validateNickname(_nickname)
}

fun updateNickname(nickname: String) {
validateNickname(nickname)
_nickname = nickname
}

private companion object {
fun validateNickname(nickname: String) {
require(nickname.isNotBlank()) { "닉네임은 비어 있을 수 없습니다." }
require(nickname.length <= 255) { "닉네임은 255자를 초과할 수 없습니다." }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.mogumogu.momogo.user.infra

import com.mogumogu.momogo.user.domain.LoginAccount
import org.springframework.data.jpa.repository.JpaRepository

interface LoginAccountRepository : JpaRepository<LoginAccount, Long>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.mogumogu.momogo.user.infra

import com.mogumogu.momogo.user.domain.RefreshToken
import org.springframework.data.jpa.repository.JpaRepository

interface RefreshTokenRepository : JpaRepository<RefreshToken, Long>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.mogumogu.momogo.user.infra

import com.mogumogu.momogo.user.domain.User
import org.springframework.data.jpa.repository.JpaRepository

interface UserRepository : JpaRepository<User, Long>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.mogumogu.momogo.user.domain

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe

class LoginAccountTest : BehaviorSpec({

given("유효한 로그인 계정 정보가 있으면") {
`when`("로그인 계정을 생성할 때") {
then("회원과 로그인 제공자 정보를 조회할 수 있다") {
val user = User(_nickname = "모고")
val loginAccount = LoginAccount(
_id = 1L,
_user = user,
_provider = LoginProvider.APPLE,
_providerId = "apple-user-1",
)

loginAccount.id shouldBe 1L
loginAccount.user shouldBe user
loginAccount.provider shouldBe LoginProvider.APPLE
loginAccount.providerId shouldBe "apple-user-1"
}
}
}

given("유효하지 않은 로그인 제공자 회원 ID가 있으면") {
val user = User(_nickname = "모고")

`when`("빈 값으로 로그인 계정을 생성할 때") {
then("생성을 거부한다") {
listOf("", " ", "\t").forEach { providerId ->
shouldThrow<IllegalArgumentException> {
LoginAccount(
_user = user,
_provider = LoginProvider.GUEST,
_providerId = providerId,
)
}.message shouldBe "로그인 제공자 회원 ID는 비어 있을 수 없습니다."
}
}
}

`when`("255자를 초과한 값으로 로그인 계정을 생성할 때") {
then("생성을 거부한다") {
shouldThrow<IllegalArgumentException> {
LoginAccount(
_user = user,
_provider = LoginProvider.KAKAO,
_providerId = "a".repeat(256),
)
}.message shouldBe "로그인 제공자 회원 ID는 255자를 초과할 수 없습니다."
}
}
}
})
Loading