diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index 7606011..0c44771 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -67,12 +67,14 @@ jobs:
V_DISCORD_WEBHOOK_5XX_URL: ${{ secrets.DISCORD_WEBHOOK_5XX_URL }}
V_DISCORD_WEBHOOK_4XX_URL: ${{ secrets.DISCORD_WEBHOOK_4XX_URL }}
V_DISCORD_WEBHOOK_INQUIRY_URL: ${{ secrets.DISCORD_WEBHOOK_INQUIRY_URL }}
+ V_MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
+ V_MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 2222
- envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL
+ envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD
script: |
echo "$V_GHCR_TOKEN" | docker login ghcr.io -u "$V_GHCR_USER" --password-stdin
@@ -98,6 +100,8 @@ jobs:
printf 'DISCORD_WEBHOOK_5XX_URL=%s\n' "$V_DISCORD_WEBHOOK_5XX_URL" >> ~/compose/be.env
printf 'DISCORD_WEBHOOK_4XX_URL=%s\n' "$V_DISCORD_WEBHOOK_4XX_URL" >> ~/compose/be.env
printf 'DISCORD_WEBHOOK_INQUIRY_URL=%s\n' "$V_DISCORD_WEBHOOK_INQUIRY_URL" >> ~/compose/be.env
+ printf 'MAIL_USERNAME=%s\n' "$V_MAIL_USERNAME" >> ~/compose/be.env
+ printf 'MAIL_PASSWORD=%s\n' "$V_MAIL_PASSWORD" >> ~/compose/be.env
chmod +x ~/scripts/deploy.sh
bash ~/scripts/deploy.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 956a7e9..a16f8a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,10 @@ name: CI
on:
pull_request:
- branches: [main]
+ branches: [main, develop]
+
+permissions:
+ contents: read
jobs:
test:
diff --git a/README.md b/README.md
index ff95b64..f2d1299 100644
--- a/README.md
+++ b/README.md
@@ -156,8 +156,9 @@ open -a Docker && docker start redis # Docker 데몬이 꺼져 있으면 먼
-- **CI** (`ci.yml`): PR 생성 시 PostgreSQL·Redis 컨테이너를 띄워 전체 테스트 실행 + Docker 이미지 빌드 검증
-- **CD** (`cd.yml`): `main` 브랜치 push 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행
+- 작업 브랜치 → `develop` PR/병합 → (여러 작업 누적 후) `develop` → `main` PR/병합 순으로 운영 서버에 배포됩니다.
+- **CI** (`ci.yml`): `main`/`develop`으로의 PR 생성 시 PostgreSQL·Redis 컨테이너를 띄워 전체 테스트 실행 + Docker 이미지 빌드 검증
+- **CD** (`cd.yml`): `main` 브랜치 push(= `develop` → `main` 병합) 시 Docker 이미지를 GHCR에 push하고, Lightsail 인스턴스로 SSH 접속해 `scripts/deploy.sh` 실행
- Nginx가 `be-blue`(:8080)/`be-green`(:8081) 중 활성 슬롯으로만 트래픽을 전달하고, Redis는 두 슬롯이 공유합니다. Data Pipeline(`data`)도 같은 Docker Compose에 포함되어 별도 인스턴스 없이 함께 배포됩니다.
- 배포 시 standby 슬롯에 새 이미지를 pull → `/actuator/health` 체크 통과 → Nginx upstream 전환 → 이전 슬롯 정지 순으로 무중단 배포합니다.
- DB는 별도 Lightsail 인스턴스(Managed PostgreSQL)로 분리되어 두 슬롯이 공통으로 바라보고, 4xx/5xx 에러·공연 데이터 수집 결과·문의 접수는 각각 Discord Webhook으로 알림됩니다.
diff --git a/build.gradle b/build.gradle
index 78e0038..94fccae 100644
--- a/build.gradle
+++ b/build.gradle
@@ -29,6 +29,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+ implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
@@ -40,6 +43,7 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.security:spring-security-test'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
diff --git a/src/main/java/com/Coming/Backend/BackendApplication.java b/src/main/java/com/Coming/Backend/BackendApplication.java
index 43fa2ad..1153006 100644
--- a/src/main/java/com/Coming/Backend/BackendApplication.java
+++ b/src/main/java/com/Coming/Backend/BackendApplication.java
@@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableJpaAuditing
+@EnableScheduling
public class BackendApplication {
public static void main(String[] args) {
diff --git a/src/main/java/com/Coming/Backend/auth/entity/User.java b/src/main/java/com/Coming/Backend/auth/entity/User.java
index acb8070..7d9421e 100644
--- a/src/main/java/com/Coming/Backend/auth/entity/User.java
+++ b/src/main/java/com/Coming/Backend/auth/entity/User.java
@@ -9,7 +9,6 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
-import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -51,26 +50,13 @@ public class User extends BaseTimeEntity {
@Column(name = "birth_year")
private Integer birthYear;
- @Column(name = "agreed_terms")
- private Boolean agreedTerms;
-
- @Column(name = "agreed_privacy")
- private Boolean agreedPrivacy;
-
@Column(name = "agreed_marketing")
private Boolean agreedMarketing;
- @Column(name = "agreed_at")
- private LocalDateTime agreedAt;
-
- public void completeRegistration(String nickname, int birthYear,
- boolean agreedTerms, boolean agreedPrivacy, boolean agreedMarketing) {
+ public void completeRegistration(String nickname, int birthYear, boolean agreedMarketing) {
this.nickname = nickname;
this.birthYear = birthYear;
- this.agreedTerms = agreedTerms;
- this.agreedPrivacy = agreedPrivacy;
this.agreedMarketing = agreedMarketing;
- this.agreedAt = LocalDateTime.now();
this.role = UserRole.USER;
}
@@ -91,14 +77,15 @@ public void updateEmail(String email) {
this.email = email;
}
+ public boolean hasNoEmail() {
+ return email == null || email.isBlank();
+ }
+
public void reactivate() {
this.status = UserStatus.ACTIVE;
this.role = UserRole.PENDING;
this.nickname = null;
this.birthYear = null;
- this.agreedTerms = null;
- this.agreedPrivacy = null;
this.agreedMarketing = null;
- this.agreedAt = null;
}
}
diff --git a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java
index cf1542f..d462660 100644
--- a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java
+++ b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java
@@ -1,6 +1,7 @@
package com.Coming.Backend.auth.repository;
import com.Coming.Backend.auth.entity.User;
+import com.Coming.Backend.auth.entity.UserStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
@@ -14,4 +15,6 @@ public interface UserRepository extends JpaRepository {
List findAllByIdIn(Collection ids);
boolean existsByNickname(String nickname);
+
+ List findByStatus(UserStatus status);
}
diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java
index 9439be4..8d5a6b3 100644
--- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java
+++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java
@@ -25,6 +25,14 @@
import com.Coming.Backend.auth.repository.UserRepository;
import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository;
import com.Coming.Backend.inquiry.repository.InquiryRepository;
+import com.Coming.Backend.policy.entity.PolicyDocument;
+import com.Coming.Backend.policy.entity.PolicyType;
+import com.Coming.Backend.policy.entity.UserPolicyAgreement;
+import com.Coming.Backend.policy.exception.PolicyNotFoundException;
+import com.Coming.Backend.policy.repository.PolicyDocumentRepository;
+import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
@@ -43,6 +51,8 @@ public class AuthService {
private final UserFollowArtistRepository userFollowArtistRepository;
private final UserConcertCalendarRepository userConcertCalendarRepository;
private final InquiryRepository inquiryRepository;
+ private final PolicyDocumentRepository policyDocumentRepository;
+ private final UserPolicyAgreementRepository userPolicyAgreementRepository;
/**
* Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다.
@@ -128,11 +138,11 @@ public TokenResponse register(Long userId, RegisterRequest request) {
user.completeRegistration(
request.nickname(),
request.birthYear(),
- request.agreedTerms(),
- request.agreedPrivacy(),
Boolean.TRUE.equals(request.agreedMarketing())
);
try {
+ recordPolicyAgreement(userId, PolicyType.TERMS);
+ recordPolicyAgreement(userId, PolicyType.PRIVACY);
userRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new NicknameDuplicateException();
@@ -142,6 +152,25 @@ public TokenResponse register(Long userId, RegisterRequest request) {
return new TokenResponse(accessToken);
}
+ /**
+ * 현재 시행 중인 정책 버전에 대한 사용자 동의 이력을 기록한다. 이미 동의 이력이 있으면 건너뛴다(탈퇴 후 재가입 대비).
+ */
+ private void recordPolicyAgreement(Long userId, PolicyType type) {
+ PolicyDocument currentPolicy = policyDocumentRepository
+ .findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc(type, LocalDate.now())
+ .orElseThrow(PolicyNotFoundException::new);
+ if (userPolicyAgreementRepository.existsByUserIdAndPolicyId(userId, currentPolicy.getId())) {
+ return;
+ }
+ userPolicyAgreementRepository.save(
+ UserPolicyAgreement.builder()
+ .userId(userId)
+ .policyId(currentPolicy.getId())
+ .agreedAt(LocalDateTime.now())
+ .build()
+ );
+ }
+
private void validateRegisterRequest(RegisterRequest request) {
if (!Boolean.TRUE.equals(request.agreedTerms()) || !Boolean.TRUE.equals(request.agreedPrivacy())) {
throw new TermsNotAgreedException();
diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java
index bf2a072..8e27d57 100644
--- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java
+++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java
@@ -118,7 +118,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/api/artists/**",
"/api/concerts/**",
"/api/releases/**",
- "/api/calendar"
+ "/api/calendar",
+ "/api/mentions/search",
+ "/api/posts/**",
+ "/api/search",
+ "/api/entities/**"
).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/auth/register").hasRole("PENDING")
diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java
index dfd5f65..29abd30 100644
--- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java
+++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java
@@ -49,12 +49,28 @@ public enum ErrorCode {
INVALID_INQUIRY_STATUS(HttpStatus.BAD_REQUEST, "변경 불가능한 문의 상태입니다."),
TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 대상입니다."),
+ // Post
+ POST_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."),
+ ALREADY_RECOMMENDED(HttpStatus.CONFLICT, "이미 추천한 게시글입니다."),
+ NOT_RECOMMENDED(HttpStatus.BAD_REQUEST, "추천하지 않은 게시글입니다."),
+ POST_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "본문은 10000자를 초과할 수 없습니다."),
+
+ // Comment
+ COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."),
+ INVALID_REPLY_DEPTH(HttpStatus.BAD_REQUEST, "답글에는 답글을 작성할 수 없습니다."),
+ ALREADY_LIKED(HttpStatus.CONFLICT, "이미 좋아요한 댓글입니다."),
+ NOT_LIKED(HttpStatus.BAD_REQUEST, "좋아요하지 않은 댓글입니다."),
+
// Pipeline
PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."),
PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."),
PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."),
PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."),
+ // Policy
+ POLICY_VERSION_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 정책 버전입니다."),
+ POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 정책입니다."),
+
// Common
RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."),
INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다."),
diff --git a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java
index b856be4..559824b 100644
--- a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java
+++ b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java
@@ -35,6 +35,10 @@ public interface ConcertRepository extends JpaRepository {
@Query("UPDATE Concert c SET c.viewCount = c.viewCount + 1 WHERE c.id = :id")
void incrementViewCount(@Param("id") Long id);
+ @Query(value = "SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC, c.id DESC",
+ countQuery = "SELECT COUNT(c) FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q")
+ Page searchByTitleForMention(@Param("hidden") Collection hidden, @Param("q") String q, Pageable pageable);
+
@Query("SELECT c FROM Concert c WHERE c.startDate <= :lastDay AND c.endDate >= :firstDay AND c.status NOT IN :hidden ORDER BY c.startDate ASC")
List findByDateRange(@Param("firstDay") LocalDate firstDay, @Param("lastDay") LocalDate lastDay, @Param("hidden") Collection hidden);
diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java
new file mode 100644
index 0000000..7077595
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java
@@ -0,0 +1,54 @@
+package com.Coming.Backend.policy.batch;
+
+import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.job.Job;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.job.parameters.RunIdIncrementer;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.Step;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+@Configuration
+@RequiredArgsConstructor
+public class PolicyNotificationBatchConfig {
+
+ private static final int CHUNK_SIZE = 20;
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet;
+ private final PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader;
+ private final PolicyNotificationMailProcessor policyNotificationMailProcessor;
+ private final PolicyNotificationTargetWriter policyNotificationTargetWriter;
+
+ @Bean
+ public Job policyNotificationJob() {
+ return new JobBuilder("policyNotificationJob", jobRepository)
+ .incrementer(new RunIdIncrementer())
+ .start(targetCreationStep())
+ .next(mailSendStep())
+ .build();
+ }
+
+ @Bean
+ public Step targetCreationStep() {
+ return new StepBuilder("targetCreationStep", jobRepository)
+ .tasklet(policyNotificationTargetCreationTasklet, transactionManager)
+ .build();
+ }
+
+ @Bean
+ public Step mailSendStep() {
+ return new StepBuilder("mailSendStep", jobRepository)
+ .chunk(CHUNK_SIZE)
+ .reader(policyNotificationPendingTargetReader)
+ .processor(policyNotificationMailProcessor)
+ .writer(policyNotificationTargetWriter)
+ .transactionManager(transactionManager)
+ .build();
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java
new file mode 100644
index 0000000..784d555
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java
@@ -0,0 +1,51 @@
+package com.Coming.Backend.policy.batch;
+
+import com.Coming.Backend.policy.event.PolicyRegisteredEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.job.Job;
+import org.springframework.batch.core.job.parameters.JobParameters;
+import org.springframework.batch.core.job.parameters.JobParametersBuilder;
+import org.springframework.batch.core.launch.JobOperator;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 실행한다.
+ * AFTER_COMMIT 콜백과 같은 스레드에서 배치(자체 트랜잭션 포함)를 직접 실행하면 커밋 중이던
+ * 트랜잭션 동기화 상태와 충돌해 JobInterruptedException이 발생하므로, 별도 스레드에서 실행하고
+ * join으로 대기해 호출자 입장에서는 동기로 완료를 기다리게 한다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PolicyNotificationJobTrigger {
+
+ private final JobOperator jobOperator;
+ private final Job policyNotificationJob;
+
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void onPolicyRegistered(PolicyRegisteredEvent event) {
+ Thread jobThread = Thread.ofVirtual().start(() -> runJob(event.policyId()));
+ try {
+ jobThread.join();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.error("정책 알림 배치 대기 중 인터럽트 발생 — policyId: {}", event.policyId(), e);
+ }
+ }
+
+ private void runJob(Long policyId) {
+ try {
+ JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext(
+ new JobParametersBuilder()
+ .addLong("policyId", policyId)
+ .toJobParameters()
+ );
+ jobOperator.run(policyNotificationJob, jobParameters);
+ } catch (Exception e) {
+ log.error("정책 알림 배치 실행 실패 — policyId: {}", policyId, e);
+ }
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java
new file mode 100644
index 0000000..00c0ef1
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java
@@ -0,0 +1,45 @@
+package com.Coming.Backend.policy.batch;
+
+import com.Coming.Backend.auth.entity.User;
+import com.Coming.Backend.auth.repository.UserRepository;
+import com.Coming.Backend.policy.entity.PolicyDocument;
+import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
+import com.Coming.Backend.policy.exception.PolicyNotFoundException;
+import com.Coming.Backend.policy.repository.PolicyDocumentRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.infrastructure.item.ItemProcessor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * PENDING 상태의 PolicyNotificationTarget에 대해 정책 변경 고지 메일을 발송하고 상태(SENT/FAILED)를 갱신한다.
+ */
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class PolicyNotificationMailProcessor implements ItemProcessor {
+
+ private final UserRepository userRepository;
+ private final PolicyDocumentRepository policyDocumentRepository;
+ private final PolicyNotificationSender policyNotificationSender;
+
+ @Value("#{jobParameters['policyId']}")
+ private Long policyId;
+
+ private PolicyDocument policyDocument;
+
+ @Override
+ public PolicyNotificationTarget process(PolicyNotificationTarget target) {
+ User user = userRepository.findById(target.getUserId()).orElse(null);
+ policyNotificationSender.sendAndMark(target, user, resolvePolicyDocument());
+ return target;
+ }
+
+ private PolicyDocument resolvePolicyDocument() {
+ if (policyDocument == null) {
+ policyDocument = policyDocumentRepository.findById(policyId).orElseThrow(PolicyNotFoundException::new);
+ }
+ return policyDocument;
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java
new file mode 100644
index 0000000..f98831b
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java
@@ -0,0 +1,49 @@
+package com.Coming.Backend.policy.batch;
+
+import com.Coming.Backend.policy.entity.NotificationStatus;
+import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
+import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository;
+import java.util.Iterator;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.infrastructure.item.ItemReader;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Component;
+
+/**
+ * PENDING 상태의 정책 알림 대상을 id 오름차순 커서 기반으로 읽는다.
+ * offset 페이징을 사용하면 처리된 항목이 PENDING에서 벗어나며 결과 집합이 줄어들어
+ * 다음 페이지 조회 시 대상을 건너뛰는 문제가 있어, 마지막으로 읽은 id를 커서로 다음 배치를 조회한다.
+ */
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class PolicyNotificationPendingTargetReader implements ItemReader {
+
+ private static final int PAGE_SIZE = 20;
+
+ private final PolicyNotificationTargetRepository policyNotificationTargetRepository;
+
+ @Value("#{jobParameters['policyId']}")
+ private Long policyId;
+
+ private Long lastId = 0L;
+ private Iterator currentBatch = List.of().iterator();
+
+ @Override
+ public PolicyNotificationTarget read() {
+ if (!currentBatch.hasNext()) {
+ List batch = policyNotificationTargetRepository
+ .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc(
+ policyId, NotificationStatus.PENDING, lastId, PageRequest.of(0, PAGE_SIZE));
+ if (batch.isEmpty()) {
+ return null;
+ }
+ lastId = batch.get(batch.size() - 1).getId();
+ currentBatch = batch.iterator();
+ }
+ return currentBatch.next();
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java
new file mode 100644
index 0000000..0aa0d58
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java
@@ -0,0 +1,55 @@
+package com.Coming.Backend.policy.batch;
+
+import com.Coming.Backend.auth.entity.User;
+import com.Coming.Backend.auth.repository.UserRepository;
+import com.Coming.Backend.policy.entity.NotificationStatus;
+import com.Coming.Backend.policy.entity.PolicyDocument;
+import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
+import com.Coming.Backend.policy.repository.PolicyDocumentRepository;
+import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 발송 실패(FAILED)한 정책 알림 대상을 최대 3회까지 1시간 간격으로 재시도한다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PolicyNotificationRetryScheduler {
+
+ private static final int MAX_RETRY_COUNT = 3;
+
+ private final PolicyNotificationTargetRepository policyNotificationTargetRepository;
+ private final UserRepository userRepository;
+ private final PolicyDocumentRepository policyDocumentRepository;
+ private final PolicyNotificationSender policyNotificationSender;
+
+ @Scheduled(fixedRate = 3_600_000)
+ @Transactional
+ public void retryFailedNotifications() {
+ List retryTargets = policyNotificationTargetRepository
+ .findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, MAX_RETRY_COUNT);
+ if (retryTargets.isEmpty()) {
+ return;
+ }
+ log.info("정책 알림 재시도 시작 — 대상 건수: {}", retryTargets.size());
+ for (PolicyNotificationTarget target : retryTargets) {
+ retryOne(target);
+ }
+ }
+
+ private void retryOne(PolicyNotificationTarget target) {
+ User user = userRepository.findById(target.getUserId()).orElse(null);
+ PolicyDocument policyDocument = policyDocumentRepository.findById(target.getPolicyId()).orElse(null);
+ if (policyDocument == null) {
+ target.markFailed();
+ return;
+ }
+ policyNotificationSender.sendAndMark(target, user, policyDocument);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java
new file mode 100644
index 0000000..de60499
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java
@@ -0,0 +1,36 @@
+package com.Coming.Backend.policy.batch;
+
+import com.Coming.Backend.auth.entity.User;
+import com.Coming.Backend.policy.entity.PolicyDocument;
+import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
+import com.Coming.Backend.policy.mail.PolicyNoticeMailSender;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 정책 알림 대상에게 메일 발송을 시도하고 결과에 따라 상태(SENT/FAILED)를 마킹한다.
+ * 배치 Step2 Processor와 재시도 스케줄러가 공통으로 사용한다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PolicyNotificationSender {
+
+ private final PolicyNoticeMailSender policyNoticeMailSender;
+
+ public void sendAndMark(PolicyNotificationTarget target, User user, PolicyDocument policyDocument) {
+ if (user == null || user.hasNoEmail()) {
+ log.warn("수신 이메일이 없어 발송 실패 처리 — targetId: {}", target.getId());
+ target.markFailed();
+ return;
+ }
+ try {
+ policyNoticeMailSender.send(user.getEmail(), policyDocument);
+ target.markSent();
+ } catch (Exception e) {
+ log.warn("정책 변경 고지 메일 발송 실패 — targetId: {}", target.getId(), e);
+ target.markFailed();
+ }
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java
new file mode 100644
index 0000000..7a20476
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java
@@ -0,0 +1,62 @@
+package com.Coming.Backend.policy.batch;
+
+import com.Coming.Backend.auth.entity.User;
+import com.Coming.Backend.auth.entity.UserStatus;
+import com.Coming.Backend.auth.repository.UserRepository;
+import com.Coming.Backend.policy.entity.NotificationStatus;
+import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
+import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.StepContribution;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.infrastructure.repeat.RepeatStatus;
+import org.springframework.stereotype.Component;
+
+/**
+ * 정책 발행 시 ACTIVE 유저 중 아직 발송 대상이 아닌 유저를 PENDING 상태의 PolicyNotificationTarget으로 등록한다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PolicyNotificationTargetCreationTasklet implements Tasklet {
+
+ private final UserRepository userRepository;
+ private final PolicyNotificationTargetRepository policyNotificationTargetRepository;
+
+ @Override
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ Long policyId = chunkContext.getStepContext().getStepExecution().getJobParameters().getLong("policyId");
+
+ Set existingTargetUserIds = policyNotificationTargetRepository.findByPolicyId(policyId).stream()
+ .map(PolicyNotificationTarget::getUserId)
+ .collect(Collectors.toSet());
+
+ List activeUsers = userRepository.findByStatus(UserStatus.ACTIVE);
+ int created = 0;
+ for (User user : activeUsers) {
+ if (existingTargetUserIds.contains(user.getId())) {
+ continue;
+ }
+ if (user.hasNoEmail()) {
+ log.warn("이메일이 없어 정책 알림 대상에서 제외 — userId: {}", user.getId());
+ continue;
+ }
+ policyNotificationTargetRepository.save(
+ PolicyNotificationTarget.builder()
+ .policyId(policyId)
+ .userId(user.getId())
+ .status(NotificationStatus.PENDING)
+ .retryCount(0)
+ .build()
+ );
+ created++;
+ }
+ log.info("정책 알림 대상 생성 완료 — policyId: {}, 생성 건수: {}", policyId, created);
+ return RepeatStatus.FINISHED;
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java
new file mode 100644
index 0000000..b9f946e
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java
@@ -0,0 +1,20 @@
+package com.Coming.Backend.policy.batch;
+
+import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
+import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.infrastructure.item.Chunk;
+import org.springframework.batch.infrastructure.item.ItemWriter;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class PolicyNotificationTargetWriter implements ItemWriter {
+
+ private final PolicyNotificationTargetRepository policyNotificationTargetRepository;
+
+ @Override
+ public void write(Chunk extends PolicyNotificationTarget> chunk) {
+ policyNotificationTargetRepository.saveAll(chunk.getItems());
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java
new file mode 100644
index 0000000..ad7d817
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java
@@ -0,0 +1,32 @@
+package com.Coming.Backend.policy.controller;
+
+import com.Coming.Backend.policy.dto.PolicyRegisterRequest;
+import com.Coming.Backend.policy.dto.PolicyResponse;
+import com.Coming.Backend.policy.service.PolicyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "Policy")
+@RestController
+@RequestMapping("/api/admin/policies")
+@RequiredArgsConstructor
+public class PolicyController {
+
+ private final PolicyService policyService;
+
+ @Operation(summary = "정책 버전 등록")
+ @ApiResponse(responseCode = "409", description = "POLICY_VERSION_DUPLICATE")
+ @PostMapping
+ public ResponseEntity registerPolicy(@Valid @RequestBody PolicyRegisterRequest request) {
+ return ResponseEntity.status(HttpStatus.CREATED).body(policyService.registerPolicy(request));
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java
new file mode 100644
index 0000000..2ab920d
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java
@@ -0,0 +1,27 @@
+package com.Coming.Backend.policy.dto;
+
+import com.Coming.Backend.policy.entity.PolicyType;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDate;
+
+public record PolicyRegisterRequest(
+ @NotNull
+ PolicyType type,
+
+ @NotBlank
+ @Size(max = 50)
+ String version,
+
+ @NotNull
+ LocalDate effectiveDate,
+
+ @NotBlank
+ String changeSummary,
+
+ @NotBlank
+ @Size(max = 500)
+ String detailUrl
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java
new file mode 100644
index 0000000..722f989
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java
@@ -0,0 +1,25 @@
+package com.Coming.Backend.policy.dto;
+
+import com.Coming.Backend.policy.entity.PolicyDocument;
+import com.Coming.Backend.policy.entity.PolicyType;
+import java.time.LocalDate;
+
+public record PolicyResponse(
+ Long id,
+ PolicyType type,
+ String version,
+ LocalDate effectiveDate,
+ String changeSummary,
+ String detailUrl
+) {
+ public static PolicyResponse from(PolicyDocument policyDocument) {
+ return new PolicyResponse(
+ policyDocument.getId(),
+ policyDocument.getType(),
+ policyDocument.getVersion(),
+ policyDocument.getEffectiveDate(),
+ policyDocument.getChangeSummary(),
+ policyDocument.getDetailUrl()
+ );
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java
new file mode 100644
index 0000000..774b45c
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java
@@ -0,0 +1,5 @@
+package com.Coming.Backend.policy.entity;
+
+public enum NotificationStatus {
+ PENDING, SENT, FAILED
+}
diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java
new file mode 100644
index 0000000..2058304
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java
@@ -0,0 +1,46 @@
+package com.Coming.Backend.policy.entity;
+
+import com.Coming.Backend.common.entity.BaseCreatedEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.LocalDate;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "policy_document")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+public class PolicyDocument extends BaseCreatedEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "type", nullable = false, length = 20)
+ private PolicyType type;
+
+ @Column(name = "version", nullable = false, length = 50)
+ private String version;
+
+ @Column(name = "effective_date", nullable = false)
+ private LocalDate effectiveDate;
+
+ @Column(name = "change_summary", nullable = false, columnDefinition = "text")
+ private String changeSummary;
+
+ @Column(name = "detail_url", nullable = false, length = 500)
+ private String detailUrl;
+}
diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java
new file mode 100644
index 0000000..7a6ba98
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java
@@ -0,0 +1,58 @@
+package com.Coming.Backend.policy.entity;
+
+import com.Coming.Backend.common.entity.BaseCreatedEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "policy_notification_target")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+public class PolicyNotificationTarget extends BaseCreatedEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "policy_id", nullable = false)
+ private Long policyId;
+
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ private NotificationStatus status;
+
+ @Column(name = "sent_at")
+ private LocalDateTime sentAt;
+
+ @Column(name = "retry_count", nullable = false)
+ private int retryCount;
+
+ public void markSent() {
+ this.status = NotificationStatus.SENT;
+ this.sentAt = LocalDateTime.now();
+ }
+
+ public void markFailed() {
+ if (this.status == NotificationStatus.FAILED) {
+ this.retryCount += 1;
+ }
+ this.status = NotificationStatus.FAILED;
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java
new file mode 100644
index 0000000..d6eaf4a
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java
@@ -0,0 +1,5 @@
+package com.Coming.Backend.policy.entity;
+
+public enum PolicyType {
+ TERMS, PRIVACY
+}
diff --git a/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java
new file mode 100644
index 0000000..2c9158c
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java
@@ -0,0 +1,36 @@
+package com.Coming.Backend.policy.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "user_policy_agreement")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+public class UserPolicyAgreement {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+
+ @Column(name = "policy_id", nullable = false)
+ private Long policyId;
+
+ @Column(name = "agreed_at", nullable = false)
+ private LocalDateTime agreedAt;
+}
diff --git a/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java
new file mode 100644
index 0000000..60e0886
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java
@@ -0,0 +1,4 @@
+package com.Coming.Backend.policy.event;
+
+public record PolicyRegisteredEvent(Long policyId) {
+}
diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java
new file mode 100644
index 0000000..4318931
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.policy.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class PolicyNotFoundException extends BusinessException {
+
+ public PolicyNotFoundException() {
+ super(ErrorCode.POLICY_NOT_FOUND);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java
new file mode 100644
index 0000000..ab706a5
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.policy.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class PolicyVersionDuplicateException extends BusinessException {
+
+ public PolicyVersionDuplicateException() {
+ super(ErrorCode.POLICY_VERSION_DUPLICATE);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java
new file mode 100644
index 0000000..cd4ae23
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java
@@ -0,0 +1,71 @@
+package com.Coming.Backend.policy.mail;
+
+import com.Coming.Backend.policy.entity.PolicyDocument;
+import com.Coming.Backend.policy.entity.PolicyType;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import java.io.UnsupportedEncodingException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Component;
+import org.thymeleaf.TemplateEngine;
+import org.thymeleaf.context.Context;
+
+@Component
+@RequiredArgsConstructor
+public class PolicyNoticeMailSender {
+
+ private static final String TEMPLATE_NAME = "mail/policy-change-notice";
+ private static final String FROM_NAME = "커밍";
+ private static final String LOGO_CONTENT_ID = "coming-logo";
+ private static final String LOGO_PATH = "mail-assets/logo.png";
+
+ private final JavaMailSender javaMailSender;
+ private final TemplateEngine templateEngine;
+
+ @Value("${spring.mail.username}")
+ private String fromAddress;
+
+ /**
+ * 정책 변경 고지 메일을 발송한다.
+ *
+ * @throws IllegalStateException 메일 메시지 생성에 실패한 경우
+ */
+ public void send(String toEmail, PolicyDocument policyDocument) {
+ try {
+ MimeMessage message = javaMailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
+ helper.setTo(toEmail);
+ helper.setFrom(fromAddress, FROM_NAME);
+ helper.setSubject(buildSubject(policyDocument));
+ helper.setText(renderHtml(policyDocument), true);
+ helper.addInline(LOGO_CONTENT_ID, new ClassPathResource(LOGO_PATH));
+ javaMailSender.send(message);
+ } catch (MessagingException | UnsupportedEncodingException e) {
+ throw new IllegalStateException("정책 변경 고지 메일 메시지 생성에 실패했습니다.", e);
+ }
+ }
+
+ private String buildSubject(PolicyDocument policyDocument) {
+ return "[커밍] " + labelOf(policyDocument.getType()) + " 변경 안내";
+ }
+
+ private String renderHtml(PolicyDocument policyDocument) {
+ Context context = new Context();
+ context.setVariable("policyTypeLabel", labelOf(policyDocument.getType()));
+ context.setVariable("version", policyDocument.getVersion());
+ context.setVariable("effectiveDate", policyDocument.getEffectiveDate());
+ context.setVariable("changeSummary", policyDocument.getChangeSummary());
+ context.setVariable("detailUrl", policyDocument.getDetailUrl());
+ context.setVariable("fromAddress", fromAddress);
+ context.setVariable("logoContentId", LOGO_CONTENT_ID);
+ return templateEngine.process(TEMPLATE_NAME, context);
+ }
+
+ private String labelOf(PolicyType type) {
+ return type == PolicyType.TERMS ? "이용약관" : "개인정보처리방침";
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java
new file mode 100644
index 0000000..9b76ace
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java
@@ -0,0 +1,15 @@
+package com.Coming.Backend.policy.repository;
+
+import com.Coming.Backend.policy.entity.PolicyDocument;
+import com.Coming.Backend.policy.entity.PolicyType;
+import java.time.LocalDate;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PolicyDocumentRepository extends JpaRepository {
+
+ boolean existsByTypeAndVersion(PolicyType type, String version);
+
+ Optional findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc(
+ PolicyType type, LocalDate today);
+}
diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java
new file mode 100644
index 0000000..ae0fdb1
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java
@@ -0,0 +1,17 @@
+package com.Coming.Backend.policy.repository;
+
+import com.Coming.Backend.policy.entity.NotificationStatus;
+import com.Coming.Backend.policy.entity.PolicyNotificationTarget;
+import java.util.List;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PolicyNotificationTargetRepository extends JpaRepository {
+
+ List findByPolicyId(Long policyId);
+
+ List findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc(
+ Long policyId, NotificationStatus status, Long id, Pageable pageable);
+
+ List findByStatusAndRetryCountLessThan(NotificationStatus status, int retryCount);
+}
diff --git a/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java
new file mode 100644
index 0000000..32f5ebb
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java
@@ -0,0 +1,9 @@
+package com.Coming.Backend.policy.repository;
+
+import com.Coming.Backend.policy.entity.UserPolicyAgreement;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface UserPolicyAgreementRepository extends JpaRepository {
+
+ boolean existsByUserIdAndPolicyId(Long userId, Long policyId);
+}
diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java
new file mode 100644
index 0000000..12ce0f0
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java
@@ -0,0 +1,49 @@
+package com.Coming.Backend.policy.service;
+
+import com.Coming.Backend.policy.dto.PolicyRegisterRequest;
+import com.Coming.Backend.policy.dto.PolicyResponse;
+import com.Coming.Backend.policy.entity.PolicyDocument;
+import com.Coming.Backend.policy.event.PolicyRegisteredEvent;
+import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException;
+import com.Coming.Backend.policy.repository.PolicyDocumentRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PolicyService {
+
+ private final PolicyDocumentRepository policyDocumentRepository;
+ private final ApplicationEventPublisher eventPublisher;
+
+ /**
+ * 새 정책 버전을 등록한다. 등록 완료(커밋) 후 정책 알림 배치가 트리거된다.
+ *
+ * @throws PolicyVersionDuplicateException 동일 type·version이 이미 등록된 경우
+ */
+ @Transactional
+ public PolicyResponse registerPolicy(PolicyRegisterRequest request) {
+ if (policyDocumentRepository.existsByTypeAndVersion(request.type(), request.version())) {
+ throw new PolicyVersionDuplicateException();
+ }
+ PolicyDocument policyDocument = PolicyDocument.builder()
+ .type(request.type())
+ .version(request.version())
+ .effectiveDate(request.effectiveDate())
+ .changeSummary(request.changeSummary())
+ .detailUrl(request.detailUrl())
+ .build();
+ PolicyDocument saved;
+ try {
+ saved = policyDocumentRepository.saveAndFlush(policyDocument);
+ } catch (DataIntegrityViolationException e) {
+ throw new PolicyVersionDuplicateException();
+ }
+ eventPublisher.publishEvent(new PolicyRegisteredEvent(saved.getId()));
+ return PolicyResponse.from(saved);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/controller/CommentController.java b/src/main/java/com/Coming/Backend/post/controller/CommentController.java
new file mode 100644
index 0000000..9af008e
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/controller/CommentController.java
@@ -0,0 +1,55 @@
+package com.Coming.Backend.post.controller;
+
+import com.Coming.Backend.post.dto.CommentLikeCountResponse;
+import com.Coming.Backend.post.service.CommentService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "Comment")
+@RestController
+@RequestMapping("/api/comments")
+@RequiredArgsConstructor
+public class CommentController {
+
+ private final CommentService commentService;
+
+ @Operation(summary = "댓글 삭제")
+ @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)")
+ @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND")
+ @DeleteMapping("/{commentId}")
+ public ResponseEntity delete(
+ @AuthenticationPrincipal Long userId,
+ @PathVariable Long commentId) {
+ commentService.delete(userId, commentId);
+ return ResponseEntity.ok().build();
+ }
+
+ @Operation(summary = "댓글 좋아요")
+ @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND")
+ @ApiResponse(responseCode = "409", description = "ALREADY_LIKED")
+ @PostMapping("/{commentId}/like")
+ public ResponseEntity like(
+ @AuthenticationPrincipal Long userId,
+ @PathVariable Long commentId) {
+ return ResponseEntity.ok(commentService.like(userId, commentId));
+ }
+
+ @Operation(summary = "댓글 좋아요 취소")
+ @ApiResponse(responseCode = "400", description = "NOT_LIKED")
+ @ApiResponse(responseCode = "404", description = "COMMENT_NOT_FOUND")
+ @DeleteMapping("/{commentId}/like")
+ public ResponseEntity unlike(
+ @AuthenticationPrincipal Long userId,
+ @PathVariable Long commentId) {
+ return ResponseEntity.ok(commentService.unlike(userId, commentId));
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java b/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java
new file mode 100644
index 0000000..e073db3
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/controller/EntityPostController.java
@@ -0,0 +1,41 @@
+package com.Coming.Backend.post.controller;
+
+import com.Coming.Backend.common.response.PageResponse;
+import com.Coming.Backend.post.dto.PostSummaryResponse;
+import com.Coming.Backend.post.entity.EntityType;
+import com.Coming.Backend.post.service.PostService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "Post")
+@Validated
+@RestController
+@RequestMapping("/api/entities")
+@RequiredArgsConstructor
+public class EntityPostController {
+
+ private final PostService postService;
+
+ @Operation(summary = "엔티티별 게시글 백링크 조회")
+ @ApiResponse(responseCode = "400", description = "INVALID_INPUT (허용되지 않은 sort 값)")
+ @GetMapping("/{type}/{id}/posts")
+ public ResponseEntity> getBacklinks(
+ @PathVariable EntityType type,
+ @PathVariable Long id,
+ @RequestParam(defaultValue = "latest") String sort,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return ResponseEntity.ok(postService.getBacklinks(type, id, sort, page, size));
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/controller/MentionController.java b/src/main/java/com/Coming/Backend/post/controller/MentionController.java
new file mode 100644
index 0000000..de08232
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/controller/MentionController.java
@@ -0,0 +1,40 @@
+package com.Coming.Backend.post.controller;
+
+import com.Coming.Backend.common.response.PageResponse;
+import com.Coming.Backend.post.dto.EntityCardResponse;
+import com.Coming.Backend.post.entity.EntityType;
+import com.Coming.Backend.post.service.MentionService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "Post")
+@Validated
+@RestController
+@RequestMapping("/api/mentions")
+@RequiredArgsConstructor
+public class MentionController {
+
+ private final MentionService mentionService;
+
+ @Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성, 무한 스크롤)")
+ @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백, page 음수 또는 limit 범위 초과)")
+ @GetMapping("/search")
+ public ResponseEntity> search(
+ @RequestParam EntityType type,
+ @RequestParam @NotBlank String q,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(20) int limit) {
+ return ResponseEntity.ok(mentionService.search(type, q, page, limit));
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java b/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java
new file mode 100644
index 0000000..adfd403
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/controller/PostCommentController.java
@@ -0,0 +1,57 @@
+package com.Coming.Backend.post.controller;
+
+import com.Coming.Backend.common.response.PageResponse;
+import com.Coming.Backend.post.dto.CommentCreateRequest;
+import com.Coming.Backend.post.dto.CommentCreateResponse;
+import com.Coming.Backend.post.dto.CommentResponse;
+import com.Coming.Backend.post.service.CommentService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "Comment")
+@Validated
+@RestController
+@RequestMapping("/api/posts/{postId}/comments")
+@RequiredArgsConstructor
+public class PostCommentController {
+
+ private final CommentService commentService;
+
+ @Operation(summary = "게시글 댓글·답글 목록 조회")
+ @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND")
+ @GetMapping
+ public ResponseEntity> getComments(
+ @PathVariable Long postId,
+ @AuthenticationPrincipal Long userId,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return ResponseEntity.ok(commentService.getComments(postId, userId, page, size));
+ }
+
+ @Operation(summary = "댓글 또는 답글 작성")
+ @ApiResponse(responseCode = "400", description = "INVALID_REPLY_DEPTH")
+ @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND | COMMENT_NOT_FOUND")
+ @PostMapping
+ public ResponseEntity create(
+ @PathVariable Long postId,
+ @AuthenticationPrincipal Long userId,
+ @Valid @RequestBody CommentCreateRequest request) {
+ return ResponseEntity.status(HttpStatus.CREATED).body(commentService.create(userId, postId, request));
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java
new file mode 100644
index 0000000..782fb39
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java
@@ -0,0 +1,129 @@
+package com.Coming.Backend.post.controller;
+
+import com.Coming.Backend.common.response.PageResponse;
+import com.Coming.Backend.post.dto.PostCreateRequest;
+import com.Coming.Backend.post.dto.PostCreateResponse;
+import com.Coming.Backend.post.dto.PostDetailResponse;
+import com.Coming.Backend.post.dto.PostSummaryResponse;
+import com.Coming.Backend.post.dto.PostUpdateRequest;
+import com.Coming.Backend.post.dto.RecommendCountResponse;
+import com.Coming.Backend.post.dto.TrendingTagResponse;
+import com.Coming.Backend.post.entity.PostCategory;
+import com.Coming.Backend.post.service.PostService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@Tag(name = "Post")
+@Validated
+@RestController
+@RequestMapping("/api/posts")
+@RequiredArgsConstructor
+public class PostController {
+
+ private final PostService postService;
+
+ @Operation(summary = "게시글 작성")
+ @PostMapping
+ public ResponseEntity create(
+ @AuthenticationPrincipal Long userId,
+ @Valid @RequestBody PostCreateRequest request) {
+ return ResponseEntity.status(HttpStatus.CREATED).body(postService.create(userId, request));
+ }
+
+ @Operation(summary = "게시글 상세 조회")
+ @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND")
+ @GetMapping("/{id}")
+ public ResponseEntity getDetail(
+ @PathVariable Long id,
+ @AuthenticationPrincipal Long userId) {
+ return ResponseEntity.ok(postService.getDetail(id, userId));
+ }
+
+ @Operation(summary = "게시글 목록 조회")
+ @GetMapping
+ public ResponseEntity> getList(
+ @RequestParam(required = false) PostCategory category,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return ResponseEntity.ok(postService.getList(category, page, size));
+ }
+
+ @Operation(summary = "이번 주 인기 게시글 조회")
+ @GetMapping("/popular")
+ public ResponseEntity> getPopular(
+ @RequestParam(defaultValue = "7") @Min(1) int days,
+ @RequestParam(defaultValue = "5") @Min(1) @Max(100) int limit) {
+ return ResponseEntity.ok(postService.getPopular(days, limit));
+ }
+
+ @Operation(summary = "최근 많이 언급된 태그 조회")
+ @GetMapping("/trending-tags")
+ public ResponseEntity> getTrendingTags(
+ @RequestParam(defaultValue = "7") @Min(1) int days,
+ @RequestParam(defaultValue = "10") @Min(1) @Max(100) int limit) {
+ return ResponseEntity.ok(postService.getTrendingTags(days, limit));
+ }
+
+ @Operation(summary = "게시글 수정")
+ @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)")
+ @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND")
+ @PatchMapping("/{id}")
+ public ResponseEntity update(
+ @AuthenticationPrincipal Long userId,
+ @PathVariable Long id,
+ @Valid @RequestBody PostUpdateRequest request) {
+ postService.update(userId, id, request);
+ return ResponseEntity.ok().build();
+ }
+
+ @Operation(summary = "게시글 삭제")
+ @ApiResponse(responseCode = "403", description = "FORBIDDEN (작성자 본인 아님)")
+ @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND")
+ @DeleteMapping("/{id}")
+ public ResponseEntity delete(
+ @AuthenticationPrincipal Long userId,
+ @PathVariable Long id) {
+ postService.delete(userId, id);
+ return ResponseEntity.ok().build();
+ }
+
+ @Operation(summary = "게시글 추천")
+ @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND")
+ @ApiResponse(responseCode = "409", description = "ALREADY_RECOMMENDED")
+ @PostMapping("/{id}/recommend")
+ public ResponseEntity recommend(
+ @AuthenticationPrincipal Long userId,
+ @PathVariable Long id) {
+ return ResponseEntity.ok(postService.recommend(userId, id));
+ }
+
+ @Operation(summary = "게시글 추천 취소")
+ @ApiResponse(responseCode = "400", description = "NOT_RECOMMENDED")
+ @ApiResponse(responseCode = "404", description = "POST_NOT_FOUND")
+ @DeleteMapping("/{id}/recommend")
+ public ResponseEntity unrecommend(
+ @AuthenticationPrincipal Long userId,
+ @PathVariable Long id) {
+ return ResponseEntity.ok(postService.unrecommend(userId, id));
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/controller/SearchController.java b/src/main/java/com/Coming/Backend/post/controller/SearchController.java
new file mode 100644
index 0000000..951a798
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/controller/SearchController.java
@@ -0,0 +1,38 @@
+package com.Coming.Backend.post.controller;
+
+import com.Coming.Backend.common.response.PageResponse;
+import com.Coming.Backend.post.dto.PostSummaryResponse;
+import com.Coming.Backend.post.service.PostService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "Post")
+@Validated
+@RestController
+@RequestMapping("/api/search")
+@RequiredArgsConstructor
+public class SearchController {
+
+ private final PostService postService;
+
+ @Operation(summary = "게시글 통합 검색")
+ @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 trim 후 2자 미만)")
+ @GetMapping
+ public ResponseEntity> search(
+ @RequestParam @NotBlank String q,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return ResponseEntity.ok(postService.search(q, page, size));
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java
new file mode 100644
index 0000000..dacbf3a
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/CommentCreateRequest.java
@@ -0,0 +1,13 @@
+package com.Coming.Backend.post.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record CommentCreateRequest(
+ @NotBlank
+ @Size(max = 500, message = "댓글은 500자를 초과할 수 없습니다")
+ String content,
+
+ Long parentCommentId
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java
new file mode 100644
index 0000000..d993711
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/CommentCreateResponse.java
@@ -0,0 +1,4 @@
+package com.Coming.Backend.post.dto;
+
+public record CommentCreateResponse(Long id) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java
new file mode 100644
index 0000000..04a9e84
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/CommentLikeCountResponse.java
@@ -0,0 +1,4 @@
+package com.Coming.Backend.post.dto;
+
+public record CommentLikeCountResponse(Long likeCount) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java b/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java
new file mode 100644
index 0000000..6ba29a3
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/CommentResponse.java
@@ -0,0 +1,16 @@
+package com.Coming.Backend.post.dto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record CommentResponse(
+ Long id,
+ String authorNickname,
+ boolean isAuthor,
+ String content,
+ Long likeCount,
+ Boolean isLiked,
+ LocalDateTime createdAt,
+ List replies
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java
new file mode 100644
index 0000000..dbee09f
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java
@@ -0,0 +1,14 @@
+package com.Coming.Backend.post.dto;
+
+import com.Coming.Backend.post.entity.EntityType;
+
+public record EntityCardResponse(
+ EntityType type,
+ Long id,
+ String title,
+ String subtitle,
+ String thumbnailUrl,
+ // TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null.
+ Long releaseGroupId
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java b/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java
new file mode 100644
index 0000000..39dee73
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/EntityTagRequest.java
@@ -0,0 +1,13 @@
+package com.Coming.Backend.post.dto;
+
+import com.Coming.Backend.post.entity.EntityType;
+import jakarta.validation.constraints.NotNull;
+
+public record EntityTagRequest(
+ @NotNull
+ EntityType entityType,
+
+ @NotNull
+ Long entityId
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java
new file mode 100644
index 0000000..9581d77
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateRequest.java
@@ -0,0 +1,25 @@
+package com.Coming.Backend.post.dto;
+
+import com.Coming.Backend.post.entity.PostCategory;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+import java.util.List;
+
+public record PostCreateRequest(
+ @NotNull
+ PostCategory category,
+
+ @NotBlank
+ @Size(max = 255)
+ String title,
+
+ @NotNull
+ Object content,
+
+ @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다")
+ List<@NotNull @Valid EntityTagRequest> entityTags
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java
new file mode 100644
index 0000000..8f94de8
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostCreateResponse.java
@@ -0,0 +1,4 @@
+package com.Coming.Backend.post.dto;
+
+public record PostCreateResponse(Long id) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java
new file mode 100644
index 0000000..50e4068
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostDetailResponse.java
@@ -0,0 +1,23 @@
+package com.Coming.Backend.post.dto;
+
+import com.Coming.Backend.post.entity.PostCategory;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record PostDetailResponse(
+ Long id,
+ String authorNickname,
+ PostCategory category,
+ String title,
+ Object content,
+ List entityTags,
+ Long recommendCount,
+ Long viewCount,
+ Long commentCount,
+ Boolean isRecommended,
+ boolean isAuthor,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java
new file mode 100644
index 0000000..bcfdb68
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java
@@ -0,0 +1,17 @@
+package com.Coming.Backend.post.dto;
+
+import com.Coming.Backend.post.entity.EntityType;
+
+public record PostEntityTagResponse(
+ EntityType entityType,
+ Long entityId,
+ String title,
+ String subtitle,
+ String thumbnailUrl,
+ // TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null.
+ Long releaseGroupId
+) {
+ public static PostEntityTagResponse of(EntityType entityType, Long entityId, EntityCardResponse card) {
+ return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl(), card.releaseGroupId());
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java
new file mode 100644
index 0000000..fe3370c
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostSummaryResponse.java
@@ -0,0 +1,18 @@
+package com.Coming.Backend.post.dto;
+
+import com.Coming.Backend.post.entity.PostCategory;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record PostSummaryResponse(
+ Long id,
+ String authorNickname,
+ PostCategory category,
+ String title,
+ List entityTags,
+ Long recommendCount,
+ Long viewCount,
+ LocalDateTime createdAt
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java
new file mode 100644
index 0000000..8f19709
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/PostUpdateRequest.java
@@ -0,0 +1,23 @@
+package com.Coming.Backend.post.dto;
+
+import com.Coming.Backend.post.entity.PostCategory;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+import java.util.List;
+
+public record PostUpdateRequest(
+ PostCategory category,
+
+ @Pattern(regexp = "(?s).*\\S.*", message = "제목은 공백일 수 없습니다")
+ @Size(max = 255)
+ String title,
+
+ Object content,
+
+ @Size(max = 10, message = "태그는 10개를 초과할 수 없습니다")
+ List<@NotNull @Valid EntityTagRequest> entityTags
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java b/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java
new file mode 100644
index 0000000..74380d3
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/RecommendCountResponse.java
@@ -0,0 +1,4 @@
+package com.Coming.Backend.post.dto;
+
+public record RecommendCountResponse(Long recommendCount) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java
new file mode 100644
index 0000000..fa8b764
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/dto/TrendingTagResponse.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.dto;
+
+import com.Coming.Backend.post.entity.EntityType;
+
+public record TrendingTagResponse(
+ EntityType entityType,
+ Long entityId,
+ String title,
+ Long count
+) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/entity/Comment.java b/src/main/java/com/Coming/Backend/post/entity/Comment.java
new file mode 100644
index 0000000..dde5037
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/Comment.java
@@ -0,0 +1,73 @@
+package com.Coming.Backend.post.entity;
+
+import com.Coming.Backend.common.entity.BaseCreatedEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "comment")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+public class Comment extends BaseCreatedEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "post_id", nullable = false)
+ private Long postId;
+
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+
+ @Column(name = "parent_comment_id")
+ private Long parentCommentId;
+
+ @Column(name = "content", nullable = false, columnDefinition = "text")
+ private String content;
+
+ @Column(name = "like_count", nullable = false)
+ private Long likeCount;
+
+ @Column(name = "is_deleted", nullable = false)
+ private boolean deleted;
+
+ private static final String DELETED_CONTENT_PLACEHOLDER = "삭제된 댓글입니다";
+
+ public boolean isAuthoredBy(Long userId) {
+ return userId != null && this.userId.equals(userId);
+ }
+
+ public boolean isReply() {
+ return parentCommentId != null;
+ }
+
+ public void softDelete() {
+ this.deleted = true;
+ }
+
+ /**
+ * 소프트 삭제된 댓글은 본문 대신 플레이스홀더를 노출한다.
+ */
+ public String getDisplayContent() {
+ return deleted ? DELETED_CONTENT_PLACEHOLDER : content;
+ }
+
+ /**
+ * 소프트 삭제된 댓글은 작성자 정보를 노출하지 않는다(isAuthoredBy와 달리 삭제 여부까지 반영).
+ */
+ public boolean isVisibleAuthor(Long userId) {
+ return !deleted && isAuthoredBy(userId);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/entity/CommentLike.java b/src/main/java/com/Coming/Backend/post/entity/CommentLike.java
new file mode 100644
index 0000000..acdd70e
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/CommentLike.java
@@ -0,0 +1,33 @@
+package com.Coming.Backend.post.entity;
+
+import com.Coming.Backend.common.entity.BaseCreatedEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "comment_like")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+public class CommentLike extends BaseCreatedEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+
+ @Column(name = "comment_id", nullable = false)
+ private Long commentId;
+}
diff --git a/src/main/java/com/Coming/Backend/post/entity/EntityType.java b/src/main/java/com/Coming/Backend/post/entity/EntityType.java
new file mode 100644
index 0000000..a6f1bac
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/EntityType.java
@@ -0,0 +1,5 @@
+package com.Coming.Backend.post.entity;
+
+public enum EntityType {
+ CONCERT, ARTIST, RELEASE, TRACK
+}
diff --git a/src/main/java/com/Coming/Backend/post/entity/Post.java b/src/main/java/com/Coming/Backend/post/entity/Post.java
new file mode 100644
index 0000000..9c42023
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/Post.java
@@ -0,0 +1,68 @@
+package com.Coming.Backend.post.entity;
+
+import com.Coming.Backend.common.entity.BaseTimeEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+@Entity
+@Table(name = "post")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+public class Post extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "category", nullable = false, length = 20)
+ private PostCategory category;
+
+ @Column(name = "title", nullable = false, length = 255)
+ private String title;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(name = "content", nullable = false, columnDefinition = "jsonb")
+ private String content;
+
+ @Column(name = "content_text", nullable = false, columnDefinition = "text")
+ private String contentText;
+
+ @Column(name = "recommend_count", nullable = false)
+ private Long recommendCount;
+
+ @Column(name = "view_count", nullable = false)
+ private Long viewCount;
+
+ @Column(name = "comment_count", nullable = false)
+ private Long commentCount;
+
+ public boolean isAuthoredBy(Long userId) {
+ return userId != null && this.userId.equals(userId);
+ }
+
+ public void update(PostCategory category, String title, String content, String contentText) {
+ if (category != null) this.category = category;
+ if (title != null) this.title = title;
+ if (content != null) this.content = content;
+ if (contentText != null) this.contentText = contentText;
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/entity/PostCategory.java b/src/main/java/com/Coming/Backend/post/entity/PostCategory.java
new file mode 100644
index 0000000..704c495
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/PostCategory.java
@@ -0,0 +1,5 @@
+package com.Coming.Backend.post.entity;
+
+public enum PostCategory {
+ REVIEW, INFO, FREE
+}
diff --git a/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java b/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java
new file mode 100644
index 0000000..f53d02d
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/PostEntityTag.java
@@ -0,0 +1,39 @@
+package com.Coming.Backend.post.entity;
+
+import com.Coming.Backend.common.entity.BaseCreatedEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "post_entity_tag")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+public class PostEntityTag extends BaseCreatedEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "post_id", nullable = false)
+ private Long postId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "entity_type", nullable = false, length = 20)
+ private EntityType entityType;
+
+ @Column(name = "entity_id", nullable = false)
+ private Long entityId;
+}
diff --git a/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java b/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java
new file mode 100644
index 0000000..7751b94
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/entity/PostRecommend.java
@@ -0,0 +1,33 @@
+package com.Coming.Backend.post.entity;
+
+import com.Coming.Backend.common.entity.BaseCreatedEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "post_recommend")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder
+public class PostRecommend extends BaseCreatedEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+
+ @Column(name = "post_id", nullable = false)
+ private Long postId;
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java b/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java
new file mode 100644
index 0000000..21f680d
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/AlreadyLikedException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class AlreadyLikedException extends BusinessException {
+
+ public AlreadyLikedException() {
+ super(ErrorCode.ALREADY_LIKED);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java b/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java
new file mode 100644
index 0000000..c453a63
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/AlreadyRecommendedException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class AlreadyRecommendedException extends BusinessException {
+
+ public AlreadyRecommendedException() {
+ super(ErrorCode.ALREADY_RECOMMENDED);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java b/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java
new file mode 100644
index 0000000..d32b1ec
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/CommentForbiddenException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class CommentForbiddenException extends BusinessException {
+
+ public CommentForbiddenException() {
+ super(ErrorCode.FORBIDDEN);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java b/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java
new file mode 100644
index 0000000..2b7de00
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/CommentNotFoundException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class CommentNotFoundException extends BusinessException {
+
+ public CommentNotFoundException() {
+ super(ErrorCode.COMMENT_NOT_FOUND);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java b/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java
new file mode 100644
index 0000000..958b703
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/InvalidReplyDepthException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class InvalidReplyDepthException extends BusinessException {
+
+ public InvalidReplyDepthException() {
+ super(ErrorCode.INVALID_REPLY_DEPTH);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java b/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java
new file mode 100644
index 0000000..8c0c6e1
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/NotLikedException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class NotLikedException extends BusinessException {
+
+ public NotLikedException() {
+ super(ErrorCode.NOT_LIKED);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java b/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java
new file mode 100644
index 0000000..0caf1b7
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/NotRecommendedException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class NotRecommendedException extends BusinessException {
+
+ public NotRecommendedException() {
+ super(ErrorCode.NOT_RECOMMENDED);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java b/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java
new file mode 100644
index 0000000..717ce5d
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/PostContentTooLongException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class PostContentTooLongException extends BusinessException {
+
+ public PostContentTooLongException() {
+ super(ErrorCode.POST_CONTENT_TOO_LONG);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java b/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java
new file mode 100644
index 0000000..2a9adb7
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/PostForbiddenException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class PostForbiddenException extends BusinessException {
+
+ public PostForbiddenException() {
+ super(ErrorCode.FORBIDDEN);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java b/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java
new file mode 100644
index 0000000..1d11181
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/exception/PostNotFoundException.java
@@ -0,0 +1,11 @@
+package com.Coming.Backend.post.exception;
+
+import com.Coming.Backend.common.exception.BusinessException;
+import com.Coming.Backend.common.exception.ErrorCode;
+
+public class PostNotFoundException extends BusinessException {
+
+ public PostNotFoundException() {
+ super(ErrorCode.POST_NOT_FOUND);
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java
new file mode 100644
index 0000000..b476773
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/CommentLikeRepository.java
@@ -0,0 +1,26 @@
+package com.Coming.Backend.post.repository;
+
+import com.Coming.Backend.post.entity.CommentLike;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface CommentLikeRepository extends JpaRepository {
+
+ boolean existsByUserIdAndCommentId(Long userId, Long commentId);
+
+ Optional findByUserIdAndCommentId(Long userId, Long commentId);
+
+ @Query("SELECT cl.commentId FROM CommentLike cl WHERE cl.userId = :userId AND cl.commentId IN :commentIds")
+ List findLikedCommentIds(@Param("userId") Long userId, @Param("commentIds") Collection commentIds);
+
+ void deleteByCommentIdIn(Collection commentIds);
+
+ @Modifying
+ @Query("DELETE FROM CommentLike cl WHERE cl.commentId IN (SELECT c.id FROM Comment c WHERE c.postId = :postId)")
+ void deleteByCommentPostId(@Param("postId") Long postId);
+}
diff --git a/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java
new file mode 100644
index 0000000..7fa285a
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/CommentRepository.java
@@ -0,0 +1,35 @@
+package com.Coming.Backend.post.repository;
+
+import com.Coming.Backend.post.entity.Comment;
+import java.util.Collection;
+import java.util.List;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface CommentRepository extends JpaRepository {
+
+ @Query("SELECT c FROM Comment c WHERE c.postId = :postId AND c.parentCommentId IS NULL ORDER BY c.createdAt ASC, c.id ASC")
+ Page findTopLevelByPostId(@Param("postId") Long postId, Pageable pageable);
+
+ List findByParentCommentIdInOrderByCreatedAtAscIdAsc(Collection parentCommentIds);
+
+ @Query("SELECT c.id FROM Comment c WHERE c.postId = :postId")
+ List findIdsByPostId(@Param("postId") Long postId);
+
+ void deleteByPostId(Long postId);
+
+ @Modifying
+ @Query("UPDATE Comment c SET c.likeCount = c.likeCount + 1 WHERE c.id = :id")
+ void incrementLikeCount(@Param("id") Long id);
+
+ @Modifying
+ @Query("UPDATE Comment c SET c.likeCount = c.likeCount - 1 WHERE c.id = :id")
+ void decrementLikeCount(@Param("id") Long id);
+
+ @Query("SELECT c.likeCount FROM Comment c WHERE c.id = :id")
+ Long findLikeCountById(@Param("id") Long id);
+}
diff --git a/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java b/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java
new file mode 100644
index 0000000..1bad34a
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/EntityTagCount.java
@@ -0,0 +1,6 @@
+package com.Coming.Backend.post.repository;
+
+import com.Coming.Backend.post.entity.EntityType;
+
+public record EntityTagCount(EntityType entityType, Long entityId, Long count) {
+}
diff --git a/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java
new file mode 100644
index 0000000..90eea81
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/PostEntityTagRepository.java
@@ -0,0 +1,34 @@
+package com.Coming.Backend.post.repository;
+
+import com.Coming.Backend.post.entity.PostEntityTag;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.List;
+
+public interface PostEntityTagRepository extends JpaRepository {
+
+ List findByPostId(Long postId);
+
+ List findByPostIdIn(Collection postIds);
+
+ void deleteByPostId(Long postId);
+
+ /**
+ * 태그 자체가 아닌 게시글 작성일(post.createdAt) 기준으로 필터링한다.
+ * 태그는 게시글 수정 시 삭제 후 재삽입되므로, 태그의 createdAt으로 필터링하면
+ * 단순 수정만으로 최근 언급인 것처럼 집계될 수 있다.
+ */
+ @Query("""
+ SELECT new com.Coming.Backend.post.repository.EntityTagCount(t.entityType, t.entityId, COUNT(t))
+ FROM PostEntityTag t
+ WHERE t.postId IN (SELECT p.id FROM Post p WHERE p.createdAt >= :since)
+ GROUP BY t.entityType, t.entityId
+ ORDER BY COUNT(t) DESC
+ """)
+ List findTrendingEntityTags(@Param("since") LocalDateTime since, Pageable pageable);
+}
diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java
new file mode 100644
index 0000000..8b833e0
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/PostRecommendRepository.java
@@ -0,0 +1,13 @@
+package com.Coming.Backend.post.repository;
+
+import com.Coming.Backend.post.entity.PostRecommend;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostRecommendRepository extends JpaRepository {
+
+ boolean existsByUserIdAndPostId(Long userId, Long postId);
+
+ long deleteByUserIdAndPostId(Long userId, Long postId);
+
+ void deleteByPostId(Long postId);
+}
diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java
new file mode 100644
index 0000000..2a6b258
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java
@@ -0,0 +1,77 @@
+package com.Coming.Backend.post.repository;
+
+import com.Coming.Backend.post.entity.EntityType;
+import com.Coming.Backend.post.entity.Post;
+import com.Coming.Backend.post.entity.PostCategory;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public interface PostRepository extends JpaRepository {
+
+ @Query("SELECT p FROM Post p WHERE (:category IS NULL OR p.category = :category) ORDER BY p.createdAt DESC")
+ Page findPosts(@Param("category") PostCategory category, Pageable pageable);
+
+ @Query("SELECT p FROM Post p WHERE p.createdAt >= :since ORDER BY p.recommendCount DESC, p.createdAt DESC")
+ List findPopularPosts(@Param("since") LocalDateTime since, Pageable pageable);
+
+ @Modifying
+ @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id")
+ void incrementViewCount(@Param("id") Long id);
+
+ @Modifying
+ @Query("UPDATE Post p SET p.recommendCount = p.recommendCount + 1 WHERE p.id = :id")
+ void incrementRecommendCount(@Param("id") Long id);
+
+ @Modifying
+ @Query("UPDATE Post p SET p.recommendCount = p.recommendCount - 1 WHERE p.id = :id")
+ void decrementRecommendCount(@Param("id") Long id);
+
+ @Query("SELECT p.recommendCount FROM Post p WHERE p.id = :id")
+ Long findRecommendCountById(@Param("id") Long id);
+
+ @Modifying
+ @Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :id")
+ void incrementCommentCount(@Param("id") Long id);
+
+ /**
+ * RELEASE 조회 시, 해당 릴리즈에 속한 트랙(Track.releaseGroupId)이 태그된 게시글도 함께 포함한다.
+ * 트랙 앵커가 앨범 상세 페이지로 귀결되는 구조(`/releases/{releaseGroupId}#track-{id}`)이므로,
+ * 트랙을 언급한 글도 해당 앨범의 관련 게시글로 노출되어야 사용자에게 자연스럽다.
+ */
+ @Query("""
+ SELECT p FROM Post p
+ WHERE p.id IN (
+ SELECT t.postId FROM PostEntityTag t
+ WHERE (t.entityType = :entityType AND t.entityId = :entityId)
+ OR (:entityType = com.Coming.Backend.post.entity.EntityType.RELEASE
+ AND t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK
+ AND t.entityId IN (SELECT tr.id FROM Track tr WHERE tr.releaseGroupId = :entityId))
+ )
+ """)
+ Page findByEntityTag(@Param("entityType") EntityType entityType, @Param("entityId") Long entityId, Pageable pageable);
+
+ @Query("""
+ SELECT p FROM Post p
+ WHERE LOWER(p.title) LIKE :q OR LOWER(p.contentText) LIKE :q
+ OR p.id IN (
+ SELECT t.postId FROM PostEntityTag t
+ WHERE (t.entityType = com.Coming.Backend.post.entity.EntityType.ARTIST
+ AND t.entityId IN (SELECT a.id FROM Artist a WHERE LOWER(a.name) LIKE :q))
+ OR (t.entityType = com.Coming.Backend.post.entity.EntityType.CONCERT
+ AND t.entityId IN (SELECT c.id FROM Concert c WHERE LOWER(c.title) LIKE :q))
+ OR (t.entityType = com.Coming.Backend.post.entity.EntityType.RELEASE
+ AND t.entityId IN (SELECT r.id FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q))
+ OR (t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK
+ AND t.entityId IN (SELECT tr.id FROM Track tr WHERE LOWER(tr.title) LIKE :q))
+ )
+ ORDER BY p.createdAt DESC
+ """)
+ Page searchPosts(@Param("q") String q, Pageable pageable);
+}
diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java
new file mode 100644
index 0000000..31a4138
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java
@@ -0,0 +1,210 @@
+package com.Coming.Backend.post.service;
+
+import com.Coming.Backend.auth.entity.User;
+import com.Coming.Backend.auth.repository.UserRepository;
+import com.Coming.Backend.common.response.PageResponse;
+import com.Coming.Backend.post.dto.CommentCreateRequest;
+import com.Coming.Backend.post.dto.CommentCreateResponse;
+import com.Coming.Backend.post.dto.CommentLikeCountResponse;
+import com.Coming.Backend.post.dto.CommentResponse;
+import com.Coming.Backend.post.entity.Comment;
+import com.Coming.Backend.post.entity.CommentLike;
+import com.Coming.Backend.post.exception.AlreadyLikedException;
+import com.Coming.Backend.post.exception.CommentForbiddenException;
+import com.Coming.Backend.post.exception.CommentNotFoundException;
+import com.Coming.Backend.post.exception.InvalidReplyDepthException;
+import com.Coming.Backend.post.exception.NotLikedException;
+import com.Coming.Backend.post.exception.PostNotFoundException;
+import com.Coming.Backend.post.repository.CommentLikeRepository;
+import com.Coming.Backend.post.repository.CommentRepository;
+import com.Coming.Backend.post.repository.PostRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class CommentService {
+
+ private final CommentRepository commentRepository;
+ private final CommentLikeRepository commentLikeRepository;
+ private final PostRepository postRepository;
+ private final UserRepository userRepository;
+
+ /**
+ * 게시글의 댓글·답글 목록을 조회한다. 최상위 댓글만 페이지네이션 대상이며, 답글은 각 최상위 댓글에 전체 포함된다.
+ *
+ * @param userId 인증 사용자 ID. null이면 isLiked는 null, isAuthor는 false로 반환된다.
+ */
+ public PageResponse getComments(Long postId, Long userId, int page, int size) {
+ if (!postRepository.existsById(postId)) {
+ throw new PostNotFoundException();
+ }
+
+ Pageable pageable = PageRequest.of(page, size);
+ Page topLevelPage = commentRepository.findTopLevelByPostId(postId, pageable);
+ List topLevelComments = topLevelPage.getContent();
+
+ List topLevelIds = topLevelComments.stream().map(Comment::getId).toList();
+ Map> repliesByParentId = topLevelIds.isEmpty()
+ ? Map.of()
+ : commentRepository.findByParentCommentIdInOrderByCreatedAtAscIdAsc(topLevelIds).stream()
+ .collect(Collectors.groupingBy(Comment::getParentCommentId));
+
+ List allComments = Stream.concat(
+ topLevelComments.stream(),
+ repliesByParentId.values().stream().flatMap(List::stream)
+ ).toList();
+ Map nicknameByUserId = findNicknames(allComments);
+
+ // 소프트 삭제된 댓글은 좋아요 여부를 노출하지 않으므로 배치 조회 대상에서 제외한다.
+ List notDeletedComments = allComments.stream().filter(comment -> !comment.isDeleted()).toList();
+ Set likedCommentIds = findLikedCommentIds(userId, notDeletedComments);
+
+ List content = topLevelComments.stream()
+ .map(comment -> toResponse(comment, repliesByParentId.getOrDefault(comment.getId(), List.of()),
+ userId, nicknameByUserId, likedCommentIds))
+ .toList();
+
+ return new PageResponse<>(content, topLevelPage.getNumber(), topLevelPage.getSize(),
+ topLevelPage.getTotalElements(), topLevelPage.getTotalPages());
+ }
+
+ /**
+ * 댓글 또는 답글을 작성한다. parentCommentId가 주어지면 답글로 작성하며, 상위 댓글이 이미 답글이면
+ * InvalidReplyDepthException을 던진다(2단계 이상 중첩 금지).
+ */
+ @Transactional
+ public CommentCreateResponse create(Long userId, Long postId, CommentCreateRequest request) {
+ if (!postRepository.existsById(postId)) {
+ throw new PostNotFoundException();
+ }
+
+ Long parentCommentId = request.parentCommentId();
+ if (parentCommentId != null) {
+ Comment parent = commentRepository.findById(parentCommentId)
+ .filter(comment -> comment.getPostId().equals(postId))
+ .orElseThrow(CommentNotFoundException::new);
+ if (parent.isReply()) {
+ throw new InvalidReplyDepthException();
+ }
+ }
+
+ Comment comment = Comment.builder()
+ .postId(postId)
+ .userId(userId)
+ .parentCommentId(parentCommentId)
+ .content(request.content())
+ .likeCount(0L)
+ .deleted(false)
+ .build();
+ commentRepository.save(comment);
+ // commentCount는 생성 시에만 증가하는 단조 카운터다. 삭제는 항상 소프트 삭제(행 유지)라 감소시키지 않는다.
+ postRepository.incrementCommentCount(postId);
+
+ return new CommentCreateResponse(comment.getId());
+ }
+
+ /**
+ * 댓글 또는 답글을 삭제한다. 작성자 본인만 삭제할 수 있다.
+ * 하드 삭제 대신 소프트 삭제로 처리해 답글이 달린 댓글이 삭제돼도 답글은 그대로 유지된다.
+ */
+ @Transactional
+ public void delete(Long userId, Long commentId) {
+ Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new);
+ if (!comment.isAuthoredBy(userId)) {
+ throw new CommentForbiddenException();
+ }
+ comment.softDelete();
+ }
+
+ /**
+ * 댓글에 좋아요를 남긴다. 이미 좋아요한 댓글이면 AlreadyLikedException을 던진다.
+ */
+ @Transactional
+ public CommentLikeCountResponse like(Long userId, Long commentId) {
+ Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new);
+ if (comment.isDeleted()) {
+ throw new CommentNotFoundException();
+ }
+ if (commentLikeRepository.existsByUserIdAndCommentId(userId, commentId)) {
+ throw new AlreadyLikedException();
+ }
+ try {
+ commentLikeRepository.save(CommentLike.builder()
+ .userId(userId)
+ .commentId(commentId)
+ .build());
+ } catch (DataIntegrityViolationException e) {
+ throw new AlreadyLikedException();
+ }
+ commentRepository.incrementLikeCount(commentId);
+ return new CommentLikeCountResponse(commentRepository.findLikeCountById(commentId));
+ }
+
+ /**
+ * 댓글 좋아요를 취소한다. 좋아요한 적 없으면 NotLikedException을 던진다.
+ */
+ @Transactional
+ public CommentLikeCountResponse unlike(Long userId, Long commentId) {
+ Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new);
+ if (comment.isDeleted()) {
+ throw new CommentNotFoundException();
+ }
+ CommentLike like = commentLikeRepository.findByUserIdAndCommentId(userId, commentId)
+ .orElseThrow(NotLikedException::new);
+ commentLikeRepository.delete(like);
+ commentRepository.decrementLikeCount(commentId);
+ return new CommentLikeCountResponse(commentRepository.findLikeCountById(commentId));
+ }
+
+ private CommentResponse toResponse(Comment comment, List replies, Long userId,
+ Map nicknameByUserId, Set likedCommentIds) {
+ List replyResponses = replies.stream()
+ .map(reply -> toResponse(reply, List.of(), userId, nicknameByUserId, likedCommentIds))
+ .toList();
+
+ String authorNickname = nicknameByUserId.get(comment.getUserId());
+ Boolean isLiked = userId == null || comment.isDeleted() ? null : likedCommentIds.contains(comment.getId());
+
+ return new CommentResponse(
+ comment.getId(),
+ authorNickname,
+ comment.isVisibleAuthor(userId),
+ comment.getDisplayContent(),
+ comment.getLikeCount(),
+ isLiked,
+ comment.getCreatedAt(),
+ replyResponses
+ );
+ }
+
+ private Map findNicknames(List comments) {
+ if (comments.isEmpty()) {
+ return Map.of();
+ }
+ Set userIds = comments.stream().map(Comment::getUserId).collect(Collectors.toSet());
+ return userRepository.findAllByIdIn(userIds).stream()
+ .collect(Collectors.toMap(User::getId, User::getNickname));
+ }
+
+ private Set findLikedCommentIds(Long userId, List comments) {
+ if (userId == null || comments.isEmpty()) {
+ return Set.of();
+ }
+ List commentIds = comments.stream().map(Comment::getId).toList();
+ return new HashSet<>(commentLikeRepository.findLikedCommentIds(userId, commentIds));
+ }
+}
diff --git a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java
new file mode 100644
index 0000000..22c8575
--- /dev/null
+++ b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java
@@ -0,0 +1,112 @@
+package com.Coming.Backend.post.service;
+
+import com.Coming.Backend.artist.entity.Artist;
+import com.Coming.Backend.artist.repository.ArtistRepository;
+import com.Coming.Backend.concert.entity.Concert;
+import com.Coming.Backend.concert.repository.ConcertRepository;
+import com.Coming.Backend.post.dto.EntityCardResponse;
+import com.Coming.Backend.post.entity.EntityType;
+import com.Coming.Backend.release.entity.ReleaseGroup;
+import com.Coming.Backend.release.entity.Track;
+import com.Coming.Backend.release.repository.ReleaseGroupRepository;
+import com.Coming.Backend.release.repository.TrackRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class EntityLookupService {
+
+ private final ConcertRepository concertRepository;
+ private final ArtistRepository artistRepository;
+ private final ReleaseGroupRepository releaseGroupRepository;
+ private final TrackRepository trackRepository;
+
+ /**
+ * entityType·entityId 키 목록에 대응하는 엔티티 카드 정보를 일괄 조회한다.
+ * 참조 대상이 삭제된 경우 결과 Map에서 해당 키가 제외된다.
+ */
+ public Map findCards(Collection keys) {
+ Map result = new HashMap<>();
+
+ concertRepository.findAllById(idsOf(keys, EntityType.CONCERT)).forEach(concert ->
+ result.put(new EntityKey(EntityType.CONCERT, concert.getId()), toCard(concert)));
+
+ artistRepository.findAllById(idsOf(keys, EntityType.ARTIST)).forEach(artist ->
+ result.put(new EntityKey(EntityType.ARTIST, artist.getId()), toCard(artist)));
+
+ List releases = releaseGroupRepository.findAllById(idsOf(keys, EntityType.RELEASE));
+ Map artistNames = artistRepository.findAllById(
+ releases.stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet())
+ ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName));
+ releases.forEach(release ->
+ result.put(new EntityKey(EntityType.RELEASE, release.getId()),
+ toCard(release, artistNames.get(release.getArtistId()))));
+
+ List