묻고 답해요
158만명의 커뮤니티!! 함께 토론해봐요.
인프런 TOP Writers
-
해결됨토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
같은 계층에서의 의존성 관리 질문
안녕하세요 Toby!먼저 좋은 강의 올려주셔서 감사 말씀 드립니다.열심히 강의를 듣던 와중에 궁금한 점이 있어서 질문드립니다. 회원 애플리케이션 기능 추가 강의에서 보면,MemberModifyService 클래스에서 MemberFinder 빈을 주입받아 사용하도록 구현해주셨습니다. 관련해서 같은 application 계층에서 서로 DI 받는 구조로 구성하게 될 경우, 발생하는 순환 참조와 같은 문제점들에 대해서는 어떻게 관리하는게 좋을까요? 그리고 그런 문제점이 발생하지 않도록 예방하기 위해서는 어떤 방법이 있을까요?
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
DB 설정 문제
안녕하세요 프로젝트 초기 설정을 토비님 강의 진행대로 따라하고 있는 중입니다.제가 docker 는 잘 몰라서 강의 자료에 있는 Rancher Desktop 을 그냥 설치했구요서버 구동하기 전에 먼저 켜고 토비님 강의 순서 대로 진행했습니다 compose.yaml 의 내용을 수정하기 전에는 오류없이 잘 되는데 토비님이 작성하신 대로 수정을 해서 서버를 구동하면 계속 오류가 발생하고 있습니다 제 디비에 문제가 있나 싶어서 mysql를 완전 삭제하고 재설치 까지 해서 다시 해봐도 이전과 계속 같은 오류가 발생하는데 ai 를 통해서 해결해보려고 해도 해결을 못하고 있습니다 혹시 확인해보시고 알려주셨으면 합니다
-
미해결Readable Code: 읽기 좋은 코드를 작성하는 사고법
강의 내용 정리 및 자료 제작 툴 문의 드립니다.
학습 관련 질문을 남겨주세요. 어떤 부분이 고민인지, 무엇이 문제인지 상세히 작성하면 더 좋아요!먼저 유사한 질문이 있었는지 검색해 보세요.서로 예의를 지키며 존중하는 문화를 만들어가요. 강사님 안녕하세요. 🙂 우선, 테스트 코드에 이어 양질의 강의 제공해 주셔서 감사합니다! 🙏🏻프로젝트 리팩토링 단계에서 본 강의를 접한 덕분에 많이 배우고 있습니다! 혹 제가 이해한 내용을 바탕으로 블로그 혹은 깃허브에 정리해도 될지요?출처는 기재할 예정이며, 블로그 수익과는 전혀 관계없습니다. 더불어 강의 자료 제작 시 사용하시는 드로잉 툴?이 어떤 것인지도 궁금합니다. 답변 기다리겠습니다, 감사합니다!
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
안녕하세요. 토비님! 도메인의 다양한 업데이트 요구사항을 Web API 계층에서 어떻게 다뤄야 할까요?
안녕하세요, 토비님. 강의를 들으며 많은 인사이트를 얻고 있습니다.강의를 완강한 후에도 내면적으로 정리되지 않은 부분이 있어 조심스럽게 질문을 드리게 되었습니다.생성과 관련된 설계는 강의에서 잘 이해가 되었지만, 업데이트(update)와 관련된 내용은 직접적으로 다뤄지지 않아 고민이 생겼습니다. 특히, 제 고민은 다음과 같습니다."도메인의 비즈니스 규칙이 Web API 설계에 어느 정도까지 직접적으로 드러나야 하는가?"현재 도메인 로직에서는 사용자의 여러 정보를 변경할 수 있는 비즈니스 규칙이 존재합니다. 예를 들어:비밀번호 변경 기타 세부정보 변경비즈니스적으로는 각각의 규칙이 잘 정의되어 있고, 각각의 변경 로직도 Member 객체 내에 명확히 메서드로 존재합니다.여기서, 이러한 비즈니스에 대해서 API에 어떻게 노출시켜야 하는가에 대해서 두 가지 선택지가 고려됩니다.1. 비즈니스 정의를 역할 별로 구성한다.POST /api/v1/members/{id}/change-password POST /api/v1/members/{id}/change-nickname생각이 나는 장단점은 다음과 같습니다.장점: 비즈니스에 따라 API를 관리하여 클라이언트가 이해하기 용이합니다.단점: 수정 가능한 필드가 많아질수록 API의 개수가 증가하며, 유지보수가 어려워질 수 있고, Restful 규칙에 위배됩니다.2. 하나의 update API로 통합한다.PATCH /api/v1/members/{id} { "password": "originalPassword123!", // nullable "detailRequest": { // nullable "email": "user@example.com", "nickname": "nickname123", "password": "newPassword456!" } }장점: API가 간결하여 확장이 용이하며, 클라이언트는 필요한 값만 상황에 따라 요청하면 됩니다.단점: API가 비즈니스 책임에 명확하지 않을 수 있습니다.결론적인 질문은 다음과 같이 정리 할 수 있을 것 같습니다.비즈니스 로직이 도메인 레이어에 잘 분리되어 있는 경우, API 계층에서도 분리하여 표현하는 것이 좋은가요?도메인의 역할만 명확하다면 API는 통합해서 update 형식으로 만들어도 괜찮은가요?만약, 후자로 처리를 한다면 어디서 처리를 하는게 좋아보이시나요?서비스 계층도메인 계층// MemberModifyService public void update(Long memberId, MemberUpdateRequest request) { Member member = memberFinder.find(memberId); if (request.password() != null) { member.changePassword(request.password()); } if (request.detailRequest() != null) { member.updateInfo(); } } -------- // MemberModifyService public void update(Long memberId, MemberUpdateRequest request) { Member member = memberFinder.find(memberId); member.update(request); } // Member public void update(MemberUpdateRequest request) { if (request.password() != null) { changePassword(request.password()); } if (request.detailRequest() != null) { updateInfo(); } }뭔가, 이런 고민이 계속 드는 이유가 외부 계층에 종속적이지 않고 도메인에 의존하여 개발을 하더라도 실제로 저희가 처한 상황은 대부분 WebAPI 계층에서의 요청이 많다보니 외부의 행위 또한 도메인에 종속되어야 하는가 하는 고민이 생긴 것 같습니다. 양질의 강의 제공해주셔서 감사드립니다!
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
@NaturalIdCache에 대한 보충 설명 및 사용법 공유
'25. 엔티티의 자연키 지정' 영상의 후반부에 적용한 @NaturalIdCache에 대해 추가로 학습한 내용이, 저처럼 해당 애노테이션을 처음 접한 분들에게 도움이 될 것 같아서 글을 작성합니다. 강의에서 오해가 있을 수 있는 부분, 그리고 자연키에 캐시를 적용하는 방법을 정리해 보았습니다. 강의 내용과 실제 동작의 차이점강의에서는 “같은 트랜잭션 안에서 같은 아이디 값을 가지고 여러 번 조회 시 Persistence Context 에 캐시된 값을 꺼내오는 것 처럼. @NaturalIdCache를 적용하면 이것도 영속 컨텍스트에 캐싱이 된다.”고 말씀하셨습니다. 해당 내용에 대한 이해를 돕기 위해 Hibernate의 두 가지 캐시에 대해 간단히 짚고 넘어가겠습니다.1차 캐시 (First-Level Cache): 세션(영속성 컨텍스트) 범위의 캐시입니다. 같은 트랜잭션 안에서만 유효하며, 트랜잭션이 끝나면 사라집니다. Spring Data JPA에서는 기본적으로 @Id 에 대한 조회를 1차 캐시합니다. 2차 캐시 (Second-Level Cache): 세션 팩토리 범위의 캐시로, 여러 세션에서 데이터를 공유할 수 있습니다. 적용하려면 별도의 의존성 추가 및 캐시 관련 설정(@Cache 등)이 필요합니다. 따라서, "같은 트랜잭션 안에서 캐시된 값을 꺼내온다."는 말은 세션 범위의 1차 캐시로 해석됩니다. 하지만 제가 직접 테스트해 본 결과, @NaturalIdCache는 1차 캐시가 아닌 2차 캐시와 관련이 있었으며, 1차 캐시를 적용하기 위해서는 다른 방법이 필요했습니다. 테스트를 통한 확인자연키에 대한 1차 캐시 동작을 확인하기 위해, 강의에서 적용한 Member 엔티티의 @NaturalIdCache 를 제거하고, 자연키(Email)에 @NaturalId만 적용한 상황에서 두 가지 방식으로 테스트를 진행했습니다. 테스트1: findByEmail 메서드를 사용한 조회Java@Test void NaturalIdFirstLevelCache() { Member member = Member.register(createMemberRegisterRequest(), createPasswordEncoder()); memberRepository.save(member); entityManager.flush(); entityManager.clear(); System.out.println("회원 저장 및 persistence context 초기화 완료"); // 같은 email(Natural ID)로 두 번 조회 Member findMember1 = memberRepository.findByEmail(member.getEmail()).get(); Member findMember2 = memberRepository.findByEmail(member.getEmail()).get(); assertThat(findMember1).isSameAs(findMember2); } Spring Data의 쿼리 메서드를 사용하여 이메일로 조회하는 findByEmail 메서드를 만들고, 한 트랜잭션에서 같은 회원을 두 번 조회했습니다. 자연키에 대한 1차 캐시가 동작한다면, SELECT 쿼리는 한 번만 실행되어야 합니다.결과는 SELECT 쿼리가 두 번 실행되었습니다. 즉, 자연키에 대한 1차 캐시가 동작하지 않았습니다. 테스트2: Hibernate의 자연키 관련 API를 사용한 조회@NaturalId를 다루는 글들을 찾아본 결과 Hibernate가 제공하는 자연키 관련 API가 있다는 것을 확인했고, 이를 적용하기 위해 커스텀 리포지토리를 구현했습니다.Java@Repository @RequiredArgsConstructor public class CustomizedMemberRepositoryImpl implements CustomizedMemberRepository { private final EntityManager entityManager; @Override public Optional<Member> findByNaturalId(Email naturalId) { return entityManager.unwrap(Session.class) .bySimpleNaturalId(Member.class) .loadOptional(naturalId); } } 그리고, 테스트 1과 같은 방식으로 테스트를 진행하였습니다. @Test void NaturalIdApi() { Member member = Member.register(createMemberRegisterRequest(), createPasswordEncoder()); memberRepository.save(member); entityManager.flush(); entityManager.clear(); System.out.println("회원 저장 및 persistence context 초기화 완료"); Member findMember1 = memberRepository.findByNaturalId(member.getEmail()).get(); Member findMember2 = memberRepository.findByNaturalId(member.getEmail()).get(); assertThat(findMember1).isSameAs(findMember2); }결과는 SELECT 쿼리가 한 번만 실행되었습니다. 이를 통해 자연키에 대한 1차 캐시는 @NaturalIdCache 애노테이션과 무관하게, 전용 API를 사용해야만 동작하는 것을 확인했습니다. @NaturalIdCache의 용도@NaturalIdCache Javadoc에는 다음과 같은 설명이 있습니다.Specifies that mappings from the natural id values of the annotated entity to the corresponding entity id values should be cached in the shared second-level cache.…중략This annotation is usually used in combination with Cache, since a round trip may only be avoided if the entity itself is also available in the cache.대략 “natural id와 상응하는 id에 대한 매핑을 2차 캐시에 저장하는 애노테이션이고, 엔티티가 캐시되어있어야 하기 때문에 일반적으로 Cache와 함께 사용된다.”라고 해석됩니다. 즉, 1차 캐시가 아닌 2차 캐시를 위한 애노테이션입니다. 정리2차 캐시 관련 설정 및 테스트를 마저 진행한 후 최종 정리한 내용은 다음과 같습니다. 자연키의 1차 캐시@NaturalIdCache 애노테이션과 관련 없습니다. 자연키에 @NaturalId만 붙이면 됩니다.반드시 Hibernate Session의 bySimpleNaturalId() 같은 전용 API를 사용해야 적용됩니다. 자연키의 2차 캐시@Cache와 @NaturalIdCache를 함께 사용해야 동작합니다.@Cache만 사용 시 @Id로 조회할 때만 2차 캐시가 동작합니다.@NaturalIdCache만 사용 시 자연키와 ID에 대한 매핑 정보는 캐시 히트되는 걸 확인했지만, ID와 엔티티에 대한 캐시가 없어서 캐시가 적용되지 않았습니다. @Cache와 @NaturalIdCache 모두 사용 시 ID를 통한 조회와 자연키를 통한 조회 모두 2차 캐시가 적용됩니다. 참고 자료Hibernate6.6 공식 문서NaturalCache javadocsbaeldung: Hibernate Natural IDs in Spring BootSpring Custom Repository 글의 오류나 부족한 내용을 알고 계신 분은 코멘트를 달아주시면 감사하겠습니다.
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
jpa/도메인 엔티티 분리에 대한 궁금한 점이 있습니다.
토비님 안녕하세요. 강의 너무 즐겁게 잘 수강하고 있는 한 개발자 입니다.강의 수강 중에 궁금한 점이 생겨서, 토비님의 의견이 궁금해서 한 가지 질문 드리고자 합니다.33. 엔티티 클래스와 JPA 매핑 정보 분리강의에서 분리를 xml로 분리해서 매핑 하는 예를 들어주셔서, 이 부분에 대해서 궁금한 점이 있습니다. 개인적으로 xml 매핑작성 생산 비용과 jpa/도메인 엔티티를 분리해서 작성하는 비용이 크게 차이나지 않는것 같단 생각이 들긴합니다. 결국 xml이든, 코드든 분리해서 작성 비용이 필요한 것 같아요.그렇다면, 여기에서 관리 포인트를 이중(xml,코드)으로 가져가는게 나을지, jpa/도메인 엔티티를 분리해서 코드에서 관리하는게 나을지? 고민이 되는데요. 토비님의 의견은 어떠신지 궁금합니다.ai 자동완성 기능 활용코드도 마찬가지로, 애노테이션 빼줘, 붙여줘 하면 어느정도 잘 만들어주긴 하더라구요. 이 부분도 어떻게 생각하시는지 궁금합니다.코파일럿에 xml 매핑정보 만들어줘 하는 내용과, 코드로 애노테이션 붙여줘, 빼줘 해서 복/붙하는 행위 자체가 크게 다르지 않은것 같다는 생각이 들긴해서 이 부분은 어떻게 생각하시는지도 궁금합니다.유지보수 관점에서생산비용 보다, 개발 완료 후 유지보수를 하는데 있어서 그래도 xml/코드 두가지 중 1개를 선택해야한다면 어떻게 관리하는것이 나을까요?? 바쁘신 와중에도 질문 확인하고 답변 주시는 점 미리 감사드립니다.
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
안녕하세요! 강의 완강했습니다! 혹시 다음 강의는 대략적으로 언제 오픈 될까요?
안녕하세요! 강의 완강했습니다! 혹시 다음 강의는 대략적으로 언제쯤 오픈 될까요?
-
해결됨토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
도메인 모델의 화살표는 특별한 의미가 있을까요?
draw.io에서 도메인 모델을 설명해주시는 부분에 대해서 질문드립니다.도표의 다른 선들은 모두 화살표가 없는데, 수강은 회원과 강의 모델로 화살표가 있어서 어떤 의미인지 궁금합니다.
-
해결됨토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
Kotlin 에서 JPA Entity 생성시 질문
안녕하세요. 토비님!현재 저는 강의 내용을 Kotlin springboot 로 따라가고 있습니다.JPA Entity 클래스를 생성할 때, 자바에선 롬복까지 이용해서 Getter 만 만들어놓고 setter 는 닫아놓는 게 쉽게 되는데, 이걸 코틀린에서는 롬복을 사용하지 않다보니 코틀린스러우면서도 깔끔하게 사용하는 방법에 대해서 애를 먹고 있습니다. 찾아보니 3가지 방법 있는 것 같습니다.방법1. 자바랑 가장 비슷하게, 필드를 모두 private 으로 생성하고 getter 는 롬복 대신 직접 선언.@Entity open class Member( @Column(name = "email", unique = true, nullable = false) private var email: String, @Column(name = "nickname", nullable = false) private var nickname: String, @Column(name = "passwordHash", nullable = false) private var passwordHash: String, @Column(name = "status", nullable = false) @Enumerated(EnumType.STRING) private var status: MemberStatus = MemberStatus.PENDING, ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0L fun getEmail(): String = email fun getNickname(): String = nickname fun getPasswordHash(): String = passwordHash fun getStatus(): MemberStatus = status } 방법2. getter 를 좀 더 코틀린스럽게 사용하기 위해 내부 필드를 _를 붙여서 선언 @Entity class Member2( @Column(name = "email", unique = true, nullable = false) private var _email: String, @Column(name = "nickname", nullable = false) private var _nickname: String, @Column(name = "passwordHash", nullable = false) private var _passwordHash: String, @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) private var _status: MemberStatus = MemberStatus.PENDING, ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0L val email: String get() = _email val nickname: String get() = _nickname val passwordHash: String get() = _passwordHash val status: MemberStatus get() = _status }방법3. protected set 사용@Entity open class Member3( email: String, nickname: String, passwordHash: String, status: MemberStatus = MemberStatus.PENDING, ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0L @Column(name = "email", unique = true, nullable = false) var email: String = email protected set @Column(name = "nickname", nullable = false) var nickname: String = nickname protected set @Column(name = "passwordHash", nullable = false) var passwordHash: String = passwordHash protected set @Column(name = "status", nullable = false) @Enumerated(EnumType.STRING) var status: MemberStatus = status protected set } 방법4(?). 전부 public val 로 선언하고, 변경시 새로운 객체 생성@Entity @Table(name = "members") class Member4( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0L, @Column(name = "email", unique = true, nullable = false) val email: String, @Column(name = "nickname", nullable = false) val nickname: String, @Column(name = "passwordHash", nullable = false) val passwordHash: String, @Column(name = "status", nullable = false) @Enumerated(EnumType.STRING) val status: MemberStatus = MemberStatus.PENDING, ) { fun updateNickname(newNickname: String): Member4 { require(newNickname.isNotBlank()) { "Nickname cannot be blank" } return Member4( id = this.id, email = this.email, nickname = newNickname, passwordHash = this.passwordHash, status = this.status, ) } } 어느 방식을 선택하는게 현명할까요? 토비님은 평소에 어떻게 하시는지 궁금합니다.
-
해결됨토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
테스트 간헐적 실패에서 대해서 다루나요?
안녕하세요섹션5 코드 다듬기까지 들은 시점에서 테스트 코드를 여러번 실행했을때 간헐적으로 실패를 경험합니다혹시 해당 내용 수정하는 부분이 끝까지 들으면 나오나요아래 에러내용 삽입했습니다. Failed to resolve parameter [com.clean.splearn.application.provided.MemberFinder memberFinder] in constructor [com.clean.splearn.application.provided.MemberFinderTest(com.clean.splearn.application.provided.MemberFinder,com.clean.splearn.application.provided.MemberRegister,jakarta.persistence.EntityManager)]: Failed to load ApplicationContext for [WebMergedContextConfiguration@350ec690 testClass = com.clean.splearn.application.provided.MemberFinderTest, locations = [], classes = [com.clean.splearn.SplearnApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = [], propertySourceProperties = ["org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true"], contextCustomizers = [org.springframework.boot.test.autoconfigure.OnFailureConditionReportContextCustomizerFactory$OnFailureConditionReportContextCustomizer@476fe690, org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@1f, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizer@6dd1c3ed, [ImportsContextCustomizer@49cb1baf key = [com.clean.splearn.SplearnTestConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@7fdab70c, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@a451491, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@2aff9dff, org.springframework.boot.test.web.reactor.netty.DisableReactorResourceFactoryGlobalResourcesContextCustomizerFactory$DisableReactorResourceFactoryGlobalResourcesContextCustomizerCustomizer@15639440, org.springframework.test.context.support.DynamicPropertiesContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestAnnotation@cbfbc3c], resourceBasePath = "src/main/webapp", contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null] org.junit.jupiter.api.extension.ParameterResolutionException: Failed to resolve parameter [com.clean.splearn.application.provided.MemberFinder memberFinder] in constructor [com.clean.splearn.application.provided.MemberFinderTest(com.clean.splearn.application.provided.MemberFinder,com.clean.splearn.application.provided.MemberRegister,jakarta.persistence.EntityManager)]: Failed to load ApplicationContext for [WebMergedContextConfiguration@350ec690 testClass = com.clean.splearn.application.provided.MemberFinderTest, locations = [], classes = [com.clean.splearn.SplearnApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = [], propertySourceProperties = ["org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true"], contextCustomizers = [org.springframework.boot.test.autoconfigure.OnFailureConditionReportContextCustomizerFactory$OnFailureConditionReportContextCustomizer@476fe690, org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@1f, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizer@6dd1c3ed, [ImportsContextCustomizer@49cb1baf key = [com.clean.splearn.SplearnTestConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@7fdab70c, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@a451491, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@2aff9dff, org.springframework.boot.test.web.reactor.netty.DisableReactorResourceFactoryGlobalResourcesContextCustomizerFactory$DisableReactorResourceFactoryGlobalResourcesContextCustomizerCustomizer@15639440, org.springframework.test.context.support.DynamicPropertiesContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestAnnotation@cbfbc3c], resourceBasePath = "src/main/webapp", contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null] at java.base/java.util.Optional.orElseGet(Optional.java:364) at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) Caused by: java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@350ec690 testClass = com.clean.splearn.application.provided.MemberFinderTest, locations = [], classes = [com.clean.splearn.SplearnApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceDescriptors = [], propertySourceProperties = ["org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true"], contextCustomizers = [org.springframework.boot.test.autoconfigure.OnFailureConditionReportContextCustomizerFactory$OnFailureConditionReportContextCustomizer@476fe690, org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@1f, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizer@6dd1c3ed, [ImportsContextCustomizer@49cb1baf key = [com.clean.splearn.SplearnTestConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@7fdab70c, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@a451491, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@2aff9dff, org.springframework.boot.test.web.reactor.netty.DisableReactorResourceFactoryGlobalResourcesContextCustomizerFactory$DisableReactorResourceFactoryGlobalResourcesContextCustomizerCustomizer@15639440, org.springframework.test.context.support.DynamicPropertiesContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestAnnotation@cbfbc3c], resourceBasePath = "src/main/webapp", contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null] at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:180) at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:130) at org.springframework.test.context.junit.jupiter.SpringExtension.getApplicationContext(SpringExtension.java:351) at org.springframework.test.context.junit.jupiter.SpringExtension.resolveParameter(SpringExtension.java:337) ... 3 more
-
해결됨토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
JPA Entity Class 와 Domain Model Entity Class 를 분리해야 하는가? 에 대한 추가 질문
안녕하세요. 토비님!먼저 제가 정말 오랜 기간 고민해온 주제에 대해서 이렇게 강의를 내주셔서 너무 감사드립니다!!특히 강의에서 여러 차례에 걸쳐서 깊게 다룬, "JPA Entity Class 와 Domain Model Entity Class 를 분리해야 하는가?" 에 대한 토비님의 의견이 너무 공감되고 유익했습니다! 감사합니다!그리고 이에 대해서 두 가지 질문이 있습니다.첫 번째는,저는 사실 지난 3년 간 스타트업에서 Domain Entity 와 JPA Entity 를 분리해서 사용해왔습니다.그런데 강의에서 다뤄주신 내용이 제가 겪으며 갖게 된 생각과는 결이 조금 다르다고 느껴서 이렇게 질문을 드리게 되었습니다.제가 느꼈던 "Domain Entity 와 JPA Entity 를 분리해서 사용했을 때의 장단점"은 이랬습니다.장점 1. 도메인 모델의 수정이 DB 데이터 마이그레이션을 꼭 강제하는 게 아니라서, 도메인 모델 수정이 매우 자유롭습니다.상황에 따라 의도적으로 JPA Entity 클래스와 도메인 모델 클래스를 다른 모양으로 만들어두고 유지할 수 있습니다.(ex. User, UserDetail 이 쪼개져 있었는데, 어떠한 의사결정으로 인해 User 하나로 합쳐서 관리하는게 맞다는 판단이 든 경우, UserEntity, UserDetailEntity 는 놔둔 채로 User 만 합치는 게 가능.)장점 2. 도메인 모델들을 완전히 정규화된 구조로 가져갈 수 있습니다. 회사에서 비즈니스가 발전하다보면 종종 어쩔 수 없이 테이블에 성능을 위한 반정규화 필드나 soft delete 용 deleted_at 필드 등을 넣게 됩니다. 하지만 사실 이런 필드들은 순수 비즈니스 로직을 기술하는데에는 방해가 될 뿐인, 너무 Technical 한 부분들입니다. 이러한 반정규화 or 기능 필드들을 도메인 모델에는 넣지 않고 JPA Entity 에만 넣어서 어댑터에서 처리하면, 도메인 모델 내에서는 항상 순수 비즈니스 로직만을 기술할 수 있게됩니다.단점 1. JPA 의 lazy loading 을 활용할 수 없습니다. 항상 도메인 모델 객체는 완전한 상태로만 존재합니다. 그래서 성능 문제로 CQRS 패턴이 강제됩니다. 특히 저는 GraphQL 을 사용중이라 더욱 더 CQRS 가 필요했습니다.단점 2. Repository 의 save(= upsert) 로직을 직접 구현해야 합니다. 특히 복잡한 비즈니스의 핵심 도메인 모델들은 관계된 테이블도 많아가지고 이 save 구현이 엄청나게 길어집니다. 저는 이 save 로직을 직접 구현하고 관리할 때 회의감이 가장 많이 들었습니다. Spring Data JPA 가 그 동안 얼마나 압도적인 생산성을 제공해주고 있었는지도 느끼게 됐습니다.저는 스타트업에서 일하고 있다보니 특히 생산성을 중요하게 생각합니다.그런 점에서도 위의 장점들과 단점들이 모두 너무 치명적으로 느껴졌습니다.그래서 이 관점들에 대한 토비님의 의견과 토비님이 제 상황이시라면 어떤 선택을 하셨을지가 너무너무 궁금합니다. (참고로 저는 Kotlin Springboot + Spring Data JPA + Kotlin JDSL + GraphQL-Kotlin(code first)을 사용하고 있습니다.)두 번째는,사실 저는 Kotlin Springboot 를 사용중인데, 코틀린 언어와 JPA 가 너무 안 어울린다는 생각을 종종 합니다.예를 들면, DB table 에 정의된 column default 값을 쓰려면 JPA Entity 의 해당 필드에 null 을 넣어서 보내야하는데, 보통 테이블 컬럼에 default 를 쓰는 경우는 대부분 해당 컬럼이 not null 타입입니다. 그래서 당연히 JPA Entity 에도 필드 타입은 not null 로 하고 싶어집니다.물론 @Column(insertable=false)를 사용하긴 하지만, 결국 그럼 이 JPA Entity 객체를 생성할 때 해당 필드에 실제로 저장되지도 않을 값을 거짓으로 넣어야 하는 상황이 생깁니다.그래서 조금 더 Kotlin 에 잘 맞는 Exposed 를 고려하자니, 도메인 모델 클래스와 JPA Entity 클래스 분리가 강제되는 느낌이고, 이게 과연 JPA 의 생산성을 따라올 수 있을까? 올바른 선택이 맞을까? 하는 의문이 듭니다.그래서 궁금한 점은,혹시 토비님이 추천하시는 좀 더 Kotlin 언어에 잘 맞고 Kotlin Springboot 를 생산적으로 사용할 수 있는 방법이 있을까요?
-
해결됨토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
동일 계층의 애플리케이션 서비스가 서로의 인터페이스를 사용하여 의존하는 경우에 대해서
안녕하세요? 존경하는 토비님 작은 질문하나 드리겠습니다. 회원 애플리케이션 기능 추가의 25:00~26:00 에서는 MemberFinder 인터페이스를 정의하고 기존 MemberService에 혼재된 멤버 조회로직을 CQRS에 따라 구현클래스를 분리했습니다. 그리고 이를 MemberModifyService에서 사용하고 있습니다.말씀 주신 것처럼 단순히 조회이고 변경이 없다는 가정하에 이처럼 인터페이스를 정의하고 이를 통해 사용을 하는 것은 잘 이해가 됩니다!다만 저는 같은 계층에서 있는 서비스 빈이 서로를 의존하지 않는 방향으로 개발을 해오고 있었는데요. 그래서 토비님의 방식에 대해 공부하면서 같은 계층에서 인터페이스 포트를 통해서 호출하는 것은 괜찮을까 고민이 들었습니다.만약 그게 설계적으로도 문제가 없다면 앞으로도 토비님의 방식으로 개발하고 싶은데요. 분명 토비님께서 오랫동안 고민하신 개발 원칙/기준이 있을 것 같아서 여쭤봅니다.같은 계층에 있는 애플리케이션 서비스 빈이 서로를 의존, 또는 인터페이스를 통해 사용되는 경우 지켜야할 기준이나 원칙이 있을까요?*추신저는 토스의 김재님의 [블로그](https://geminikims.medium.com/%EC%A7%80%EC%86%8D-%EC%84%B1%EC%9E%A5-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%86%8C%ED%94%84%ED%8A%B8%EC%9B%A8%EC%96%B4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%96%B4%EA%B0%80%EB%8A%94-%EB%B0%A9%EB%B2%95-97844c5dab63)를 접해서 위와 같이 개발해오고 있었습니다. 저는 계층을 하나 두고 유틸성 빈을 만들고 이를 사용하게끔 했습니다.(블로그내용 아래 일부 발췌) > 네 번째 규칙동일 레이어 간에는 서로 참조하지 않아야 한다.(다만, Implement Layer는 예외적으로 서로 참조가 가능합니다.)
-
해결됨토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
도메인 로직에 대해서 궁금한 것이 있습니다.
현재 Member 도메인 모델 확장 까지의 수업을 듣고 궁금한 것이 있습니다! 저희가 이번 수업을 포함하여 지금까지 요구사항(도메인) -> 도메인 모델(회원) -> 비밀번호 해시라는 일련의 과정을 통해 비밀번호 암호화와 관련한 도메인 모델의 규칙, 속성, 행위 등을 뽑아냈기 때문에 비밀번호 암호화까지 도메인 로직에 포함된다고 이해했습니다. <인터페이스 위치를 결정하는 기준에 대해> 해당 답변에서도 PasswordEncoder를 어디에 둘지 결정할 때 사용한 기준은 도메인 모델을 이야기할 때 이게 등장하는가 라고 말씀해주시기도 했고요! 그렇다면 개발은 결국 요구사항을 토대로 진행되는 것이기에 모든 코드가 도메인 로직으로 분류되어야 하는 것 아닌가? 하는 의문점이 생겼습니다! 토비의 생각을 듣고 싶습니다!
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
Auto Increment 질문
안녕하세요 토비님. 강의 정말 잘 보고 있습니다.다름이 아니라 Auto Increment 전략을 현업에서도 자주 사용하시는지 궁금해서 문의드립니다. Mysql에서 Auto Increment를 사용하니 bluk insert가 안되는 구조던데 문제가 있으셨던 적은 없는지 궁금합니다. bluk insert를 jdbc template으로 구현했더니 갈레라 3중화 구조라 ID가 3씩 증가하는 문제가 있어서 곤란한 경험이 있습니다. 이럴 경우 ID 전략을 UUID나 Snowflake 이런식으로 가져가야 하는지 궁금합니다.아니면 다른 해결 방법이 있으시다면 알려주시면 감사하겠습니다.
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
MSA 환경에서 도메인 모델의 정의 범위에 대한 질문
안녕하세요, 토비님.도메인 모델의 정의 범위에 대해 토비님의 생각이 궁금하여 질문드립니다. 현재 저는 MSA 환경에서 여러 시스템이 나뉘어 있는 구조에서 일하고 있습니다. 보통 팀에서는 “우리가 생성·저장하는 데이터 구조”를 도메인 모델로 이해하는 경우가 많은데요,저는 도메인 모델의 범위가 그보다 더 넓다고 생각하고 있습니다. 예를 들어, 주문시스템이 있다고 할 때, 주문시스템은 주문데이터를 생성/관리하는 책임을 지겠지만 이를 위해서여러 시스템(상품, 프로모션, 결제 등)에서 데이터를 조회해 조합하여 업무 규칙을 수행하는 책임을 가질 수 있습니다. 이 경우, 다른 시스템이 생성·관리하는 개념이라 하더라도,주문시스템 내부에서의 목적과 규칙에 따라 주문에 맞는 방식으로 추상화된 모델을 정의하는 것이바로 도메인 모델이라고 생각하는데요. 즉, 외부 개념이라도 주문시스템의 책임 하에 있는 로직과 규칙이 있다면, 그것은 주문의 도메인이다라는 관점입니다. 이와 같은 관점에 대해 토비님은 어떻게 생각하시는지,도메인 모델 정의의 기준에 대해 의견을 듣고 싶습니다. 감사합니다.
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
테스트 준비 과정에서 서비스 메서드 호출
@Test void find() { Member member = memberRegister.register(MemberFixture.createMemberRegisterRequest()); entityManager.flush(); entityManager.clear(); Member found = memberFinder.find(member.getId()); assertThat(member.getId()).isEqualTo(found.getId()); }현재 코드에서 위와 같이 테스트에서 회원 저장을 위해 memberRegister.register를 호출하고 있습니다.그런데 memberRegister.register에는 단순히 회원을 저장하는 것 외에도 이메일 전송 같은 부가적인 로직이 포함되어 있습니다. 이러한 부가적인 로직때문에 테스트 속도가 느려진다던가, 테스트가 실패하는 원인이 될 수 있다고 생각이 들었습니다.이처럼 테스트 준비에 필요하지 않은 부가 로직까지 수행되는 상황에서, memberRegister.register를 테스트 준비 용도로 사용하는 것이 적절한지 토비님의 생각이 궁금합니다.
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
도메인 모델에서의 검증과 애플리케이션 레벨 검증의 경계
도메인 모델에서의 검증과 애플리케이션 레벨 검증의 경계에 대해 질문드립니다.현재 도메인 모델에서는 이메일 형식이 올바른지, 닉네임이나 비밀번호가 null이 아닌지 같은 최소한의 조건만 검증하고 있습니다. 반면, 비밀번호가 8자 이상인지, 닉네임이 5자 이상인지 같은 검증은 애플리케이션 레이어에서 처리하고 있습니다. 그런데 닉네임이 5자 이상이어야 한다 같은 규칙도 도메인 규칙으로 볼 수 있지 않을까 라고 생각이 들어 해당 검증 역시 도메인 모델에서 처리하는게 맞지 않나 라는 생각이 드는데,도메인 모델에서의 검증과 애플리케이션 레벨 검증의 경계는 어디까지 두는 게 좋은지 토비님 의견이 궁금합니다.말씀하신것을 토대로 예측해 봤을때 형식이나 정책적 요구사항(변경 가능성이 있는 규칙)은 애플리케이션 레이어에서, 도메인의 본질적 불변 조건은 도메인에서 검증하는 걸까요?
-
미해결토비의 클린 스프링 - 도메인 모델 패턴과 헥사고날 아키텍처 Part 1
Member#register() 메서드명이 모호하게 느껴집니다.
Member#create() 메서드를 register로 공통언어를 바꾸셨는데, 뜻이 모호해진 것 같아서 질문드립니다! 제가 생각하기에 register(등록)이라는 단어는 생성과 영속화라는 두가지의 행위를 함축한 단어로 느껴집니다. 따라서 MemberService#register()는 너무 자연스럽습니다. 실제로 Member를 생성한 후에 MemberRepository를 통하여 영속화까지 하는 내용으로 구현되어있습니다.하지만 Member#register()는 이와 다르게 Java 객체를 생성하기만 하는 것이라, 메서드명과 실제 동작이 불일치한다고 느껴집니다. 하지만 또 동시에 도메인 모델을 글로 작성하는 과정에서 '멤버를 등록한다.' 라는 말을 쓰는 것은 자연스럽습니다. 그 구현이 코드적으로 Member에 있는 것만이 부자연스럽습니다.이런 경우에는 Member라는 객체만으로 도메인 모델을 온전히 표현해내기가 어려운 것일까요? 해당 모델을 표현하기 위해서는 MemberService 같은 코드가 꼭 필요한 걸까요?