Skip to content

[Refactor/#14] 도메인 모듈 패키지 구조·경계 검증 방식 변경 및 infrastructure:db 설정 골격 - #15

Merged
tnals0924 merged 10 commits into
mainfrom
refactor/#14-domain-module-structure
Sep 8, 2026
Merged

[Refactor/#14] 도메인 모듈 패키지 구조·경계 검증 방식 변경 및 infrastructure:db 설정 골격#15
tnals0924 merged 10 commits into
mainfrom
refactor/#14-domain-module-structure

Conversation

@tnals0924

Copy link
Copy Markdown
Member

#️⃣연관된 이슈

🎯 해결하려는 문제가 무엇인가요?

엔티티 26개를 추가하는 #13에 앞서, 그 코드가 올라갈 도메인 모듈의 구조와 경계 검증 방식을 먼저 정한다. 아울러 infrastructure:db가 실제 MySQL에 붙을 수 있는 설정 골격을 만든다. 이 PR은 기존 member 코드에만 적용하며 새 테이블·엔티티는 포함하지 않는다.

❓ 왜 해결해야 하나요?

  • 기존 컨벤션(공개 타입을 모듈 최상위에, 구현은 internal)은 도메인·계층 단위로 패키지를 나누고 싶은 요구와 맞지 않았다.
  • 계층 패키지로 나누면 Modulith 기본 규칙상 하위 패키지가 전부 비공개가 되어, impl 하나를 감추려고 공개 패키지마다 @NamedInterface를 달아야 한다. 도메인이 늘수록 부담이 커진다.
  • 이 결정이 엔티티 65개 파일과 같은 PR에 섞이면 리뷰가 되지 않는다.

⭐ 어떻게 해결했나요?

  • 패키지 구조: {module}.domain.{도메인}.{domain|repository|service|service.impl}. 도메인 객체만 있는 도메인도 계층 패키지를 생략하지 않는다.
  • 공개 경계: 도메인 모듈 4개를 @ApplicationModule(type = OPEN)으로 두고, service.impl 차단은 ArchUnit 테스트 DomainImplAccessTests가 담당한다. 규칙은 두 개 — ..service.impl..은 같은 패키지 안에서만 참조 가능, ..service.impl..에 public 클래스 금지.
  • 도메인 객체: record 대신 일반 클래스(@Getter + @EqualsAndHashCode + private 전체 생성자 + static of(...)). setter는 Lombok으로 만들지 않고 필요한 필드에만 수동으로 둔다.
  • soft delete: deleted_at DATETIMEis_deleted TINYINT(1) (BaseSoftDeleteEntity.isDeleted).
  • db 설정 골격: application-infrastructure-db.yml(MySQL, ddl-auto: validate, Flyway), .env.exampleDB_*, BaseCreatedTimeEntity, db 모듈의 도메인 모듈 의존.
  • 테스트: DB를 쓰는 테스트는 두지 않기로 하여 H2 의존성과 contextLoads를 제거했다. 남는 테스트는 ModularityTests(모듈 간 순환·의존)와 DomainImplAccessTests(impl 경계)다.
  • 컨벤션 문서 6개를 위 규칙으로 갱신했다.

🧩 이 PR의 한계 & 트레이드오프

  • OPEN 모듈은 Modulith 문서상 "점진적 이행용"이다. 그 경고는 최상위 공개 구조를 전제로 한 것이고, 여기서는 계층 패키지를 의도적으로 택한 결과다. 대신 Modulith verify()의 역할이 모듈 간 순환·의존 검사로 줄고, 모듈 내부 경계는 ArchUnit이 맡는다.
  • contextLoads가 없어져 Entity 매핑 오류는 애플리케이션 기동(bootRun)에서만 드러난다.
  • coding-style.md 2-1절(도메인 객체 record)과 flyway-migration.md(deleted_at 예시)는 이 PR에서 고치지 않았다. 별도 docs 작업이 필요하다.

⛓️ 기존 기능에 미치는 영향

  • Member가 record에서 클래스로 바뀌어 접근자가 id()getId(), 생성이 new Member(...)Member.of(...)로 바뀐다. 현재 호출처는 MemberJpaEntity뿐이다.
  • Member.studentNostudentId로 이름을 바꿨다.
  • MemberService 등 member 타입의 패키지가 바뀐다. 현재 다른 모듈에서 참조하는 곳은 infrastructure:db뿐이며 함께 수정했다.
  • bootstrap/application.yamlapplication-infrastructure-db.yml을 import하므로 기동 시 DB_URL/DB_USERNAME/DB_PASSWORD 환경변수가 필요하다.

🔀 Edge Case & 실패 시나리오

  • service.impl에 public 클래스를 두거나 다른 패키지에서 참조하면 DomainImplAccessTests가 실패한다. 실험으로 확인했다(임시 파일은 제거).
  • 새 모듈을 만들 때 기본 패키지에 package-info.java(OPEN)를 빠뜨리면 그 모듈의 하위 패키지 참조가 verify()에서 실패한다.

📋 검토한 대안과 선택 이유

  • @NamedInterface를 공개 계층 패키지마다 선언: 정확하지만 도메인당 package-info.java 3개가 늘고 하나만 빠져도 verify()가 깨진다. 처음 이 방식으로 구현했다가 OPEN + ArchUnit으로 바꿨다.
  • 원래 컨벤션(최상위 공개 + internal) 유지: 어노테이션이 필요 없지만 도메인·계층 단위 패키지를 포기해야 한다.
  • 테스트에 H2 + ddl-auto: create-drop 유지: Entity 매핑 검증에는 유용하지만 DB를 쓰는 테스트를 두지 않기로 한 방침과 어긋나 제거했다.

💬 리뷰 포인트

  • [r] architecture.md 4-3절 — 패키지 구조와 OPEN + ArchUnit 방식에 동의하는지
  • [r] DomainImplAccessTests 규칙 1이 같은 도메인의 service 패키지에서 impl을 참조하는 것도 막는데, 이 강도가 적절한지
  • [c] 도메인 객체를 record 대신 클래스로 두는 결정(coding-style.md 2-10·2-11절과의 정합)
  • [c] is_deleted boolean으로의 변경

@sangrae2325

sangrae2325 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator
  1. 이미 정해진 도메인수와 도메인별 공개 계층 패키지수가 많아서, 현재 구현 하신 방식에 동의합니다.
  2. service 패키지는 외부에 공개되는 패키지라서 현재처럼 impl 참조를 막는 강도가 적절해보입니다.
  3. 앞으로 모든 도메인 객체를 일반 클래스로 두는 방향인지, 필요에 따라 record와 클래스를 구분해서 사용하는 방향인지 궁금합니다.

@jjunh33

jjunh33 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator
  1. 도메인, 계층별 패키지가 많아서 @NamedInterface를 각각 관리하는 것보다 OPEN + ArchUnit으로 검증하는 현재 방식이 관리 측면에서 더 좋아보입니다.
  2. service가 외부에 공개되는 계층인 만큼 service.impl에 직접 의존하지 못하도록 제한하는 것에 동의합니다.
  3. 삭제일시는 updated_at으로 확인할 수 있기 때문에 deleted_at 대신 is_deleted를 boolean으로 사용하는 구조에 동의합니다.

@xeoxxn
xeoxxn self-requested a review September 7, 2026 05:37
private String studentId;
private String name;

public static Member of(Long id, String studentId, String name) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

여기서 왜 생성자를 private으로 제한하고 of()로 생성하도록 했는지가 궁금합니다. 지금은 단순히 생성자를 그대로 호출하는 형태인 거 같은데 추후에 이 부분에 객체 생성에 대한 검증이나 로직이 추가적으로 들어가는 걸까요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

생성자를 감추고 static 메서드로 객체를 생성하는 패턴이 정적 팩토리 메서드 패턴입니다. 말씀하신 것처럼 로직을 추가할 수도 있지만, 메서드의 이름으로 생성되는 경로를 구분할 수도 있습니다.
이 케이스 같은 경우에는 단순히 생성자를 만드는 것과 별 차이가 없지만 다른 곳에서 사용할 경우를 대비해 정적 팩토리 메서드 패턴으로 객체를 생성하도록 통일했습니다.

아래 링크 참고해 주세요~
https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EC%A0%95%EC%A0%81-%ED%8C%A9%ED%86%A0%EB%A6%AC-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%83%9D%EC%84%B1%EC%9E%90-%EB%8C%80%EC%8B%A0-%EC%82%AC%EC%9A%A9%ED%95%98%EC%9E%90

@leegain1

leegain1 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator
  1. 원래 도메인 구조에서는 internal 하위는 모두 비공개로, 외부 접근이 차단되는 구조인데, 도메인이 많아지면 도메인 객체인지,서비스인지 구분이 잘 안되는 문제와, 모듈리스가 이를 모두 내부 구현으로 보기때문에 공개할 패키지마다 모든 파일에 어노테이션을 붙여야되는 문제 두가지가 있는걸로 이해했습니다!(pr에 써있지만.. 이해가 필요했어요...) 그래서 open으로 선언하여 선언한 모듈의 공개할 패키지마다 전부 공개 취급할 수 있다! 라고 이해했습니다 . 진짜로 감출것은 패키지private으로 선언하고, archunit이라는 테스트 도구로 이중으로 막는 구조로 바꿈으로써 구조적으로 읽기도 편해지고, 외부 차단할만한 impl같은 것은 두번 잠글 수 있으니 좋은 방향인 것 같습니다 !

  2. soft delete 방식은 행을 진짜로 지우는 대신 지워진걸로 표시하고 데이터는 남겨두는 방식으로 이해했습니다. 저희 서비스는 여러 데이터가 하나의 유저를 참조하고 있고, 기존방식으로 계정이 삭제됐을 시 대여기록이 전부 사라지거나 fk에 걸려 삭제자체가 실패되는 문제가 있어 이렇게 수정한거 좋은 방법인것 같습니다.
    대신 유니크 제약 문제(재등록이 막힘), 모든 조회할때 삭제제외 조건을 붙여야하는 트레이드오프가 생기는걸로 파악했습니다!
    그래서 indByIdAndIsDeletedFalse처럼 조건을 메서드명에 명시해놓는 방법으로 해결할 수 있는걸 확인했고, coding-style 문서에도 "삭제 제외 조회는 각 {Domain}JpaRepository' 에서 처리한다” 도 같은 맥락으로 이해했습니다. 그래서 repository 메서드를 추가할때 이부분을 놓치지않도록 주의해야될 것 같습니다!

Comment on lines +8 to +9
@Getter
@EqualsAndHashCode

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

여기서 EqualsAndHashCode 사용한 건
record일 때는 Lombok 없이도 언어 차원에서 자동으로 만들어줬지만 일반 클래스로 바꾸면서 @Getter + @EqualsAndHashCode 조합 사용해서 record와 비슷하게 재현한 거 맞나요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

값을 표현하는 vo의 형태로 두었다고 생각해주시면 될 거 같습니다.
equals()와 `hashCode()'를 왜 쓰는지에 대한 것도 한 번 찾아보시면 좋을 거 같아요~

Comment thread gradle/libs.versions.toml
springBootStarterDataJpaTest = { module = "org.springframework.boot:spring-boot-starter-data-jpa-test" }

# Spring Modulith — verify() boundary checks only, bootstrap test scope
springModulithApi = { module = "org.springframework.modulith:spring-modulith-api" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

여기 이건 @ApplicationModule(type = OPEN) 어노테이션 컴파일 하기 위해 추가한걸로 이해했습니다!!
사소한 거긴 한데, SpringModulithApi 자체는 member, event, welfare, internal 4개 도메인 모듈의 build.gradle.kts에 compileOnly로 들어가있는데, 이거는 bootstrap test scope가 맞는지 궁금합니다! 만약 아니라면, 주석을 이 줄 밑으로 내리는 게 더 정확할 거라고 생각합니다..!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

원래는 bootstrap test에서만 verify() 사용을 위해서 추가했던 건데, 말씀하신대로 패키지를 오픈하기 위해서 @ApplicationModule을 사용하다보니 생각보다 많은 부분에서 사용하게 되었습니다.
사실 좀 더 고민을 해봐야겠지만 지금 spring modulith 자체를 걷어내는 방법도 고민 중에 있어서 이건 추후에 좀 더 고민해보겠습니다!

@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "is_deleted", nullable = false)
private boolean isDeleted = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

deleted_at 대신 updatedAt 사용하여 중복저장 안하고, 삭제 여부 심플하게 변경하는 거 확인했습니다!
그리고 저장 크기 차이에서 인덱스 크기가 작아져 조회가 빨라진다는 장점이 있는 걸로 이해했는데 이부분 맞는지 확인 부탁드립니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

이건 말씀하신 부분과는 조금 많이 다릅니다...

MySQL은 유니크 인덱스에서 NULL을 서로 다른 값으로 취급합니다. 그래서 예를 들어 "삭제되지 않은 행 중에는 email 중복 불가"를 UNIQUE (email, deleted_at)으로 걸어도, 살아있는 행은 deleted_at이 전부 NULL이라 제약이 전혀 동작하지 않습니다.
대신 is_deletedNOT NULL이라 UNIQUE (email, is_deleted)가 의도대로 걸리게 됩니다.

관련 내용 한 번 더 찾아보시면 좋을 거 같아요~

[Feat/#13] Flyway 마이그레이션·JPA 엔티티 26개 추가
@tnals0924
tnals0924 merged commit 231acc5 into main Sep 8, 2026
@tnals0924
tnals0924 deleted the refactor/#14-domain-module-structure branch September 8, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

도메인 모듈 패키지 구조·경계 검증 방식 변경 및 infrastructure:db 설정 골격

5 participants