Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fcd8d66
refactor: 문서 목록 최신 저장 조회를 배치화
lunarbae628 Apr 30, 2026
e9a186d
refactor: EntityGraph로 thumbnail, thumbnail.currentImage 조회
lunarbae628 May 1, 2026
004f770
refactor: Mongo 삭제 outbox originType 삭제 및 MongoDeleteOutboxFactory의 유…
lunarbae628 May 1, 2026
18bd523
refactor: Mongo/S3 삭제 Outbox 관련 클래스명을 역할에 맞게 정리
lunarbae628 May 2, 2026
ddca378
feat: 문서 목록 CQRS 기반을 위한 도메인 이벤트 Outbox 추가
lunarbae628 May 4, 2026
db46aec
feat: 문서 목록 read model 이벤트 payload 분리
lunarbae628 May 7, 2026
8cb0867
feat: 문서 생성과 제목 변경에 문서 목록 이벤트 발행 연결
lunarbae628 May 7, 2026
c0b32bf
fix: 인프라 인증서 갱신 스크립트 Slack payload 생성 방식 수정
lunarbae628 May 7, 2026
5ccea77
fix: 사용하지 않는 PreviewExtractor 제거
lunarbae628 May 7, 2026
23cc618
chore: eventoutbox 주기 수정(application.yml)
lunarbae628 May 7, 2026
869306c
chore: 도메인 이벤트 outbox wake-up 전용 Async executor 추가 및 미사용 import 제거
lunarbae628 May 7, 2026
95a2506
feat: 문서 목록 read model 갱신 이벤트 발행 연결
lunarbae628 May 8, 2026
2f22942
test: 문서 목록 read model projection 검증 보강
lunarbae628 May 8, 2026
a555f64
chore: 미사용 import 정리
lunarbae628 May 8, 2026
a0d5fd0
feat: 문서 목록 조회를 read model 기반 QueryService로 전환
lunarbae628 May 10, 2026
cad4620
chore: 테스트 출력 및 환경별 로그 설정 정리
lunarbae628 May 10, 2026
2b6568b
chore: 테스트 passed 로그 제거
lunarbae628 May 10, 2026
d8c8057
fix: local 로그 설정 boolean 바인딩 오류 수정
lunarbae628 May 10, 2026
072fd82
test: 문서 QueryService 테스트 패키지 정리 및 썸네일 URL 검증 추가
lunarbae628 May 11, 2026
85c7d0e
feat: local/test Mongo cleanup 추가
lunarbae628 May 11, 2026
dad62b3
refactor: CommitQueryService 책임 분리
lunarbae628 May 11, 2026
506fc02
refactor: BranchQueryService 책임 분리
lunarbae628 May 11, 2026
2a02f97
refactor: SaveQueryService 책임 분리
lunarbae628 May 11, 2026
993d593
refactor: 이미지와 썸네일 QueryService 명명 정리
lunarbae628 May 11, 2026
f3016a8
feat: 문서 목록 read model backfill job 추가
lunarbae628 May 12, 2026
2069b20
refactor: 문서 목록 backfill 패키지 분리
lunarbae628 May 12, 2026
afdbedb
chore: backfill 실행 설정 정리
lunarbae628 May 13, 2026
0dfd1a8
test: 문서 목록 read model 삭제 제외 조회 검증
lunarbae628 May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ repositories {

test {
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
events "skipped", "failed"
showStandardStreams = false
exceptionFormat "full"
showCauses true
showExceptions true
Expand Down
23 changes: 23 additions & 0 deletions infra/scripts/backfill-doc-list-local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${ENV_FILE:-"$ROOT_DIR/infra/.local.env"}"

if [[ ! -f "$ENV_FILE" ]]; then
echo "env file not found: $ENV_FILE" >&2
exit 1
fi

set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a

export SPRING_PROFILES_ACTIVE="${BACKFILL_PROFILES:-local,backfill}"
export DDL_AUTO="${BACKFILL_DDL_AUTO:-none}"
export MONGO_LOCAL_CLEANUP_ENABLED="false"
export PERF_SEED_USER_COUNT="0"
export DOC_LIST_READ_MODEL_BACKFILL_BATCH_SIZE="${DOC_LIST_READ_MODEL_BACKFILL_BATCH_SIZE:-100}"

bash "$ROOT_DIR/gradlew" bootRun
10 changes: 6 additions & 4 deletions infra/scripts/cert_renew.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,21 @@ ENV_FILE="$ROOT/docsa-alert.env"
notify_slack () {
local msg="$1"
[[ -z "${SLACK_WEBHOOK_URL:-}" ]] && return 0

local payload
payload="$(python3 -c 'import json, sys; print(json.dumps({"text": sys.argv[1]}))' "$msg")"

curl -sS -X POST -H 'Content-type: application/json' \
--data "{\"text\":${msg@Q}}" \
--data "$payload" \
"$SLACK_WEBHOOK_URL" >/dev/null 2>&1 || true
}

# ---- 1) certbot renew 실행 ----
out="$(
docker compose run --rm --no-deps certbot_renew \
certbot renew --webroot -w /var/www/certbot 2>&1 || true
renew --webroot -w /var/www/certbot 2>&1 || true
)"

echo "$out"

# ---- 2) 실패 감지 ----
# certbot이 실패할 때 자주 나오는 문구 위주로 (오탐 줄임)
if echo "$out" | grep -Eqi \
Expand Down
2 changes: 0 additions & 2 deletions src/main/java/io/ejangs/docsa/DocsaApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import io.swagger.v3.oas.annotations.info.Info;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@OpenAPIDefinition(
Expand All @@ -14,7 +13,6 @@
description = "Docsa의 백엔드 API 명세입니다."
)
)
@EnableAsync
@EnableScheduling
@SpringBootApplication
public class DocsaApplication {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@
import io.ejangs.docsa.domain.branch.entity.Branch;
import io.ejangs.docsa.domain.commit.entity.Commit;
import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto;
import io.ejangs.docsa.domain.doc.entity.Doc;
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode;
import io.ejangs.docsa.global.util.RenewUpdatedAtHelper;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class BranchQueryService {
public class BranchReader {

private final BranchRepository branchRepository;

Expand All @@ -29,31 +27,11 @@ public List<BranchGraphDto> getBranchGraphList(Long docId) {
return branchRepository.findBranchGraphDtoList(docId);
}

public Branch save(Branch branch) {
return branchRepository.save(branch);
}

public Branch createBranch(Doc doc, String branchName) {
Branch branch = branchRepository.save(Branch.builder().name(branchName).doc(doc).build());
RenewUpdatedAtHelper.touch(branch);
return branch;
}

public Branch createBranch(Doc doc, String branchName, Commit fromCommit) {
Branch branch = Branch.builder().name(branchName).doc(doc).fromCommit(fromCommit).build();
return branchRepository.save(branch);
}

@Transactional(readOnly = true)
public boolean existsSubBranchByFromCommitIds(List<Long> commitIds) {
return branchRepository.existsByFromCommitIdIn(commitIds);
}

public void delete(Branch branch) {
branchRepository.delete(branch);
}

//TODO: 검증하는 메소드는 별도의 ex.ValidationService로 분리(모든 도메인)
public void checkBranchInDocOwnedByUser(Long documentId, Long branchId, Long userId) {
boolean exists =
branchRepository.existsByIdAndDocIdAndDocUserId(branchId, documentId, userId);
Expand Down
46 changes: 19 additions & 27 deletions src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,17 @@
import io.ejangs.docsa.domain.branch.dto.response.BranchRenameResponse;
import io.ejangs.docsa.domain.branch.entity.Branch;
import io.ejangs.docsa.domain.branch.util.BranchMapper;
import io.ejangs.docsa.domain.commit.app.CommitQueryService;
import io.ejangs.docsa.domain.commit.app.CommitReader;
import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository;
import io.ejangs.docsa.domain.commit.entity.Commit;
import io.ejangs.docsa.domain.doc.app.create.DocQueryService;
import io.ejangs.docsa.domain.doc.app.DocReader;
import io.ejangs.docsa.domain.edge.app.EdgeService;
import io.ejangs.docsa.domain.doc.entity.Doc;
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode;
import io.ejangs.docsa.global.exception.errorcode.DocErrorCode;
import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer;
import io.ejangs.docsa.global.outbox.mongo.util.MongoDeleteMapper;
import io.ejangs.docsa.global.util.RenewUpdatedAtHelper;

Expand All @@ -37,13 +34,14 @@
@RequiredArgsConstructor
public class BranchService {

private final DocQueryService docQueryService;
private final BranchQueryService branchQueryService;
private final CommitQueryService commitQueryService;
private final DocReader docReader;
private final BranchReader branchReader;
private final BranchWriter branchWriter;
private final CommitReader commitReader;
private final CommitBlockSequenceRepository commitBlockSequenceRepository;
private final EdgeService edgeService;
private final BranchCreateOrchestrator branchCreateOrchestrator;
private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory;
private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer;

public BranchCreateResponse createBranch(Long documentId, BranchCreateRequest request,
Long userId) {
Expand All @@ -55,18 +53,18 @@ public BranchCreateResponse createBranch(Long documentId, BranchCreateRequest re

private BranchCreateContext prepareBranchCreateContext(Long documentId,
BranchCreateRequest request, Long userId) {
docQueryService.checkByIdAndUserId(documentId, userId);
docReader.checkByIdAndUserId(documentId, userId);

Long fromCommitId = request.fromCommitId();

Commit fromCommit = commitQueryService.getById(fromCommitId);
Commit fromCommit = commitReader.getById(fromCommitId);
Branch fromBranch = fromCommit.getBranch();

if (!fromBranch.getDoc().getId().equals(documentId)) {
throw new CustomException(DocErrorCode.COMMIT_NOT_IN_DOCUMENT);
}

branchQueryService.checkDuplicatedWithBranchName(documentId, request.name());
branchReader.checkDuplicatedWithBranchName(documentId, request.name());


return new BranchCreateContext(
Expand All @@ -83,8 +81,8 @@ public BranchRenameResponse renameBranch(Long documentId, Long branchId, String
Long userId) {

// 1. 브랜치 검증
branchQueryService.checkBranchInDocOwnedByUser(documentId, branchId, userId);
Branch branch = branchQueryService.getById(branchId);
branchReader.checkBranchInDocOwnedByUser(documentId, branchId, userId);
Branch branch = branchReader.getById(branchId);
checkDefaultBranch(branch);

// 2. 브랜치 이름 수정 후 브랜치와 문서의 수정시각 갱신
Expand All @@ -105,8 +103,8 @@ public BranchRenameResponse renameBranch(Long documentId, Long branchId, String
public void deleteBranch(Long documentId, Long branchId, Long userId) {

// 1. 브랜치 검증
branchQueryService.checkBranchInDocOwnedByUser(documentId, branchId, userId);
Branch branch = branchQueryService.getById(branchId);
branchReader.checkBranchInDocOwnedByUser(documentId, branchId, userId);
Branch branch = branchReader.getById(branchId);

// 2. main브랜치는 삭제가 불가능하도록 함
checkDefaultBranch(branch);
Expand All @@ -115,7 +113,7 @@ public void deleteBranch(Long documentId, Long branchId, Long userId) {
List<Commit> branchCommits = branch.getCommits();
List<Long> commitsIds = branchCommits.stream().map(Commit::getId).toList();

if (branchQueryService.existsSubBranchByFromCommitIds(commitsIds)) {
if (branchReader.existsSubBranchByFromCommitIds(commitsIds)) {
throw new CustomException(BranchErrorCode.SUB_BRANCH_DELETE_UNAVAILABLE);
}

Expand All @@ -133,15 +131,9 @@ public void deleteBranch(Long documentId, Long branchId, Long userId) {
doc.getBranches().remove(branch);

// 8. 브랜치, 나머지 RDB 브랜치 메타데이터 CASCADE 삭제
branchQueryService.delete(branch);

mongoDeleteOutboxFactory.create(
TriggerType.DELETE,
DomainType.BRANCH,
OriginType.BRANCH_ID,
branchId,
deletableMongoIds
);
branchWriter.delete(branch);

mongoDeleteJobEnqueuer.enqueueBranchDeletion(branchId, deletableMongoIds);

}

Expand Down
35 changes: 35 additions & 0 deletions src/main/java/io/ejangs/docsa/domain/branch/app/BranchWriter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.ejangs.docsa.domain.branch.app;

import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository;
import io.ejangs.docsa.domain.branch.entity.Branch;
import io.ejangs.docsa.domain.commit.entity.Commit;
import io.ejangs.docsa.domain.doc.entity.Doc;
import io.ejangs.docsa.global.util.RenewUpdatedAtHelper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class BranchWriter {

private final BranchRepository branchRepository;

public Branch save(Branch branch) {
return branchRepository.save(branch);
}

public Branch createBranch(Doc doc, String branchName) {
Branch branch = branchRepository.save(Branch.builder().name(branchName).doc(doc).build());
RenewUpdatedAtHelper.touch(branch);
return branch;
}

public Branch createBranch(Doc doc, String branchName, Commit fromCommit) {
Branch branch = Branch.builder().name(branchName).doc(doc).fromCommit(fromCommit).build();
return branchRepository.save(branch);
}

public void delete(Branch branch) {
branchRepository.delete(branch);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package io.ejangs.docsa.domain.branch.app.create;

import io.ejangs.docsa.domain.branch.app.BranchQueryService;
import io.ejangs.docsa.domain.branch.app.BranchWriter;
import io.ejangs.docsa.domain.branch.dto.BranchCreateContext;
import io.ejangs.docsa.domain.branch.dto.response.BranchCreateResponse;
import io.ejangs.docsa.domain.branch.entity.Branch;
import io.ejangs.docsa.domain.branch.util.BranchMapper;
import io.ejangs.docsa.domain.save.app.SaveQueryService;
import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory;
import io.ejangs.docsa.domain.save.app.SaveWriter;
import io.ejangs.docsa.domain.save.entity.Save;
import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher;
import io.ejangs.docsa.global.outbox.event.model.AggregateType;
import io.ejangs.docsa.global.outbox.event.model.DomainEventType;
import io.ejangs.docsa.global.util.RenewUpdatedAtHelper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand All @@ -16,17 +20,21 @@
@RequiredArgsConstructor
public class BranchCreateMySqlTxService {

private final BranchQueryService branchQueryService;
private final SaveQueryService saveQueryService;
private final BranchWriter branchWriter;
private final SaveWriter saveWriter;
private final DomainEventOutboxPublisher domainEventOutboxPublisher;

@Transactional(rollbackFor = Exception.class)
public BranchCreateResponse createMySqlPart(BranchCreateContext context, String saveContentId) {

Branch newBranch = branchQueryService.createBranch(context.doc(), context.branchName(), context.fromCommit());
Branch newBranch = branchWriter.createBranch(context.doc(), context.branchName(), context.fromCommit());

Save save = saveQueryService.createSave(newBranch, saveContentId);
Save save = saveWriter.createSave(newBranch, saveContentId);
RenewUpdatedAtHelper.touch(save);

domainEventOutboxPublisher.publish(DomainEventType.DOC_ACTIVITY_CHANGED, AggregateType.DOC, context.doc()
.getId(), DocPayloadFactory.activityChanged(context.doc().getId(), save));

return BranchMapper.toBranchCreateResponse(newBranch, save);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode;
import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -21,7 +18,7 @@ public class BranchCreateOrchestrator {

private final BranchCreateMongoTxService branchCreateMongoTxService;
private final BranchCreateMySqlTxService branchCreateMySqlTxService;
private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory;
private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer;

public BranchCreateResponse create(BranchCreateContext context) {
String saveContentId = branchCreateMongoTxService.createSaveContentFromCommit(
Expand All @@ -32,13 +29,7 @@ public BranchCreateResponse create(BranchCreateContext context) {
} catch (Exception e) {
log.warn("[SAGA] 브랜치/저장 생성 실패 -> Mongo 삭제 Outbox 기록.", e);
MongoIdsDto compensateTarget = new MongoIdsDto(List.of(saveContentId), null, null);
mongoDeleteOutboxFactory.create(
TriggerType.COMPENSATE,
DomainType.BRANCH,
OriginType.SAVE_CONTENT_ID,
saveContentId,
compensateTarget
);
mongoDeleteJobEnqueuer.enqueueBranchCreateCompensation(saveContentId, compensateTarget);
throw new CustomException(BranchErrorCode.FAIL_CREATE_BRANCH);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto;
import io.ejangs.docsa.domain.branch.entity.Branch;
import io.ejangs.docsa.domain.doc.dto.LatestSaveIdDto;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -28,6 +29,26 @@ public interface BranchRepository extends JpaRepository<Branch, Long> {
""")
List<BranchGraphDto> findBranchGraphDtoList(@Param("docId") Long docId);

@Query("""
SELECT new io.ejangs.docsa.domain.doc.dto.LatestSaveIdDto(
b.doc.id,
s.id
)
FROM Branch b
JOIN b.save s
WHERE b.doc.id IN :docIds
AND NOT EXISTS (
SELECT 1
FROM Branch newer
WHERE newer.doc.id = b.doc.id
AND (
newer.updatedAt > b.updatedAt
OR (newer.updatedAt = b.updatedAt AND newer.id > b.id)
)
)
""")
List<LatestSaveIdDto> findLatestSaveIdsByDocIds(@Param("docIds") List<Long> docIds);

boolean existsByIdAndDocIdAndDocUserId(Long branchId, Long documentId, Long userId);

boolean existsByFromCommitIdIn(List<Long> commitIds);
Expand Down
Loading
Loading